@adhdev/daemon-core 0.8.22 → 0.8.23

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 (35) hide show
  1. package/dist/agent-stream/types.d.ts +3 -0
  2. package/dist/cli-adapter-types.d.ts +3 -0
  3. package/dist/cli-adapters/provider-cli-adapter.d.ts +71 -11
  4. package/dist/commands/stream-commands.d.ts +1 -0
  5. package/dist/config/config.d.ts +6 -0
  6. package/dist/index.js +1162 -307
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1162 -307
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/providers/cli-provider-instance.d.ts +6 -0
  11. package/dist/providers/contracts.d.ts +59 -1
  12. package/dist/providers/control-effects.d.ts +4 -0
  13. package/dist/providers/extension-provider-instance.d.ts +9 -0
  14. package/dist/providers/ide-provider-instance.d.ts +8 -0
  15. package/dist/shared-types.d.ts +2 -1
  16. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  17. package/package.json +3 -2
  18. package/src/agent-stream/forward.ts +2 -0
  19. package/src/agent-stream/provider-adapter.ts +5 -15
  20. package/src/agent-stream/types.ts +4 -0
  21. package/src/cli-adapter-types.ts +3 -0
  22. package/src/cli-adapters/provider-cli-adapter.ts +399 -49
  23. package/src/commands/handler.ts +1 -0
  24. package/src/commands/stream-commands.ts +99 -8
  25. package/src/config/config.d.ts +1 -0
  26. package/src/config/config.ts +9 -0
  27. package/src/launch.ts +57 -11
  28. package/src/providers/cli-provider-instance.ts +148 -2
  29. package/src/providers/contracts.ts +65 -2
  30. package/src/providers/control-effects.ts +114 -0
  31. package/src/providers/extension-provider-instance.ts +163 -3
  32. package/src/providers/ide-provider-instance.ts +181 -2
  33. package/src/shared-types.d.ts +1 -0
  34. package/src/shared-types.ts +2 -1
  35. package/src/status/snapshot.ts +1 -0
@@ -441,6 +441,7 @@ export class DaemonCommandHandler implements CommandHelpers {
441
441
  case 'set_ide_extension': return Stream.handleSetIdeExtension(this, args);
442
442
 
443
443
  // ─── Extension Model / Mode Control (stream-commands.ts) ──────────
444
+ case 'invoke_provider_script': return Stream.handleProviderScript(this, args);
444
445
  case 'list_extension_models': return Stream.handleExtensionScript(this, args, 'listModels');
445
446
  case 'set_extension_model': return Stream.handleExtensionScript(this, args, 'setModel');
446
447
  case 'list_extension_modes': return Stream.handleExtensionScript(this, args, 'listModes');
@@ -96,7 +96,67 @@ export function handleSetProviderSetting(h: CommandHelpers, args: any): CommandR
96
96
 
97
97
  // ─── Extension Script Execution (Model/Mode) ─────
98
98
 
99
- export async function handleExtensionScript(h: CommandHelpers, args: any, scriptName: string): Promise<CommandResult> {
99
+ function normalizeProviderScriptArgs(args: any): Record<string, any> {
100
+ const normalizedArgs = { ...(args || {}) };
101
+ for (const key of ['mode', 'model', 'message', 'action', 'button', 'text', 'sessionId', 'value']) {
102
+ if (key in normalizedArgs && !(key.toUpperCase() in normalizedArgs)) {
103
+ normalizedArgs[key.toUpperCase()] = normalizedArgs[key];
104
+ }
105
+ }
106
+ return normalizedArgs;
107
+ }
108
+
109
+ function parseScriptResult(result: unknown): { success: boolean; payload: any } {
110
+ if (typeof result === 'string') {
111
+ try {
112
+ const parsed = JSON.parse(result);
113
+ if (parsed && typeof parsed === 'object' && parsed.success === false) {
114
+ return { success: false, payload: parsed };
115
+ }
116
+ return { success: true, payload: parsed };
117
+ } catch {
118
+ return { success: true, payload: { result } };
119
+ }
120
+ }
121
+ if (result && typeof result === 'object' && (result as any).success === false) {
122
+ return { success: false, payload: result };
123
+ }
124
+ return { success: true, payload: result };
125
+ }
126
+
127
+ function getCliScriptCommand(payload: any): { type: string; text?: string } | null {
128
+ if (!payload || typeof payload !== 'object') return null;
129
+
130
+ if (typeof payload.sendMessage === 'string' && payload.sendMessage.trim()) {
131
+ return { type: 'send_message', text: payload.sendMessage.trim() };
132
+ }
133
+
134
+ const command = payload.command;
135
+ if (!command || typeof command !== 'object') return null;
136
+ if (command.type !== 'send_message') return null;
137
+
138
+ const text = typeof command.text === 'string'
139
+ ? command.text.trim()
140
+ : typeof command.message === 'string'
141
+ ? command.message.trim()
142
+ : '';
143
+ if (!text) return null;
144
+ return { type: 'send_message', text };
145
+ }
146
+
147
+ function applyProviderPatch(h: CommandHelpers, args: any, payload: any): void {
148
+ if (!payload || typeof payload !== 'object') return;
149
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
150
+ const targetSession = targetSessionId ? h.ctx.sessionRegistry?.get(targetSessionId) : undefined;
151
+ const instanceKey = targetSession?.instanceKey || targetSessionId;
152
+ if (!instanceKey) return;
153
+ h.ctx.instanceManager?.sendEvent(instanceKey, 'provider_state_patch', {
154
+ ...payload,
155
+ extensionType: targetSession?.transport === 'cdp-webview' ? targetSession.providerType : undefined,
156
+ });
157
+ }
158
+
159
+ async function executeProviderScript(h: CommandHelpers, args: any, scriptName: string): Promise<CommandResult> {
100
160
  const { agentType, ideType } = args || {};
101
161
  if (!agentType) return { success: false, error: 'agentType is required' };
102
162
 
@@ -115,15 +175,31 @@ export async function handleExtensionScript(h: CommandHelpers, args: any, script
115
175
  return { success: false, error: `Script '${actualScriptName}' not available for ${agentType}` };
116
176
  }
117
177
 
118
- const scriptFn = provider.scripts[actualScriptName as keyof typeof provider.scripts] as Function;
119
- // Normalize args: script placeholders use UPPERCASE (${MODE}, ${MODEL}, ${MESSAGE})
120
- // but WebSocket args typically use lowercase. Add uppercase versions of common keys.
121
- const normalizedArgs = { ...args };
122
- for (const key of ['mode', 'model', 'message', 'action', 'button', 'text', 'sessionId']) {
123
- if (key in normalizedArgs && !(key.toUpperCase() in normalizedArgs)) {
124
- normalizedArgs[key.toUpperCase()] = normalizedArgs[key];
178
+ const normalizedArgs = normalizeProviderScriptArgs(args);
179
+
180
+ if (provider.category === 'cli') {
181
+ const adapter = h.getCliAdapter(args?.targetSessionId || agentType);
182
+ if (!adapter?.invokeScript) {
183
+ return { success: false, error: `CLI adapter does not support script '${actualScriptName}'` };
184
+ }
185
+ try {
186
+ const raw = await adapter.invokeScript(actualScriptName, normalizedArgs);
187
+ const parsed = parseScriptResult(raw);
188
+ if (!parsed.success) {
189
+ return { success: false, ...(parsed.payload || {}) };
190
+ }
191
+ const cliCommand = getCliScriptCommand(parsed.payload);
192
+ if (cliCommand?.type === 'send_message' && cliCommand.text) {
193
+ await adapter.sendMessage(cliCommand.text);
194
+ }
195
+ applyProviderPatch(h, args, parsed.payload);
196
+ return { success: true, ...(parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : { result: parsed.payload }) };
197
+ } catch (e: any) {
198
+ return { success: false, error: `Script execution failed: ${e.message}` };
125
199
  }
126
200
  }
201
+
202
+ const scriptFn = provider.scripts[actualScriptName as keyof typeof provider.scripts] as Function;
127
203
  const scriptCode = scriptFn(normalizedArgs);
128
204
  if (!scriptCode) return { success: false, error: `Script '${actualScriptName}' returned null` };
129
205
 
@@ -187,17 +263,32 @@ export async function handleExtensionScript(h: CommandHelpers, args: any, script
187
263
  if (typeof result === 'string') {
188
264
  try {
189
265
  const parsed = JSON.parse(result);
266
+ applyProviderPatch(h, args, parsed);
267
+ if (parsed && typeof parsed === 'object' && parsed.success === false) {
268
+ return { success: false, ...parsed };
269
+ }
190
270
  return { success: true, ...parsed };
191
271
  } catch {
192
272
  return { success: true, result };
193
273
  }
194
274
  }
275
+ applyProviderPatch(h, args, result);
195
276
  return { success: true, result };
196
277
  } catch (e: any) {
197
278
  return { success: false, error: `Script execution failed: ${e.message}` };
198
279
  }
199
280
  }
200
281
 
282
+ export async function handleExtensionScript(h: CommandHelpers, args: any, scriptName: string): Promise<CommandResult> {
283
+ return executeProviderScript(h, args, scriptName);
284
+ }
285
+
286
+ export async function handleProviderScript(h: CommandHelpers, args: any): Promise<CommandResult> {
287
+ const scriptName = typeof args?.scriptName === 'string' ? args.scriptName.trim() : '';
288
+ if (!scriptName) return { success: false, error: 'scriptName is required' };
289
+ return executeProviderScript(h, args, scriptName);
290
+ }
291
+
201
292
  // ─── IDE Extension Settings (per-IDE on/off) ─────
202
293
 
203
294
  export function handleGetIdeExtensions(h: CommandHelpers, args: any): CommandResult {
@@ -37,6 +37,7 @@ export interface ADHDevConfig {
37
37
  }>;
38
38
  disableUpstream?: boolean;
39
39
  providerDir?: string;
40
+ terminalSizingMode?: 'measured' | 'fit';
40
41
  }
41
42
  export declare function generateMachineId(): string;
42
43
  export declare function isStableMachineId(machineId?: string | null): boolean;
@@ -78,6 +78,13 @@ export interface ADHDevConfig {
78
78
 
79
79
  // Optional custom provider directory for local development
80
80
  providerDir?: string;
81
+
82
+ /**
83
+ * Browser terminal sizing behavior for dashboard CLI panes.
84
+ * Default `measured` keeps terminal size daemon-authoritative.
85
+ * `fit` opt-in restores xterm fit-based sizing for advanced users.
86
+ */
87
+ terminalSizingMode?: 'measured' | 'fit';
81
88
  }
82
89
 
83
90
  const DEFAULT_CONFIG: ADHDevConfig = {
@@ -99,6 +106,7 @@ const DEFAULT_CONFIG: ADHDevConfig = {
99
106
  providerSettings: {},
100
107
  ideSettings: {},
101
108
  disableUpstream: false,
109
+ terminalSizingMode: 'measured',
102
110
  };
103
111
 
104
112
  const MACHINE_ID_PREFIX = 'mach_';
@@ -149,6 +157,7 @@ function normalizeConfig(raw: unknown): ADHDevConfig & { activeWorkspaceId?: str
149
157
  ideSettings: isPlainObject(parsed.ideSettings) ? parsed.ideSettings : {},
150
158
  disableUpstream: asBoolean(parsed.disableUpstream, DEFAULT_CONFIG.disableUpstream ?? false),
151
159
  providerDir: asOptionalString(parsed.providerDir),
160
+ terminalSizingMode: parsed.terminalSizingMode === 'fit' ? 'fit' : 'measured',
152
161
  };
153
162
  }
154
163
 
package/src/launch.ts CHANGED
@@ -23,6 +23,7 @@ import * as path from 'path';
23
23
  import { detectIDEs } from './detection/ide-detector.js';
24
24
  import { IDEInfo } from './detection/ide-detector.js';
25
25
  import { ProviderLoader } from './providers/provider-loader.js';
26
+ import type { ProviderModule } from './providers/contracts.js';
26
27
 
27
28
  // ─── Provider-based dynamic IDE infrastructure ────────────────
28
29
  // Reads cdpPorts, processNames from provider.js — only create provider.js to add new IDE
@@ -50,6 +51,26 @@ function getWinProcessNames(): Record<string, string[]> {
50
51
  return getProviderLoader().getWinProcessNames();
51
52
  }
52
53
 
54
+ function getProviderMeta(ideId: string): ProviderModule | undefined {
55
+ return getProviderLoader().getMeta(ideId);
56
+ }
57
+
58
+ function getPreferredLaunchMethod(ideId: string, platform: NodeJS.Platform): 'auto' | 'cli' | 'app' {
59
+ const prefer = getProviderMeta(ideId)?.launch?.prefer;
60
+ const value = prefer?.[platform];
61
+ return value === 'cli' || value === 'app' || value === 'auto' ? value : 'auto';
62
+ }
63
+
64
+ function getCdpStartupTimeoutMs(ideId: string): number {
65
+ const value = getProviderMeta(ideId)?.launch?.cdpStartupTimeoutMs;
66
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 15000;
67
+ return Math.max(1000, Math.floor(value));
68
+ }
69
+
70
+ function escapeForAppleScript(value: string): string {
71
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
72
+ }
73
+
53
74
  // ─── Helpers ────────────────────────────────────
54
75
 
55
76
  /** Find available port (primary → secondary → sequential after) */
@@ -110,11 +131,11 @@ export async function killIdeProcess(ideId: string): Promise<boolean> {
110
131
  if (plat === 'darwin' && appName) {
111
132
  // macOS: graceful quit via osascript
112
133
  try {
113
- execSync(`osascript -e 'tell application "${appName}" to quit' 2>/dev/null`, {
134
+ execSync(`osascript -e 'tell application "${escapeForAppleScript(appName)}" to quit' 2>/dev/null`, {
114
135
  timeout: 5000,
115
136
  });
116
137
  } catch {
117
- try { execSync(`pkill -f "${appName}" 2>/dev/null`); } catch { }
138
+ try { execSync(`pkill -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
118
139
  }
119
140
  } else if (plat === 'win32' && winProcesses) {
120
141
  // Windows: taskkill for each process name
@@ -142,7 +163,7 @@ export async function killIdeProcess(ideId: string): Promise<boolean> {
142
163
 
143
164
  // Force terminate retry
144
165
  if (plat === 'darwin' && appName) {
145
- try { execSync(`pkill -9 -f "${appName}" 2>/dev/null`); } catch { }
166
+ try { execSync(`pkill -9 -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
146
167
  } else if (plat === 'win32' && winProcesses) {
147
168
  for (const proc of winProcesses) {
148
169
  try { execSync(`taskkill /IM "${proc}" /F 2>nul`); } catch { }
@@ -165,8 +186,23 @@ export function isIdeRunning(ideId: string): boolean {
165
186
  if (plat === 'darwin') {
166
187
  const appName = getMacAppIdentifiers()[ideId];
167
188
  if (!appName) return false;
168
- const result = execSync(`pgrep -f "${appName}" 2>/dev/null`, { encoding: 'utf-8' });
169
- return result.trim().length > 0;
189
+ try {
190
+ const result = execSync(`pgrep -x "${appName}" 2>/dev/null`, {
191
+ encoding: 'utf-8',
192
+ timeout: 3000,
193
+ });
194
+ return result.trim().length > 0;
195
+ } catch {
196
+ const result = execSync(
197
+ `osascript -e 'tell application "System Events" to count (every process whose name is "${escapeForAppleScript(appName)}")'`,
198
+ {
199
+ encoding: 'utf-8',
200
+ timeout: 3000,
201
+ stdio: ['pipe', 'pipe', 'pipe'],
202
+ },
203
+ );
204
+ return Number.parseInt(result.trim() || '0', 10) > 0;
205
+ }
170
206
  } else if (plat === 'win32') {
171
207
  const winProcesses = getWinProcessNames()[ideId];
172
208
  if (!winProcesses) return false;
@@ -350,7 +386,8 @@ export async function launchWithCdp(options: LaunchOptions = {}): Promise<Launch
350
386
 
351
387
  // Wait for CDP to enable (max 15 seconds)
352
388
  let cdpReady = false;
353
- for (let i = 0; i < 30; i++) {
389
+ const waitDeadline = Date.now() + getCdpStartupTimeoutMs(targetIde.id);
390
+ while (Date.now() < waitDeadline) {
354
391
  await new Promise(r => setTimeout(r, 500));
355
392
  if (await isCdpActive(port)) {
356
393
  cdpReady = true;
@@ -378,18 +415,27 @@ export async function launchWithCdp(options: LaunchOptions = {}): Promise<Launch
378
415
 
379
416
  async function launchMacOS(ide: IDEInfo, port: number, workspace?: string, newWindow?: boolean): Promise<void> {
380
417
  const appName = getMacAppIdentifiers()[ide.id];
418
+ const preferredMethod = getPreferredLaunchMethod(ide.id, 'darwin');
381
419
 
382
420
  const args = ['--remote-debugging-port=' + port];
383
421
  if (newWindow) args.push('--new-window');
384
422
  if (workspace) args.push(workspace);
385
423
 
386
- if (appName) {
387
- // 'open -a' execution (ensures GUI session)
388
- const openArgs = ['-a', appName, '--args', ...args];
389
- spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
390
- } else if (ide.cliCommand) {
424
+ const canUseCli = !!ide.cliCommand;
425
+ const canUseAppLauncher = !!appName;
426
+ const useAppLauncher = preferredMethod === 'app'
427
+ ? canUseAppLauncher
428
+ : preferredMethod === 'cli'
429
+ ? false
430
+ : !canUseCli && canUseAppLauncher;
431
+
432
+ if (!useAppLauncher && ide.cliCommand) {
391
433
  // CLI based execute
392
434
  spawn(ide.cliCommand, args, { detached: true, stdio: 'ignore' }).unref();
435
+ } else if (appName) {
436
+ // Fallback to `open -a` when no CLI wrapper is available or the provider prefers it.
437
+ const openArgs = ['-a', appName, '--args', ...args];
438
+ spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
393
439
  } else {
394
440
  throw new Error(`No app identifier or CLI for ${ide.displayName}`);
395
441
  }
@@ -16,9 +16,10 @@ import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
16
16
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
17
17
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
18
18
  import { StatusMonitor } from './status-monitor.js';
19
- import { ChatHistoryWriter } from '../config/chat-history.js';
19
+ import { ChatHistoryWriter, readChatHistory } from '../config/chat-history.js';
20
20
  import { LOG } from '../logging/logger.js';
21
21
  import type { ChatMessage } from '../types.js';
22
+ import { extractProviderControlValues, normalizeProviderEffects } from './control-effects.js';
22
23
 
23
24
  let CachedDatabaseSync: (new (path: string, options?: { readOnly?: boolean }) => {
24
25
  prepare(sql: string): { get(...params: Array<string | number>): unknown };
@@ -54,6 +55,8 @@ export class CliProviderInstance implements ProviderInstance {
54
55
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
55
56
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
56
57
  private lastApprovalEventAt = 0;
58
+ private controlValues: Record<string, string | number | boolean> = {};
59
+ private appliedEffectKeys = new Set<string>();
57
60
  private historyWriter: ChatHistoryWriter;
58
61
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
59
62
  readonly instanceId: string;
@@ -106,6 +109,7 @@ export class CliProviderInstance implements ProviderInstance {
106
109
  async init(context: InstanceContext): Promise<void> {
107
110
  this.context = context;
108
111
  this.settings = context.settings || {};
112
+ this.adapter.updateRuntimeSettings?.(this.settings);
109
113
  this.monitor.updateConfig({
110
114
  approvalAlert: this.settings.approvalAlert !== false,
111
115
  longGeneratingAlert: this.settings.longGeneratingAlert !== false,
@@ -129,6 +133,21 @@ export class CliProviderInstance implements ProviderInstance {
129
133
 
130
134
  // PTY spawn
131
135
  await this.adapter.spawn();
136
+ if (this.providerSessionId) {
137
+ const restoredHistory = readChatHistory(this.type, 0, 200, this.providerSessionId);
138
+ if (restoredHistory.messages.length > 0) {
139
+ this.adapter.seedCommittedMessages(
140
+ restoredHistory.messages.map((message) => ({
141
+ role: message.role,
142
+ content: message.content,
143
+ timestamp: message.receivedAt,
144
+ receivedAt: message.receivedAt,
145
+ kind: message.kind,
146
+ senderName: message.senderName,
147
+ })),
148
+ );
149
+ }
150
+ }
132
151
  if (this.providerSessionId && this.launchMode === 'resume') {
133
152
  const resumedAt = Date.now();
134
153
  this.historyWriter.appendSystemMarker(
@@ -228,6 +247,12 @@ export class CliProviderInstance implements ProviderInstance {
228
247
  }
229
248
  const runtime = this.adapter.getRuntimeMetadata();
230
249
  const parsedMessages = Array.isArray(parsedStatus?.messages) ? parsedStatus.messages : [];
250
+ const controlValues = extractProviderControlValues(this.provider.controls, parsedStatus);
251
+ if (controlValues) {
252
+ this.controlValues = controlValues;
253
+ } else if (Object.keys(this.controlValues).length > 0) {
254
+ this.controlValues = {};
255
+ }
231
256
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
232
257
 
233
258
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
@@ -251,6 +276,8 @@ export class CliProviderInstance implements ProviderInstance {
251
276
  }
252
277
  }
253
278
 
279
+ this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
280
+
254
281
  return {
255
282
  type: this.type,
256
283
  name: this.provider.name,
@@ -280,7 +307,7 @@ export class CliProviderInstance implements ProviderInstance {
280
307
  attachedClients: runtime.attachedClients || [],
281
308
  } : undefined,
282
309
  resume: this.provider.resume,
283
- controlValues: undefined, // CLI controls not yet wired from stream
310
+ controlValues: this.controlValues,
284
311
  providerControls: this.provider.controls as any,
285
312
  };
286
313
  }
@@ -294,6 +321,16 @@ export class CliProviderInstance implements ProviderInstance {
294
321
  return this.presentationMode;
295
322
  }
296
323
 
324
+ updateSettings(newSettings: Record<string, any>): void {
325
+ this.settings = { ...newSettings };
326
+ this.adapter.updateRuntimeSettings?.(this.settings);
327
+ this.monitor.updateConfig({
328
+ approvalAlert: this.settings.approvalAlert !== false,
329
+ longGeneratingAlert: this.settings.longGeneratingAlert !== false,
330
+ longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
331
+ });
332
+ }
333
+
297
334
  onEvent(event: string, data?: any): void {
298
335
  if (event === 'send_message' && data?.text) {
299
336
  void this.adapter.sendMessage(data.text).catch((e: any) => {
@@ -305,12 +342,15 @@ export class CliProviderInstance implements ProviderInstance {
305
342
  void this.adapter.resolveAction(data).catch((e: any) => {
306
343
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
307
344
  });
345
+ } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
346
+ this.applyProviderResponse(data, { phase: 'immediate' });
308
347
  }
309
348
  }
310
349
 
311
350
  dispose(): void {
312
351
  this.adapter.shutdown();
313
352
  this.monitor.reset();
353
+ this.appliedEffectKeys.clear();
314
354
  }
315
355
 
316
356
  private completedDebounceTimer: NodeJS.Timeout | null = null;
@@ -319,6 +359,7 @@ export class CliProviderInstance implements ProviderInstance {
319
359
  private detectStatusTransition(): void {
320
360
  const now = Date.now();
321
361
  const adapterStatus = this.adapter.getStatus();
362
+ const parsedStatus = this.adapter.getScriptParsedStatus?.() || null;
322
363
  const newStatus = adapterStatus.status;
323
364
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
324
365
  const chatTitle = `${this.provider.name} · ${dirName}`;
@@ -327,6 +368,7 @@ export class CliProviderInstance implements ProviderInstance {
327
368
  ? `${partial || ''}::${adapterStatus.messages.at(-1)?.content || ''}`.slice(-2000)
328
369
  : undefined;
329
370
 
371
+ const previousStatus = this.lastStatus;
330
372
  if (newStatus !== this.lastStatus) {
331
373
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
332
374
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
@@ -411,6 +453,12 @@ export class CliProviderInstance implements ProviderInstance {
411
453
  this.lastStatus = newStatus;
412
454
  }
413
455
 
456
+ this.applyProviderResponse(parsedStatus, {
457
+ phase: (newStatus === 'idle' && (previousStatus === 'generating' || previousStatus === 'waiting_approval'))
458
+ ? 'turn_completed'
459
+ : 'immediate',
460
+ });
461
+
414
462
  // Monitor check (cooldown based notification, IDE/CLI common)
415
463
  const agentKey = `${this.type}:cli`;
416
464
  const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
@@ -431,6 +479,104 @@ export class CliProviderInstance implements ProviderInstance {
431
479
  return events;
432
480
  }
433
481
 
482
+ private applyProviderResponse(data: any, options: { phase: 'immediate' | 'turn_completed' }): void {
483
+ if (!data || typeof data !== 'object') return;
484
+
485
+ const controlValues = extractProviderControlValues(this.provider.controls, data);
486
+ if (controlValues) {
487
+ this.controlValues = { ...this.controlValues, ...controlValues };
488
+ }
489
+
490
+ const effects = normalizeProviderEffects(data);
491
+ for (const effect of effects) {
492
+ const effectWhen = effect.when || 'immediate';
493
+ if (effectWhen === 'turn_completed' && options.phase !== 'turn_completed') continue;
494
+ if (effectWhen === 'immediate' && options.phase === 'turn_completed') continue;
495
+
496
+ const effectKey = this.getEffectDedupKey(effect);
497
+ if (this.appliedEffectKeys.has(effectKey)) continue;
498
+ this.appliedEffectKeys.add(effectKey);
499
+
500
+ if (effect.persist !== false) {
501
+ const persisted = this.getPersistedEffectContent(effect);
502
+ if (persisted) this.appendRuntimeSystemMessage(persisted, effectKey);
503
+ }
504
+
505
+ if (effect.type === 'message' && effect.message) {
506
+ const content = typeof effect.message.content === 'string'
507
+ ? effect.message.content
508
+ : JSON.stringify(effect.message.content);
509
+ this.pushEvent({
510
+ event: 'provider:message',
511
+ timestamp: Date.now(),
512
+ content,
513
+ role: effect.message.role || 'system',
514
+ kind: effect.message.kind,
515
+ senderName: effect.message.senderName,
516
+ });
517
+ } else if (effect.type === 'toast' && effect.toast) {
518
+ this.pushEvent({
519
+ event: 'provider:toast',
520
+ effectId: effect.id || effectKey,
521
+ timestamp: Date.now(),
522
+ message: effect.toast.message,
523
+ level: effect.toast.level || 'info',
524
+ });
525
+ } else if (effect.type === 'notification' && effect.notification) {
526
+ this.pushEvent({
527
+ event: 'provider:notification',
528
+ effectId: effect.id || effectKey,
529
+ timestamp: Date.now(),
530
+ title: effect.notification.title,
531
+ message: effect.notification.body,
532
+ content: typeof effect.notification.bubbleContent === 'string'
533
+ ? effect.notification.bubbleContent
534
+ : effect.notification.body,
535
+ level: effect.notification.level || 'info',
536
+ channels: effect.notification.channels || ['toast'],
537
+ preferenceKey: effect.notification.preferenceKey,
538
+ });
539
+ }
540
+ }
541
+
542
+ if (this.appliedEffectKeys.size > 200) {
543
+ this.appliedEffectKeys = new Set(Array.from(this.appliedEffectKeys).slice(-100));
544
+ }
545
+ }
546
+
547
+ private getEffectDedupKey(effect: { id?: string; type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string } }): string {
548
+ if (effect.id) return `provider_effect:${effect.id}`;
549
+ if (effect.type === 'message') {
550
+ const content = typeof effect.message?.content === 'string'
551
+ ? effect.message.content
552
+ : JSON.stringify(effect.message?.content || '');
553
+ return `provider_effect:message:${content}`;
554
+ }
555
+ if (effect.type === 'notification') {
556
+ return `provider_effect:notification:${effect.notification?.title || ''}:${effect.notification?.body || ''}`;
557
+ }
558
+ return `provider_effect:toast:${effect.toast?.message || ''}`;
559
+ }
560
+
561
+ private getPersistedEffectContent(effect: { type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string; bubbleContent?: unknown } }): string | null {
562
+ if (effect.type === 'message') {
563
+ return typeof effect.message?.content === 'string'
564
+ ? effect.message.content
565
+ : JSON.stringify(effect.message?.content || '');
566
+ }
567
+ if (effect.type === 'toast') {
568
+ return effect.toast?.message || null;
569
+ }
570
+ if (effect.type === 'notification') {
571
+ if (typeof effect.notification?.bubbleContent === 'string') return effect.notification.bubbleContent;
572
+ if (typeof effect.notification?.title === 'string' && effect.notification.title.trim()) {
573
+ return `${effect.notification.title}\n${effect.notification.body || ''}`.trim();
574
+ }
575
+ return effect.notification?.body || null;
576
+ }
577
+ return null;
578
+ }
579
+
434
580
  // ─── Adapter access (backward compat) ──────────────────
435
581
 
436
582
  getAdapter(): ProviderCliAdapter {
@@ -26,6 +26,10 @@ export interface ReadChatResult {
26
26
  inputContent?: string;
27
27
  model?: string;
28
28
  autoApprove?: string;
29
+ /** Explicit dynamic control values returned by the provider */
30
+ controlValues?: Record<string, string | number | boolean>;
31
+ /** Provider-driven UI effects derived from chat state */
32
+ effects?: ProviderEffect[];
29
33
  }
30
34
 
31
35
  import type { ChatMessage } from '../types.js';
@@ -46,6 +50,43 @@ export interface ModalInfo {
46
50
  height?: number;
47
51
  }
48
52
 
53
+ export interface ProviderEffectMessage {
54
+ role?: 'system' | 'assistant' | 'user';
55
+ content: string | ContentBlock[];
56
+ kind?: string;
57
+ senderName?: string;
58
+ }
59
+
60
+ export interface ProviderEffectToast {
61
+ level?: 'info' | 'success' | 'warning';
62
+ message: string;
63
+ }
64
+
65
+ export type ProviderNotificationPreferenceKey = 'disconnect' | 'completion' | 'approval' | 'browser';
66
+ export type ProviderNotificationChannel = 'bubble' | 'toast' | 'browser';
67
+
68
+ export interface ProviderEffectNotification {
69
+ title?: string;
70
+ body: string;
71
+ level?: 'info' | 'success' | 'warning';
72
+ channels?: ProviderNotificationChannel[];
73
+ preferenceKey?: ProviderNotificationPreferenceKey;
74
+ bubbleContent?: string | ContentBlock[];
75
+ }
76
+
77
+ export interface ProviderEffect {
78
+ type: 'message' | 'toast' | 'notification';
79
+ /** Stable dedup key; falls back to a content hash when omitted */
80
+ id?: string;
81
+ /** Default immediate. turn_completed fires only on generating/waiting -> idle transitions. */
82
+ when?: 'immediate' | 'turn_completed';
83
+ /** Default true. False keeps the effect UI-only. */
84
+ persist?: boolean;
85
+ message?: ProviderEffectMessage;
86
+ toast?: ProviderEffectToast;
87
+ notification?: ProviderEffectNotification;
88
+ }
89
+
49
90
  // ─── Rich Content Types (ACP Standard) ─────────────────
50
91
  // Based on ACP SDK v0.16.1 schema types.
51
92
  // All provider categories (ACP, IDE, Extension, CLI) use these as output standard.
@@ -290,13 +331,35 @@ export interface ProviderModule {
290
331
  versionCommand?: string;
291
332
  /** Versions tested by provider maintainer (informational) */
292
333
  testedVersions?: string[];
293
- /** Per-OS process names — used by launch.ts to detect/kill IDE processes */
334
+ /** Per-OS process names — used by launch.ts to detect/kill IDE processes */
294
335
  processNames?: {
295
336
  darwin?: string;
296
337
  win32?: string[];
297
338
  linux?: string[];
298
339
  [key: string]: string | string[] | undefined;
299
340
  };
341
+ /**
342
+ * IDE launch preferences.
343
+ * Lets each provider choose how its GUI app should be started per platform.
344
+ */
345
+ launch?: {
346
+ /**
347
+ * Preferred launch method by platform.
348
+ * - 'cli': use the IDE CLI wrapper/binary
349
+ * - 'app': use platform app launcher (e.g. `open -a` on macOS)
350
+ * - 'auto': let core choose a sensible default
351
+ */
352
+ prefer?: {
353
+ darwin?: 'auto' | 'cli' | 'app';
354
+ win32?: 'auto' | 'cli' | 'app';
355
+ linux?: 'auto' | 'cli' | 'app';
356
+ [key: string]: 'auto' | 'cli' | 'app' | undefined;
357
+ };
358
+ /**
359
+ * Override how long core waits for CDP to come up after launch.
360
+ */
361
+ cdpStartupTimeoutMs?: number;
362
+ };
300
363
  /** Per-OS install paths — used by detector.ts to detect IDE installation */
301
364
  paths?: {
302
365
  darwin?: string[];
@@ -580,7 +643,7 @@ export interface ProviderSettingSchema extends ProviderSettingDef {
580
643
  * - 'slider' — numeric range (temperature: 0–2)
581
644
  * - 'action' — one-shot button (show usage, restart, clear context)
582
645
  */
583
- export type ProviderControlType = 'select' | 'toggle' | 'cycle' | 'slider' | 'action';
646
+ export type ProviderControlType = 'select' | 'toggle' | 'cycle' | 'slider' | 'action' | 'display';
584
647
 
585
648
  /**
586
649
  * Where the control appears in the chat UI: