@adhdev/daemon-core 0.5.3

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 (217) hide show
  1. package/dist/index.d.ts +2662 -0
  2. package/dist/index.js +11341 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +48 -0
  5. package/providers/_builtin/.github/workflows/generate-registry.yml +57 -0
  6. package/providers/_builtin/COMPATIBILITY.md +217 -0
  7. package/providers/_builtin/CONTRIBUTING.md +200 -0
  8. package/providers/_builtin/README.md +119 -0
  9. package/providers/_builtin/_helpers/index.js +188 -0
  10. package/providers/_builtin/acp/agentpool/provider.json +54 -0
  11. package/providers/_builtin/acp/amp/provider.json +52 -0
  12. package/providers/_builtin/acp/auggie/provider.json +57 -0
  13. package/providers/_builtin/acp/autodev/provider.json +54 -0
  14. package/providers/_builtin/acp/autohand/provider.json +52 -0
  15. package/providers/_builtin/acp/blackbox-ai/provider.json +54 -0
  16. package/providers/_builtin/acp/claude-agent/provider.json +57 -0
  17. package/providers/_builtin/acp/cline-acp/provider.json +54 -0
  18. package/providers/_builtin/acp/codebuddy/provider.json +54 -0
  19. package/providers/_builtin/acp/codex-cli/provider.json +57 -0
  20. package/providers/_builtin/acp/corust-agent/provider.json +52 -0
  21. package/providers/_builtin/acp/crow-cli/provider.json +54 -0
  22. package/providers/_builtin/acp/cursor-acp/provider.json +54 -0
  23. package/providers/_builtin/acp/deepagents/provider.json +52 -0
  24. package/providers/_builtin/acp/dimcode/provider.json +54 -0
  25. package/providers/_builtin/acp/docker-cagent/provider.json +57 -0
  26. package/providers/_builtin/acp/factory-droid/provider.json +60 -0
  27. package/providers/_builtin/acp/fast-agent/provider.json +52 -0
  28. package/providers/_builtin/acp/gemini-cli/provider.json +114 -0
  29. package/providers/_builtin/acp/github-copilot/provider.json +54 -0
  30. package/providers/_builtin/acp/goose/provider.json +57 -0
  31. package/providers/_builtin/acp/junie/provider.json +52 -0
  32. package/providers/_builtin/acp/kilo/provider.json +54 -0
  33. package/providers/_builtin/acp/kimi-cli/provider.json +57 -0
  34. package/providers/_builtin/acp/minion-code/provider.json +52 -0
  35. package/providers/_builtin/acp/mistral-vibe/provider.json +57 -0
  36. package/providers/_builtin/acp/nova/provider.json +54 -0
  37. package/providers/_builtin/acp/openclaw/provider.json +54 -0
  38. package/providers/_builtin/acp/opencode/provider.json +52 -0
  39. package/providers/_builtin/acp/openhands/provider.json +54 -0
  40. package/providers/_builtin/acp/pi-acp/provider.json +52 -0
  41. package/providers/_builtin/acp/qoder/provider.json +54 -0
  42. package/providers/_builtin/acp/qwen-code/provider.json +60 -0
  43. package/providers/_builtin/acp/stakpak/provider.json +54 -0
  44. package/providers/_builtin/acp/vtcode/provider.json +54 -0
  45. package/providers/_builtin/cli/claude-cli/provider.json +100 -0
  46. package/providers/_builtin/cli/codex-cli/provider.json +89 -0
  47. package/providers/_builtin/cli/gemini-cli/provider.json +93 -0
  48. package/providers/_builtin/docs/CDP_SELECTOR_GUIDE.md +370 -0
  49. package/providers/_builtin/docs/PROVIDER_GUIDE.md +916 -0
  50. package/providers/_builtin/extension/cline/provider.json +35 -0
  51. package/providers/_builtin/extension/cline/scripts/focus_editor.js +48 -0
  52. package/providers/_builtin/extension/cline/scripts/list_chats.js +100 -0
  53. package/providers/_builtin/extension/cline/scripts/list_models.js +43 -0
  54. package/providers/_builtin/extension/cline/scripts/list_modes.js +35 -0
  55. package/providers/_builtin/extension/cline/scripts/new_session.js +85 -0
  56. package/providers/_builtin/extension/cline/scripts/open_panel.js +25 -0
  57. package/providers/_builtin/extension/cline/scripts/read_chat.js +257 -0
  58. package/providers/_builtin/extension/cline/scripts/resolve_action.js +83 -0
  59. package/providers/_builtin/extension/cline/scripts/send_message.js +95 -0
  60. package/providers/_builtin/extension/cline/scripts/set_mode.js +36 -0
  61. package/providers/_builtin/extension/cline/scripts/set_model.js +36 -0
  62. package/providers/_builtin/extension/cline/scripts/switch_session.js +206 -0
  63. package/providers/_builtin/extension/cline/scripts.js +73 -0
  64. package/providers/_builtin/extension/roo-code/provider.json +35 -0
  65. package/providers/_builtin/extension/roo-code/scripts.js +659 -0
  66. package/providers/_builtin/ide/antigravity/provider.json +68 -0
  67. package/providers/_builtin/ide/antigravity/scripts/1.106/focus_editor.js +20 -0
  68. package/providers/_builtin/ide/antigravity/scripts/1.106/list_chats.js +137 -0
  69. package/providers/_builtin/ide/antigravity/scripts/1.106/list_models.js +38 -0
  70. package/providers/_builtin/ide/antigravity/scripts/1.106/list_modes.js +48 -0
  71. package/providers/_builtin/ide/antigravity/scripts/1.106/new_session.js +75 -0
  72. package/providers/_builtin/ide/antigravity/scripts/1.106/read_chat.js +262 -0
  73. package/providers/_builtin/ide/antigravity/scripts/1.106/resolve_action.js +68 -0
  74. package/providers/_builtin/ide/antigravity/scripts/1.106/scripts.js +57 -0
  75. package/providers/_builtin/ide/antigravity/scripts/1.106/send_message.js +56 -0
  76. package/providers/_builtin/ide/antigravity/scripts/1.106/set_mode.js +34 -0
  77. package/providers/_builtin/ide/antigravity/scripts/1.106/set_model.js +47 -0
  78. package/providers/_builtin/ide/antigravity/scripts/1.106/switch_session.js +114 -0
  79. package/providers/_builtin/ide/antigravity/scripts/1.107/focus_editor.js +20 -0
  80. package/providers/_builtin/ide/antigravity/scripts/1.107/list_chats.js +137 -0
  81. package/providers/_builtin/ide/antigravity/scripts/1.107/list_models.js +61 -0
  82. package/providers/_builtin/ide/antigravity/scripts/1.107/list_modes.js +72 -0
  83. package/providers/_builtin/ide/antigravity/scripts/1.107/new_session.js +75 -0
  84. package/providers/_builtin/ide/antigravity/scripts/1.107/read_chat.js +262 -0
  85. package/providers/_builtin/ide/antigravity/scripts/1.107/resolve_action.js +68 -0
  86. package/providers/_builtin/ide/antigravity/scripts/1.107/scripts.js +67 -0
  87. package/providers/_builtin/ide/antigravity/scripts/1.107/send_message.js +56 -0
  88. package/providers/_builtin/ide/antigravity/scripts/1.107/set_mode.js +67 -0
  89. package/providers/_builtin/ide/antigravity/scripts/1.107/set_model.js +72 -0
  90. package/providers/_builtin/ide/antigravity/scripts/1.107/switch_session.js +114 -0
  91. package/providers/_builtin/ide/cursor/provider.json +70 -0
  92. package/providers/_builtin/ide/cursor/scripts/0.49/dismiss_notification.js +30 -0
  93. package/providers/_builtin/ide/cursor/scripts/0.49/focus_editor.js +13 -0
  94. package/providers/_builtin/ide/cursor/scripts/0.49/list_models.js +78 -0
  95. package/providers/_builtin/ide/cursor/scripts/0.49/list_modes.js +40 -0
  96. package/providers/_builtin/ide/cursor/scripts/0.49/list_notifications.js +23 -0
  97. package/providers/_builtin/ide/cursor/scripts/0.49/list_sessions.js +42 -0
  98. package/providers/_builtin/ide/cursor/scripts/0.49/new_session.js +20 -0
  99. package/providers/_builtin/ide/cursor/scripts/0.49/open_panel.js +23 -0
  100. package/providers/_builtin/ide/cursor/scripts/0.49/read_chat.js +75 -0
  101. package/providers/_builtin/ide/cursor/scripts/0.49/resolve_action.js +19 -0
  102. package/providers/_builtin/ide/cursor/scripts/0.49/scripts.js +78 -0
  103. package/providers/_builtin/ide/cursor/scripts/0.49/send_message.js +23 -0
  104. package/providers/_builtin/ide/cursor/scripts/0.49/set_mode.js +38 -0
  105. package/providers/_builtin/ide/cursor/scripts/0.49/set_model.js +81 -0
  106. package/providers/_builtin/ide/cursor/scripts/0.49/switch_session.js +28 -0
  107. package/providers/_builtin/ide/kiro/provider.json +67 -0
  108. package/providers/_builtin/ide/kiro/scripts/focus_editor.js +20 -0
  109. package/providers/_builtin/ide/kiro/scripts/open_panel.js +47 -0
  110. package/providers/_builtin/ide/kiro/scripts/resolve_action.js +54 -0
  111. package/providers/_builtin/ide/kiro/scripts/send_message.js +29 -0
  112. package/providers/_builtin/ide/kiro/scripts/webview_list_models.js +39 -0
  113. package/providers/_builtin/ide/kiro/scripts/webview_list_modes.js +39 -0
  114. package/providers/_builtin/ide/kiro/scripts/webview_list_sessions.js +21 -0
  115. package/providers/_builtin/ide/kiro/scripts/webview_new_session.js +34 -0
  116. package/providers/_builtin/ide/kiro/scripts/webview_read_chat.js +68 -0
  117. package/providers/_builtin/ide/kiro/scripts/webview_send_message.js +72 -0
  118. package/providers/_builtin/ide/kiro/scripts/webview_set_mode.js +15 -0
  119. package/providers/_builtin/ide/kiro/scripts/webview_set_model.js +15 -0
  120. package/providers/_builtin/ide/kiro/scripts/webview_switch_session.js +26 -0
  121. package/providers/_builtin/ide/kiro/scripts.js +62 -0
  122. package/providers/_builtin/ide/pearai/provider.json +67 -0
  123. package/providers/_builtin/ide/pearai/scripts/focus_editor.js +20 -0
  124. package/providers/_builtin/ide/pearai/scripts/list_sessions.js +38 -0
  125. package/providers/_builtin/ide/pearai/scripts/new_session.js +55 -0
  126. package/providers/_builtin/ide/pearai/scripts/open_panel.js +46 -0
  127. package/providers/_builtin/ide/pearai/scripts/resolve_action.js +54 -0
  128. package/providers/_builtin/ide/pearai/scripts/send_message.js +29 -0
  129. package/providers/_builtin/ide/pearai/scripts/webview_list_models.js +43 -0
  130. package/providers/_builtin/ide/pearai/scripts/webview_list_modes.js +35 -0
  131. package/providers/_builtin/ide/pearai/scripts/webview_list_sessions.js +62 -0
  132. package/providers/_builtin/ide/pearai/scripts/webview_new_session.js +49 -0
  133. package/providers/_builtin/ide/pearai/scripts/webview_read_chat.js +92 -0
  134. package/providers/_builtin/ide/pearai/scripts/webview_resolve_action.js +59 -0
  135. package/providers/_builtin/ide/pearai/scripts/webview_send_message.js +72 -0
  136. package/providers/_builtin/ide/pearai/scripts/webview_set_mode.js +36 -0
  137. package/providers/_builtin/ide/pearai/scripts/webview_set_model.js +36 -0
  138. package/providers/_builtin/ide/pearai/scripts/webview_switch_session.js +34 -0
  139. package/providers/_builtin/ide/pearai/scripts.js +74 -0
  140. package/providers/_builtin/ide/trae/provider.json +66 -0
  141. package/providers/_builtin/ide/trae/scripts/focus_editor.js +20 -0
  142. package/providers/_builtin/ide/trae/scripts/list_chats.js +24 -0
  143. package/providers/_builtin/ide/trae/scripts/list_models.js +39 -0
  144. package/providers/_builtin/ide/trae/scripts/list_modes.js +39 -0
  145. package/providers/_builtin/ide/trae/scripts/new_session.js +30 -0
  146. package/providers/_builtin/ide/trae/scripts/open_panel.js +44 -0
  147. package/providers/_builtin/ide/trae/scripts/read_chat.js +113 -0
  148. package/providers/_builtin/ide/trae/scripts/resolve_action.js +54 -0
  149. package/providers/_builtin/ide/trae/scripts/send_message.js +69 -0
  150. package/providers/_builtin/ide/trae/scripts/set_mode.js +15 -0
  151. package/providers/_builtin/ide/trae/scripts/set_model.js +15 -0
  152. package/providers/_builtin/ide/trae/scripts/switch_session.js +23 -0
  153. package/providers/_builtin/ide/trae/scripts.js +57 -0
  154. package/providers/_builtin/ide/vscode/provider.json +64 -0
  155. package/providers/_builtin/ide/vscode-insiders/provider.json +62 -0
  156. package/providers/_builtin/ide/vscodium/provider.json +63 -0
  157. package/providers/_builtin/ide/windsurf/provider.json +53 -0
  158. package/providers/_builtin/ide/windsurf/scripts/focus_editor.js +30 -0
  159. package/providers/_builtin/ide/windsurf/scripts/list_chats.js +117 -0
  160. package/providers/_builtin/ide/windsurf/scripts/list_models.js +39 -0
  161. package/providers/_builtin/ide/windsurf/scripts/list_modes.js +39 -0
  162. package/providers/_builtin/ide/windsurf/scripts/new_session.js +69 -0
  163. package/providers/_builtin/ide/windsurf/scripts/open_panel.js +58 -0
  164. package/providers/_builtin/ide/windsurf/scripts/read_chat.js +297 -0
  165. package/providers/_builtin/ide/windsurf/scripts/resolve_action.js +68 -0
  166. package/providers/_builtin/ide/windsurf/scripts/send_message.js +87 -0
  167. package/providers/_builtin/ide/windsurf/scripts/set_mode.js +15 -0
  168. package/providers/_builtin/ide/windsurf/scripts/set_model.js +15 -0
  169. package/providers/_builtin/ide/windsurf/scripts/switch_session.js +58 -0
  170. package/providers/_builtin/ide/windsurf/scripts.js +57 -0
  171. package/providers/_builtin/registry.json +266 -0
  172. package/providers/_builtin/validate.js +156 -0
  173. package/src/agent-stream/index.ts +6 -0
  174. package/src/agent-stream/manager.ts +286 -0
  175. package/src/agent-stream/poller.ts +154 -0
  176. package/src/agent-stream/provider-adapter.ts +138 -0
  177. package/src/agent-stream/types.ts +61 -0
  178. package/src/boot/daemon-lifecycle.ts +252 -0
  179. package/src/cdp/devtools.ts +335 -0
  180. package/src/cdp/initializer.ts +191 -0
  181. package/src/cdp/manager.ts +897 -0
  182. package/src/cdp/scanner.ts +185 -0
  183. package/src/cdp/setup.ts +150 -0
  184. package/src/cli-adapter-types.ts +25 -0
  185. package/src/cli-adapters/provider-cli-adapter.ts +448 -0
  186. package/src/commands/cdp-commands.ts +208 -0
  187. package/src/commands/chat-commands.ts +675 -0
  188. package/src/commands/cli-manager.ts +353 -0
  189. package/src/commands/handler.ts +328 -0
  190. package/src/commands/router.ts +258 -0
  191. package/src/commands/stream-commands.ts +325 -0
  192. package/src/config/chat-history.ts +211 -0
  193. package/src/config/config.ts +219 -0
  194. package/src/daemon/dev-server.ts +2378 -0
  195. package/src/daemon/scaffold-template.ts +394 -0
  196. package/src/daemon-core.ts +50 -0
  197. package/src/detection/cli-detector.ts +89 -0
  198. package/src/detection/ide-detector.ts +157 -0
  199. package/src/index.ts +103 -0
  200. package/src/installer.ts +263 -0
  201. package/src/ipc-protocol.ts +133 -0
  202. package/src/launch.ts +433 -0
  203. package/src/logging/command-log.ts +180 -0
  204. package/src/logging/logger.ts +316 -0
  205. package/src/providers/acp-provider-instance.ts +1140 -0
  206. package/src/providers/cli-provider-instance.ts +207 -0
  207. package/src/providers/contracts.ts +524 -0
  208. package/src/providers/extension-provider-instance.ts +156 -0
  209. package/src/providers/ide-provider-instance.ts +377 -0
  210. package/src/providers/index.ts +18 -0
  211. package/src/providers/provider-instance-manager.ts +182 -0
  212. package/src/providers/provider-instance.ts +112 -0
  213. package/src/providers/provider-loader.ts +1031 -0
  214. package/src/providers/status-monitor.ts +125 -0
  215. package/src/providers/version-archive.ts +266 -0
  216. package/src/status/reporter.ts +294 -0
  217. package/src/types.ts +206 -0
@@ -0,0 +1,2378 @@
1
+ /**
2
+ * Dev Server — HTTP API for Provider debugging + script development
3
+ *
4
+ * Enabled with `adhdev daemon --dev`
5
+ * Port: 19280 (fixed)
6
+ *
7
+ * API list:
8
+ * GET /api/providers — loaded provider list
9
+ * POST /api/providers/:type/script — specific script execute
10
+ * POST /api/cdp/evaluate — Execute JS expression
11
+ * POST /api/cdp/dom/query — Test selector
12
+ * GET /api/cdp/screenshot — screenshot
13
+ * POST /api/scripts/run — Execute provider script (name + params)
14
+ * GET /api/status — All status (CDP connection, provider etc)
15
+ */
16
+
17
+ import * as http from 'http';
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import * as os from 'os';
21
+ import type { ProviderLoader } from '../providers/provider-loader.js';
22
+ import type { ChildProcess } from 'child_process';
23
+ import type { DaemonCdpManager } from '../cdp/manager.js';
24
+ import { generateTemplate as genScaffoldTemplate, generateFiles as genScaffoldFiles } from './scaffold-template.js';
25
+ import { VersionArchive, detectAllVersions } from '../providers/version-archive.js';
26
+ import { LOG } from '../logging/logger.js';
27
+
28
+ export const DEV_SERVER_PORT = 19280;
29
+
30
+ export class DevServer {
31
+ private server: http.Server | null = null;
32
+ private providerLoader: ProviderLoader;
33
+ private cdpManagers: Map<string, DaemonCdpManager>;
34
+ private logFn: (msg: string) => void;
35
+ private sseClients: http.ServerResponse[] = [];
36
+ private watchScriptPath: string | null = null;
37
+ private watchScriptName: string | null = null;
38
+ private watchTimer: NodeJS.Timeout | null = null;
39
+
40
+ // Auto-implement state
41
+ private autoImplProcess: ChildProcess | null = null;
42
+ private autoImplSSEClients: http.ServerResponse[] = [];
43
+ private autoImplStatus: { running: boolean; type: string | null; progress: any[] } = { running: false, type: null, progress: [] };
44
+
45
+ constructor(options: {
46
+ providerLoader: ProviderLoader;
47
+ cdpManagers: Map<string, DaemonCdpManager>;
48
+ logFn?: (msg: string) => void;
49
+ }) {
50
+ this.providerLoader = options.providerLoader;
51
+ this.cdpManagers = options.cdpManagers;
52
+ this.logFn = options.logFn || LOG.forComponent('DevServer').asLogFn();
53
+ }
54
+
55
+ private log(msg: string): void {
56
+ this.logFn(`[DevServer] ${msg}`);
57
+ }
58
+
59
+ // ─── Route Table ─────────────────────────────────────
60
+ private readonly routes: {
61
+ method: string;
62
+ pattern: string | RegExp;
63
+ handler: (req: http.IncomingMessage, res: http.ServerResponse, params?: string[]) => Promise<void> | void;
64
+ }[] = [
65
+ // Static routes
66
+ { method: 'GET', pattern: '/api/providers', handler: (q, s) => this.handleListProviders(q, s) },
67
+ { method: 'GET', pattern: '/api/providers/versions', handler: (q, s) => this.handleDetectVersions(q, s) },
68
+ { method: 'POST', pattern: '/api/providers/reload', handler: (q, s) => this.handleReload(q, s) },
69
+ { method: 'POST', pattern: '/api/cdp/evaluate', handler: (q, s) => this.handleCdpEvaluate(q, s) },
70
+ { method: 'POST', pattern: '/api/cdp/dom/query', handler: (q, s) => this.handleCdpDomQuery(q, s) },
71
+ { method: 'POST', pattern: '/api/cdp/dom/inspect', handler: (q, s) => this.handleDomInspect(q, s) },
72
+ { method: 'POST', pattern: '/api/cdp/dom/children', handler: (q, s) => this.handleDomChildren(q, s) },
73
+ { method: 'POST', pattern: '/api/cdp/dom/analyze', handler: (q, s) => this.handleDomAnalyze(q, s) },
74
+ { method: 'POST', pattern: '/api/cdp/dom/find-text', handler: (q, s) => this.handleFindByText(q, s) },
75
+ { method: 'POST', pattern: '/api/cdp/dom/find-common', handler: (q, s) => this.handleFindCommon(q, s) },
76
+ { method: 'GET', pattern: '/api/cdp/screenshot', handler: (q, s) => this.handleScreenshot(q, s) },
77
+ { method: 'GET', pattern: '/api/cdp/targets', handler: (q, s) => this.handleCdpTargets(q, s) },
78
+ { method: 'POST', pattern: '/api/scripts/run', handler: (q, s) => this.handleScriptsRun(q, s) },
79
+ { method: 'GET', pattern: '/api/status', handler: (q, s) => this.handleStatus(q, s) },
80
+ { method: 'POST', pattern: '/api/watch/start', handler: (q, s) => this.handleWatchStart(q, s) },
81
+ { method: 'POST', pattern: '/api/watch/stop', handler: (q, s) => this.handleWatchStop(q, s) },
82
+ { method: 'GET', pattern: '/api/watch/events', handler: (q, s) => this.handleSSE(q, s) },
83
+ { method: 'POST', pattern: '/api/scaffold', handler: (q, s) => this.handleScaffold(q, s) },
84
+ // Dynamic routes (provider :type param)
85
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/script$/, handler: (q, s, p) => this.handleRunScript(p![0], q, s) },
86
+ { method: 'GET', pattern: /^\/api\/providers\/([^/]+)\/files$/, handler: (q, s, p) => this.handleListFiles(p![0], q, s) },
87
+ { method: 'GET', pattern: /^\/api\/providers\/([^/]+)\/file$/, handler: (q, s, p) => this.handleReadFile(p![0], q, s) },
88
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/file$/, handler: (q, s, p) => this.handleWriteFile(p![0], q, s) },
89
+ { method: 'GET', pattern: /^\/api\/providers\/([^/]+)\/source$/, handler: (q, s, p) => this.handleSource(p![0], q, s) },
90
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/save$/, handler: (q, s, p) => this.handleSave(p![0], q, s) },
91
+ { method: 'GET', pattern: /^\/api\/providers\/([^/]+)\/config$/, handler: (q, s, p) => this.handleProviderConfig(p![0], q, s) },
92
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/dom-context$/, handler: (q, s, p) => this.handleDomContext(p![0], q, s) },
93
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/auto-implement$/, handler: (q, s, p) => this.handleAutoImplement(p![0], q, s) },
94
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/auto-implement\/cancel$/,handler: (q, s, p) => this.handleAutoImplCancel(p![0], q, s) },
95
+ { method: 'GET', pattern: /^\/api\/providers\/([^/]+)\/auto-implement\/status$/,handler: (q, s, p) => this.handleAutoImplSSE(p![0], q, s) },
96
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/spawn-test$/, handler: (q, s, p) => this.handleSpawnTest(p![0], q, s) },
97
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/validate$/, handler: (q, s, p) => this.handleValidate(p![0], q, s) },
98
+ { method: 'POST', pattern: /^\/api\/providers\/([^/]+)\/acp-chat$/, handler: (q, s, p) => this.handleAcpChat(p![0], q, s) },
99
+ { method: 'GET', pattern: /^\/api\/providers\/([^/]+)\/script-hints$/, handler: (q, s, p) => this.handleScriptHints(p![0], q, s) },
100
+ ];
101
+
102
+ private matchRoute(method: string, pathname: string): { handler: (req: http.IncomingMessage, res: http.ServerResponse, params?: string[]) => Promise<void> | void; params?: string[] } | null {
103
+ for (const route of this.routes) {
104
+ if (route.method !== method) continue;
105
+ if (typeof route.pattern === 'string') {
106
+ if (pathname === route.pattern) return { handler: route.handler };
107
+ } else {
108
+ const m = pathname.match(route.pattern);
109
+ if (m) return { handler: route.handler, params: m.slice(1) };
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ private getEndpointList(): string[] {
116
+ return this.routes.map(r => {
117
+ const path = typeof r.pattern === 'string'
118
+ ? r.pattern
119
+ : r.pattern.source.replace(/\\\//g, '/').replace(/\(\[.*?\]\+\)/g, ':type').replace(/[\^$]/g, '');
120
+ return `${r.method.padEnd(5)} ${path}`;
121
+ });
122
+ }
123
+
124
+ async start(port = DEV_SERVER_PORT): Promise<void> {
125
+ this.server = http.createServer(async (req, res) => {
126
+ // CORS
127
+ res.setHeader('Access-Control-Allow-Origin', '*');
128
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
129
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
130
+
131
+ if (req.method === 'OPTIONS') {
132
+ res.writeHead(200);
133
+ res.end();
134
+ return;
135
+ }
136
+
137
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
138
+ const pathname = url.pathname;
139
+
140
+ try {
141
+ // ─── Route Table ───
142
+ const route = this.matchRoute(req.method || 'GET', pathname);
143
+ if (route) {
144
+ await route.handler(req, res, route.params);
145
+ } else if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') {
146
+ await this.serveStaticAsset(pathname, res);
147
+ } else if (pathname === '/' || pathname === '/console' || !pathname.startsWith('/api')) {
148
+ await this.serveConsole(req, res);
149
+ } else {
150
+ this.json(res, 404, { error: 'Not found', endpoints: this.getEndpointList() });
151
+ }
152
+ } catch (e: any) {
153
+ this.log(`Error: ${e.message}`);
154
+ this.json(res, 500, { error: e.message });
155
+ }
156
+ });
157
+
158
+ return new Promise((resolve, reject) => {
159
+ this.server!.listen(port, '127.0.0.1', () => {
160
+ this.log(`Dev server listening on http://127.0.0.1:${port}`);
161
+ resolve();
162
+ });
163
+ this.server!.on('error', (e: any) => {
164
+ if (e.code === 'EADDRINUSE') {
165
+ this.log(`Port ${port} in use, skipping dev server`);
166
+ resolve(); // non-fatal
167
+ } else {
168
+ reject(e);
169
+ }
170
+ });
171
+ });
172
+ }
173
+
174
+ stop(): void {
175
+ this.server?.close();
176
+ this.server = null;
177
+ }
178
+
179
+ // ─── Handlers ───
180
+
181
+ private async handleListProviders(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
182
+ const providers = this.providerLoader.getAll().map(p => {
183
+ const base: any = {
184
+ type: p.type,
185
+ name: p.name,
186
+ category: p.category,
187
+ icon: (p as any).icon || null,
188
+ displayName: (p as any).displayName || p.name,
189
+ };
190
+
191
+ // IDE/Extension specific
192
+ if (p.category === 'ide' || p.category === 'extension') {
193
+ base.scripts = p.scripts ? Object.keys(p.scripts).filter(k => typeof (p.scripts as any)[k] === 'function') : [];
194
+ base.inputMethod = p.inputMethod || null;
195
+ base.inputSelector = (p as any).inputSelector || null;
196
+ base.extensionId = p.extensionId || null;
197
+ base.cdpPorts = (p as any).cdpPorts || [];
198
+ }
199
+
200
+ // ACP specific
201
+ if (p.category === 'acp') {
202
+ base.spawn = (p as any).spawn || null;
203
+ base.auth = (p as any).auth || null;
204
+ base.install = (p as any).install || null;
205
+ base.hasSettings = !!(p as any).settings;
206
+ base.settingsCount = (p as any).settings ? Object.keys((p as any).settings).length : 0;
207
+ }
208
+
209
+ // CLI specific
210
+ if (p.category === 'cli') {
211
+ base.spawn = (p as any).spawn || null;
212
+ base.install = (p as any).install || null;
213
+ }
214
+
215
+ return base;
216
+ });
217
+ this.json(res, 200, { providers, count: providers.length });
218
+ }
219
+
220
+ private async handleProviderConfig(type: string, _req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
221
+ const provider = this.providerLoader.resolve(type);
222
+ if (!provider) {
223
+ this.json(res, 404, { error: `Provider not found: ${type}` });
224
+ return;
225
+ }
226
+ // Return full config (sans functions being serialized, just keys)
227
+ const config: any = { ...provider };
228
+ // Convert scripts to list of names
229
+ if (config.scripts) {
230
+ config.scriptNames = Object.keys(config.scripts).filter(k => typeof config.scripts[k] === 'function');
231
+ delete config.scripts;
232
+ }
233
+ this.json(res, 200, { type, config });
234
+ }
235
+
236
+ private async handleSpawnTest(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
237
+ const provider = this.providerLoader.resolve(type);
238
+ if (!provider) {
239
+ this.json(res, 404, { error: `Provider not found: ${type}` });
240
+ return;
241
+ }
242
+
243
+ const spawn = (provider as any).spawn;
244
+ if (!spawn) {
245
+ this.json(res, 400, { error: `Provider ${type} has no spawn config` });
246
+ return;
247
+ }
248
+
249
+ const { spawn: spawnFn } = await import('child_process');
250
+ const start = Date.now();
251
+ try {
252
+ const child = spawnFn(spawn.command, [...(spawn.args || [])], {
253
+ shell: spawn.shell ?? false,
254
+ timeout: 5000,
255
+ stdio: ['pipe', 'pipe', 'pipe'],
256
+ });
257
+
258
+ let stdout = '';
259
+ let stderr = '';
260
+ child.stdout?.on('data', (d: Buffer) => { stdout += d.toString().slice(0, 2000); });
261
+ child.stderr?.on('data', (d: Buffer) => { stderr += d.toString().slice(0, 2000); });
262
+
263
+ // Wait for first output or exit (max 3s)
264
+ await new Promise<void>((resolve) => {
265
+ const timer = setTimeout(() => { child.kill(); resolve(); }, 3000);
266
+ child.on('exit', () => { clearTimeout(timer); resolve(); });
267
+ child.stdout?.once('data', () => { setTimeout(() => { child.kill(); clearTimeout(timer); resolve(); }, 500); });
268
+ });
269
+
270
+ const elapsed = Date.now() - start;
271
+ this.json(res, 200, {
272
+ success: true,
273
+ command: `${spawn.command} ${(spawn.args || []).join(' ')}`,
274
+ elapsed,
275
+ stdout: stdout.trim(),
276
+ stderr: stderr.trim(),
277
+ exitCode: child.exitCode,
278
+ });
279
+ } catch (e: any) {
280
+ const elapsed = Date.now() - start;
281
+ this.json(res, 200, {
282
+ success: false,
283
+ command: `${spawn.command} ${(spawn.args || []).join(' ')}`,
284
+ elapsed,
285
+ error: e.message,
286
+ });
287
+ }
288
+ }
289
+
290
+ private async handleRunScript(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
291
+ const body = await this.readBody(req);
292
+ const { script: scriptName, params, ideType: scriptIdeType } = body;
293
+
294
+ const provider = this.providerLoader.resolve(type);
295
+ if (!provider) {
296
+ this.json(res, 404, { error: `Provider '${type}' not found` });
297
+ return;
298
+ }
299
+
300
+ const fn = (provider.scripts as any)?.[scriptName];
301
+ if (typeof fn !== 'function') {
302
+ this.json(res, 400, { error: `Script '${scriptName}' not found in provider '${type}'`, available: provider.scripts ? Object.keys(provider.scripts) : [] });
303
+ return;
304
+ }
305
+
306
+ const cdp = this.getCdp(scriptIdeType);
307
+ if (!cdp) {
308
+ this.json(res, 503, { error: 'No CDP connection available' });
309
+ return;
310
+ }
311
+
312
+ try {
313
+ // Pass all params to script (flexible parameter support)
314
+ // Backward compat: legacy single argument scripts (sendMessage(text)) also work
315
+ const scriptCode = params ? fn(params) : fn();
316
+ if (!scriptCode) {
317
+ this.json(res, 500, { error: 'Script function returned null' });
318
+ return;
319
+ }
320
+
321
+ // Execute webview script via evaluateInWebviewFrame
322
+ const isWebviewScript = scriptName.toLowerCase().includes('webview');
323
+ let raw: any;
324
+ if (isWebviewScript) {
325
+ const matchText = provider.webviewMatchText;
326
+ const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
327
+ raw = await cdp.evaluateInWebviewFrame(scriptCode, matchFn);
328
+ } else {
329
+ raw = await cdp.evaluate(scriptCode, 30000);
330
+ }
331
+
332
+ let result = raw;
333
+ if (typeof raw === 'string') {
334
+ try { result = JSON.parse(raw); } catch { /* keep */ }
335
+ }
336
+ this.json(res, 200, { type, script: scriptName, result });
337
+ } catch (e: any) {
338
+ this.json(res, 500, { error: `Script execution failed: ${e.message}` });
339
+ }
340
+ }
341
+
342
+ private async handleCdpEvaluate(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
343
+ const body = await this.readBody(req);
344
+ const { expression, timeout, ideType } = body;
345
+ if (!expression) {
346
+ this.json(res, 400, { error: 'expression required' });
347
+ return;
348
+ }
349
+
350
+ const cdp = ideType ? this.cdpManagers.get(ideType) : this.getAnyCdp();
351
+ if (!cdp?.isConnected) {
352
+ this.json(res, 503, { error: 'No CDP connection available' });
353
+ return;
354
+ }
355
+
356
+ try {
357
+ const raw = await cdp.evaluate(expression, timeout || 30000);
358
+ let result = raw;
359
+ if (typeof raw === 'string') {
360
+ try { result = JSON.parse(raw); } catch { /* keep */ }
361
+ }
362
+ this.json(res, 200, { result });
363
+ } catch (e: any) {
364
+ this.json(res, 500, { error: e.message });
365
+ }
366
+ }
367
+
368
+ private async handleCdpDomQuery(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
369
+ const body = await this.readBody(req);
370
+ const { selector, limit = 10, ideType } = body;
371
+ if (!selector) {
372
+ this.json(res, 400, { error: 'selector required' });
373
+ return;
374
+ }
375
+
376
+ const cdp = this.getCdp(ideType as string);
377
+ if (!cdp) {
378
+ this.json(res, 503, { error: 'No CDP connection available' });
379
+ return;
380
+ }
381
+
382
+ const expr = `(() => {
383
+ try {
384
+ const els = document.querySelectorAll('${selector.replace(/'/g, "\\'")}');
385
+ const results = [];
386
+ for (let i = 0; i < Math.min(els.length, ${limit}); i++) {
387
+ const el = els[i];
388
+ results.push({
389
+ index: i,
390
+ tag: el.tagName?.toLowerCase(),
391
+ id: el.id || null,
392
+ class: el.className && typeof el.className === 'string' ? el.className.trim().slice(0, 200) : null,
393
+ role: el.getAttribute?.('role') || null,
394
+ text: (el.textContent || '').trim().slice(0, 100),
395
+ visible: el.offsetParent !== null || el.offsetWidth > 0,
396
+ rect: (() => { try { const r = el.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }; } catch { return null; } })()
397
+ });
398
+ }
399
+ return JSON.stringify({ total: els.length, results });
400
+ } catch (e) { return JSON.stringify({ error: e.message }); }
401
+ })()`;
402
+
403
+ try {
404
+ const raw = await cdp.evaluate(expr, 10000);
405
+ const result = typeof raw === 'string' ? JSON.parse(raw) : raw;
406
+ this.json(res, 200, result);
407
+ } catch (e: any) {
408
+ this.json(res, 500, { error: e.message });
409
+ }
410
+ }
411
+
412
+ private async handleScreenshot(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
413
+ const url = new URL(req.url || '/', 'http://localhost');
414
+ const ideType = url.searchParams.get('ideType') || undefined;
415
+ const cdp = this.getCdp(ideType);
416
+ if (!cdp) {
417
+ this.json(res, 503, { error: 'No CDP connection available' });
418
+ return;
419
+ }
420
+
421
+ try {
422
+ // Get viewport metrics before capturing
423
+ let vpW = 0, vpH = 0;
424
+ try {
425
+ const metrics = await cdp.send('Page.getLayoutMetrics', {}, 3000);
426
+ const vp = metrics?.cssVisualViewport || metrics?.visualViewport;
427
+ if (vp) {
428
+ vpW = Math.round(vp.clientWidth || vp.width || 0);
429
+ vpH = Math.round(vp.clientHeight || vp.height || 0);
430
+ }
431
+ } catch { /* ignore */ }
432
+
433
+ const buf = await cdp.captureScreenshot();
434
+ if (buf) {
435
+ res.writeHead(200, {
436
+ 'Content-Type': 'image/webp',
437
+ 'X-Viewport-Width': String(vpW),
438
+ 'X-Viewport-Height': String(vpH),
439
+ });
440
+ res.end(buf);
441
+ } else {
442
+ this.json(res, 500, { error: 'Screenshot failed' });
443
+ }
444
+ } catch (e: any) {
445
+ this.json(res, 500, { error: e.message });
446
+ }
447
+ }
448
+
449
+ private async handleScriptsRun(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
450
+ const body = await this.readBody(req);
451
+ const { type, script: scriptName, params } = body;
452
+ if (!type || !scriptName) {
453
+ this.json(res, 400, { error: 'type and script required' });
454
+ return;
455
+ }
456
+ // Delegate to handleRunScript
457
+ await this.handleRunScript(type, req, res);
458
+ }
459
+
460
+ private async handleStatus(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
461
+ const providers = this.providerLoader.getAll().map(p => ({
462
+ type: p.type, name: p.name, category: p.category,
463
+ }));
464
+
465
+ const cdpStatus: Record<string, { connected: boolean }> = {};
466
+ for (const [key, cdp] of this.cdpManagers.entries()) {
467
+ cdpStatus[key] = { connected: cdp.isConnected };
468
+ }
469
+
470
+ this.json(res, 200, {
471
+ devMode: true,
472
+ providers,
473
+ cdp: cdpStatus,
474
+ uptime: process.uptime(),
475
+ });
476
+ }
477
+
478
+ private async handleReload(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
479
+ try {
480
+ this.providerLoader.reload();
481
+ const providers = this.providerLoader.getAll().map(p => ({
482
+ type: p.type, name: p.name, category: p.category,
483
+ }));
484
+ this.json(res, 200, { reloaded: true, providers });
485
+ } catch (e: any) {
486
+ this.json(res, 500, { error: e.message });
487
+ }
488
+ }
489
+
490
+ // ─── DevConsole SPA ───
491
+
492
+ private getConsoleDistDir(): string | null {
493
+ // Try to find web-devconsole/dist (Vite build output)
494
+ const candidates = [
495
+ path.resolve(__dirname, '../../web-devconsole/dist'),
496
+ path.resolve(__dirname, '../../../web-devconsole/dist'),
497
+ path.join(process.cwd(), 'packages/web-devconsole/dist'),
498
+ ];
499
+ for (const dir of candidates) {
500
+ if (fs.existsSync(path.join(dir, 'index.html'))) return dir;
501
+ }
502
+ return null;
503
+ }
504
+
505
+ private async serveConsole(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
506
+ const distDir = this.getConsoleDistDir();
507
+ if (!distDir) {
508
+ this.json(res, 500, { error: 'DevConsole not found. Run: npm run build -w packages/web-devconsole' });
509
+ return;
510
+ }
511
+ const htmlPath = path.join(distDir, 'index.html');
512
+ try {
513
+ const html = fs.readFileSync(htmlPath, 'utf-8');
514
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
515
+ res.end(html);
516
+ } catch (e: any) {
517
+ this.json(res, 500, { error: `Cannot read index.html: ${e.message}` });
518
+ }
519
+ }
520
+
521
+ // ─── Static Assets ───
522
+
523
+ private static MIME_MAP: Record<string, string> = {
524
+ '.css': 'text/css; charset=utf-8',
525
+ '.js': 'application/javascript; charset=utf-8',
526
+ '.png': 'image/png',
527
+ '.svg': 'image/svg+xml',
528
+ '.ico': 'image/x-icon',
529
+ '.woff': 'font/woff',
530
+ '.woff2': 'font/woff2',
531
+ };
532
+
533
+ private async serveStaticAsset(pathname: string, res: http.ServerResponse): Promise<void> {
534
+ const distDir = this.getConsoleDistDir();
535
+ if (!distDir) {
536
+ this.json(res, 404, { error: 'Not found' });
537
+ return;
538
+ }
539
+ // Prevent directory traversal
540
+ const safePath = path.normalize(pathname).replace(/^\.\.\//, '');
541
+ const filePath = path.join(distDir, safePath);
542
+ if (!filePath.startsWith(distDir)) {
543
+ this.json(res, 403, { error: 'Forbidden' });
544
+ return;
545
+ }
546
+ try {
547
+ const content = fs.readFileSync(filePath);
548
+ const ext = path.extname(filePath);
549
+ const contentType = DevServer.MIME_MAP[ext] || 'application/octet-stream';
550
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable' });
551
+ res.end(content);
552
+ } catch {
553
+ this.json(res, 404, { error: 'Not found' });
554
+ }
555
+ }
556
+
557
+ // ─── Watch Mode (SSE) ───
558
+
559
+ private handleSSE(_req: http.IncomingMessage, res: http.ServerResponse): void {
560
+ res.writeHead(200, {
561
+ 'Content-Type': 'text/event-stream',
562
+ 'Cache-Control': 'no-cache',
563
+ 'Connection': 'keep-alive',
564
+ 'Access-Control-Allow-Origin': '*',
565
+ });
566
+ res.write('data: {"type":"connected"}\n\n');
567
+ this.sseClients.push(res);
568
+ _req.on('close', () => {
569
+ this.sseClients = this.sseClients.filter(c => c !== res);
570
+ });
571
+ }
572
+
573
+ private sendSSE(data: any): void {
574
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
575
+ for (const client of this.sseClients) {
576
+ try { client.write(msg); } catch { /* ignore */ }
577
+ }
578
+ }
579
+
580
+ private async handleWatchStart(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
581
+ const body = await this.readBody(req);
582
+ const { type, script: scriptName, interval = 2000 } = body;
583
+ if (!type) {
584
+ this.json(res, 400, { error: 'type required' });
585
+ return;
586
+ }
587
+
588
+ this.watchScriptPath = type;
589
+ this.watchScriptName = scriptName || 'readChat';
590
+
591
+ // Stop any existing watch
592
+ if (this.watchTimer) clearInterval(this.watchTimer);
593
+
594
+ this.log(`Watch started: ${type} → ${this.watchScriptName} (every ${interval}ms)`);
595
+ this.sendSSE({ type: 'watch_started', provider: type, script: this.watchScriptName });
596
+
597
+ const runWatch = async () => {
598
+ if (!this.watchScriptPath) return;
599
+ const provider = this.providerLoader.resolve(this.watchScriptPath);
600
+ if (!provider) {
601
+ this.sendSSE({ type: 'watch_error', error: `Provider '${this.watchScriptPath}' not found` });
602
+ return;
603
+ }
604
+ const fn = (provider.scripts as any)?.[this.watchScriptName!];
605
+ if (typeof fn !== 'function') {
606
+ this.sendSSE({ type: 'watch_error', error: `Script '${this.watchScriptName}' not found` });
607
+ return;
608
+ }
609
+ const cdp = this.getAnyCdp();
610
+ if (!cdp) {
611
+ this.sendSSE({ type: 'watch_error', error: 'No CDP connection' });
612
+ return;
613
+ }
614
+ try {
615
+ const script = fn();
616
+ const start = Date.now();
617
+ const raw = await cdp.evaluate(script, 15000);
618
+ const elapsed = Date.now() - start;
619
+ let result = raw;
620
+ if (typeof raw === 'string') {
621
+ try { result = JSON.parse(raw); } catch { /* keep */ }
622
+ }
623
+ this.sendSSE({ type: 'watch_result', provider: type, script: this.watchScriptName, result, elapsed });
624
+ } catch (e: any) {
625
+ this.sendSSE({ type: 'watch_error', error: e.message });
626
+ }
627
+ };
628
+
629
+ // Run immediately then on interval
630
+ runWatch();
631
+ this.watchTimer = setInterval(runWatch, Math.max(interval, 500));
632
+
633
+ this.json(res, 200, { watching: true, type, script: this.watchScriptName, interval });
634
+ }
635
+
636
+ private async handleWatchStop(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
637
+ if (this.watchTimer) {
638
+ clearInterval(this.watchTimer);
639
+ this.watchTimer = null;
640
+ }
641
+ this.watchScriptPath = null;
642
+ this.watchScriptName = null;
643
+ this.sendSSE({ type: 'watch_stopped' });
644
+ this.json(res, 200, { watching: false });
645
+ }
646
+
647
+ // ─── Provider File Explorer ───
648
+
649
+ /** Find the provider directory on disk */
650
+ private findProviderDir(type: string): string | null {
651
+ const provider = this.providerLoader.getMeta(type);
652
+ if (!provider) return null;
653
+ const cat = provider.category;
654
+ const builtinDir = (this.providerLoader as any).builtinDir || path.resolve(__dirname, '../providers/_builtin');
655
+ const userDir = path.join(os.homedir(), '.adhdev', 'providers');
656
+
657
+ // Direct match first
658
+ const directCandidates = [
659
+ path.join(userDir, type),
660
+ path.join(builtinDir, cat, type),
661
+ path.join(builtinDir, type),
662
+ ];
663
+ for (const d of directCandidates) {
664
+ if (fs.existsSync(path.join(d, 'provider.json'))) return d;
665
+ }
666
+
667
+ // Scan category dir for matching type field
668
+ const catDir = path.join(builtinDir, cat);
669
+ if (fs.existsSync(catDir)) {
670
+ try {
671
+ for (const entry of fs.readdirSync(catDir, { withFileTypes: true })) {
672
+ if (!entry.isDirectory()) continue;
673
+ const jsonPath = path.join(catDir, entry.name, 'provider.json');
674
+ if (fs.existsSync(jsonPath)) {
675
+ try {
676
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
677
+ if (data.type === type) return path.join(catDir, entry.name);
678
+ } catch { /* skip */ }
679
+ }
680
+ }
681
+ } catch { /* skip */ }
682
+ }
683
+ return null;
684
+ }
685
+
686
+ /** GET /api/providers/:type/files — list all files in provider directory */
687
+ private async handleListFiles(type: string, _req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
688
+ const dir = this.findProviderDir(type);
689
+ if (!dir) { this.json(res, 404, { error: `Provider directory not found: ${type}` }); return; }
690
+
691
+ const files: { path: string; size: number; type: 'file' | 'dir' }[] = [];
692
+ const scan = (d: string, prefix: string) => {
693
+ try {
694
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
695
+ if (entry.name.startsWith('.') || entry.name.endsWith('.bak')) continue;
696
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
697
+ if (entry.isDirectory()) {
698
+ files.push({ path: rel, size: 0, type: 'dir' });
699
+ scan(path.join(d, entry.name), rel);
700
+ } else {
701
+ const stat = fs.statSync(path.join(d, entry.name));
702
+ files.push({ path: rel, size: stat.size, type: 'file' });
703
+ }
704
+ }
705
+ } catch { /* ignore */ }
706
+ };
707
+ scan(dir, '');
708
+ this.json(res, 200, { type, dir, files });
709
+ }
710
+
711
+ /** GET /api/providers/:type/file?path=scripts.js — read a file */
712
+ private async handleReadFile(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
713
+ const url = new URL(req.url || '/', 'http://localhost');
714
+ const filePath = url.searchParams.get('path');
715
+ if (!filePath) { this.json(res, 400, { error: 'path query param required' }); return; }
716
+
717
+ const dir = this.findProviderDir(type);
718
+ if (!dir) { this.json(res, 404, { error: `Provider directory not found: ${type}` }); return; }
719
+
720
+ // Prevent directory traversal
721
+ const fullPath = path.resolve(dir, path.normalize(filePath));
722
+ if (!fullPath.startsWith(dir)) { this.json(res, 403, { error: 'Forbidden' }); return; }
723
+ if (!fs.existsSync(fullPath) || fs.statSync(fullPath).isDirectory()) {
724
+ this.json(res, 404, { error: `File not found: ${filePath}` }); return;
725
+ }
726
+
727
+ const content = fs.readFileSync(fullPath, 'utf-8');
728
+ this.json(res, 200, { type, path: filePath, content, lines: content.split('\n').length });
729
+ }
730
+
731
+ /** POST /api/providers/:type/file — write a file { path, content } */
732
+ private async handleWriteFile(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
733
+ const body = await this.readBody(req);
734
+ const { path: filePath, content } = body;
735
+ if (!filePath || typeof content !== 'string') {
736
+ this.json(res, 400, { error: 'path and content required' }); return;
737
+ }
738
+
739
+ const dir = this.findProviderDir(type);
740
+ if (!dir) { this.json(res, 404, { error: `Provider directory not found: ${type}` }); return; }
741
+
742
+ const fullPath = path.resolve(dir, path.normalize(filePath));
743
+ if (!fullPath.startsWith(dir)) { this.json(res, 403, { error: 'Forbidden' }); return; }
744
+
745
+ try {
746
+ if (fs.existsSync(fullPath)) fs.copyFileSync(fullPath, fullPath + '.bak');
747
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
748
+ fs.writeFileSync(fullPath, content, 'utf-8');
749
+ this.log(`File saved: ${fullPath} (${content.length} chars)`);
750
+ this.providerLoader.reload();
751
+ this.json(res, 200, { saved: true, path: filePath, chars: content.length });
752
+ } catch (e: any) {
753
+ this.json(res, 500, { error: `Save failed: ${e.message}` });
754
+ }
755
+ }
756
+
757
+ // ─── Legacy Source/Save compat ───
758
+
759
+ private async handleSource(type: string, _req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
760
+ const dir = this.findProviderDir(type);
761
+ if (!dir) { this.json(res, 404, { error: `Provider not found: ${type}` }); return; }
762
+ for (const name of ['scripts.js', 'provider.json']) {
763
+ const p = path.join(dir, name);
764
+ if (fs.existsSync(p)) {
765
+ const source = fs.readFileSync(p, 'utf-8');
766
+ this.json(res, 200, { type, path: p, source, lines: source.split('\n').length });
767
+ return;
768
+ }
769
+ }
770
+ this.json(res, 404, { error: `Source file not found for '${type}'` });
771
+ }
772
+
773
+ private async handleSave(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
774
+ const body = await this.readBody(req);
775
+ const { source } = body;
776
+ if (!source || typeof source !== 'string') {
777
+ this.json(res, 400, { error: 'source (string) required' }); return;
778
+ }
779
+ const dir = this.findProviderDir(type);
780
+ if (!dir) { this.json(res, 404, { error: `Provider not found: ${type}` }); return; }
781
+ // Save to scripts.js if it exists, otherwise provider.json
782
+ const target = fs.existsSync(path.join(dir, 'scripts.js')) ? 'scripts.js' : 'provider.json';
783
+ const targetPath = path.join(dir, target);
784
+ try {
785
+ if (fs.existsSync(targetPath)) fs.copyFileSync(targetPath, targetPath + '.bak');
786
+ fs.writeFileSync(targetPath, source, 'utf-8');
787
+ this.log(`Saved provider: ${targetPath} (${source.length} chars)`);
788
+ this.providerLoader.reload();
789
+ this.json(res, 200, { saved: true, path: targetPath, chars: source.length });
790
+ } catch (e: any) {
791
+ this.json(res, 500, { error: `Save failed: ${e.message}` });
792
+ }
793
+ }
794
+
795
+ private async handleScriptHints(type: string, _req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
796
+ const dir = this.findProviderDir(type);
797
+ if (!dir) { this.json(res, 404, { error: `Provider not found: ${type}` }); return; }
798
+
799
+ // Find scripts.js in the provider dir (may be versioned)
800
+ let scriptsPath = '';
801
+ const directScripts = path.join(dir, 'scripts.js');
802
+ if (fs.existsSync(directScripts)) {
803
+ scriptsPath = directScripts;
804
+ } else {
805
+ // Check versioned scripts dirs
806
+ const scriptsDir = path.join(dir, 'scripts');
807
+ if (fs.existsSync(scriptsDir)) {
808
+ const versions = fs.readdirSync(scriptsDir).filter(d => {
809
+ return fs.statSync(path.join(scriptsDir, d)).isDirectory();
810
+ }).sort().reverse();
811
+ for (const ver of versions) {
812
+ const p = path.join(scriptsDir, ver, 'scripts.js');
813
+ if (fs.existsSync(p)) { scriptsPath = p; break; }
814
+ }
815
+ }
816
+ }
817
+
818
+ if (!scriptsPath) {
819
+ this.json(res, 200, { hints: {} });
820
+ return;
821
+ }
822
+
823
+ try {
824
+ const source = fs.readFileSync(scriptsPath, 'utf-8');
825
+ const hints: Record<string, { template: Record<string, any>; description: string }> = {};
826
+
827
+ // Parse exported functions and extract param usage
828
+ const funcRegex = /module\.exports\.(\w+)\s*=\s*function\s+\w+\s*\(params\)/g;
829
+ let match;
830
+ while ((match = funcRegex.exec(source)) !== null) {
831
+ const name = match[1];
832
+ // Find the function body (rough: from match to next module.exports or end)
833
+ const startIdx = match.index;
834
+ const nextFunc = source.indexOf('module.exports.', startIdx + 1);
835
+ const funcBody = source.substring(startIdx, nextFunc > 0 ? nextFunc : source.length);
836
+
837
+ const paramFields: Record<string, any> = {};
838
+
839
+ // Pattern 1: params?.xxx or params.xxx
840
+ const dotRegex = /params\?\.([a-zA-Z_]+)|params\.([a-zA-Z_]+)/g;
841
+ let dm;
842
+ while ((dm = dotRegex.exec(funcBody)) !== null) {
843
+ const field = dm[1] || dm[2];
844
+ if (field === 'length') continue;
845
+ if (!(field in paramFields)) {
846
+ // Infer type from context
847
+ if (/index|count|port|timeout/i.test(field)) paramFields[field] = 0;
848
+ else if (/action|text|title|message|model|mode|button|name|filter/i.test(field)) paramFields[field] = '';
849
+ else paramFields[field] = '';
850
+ }
851
+ }
852
+
853
+ // Pattern 2: typeof params === 'string' ? params : params?.xxx
854
+ const typeofRegex = /typeof params === 'string' \? params : params\?\.([a-zA-Z_]+)/g;
855
+ let tm;
856
+ while ((tm = typeofRegex.exec(funcBody)) !== null) {
857
+ const field = tm[1];
858
+ if (!(field in paramFields)) paramFields[field] = '';
859
+ }
860
+
861
+ // Pattern 3: typeof params === 'number' ? params : params?.xxx
862
+ const numRegex = /typeof params === 'number' \? params : params\?\.([a-zA-Z_]+)/g;
863
+ let nm;
864
+ while ((nm = numRegex.exec(funcBody)) !== null) {
865
+ const field = nm[1];
866
+ if (!(field in paramFields)) paramFields[field] = 0;
867
+ }
868
+
869
+ // Determine description from function name
870
+ const descriptions: Record<string, string> = {
871
+ readChat: 'No params required',
872
+ sendMessage: 'Text to send to the chat',
873
+ listSessions: 'No params required',
874
+ switchSession: 'Switch by index or title',
875
+ newSession: 'No params required',
876
+ focusEditor: 'No params required',
877
+ openPanel: 'No params required',
878
+ resolveAction: 'Approve/reject action buttons',
879
+ listNotifications: 'Optional message filter',
880
+ dismissNotification: 'Dismiss by index, message, or button',
881
+ listModels: 'No params required',
882
+ setModel: 'Model name to select',
883
+ listModes: 'No params required',
884
+ setMode: 'Mode name to select',
885
+ };
886
+
887
+ hints[name] = {
888
+ template: Object.keys(paramFields).length > 0 ? paramFields : {},
889
+ description: descriptions[name] || (Object.keys(paramFields).length > 0 ? 'Params: ' + Object.keys(paramFields).join(', ') : 'No params'),
890
+ };
891
+ }
892
+
893
+ this.json(res, 200, { hints });
894
+ } catch (e: any) {
895
+ this.json(res, 500, { error: e.message });
896
+ }
897
+ }
898
+
899
+ // ─── Validate provider.json ───
900
+ private async handleValidate(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
901
+ const body = await this.readBody(req);
902
+ const { content } = body;
903
+ const errors: string[] = [];
904
+ const warnings: string[] = [];
905
+ try {
906
+ const config = typeof content === 'string' ? JSON.parse(content) : content;
907
+ // Required fields
908
+ if (!config.type) errors.push('Missing required field: type');
909
+ if (!config.name) errors.push('Missing required field: name');
910
+ if (!config.category) errors.push('Missing required field: category');
911
+ else if (!['ide', 'extension', 'cli', 'acp'].includes(config.category)) errors.push(`Invalid category: ${config.category}`);
912
+ // Category-specific
913
+ if (config.category === 'ide' || config.category === 'extension') {
914
+ if (!config.cdpPorts || !Array.isArray(config.cdpPorts) || config.cdpPorts.length === 0)
915
+ warnings.push('IDE/Extension providers should have cdpPorts');
916
+ if (config.category === 'extension' && !config.extensionId)
917
+ warnings.push('Extension providers should have extensionId');
918
+ }
919
+ if (config.category === 'acp' || config.category === 'cli') {
920
+ if (!config.spawn) errors.push('ACP/CLI providers must have spawn config');
921
+ else {
922
+ if (!config.spawn.command) errors.push('spawn.command is required');
923
+ }
924
+ }
925
+ // Settings validation
926
+ if (config.settings) {
927
+ for (const [key, val] of Object.entries(config.settings)) {
928
+ const s = val as any;
929
+ if (!s.type) errors.push(`settings.${key}: missing type`);
930
+ else if (!['boolean', 'number', 'string', 'select'].includes(s.type))
931
+ errors.push(`settings.${key}: invalid type '${s.type}'`);
932
+ if (s.default === undefined) warnings.push(`settings.${key}: no default value`);
933
+ if (s.type === 'number' && s.min !== undefined && s.max !== undefined && s.min > s.max)
934
+ errors.push(`settings.${key}: min (${s.min}) > max (${s.max})`);
935
+ if (s.type === 'select' && (!s.options || !Array.isArray(s.options) || s.options.length === 0))
936
+ errors.push(`settings.${key}: select type requires options[]`);
937
+ }
938
+ }
939
+ // Port conflicts
940
+ if (config.cdpPorts && Array.isArray(config.cdpPorts)) {
941
+ const allProviders = this.providerLoader.getAll();
942
+ for (const port of config.cdpPorts) {
943
+ const conflict = allProviders.find(p => p.type !== type && (p as any).cdpPorts?.includes(port));
944
+ if (conflict) warnings.push(`CDP port ${port} conflicts with provider '${conflict.type}'`);
945
+ }
946
+ }
947
+ this.json(res, 200, { valid: errors.length === 0, errors, warnings });
948
+ } catch (e: any) {
949
+ this.json(res, 200, { valid: false, errors: [`Invalid JSON: ${e.message}`], warnings: [] });
950
+ }
951
+ }
952
+
953
+ // ─── ACP Chat Test ───
954
+ private async handleAcpChat(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
955
+ const body = await this.readBody(req);
956
+ const { message, timeout = 30000 } = body;
957
+ if (!message) { this.json(res, 400, { error: 'message required' }); return; }
958
+ const provider = this.providerLoader.getMeta(type);
959
+ if (!provider) { this.json(res, 404, { error: `Provider not found: ${type}` }); return; }
960
+ const spawn = (provider as any).spawn;
961
+ if (!spawn) { this.json(res, 400, { error: `Provider ${type} has no spawn config` }); return; }
962
+
963
+ const { spawn: spawnFn } = await import('child_process');
964
+ const start = Date.now();
965
+ try {
966
+ const args = [...(spawn.args || []), message];
967
+ const child = spawnFn(spawn.command, args, {
968
+ shell: spawn.shell ?? false,
969
+ timeout: timeout,
970
+ stdio: ['pipe', 'pipe', 'pipe'],
971
+ env: { ...process.env, ...(spawn.env || {}) },
972
+ });
973
+
974
+ let stdout = '';
975
+ let stderr = '';
976
+ child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
977
+ child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
978
+
979
+ await new Promise<void>((resolve) => {
980
+ const timer = setTimeout(() => { child.kill(); resolve(); }, timeout);
981
+ child.on('exit', () => { clearTimeout(timer); resolve(); });
982
+ });
983
+
984
+ const elapsed = Date.now() - start;
985
+ this.json(res, 200, {
986
+ success: true,
987
+ message,
988
+ response: stdout.trim(),
989
+ stderr: stderr.trim(),
990
+ exitCode: child.exitCode,
991
+ elapsed,
992
+ });
993
+ } catch (e: any) {
994
+ this.json(res, 200, {
995
+ success: false,
996
+ message,
997
+ error: e.message,
998
+ elapsed: Date.now() - start,
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+
1004
+ private async handleCdpTargets(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1005
+ const targets: { ide: string; connected: boolean; port: number }[] = [];
1006
+ for (const [ide, cdp] of this.cdpManagers.entries()) {
1007
+ targets.push({ ide, connected: cdp.isConnected, port: cdp.getPort() });
1008
+ }
1009
+ this.json(res, 200, { targets });
1010
+ }
1011
+
1012
+ // ─── Scaffold ───
1013
+
1014
+ private async handleScaffold(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1015
+ const body = await this.readBody(req);
1016
+ const { type, name, category = 'ide', location = 'user',
1017
+ cdpPorts, cli, processName, installPath, binary, extensionId, version } = body;
1018
+ if (!type || !name) {
1019
+ this.json(res, 400, { error: 'type and name required' });
1020
+ return;
1021
+ }
1022
+
1023
+ let targetDir: string;
1024
+ if (location === 'user') {
1025
+ targetDir = path.join(os.homedir(), '.adhdev', 'providers', type);
1026
+ } else {
1027
+ const builtinDir = path.resolve(__dirname, '../providers/_builtin');
1028
+ targetDir = path.join(builtinDir, category, type);
1029
+ }
1030
+
1031
+ const jsonPath = path.join(targetDir, 'provider.json');
1032
+ if (fs.existsSync(jsonPath)) {
1033
+ this.json(res, 409, { error: `Provider already exists at ${targetDir}`, path: targetDir });
1034
+ return;
1035
+ }
1036
+
1037
+ try {
1038
+ const result = genScaffoldFiles(type, name, category, { cdpPorts, cli, processName, installPath, binary, extensionId, version });
1039
+ fs.mkdirSync(targetDir, { recursive: true });
1040
+ fs.writeFileSync(jsonPath, result['provider.json'], 'utf-8');
1041
+ const createdFiles = ['provider.json'];
1042
+
1043
+ // Write per-function script files (new structure)
1044
+ if (result.files) {
1045
+ for (const [relPath, content] of Object.entries(result.files)) {
1046
+ const fullPath = path.join(targetDir, relPath);
1047
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1048
+ fs.writeFileSync(fullPath, content, 'utf-8');
1049
+ createdFiles.push(relPath);
1050
+ }
1051
+ }
1052
+
1053
+ this.log(`Scaffolded provider: ${targetDir} (${createdFiles.length} files)`);
1054
+ this.json(res, 201, { created: true, path: targetDir, files: createdFiles, type, name, category });
1055
+ } catch (e: any) {
1056
+ this.json(res, 500, { error: e.message });
1057
+ }
1058
+ }
1059
+
1060
+ // ─── Version Detection ───
1061
+
1062
+ private async handleDetectVersions(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1063
+ try {
1064
+ const archive = new VersionArchive();
1065
+ const results = await detectAllVersions(this.providerLoader, archive);
1066
+ const installed = results.filter(r => r.installed);
1067
+ const notInstalled = results.filter(r => !r.installed);
1068
+ this.json(res, 200, {
1069
+ total: results.length,
1070
+ installed: installed.length,
1071
+ providers: results,
1072
+ history: archive.getAll(),
1073
+ });
1074
+ } catch (e: any) {
1075
+ this.json(res, 500, { error: e.message });
1076
+ }
1077
+ }
1078
+
1079
+ // ─── DOM Inspector ───
1080
+
1081
+ private async handleDomInspect(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1082
+ const body = await this.readBody(req);
1083
+ const { x, y, selector, ideType } = body;
1084
+ const cdp = this.getCdp(ideType);
1085
+ if (!cdp) { this.json(res, 503, { error: 'No CDP connection' }); return; }
1086
+
1087
+ const selectorArg = selector ? JSON.stringify(selector) : 'null';
1088
+ const inspectScript = `(() => {
1089
+ function gs(el) {
1090
+ if (!el || el === document.body) return 'body';
1091
+ if (el.id) return '#' + CSS.escape(el.id);
1092
+ let s = el.tagName.toLowerCase();
1093
+ if (el.className && typeof el.className === 'string') {
1094
+ const cls = el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).slice(0, 3);
1095
+ if (cls.length) s += '.' + cls.map(c => CSS.escape(c)).join('.');
1096
+ }
1097
+ const p = el.parentElement;
1098
+ if (p) {
1099
+ const sibs = [...p.children].filter(c => c.tagName === el.tagName);
1100
+ if (sibs.length > 1) s += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')';
1101
+ }
1102
+ return s;
1103
+ }
1104
+ function gp(el) {
1105
+ const parts = [];
1106
+ let c = el;
1107
+ while (c && c !== document.documentElement) { parts.unshift(gs(c)); c = c.parentElement; }
1108
+ return parts;
1109
+ }
1110
+ function ni(el) {
1111
+ if (!el) return null;
1112
+ const tag = el.tagName?.toLowerCase() || '#text';
1113
+ const attrs = {};
1114
+ if (el.attributes) for (const a of el.attributes) if (a.name !== 'class' && a.name !== 'style') attrs[a.name] = a.value?.substring(0, 200);
1115
+ const cls = (el.className && typeof el.className === 'string') ? el.className.trim().split(/\\s+/).filter(Boolean).slice(0, 10) : [];
1116
+ const text = el.textContent?.trim().substring(0, 150) || '';
1117
+ const dt = [...(el.childNodes||[])].filter(n=>n.nodeType===3).map(n=>n.textContent.trim()).filter(Boolean).join(' ').substring(0,100);
1118
+ const cc = el.children?.length || 0;
1119
+ const r = el.getBoundingClientRect?.();
1120
+ return { tag, cls, attrs, text, directText: dt, childCount: cc, selector: gs(el), fullSelector: gp(el).join(' > '), rect: r ? {x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)} : null };
1121
+ }
1122
+ const sel = ${selectorArg};
1123
+ let el = sel ? document.querySelector(sel) : document.elementFromPoint(${x || 0}, ${y || 0});
1124
+ if (!el) return JSON.stringify({ error: 'No element found' });
1125
+ const info = ni(el);
1126
+ const ancestors = [];
1127
+ let pp = el.parentElement;
1128
+ while (pp && pp !== document.documentElement) {
1129
+ ancestors.push({ tag: pp.tagName.toLowerCase(), selector: gs(pp), cls: (pp.className && typeof pp.className === 'string') ? pp.className.trim().split(/\\s+/).slice(0,3) : [] });
1130
+ pp = pp.parentElement;
1131
+ }
1132
+ const children = [...(el.children||[])].slice(0,50).map(c => ni(c));
1133
+ return JSON.stringify({ element: info, ancestors: ancestors.reverse(), children });
1134
+ })()`;
1135
+
1136
+ try {
1137
+ const raw = await cdp.evaluate(inspectScript, 10000);
1138
+ let result = raw;
1139
+ if (typeof raw === 'string') { try { result = JSON.parse(raw as string); } catch { } }
1140
+ this.json(res, 200, result as Record<string, unknown>);
1141
+ } catch (e: any) {
1142
+ this.json(res, 500, { error: e.message });
1143
+ }
1144
+ }
1145
+
1146
+ private async handleDomChildren(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1147
+ const body = await this.readBody(req);
1148
+ const { selector, ideType } = body;
1149
+ const cdp = this.getCdp(ideType);
1150
+ if (!cdp) { this.json(res, 503, { error: 'No CDP connection' }); return; }
1151
+ if (!selector) { this.json(res, 400, { error: 'selector required' }); return; }
1152
+
1153
+ const script = `(() => {
1154
+ function gs(el) {
1155
+ if (!el || el === document.body) return 'body';
1156
+ if (el.id) return '#' + CSS.escape(el.id);
1157
+ let s = el.tagName.toLowerCase();
1158
+ if (el.className && typeof el.className === 'string') {
1159
+ const cls = el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).slice(0, 3);
1160
+ if (cls.length) s += '.' + cls.map(c => CSS.escape(c)).join('.');
1161
+ }
1162
+ const p = el.parentElement;
1163
+ if (p) {
1164
+ const sibs = [...p.children].filter(c => c.tagName === el.tagName);
1165
+ if (sibs.length > 1) s += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')';
1166
+ }
1167
+ return s;
1168
+ }
1169
+ const el = document.querySelector(${JSON.stringify(selector)});
1170
+ if (!el) return JSON.stringify({ error: 'Element not found' });
1171
+ const children = [...(el.children||[])].slice(0,100).map(c => {
1172
+ const tag = c.tagName?.toLowerCase();
1173
+ const cls = (c.className && typeof c.className === 'string') ? c.className.trim().split(/\\s+/).filter(Boolean).slice(0,10) : [];
1174
+ const attrs = {};
1175
+ for (const a of c.attributes) if (a.name!=='class'&&a.name!=='style') attrs[a.name] = a.value?.substring(0,200);
1176
+ const text = c.textContent?.trim().substring(0,150)||'';
1177
+ const dt = [...c.childNodes].filter(n=>n.nodeType===3).map(n=>n.textContent.trim()).filter(Boolean).join(' ').substring(0,100);
1178
+ return { tag, cls, attrs, text, directText: dt, childCount: c.children?.length||0, selector: gs(c) };
1179
+ });
1180
+ return JSON.stringify({ selector: ${JSON.stringify(selector)}, childCount: el.children?.length||0, children });
1181
+ })()`;
1182
+
1183
+ try {
1184
+ const raw = await cdp.evaluate(script, 10000);
1185
+ let result = raw;
1186
+ if (typeof raw === 'string') { try { result = JSON.parse(raw as string); } catch { } }
1187
+ this.json(res, 200, result as Record<string, unknown>);
1188
+ } catch (e: any) {
1189
+ this.json(res, 500, { error: e.message });
1190
+ }
1191
+ }
1192
+
1193
+ private async handleDomAnalyze(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1194
+ const body = await this.readBody(req);
1195
+ const { ideType, selector, x, y } = body;
1196
+ const cdp = this.getCdp(ideType);
1197
+ if (!cdp) { this.json(res, 503, { error: 'No CDP connection' }); return; }
1198
+
1199
+ const selectorArg = selector ? JSON.stringify(selector) : 'null';
1200
+ const analyzeScript = `(() => {
1201
+ function gs(el) {
1202
+ if (!el || el === document.body) return 'body';
1203
+ if (el.id) return '#' + CSS.escape(el.id);
1204
+ let s = el.tagName.toLowerCase();
1205
+ if (el.className && typeof el.className === 'string') {
1206
+ const cls = el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).slice(0, 3);
1207
+ if (cls.length) s += '.' + cls.map(c => CSS.escape(c)).join('.');
1208
+ }
1209
+ return s;
1210
+ }
1211
+ function fp(el) {
1212
+ const parts = [];
1213
+ let c = el;
1214
+ while (c && c !== document.documentElement) { parts.unshift(gs(c)); c = c.parentElement; }
1215
+ return parts.join(' > ');
1216
+ }
1217
+ function sigOf(el) {
1218
+ return el.tagName + '|' + ((el.className && typeof el.className === 'string') ? el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).sort().join('.') : '');
1219
+ }
1220
+
1221
+ // Find target element
1222
+ const sel = ${selectorArg};
1223
+ let target = sel ? document.querySelector(sel) : document.elementFromPoint(${x || 0}, ${y || 0});
1224
+ if (!target) return JSON.stringify({ error: 'Element not found' });
1225
+
1226
+ const result = {
1227
+ target: { tag: target.tagName.toLowerCase(), selector: fp(target), text: (target.textContent||'').trim().substring(0, 200) },
1228
+ siblingPattern: null,
1229
+ ancestorAnalysis: [],
1230
+ subtreeTexts: [],
1231
+ };
1232
+
1233
+ // 1. Walk UP parents — at each level, find sibling patterns
1234
+ let el = target;
1235
+ let depth = 0;
1236
+ while (el && el !== document.body && depth < 15) {
1237
+ const parent = el.parentElement;
1238
+ if (!parent) break;
1239
+
1240
+ const mySig = sigOf(el);
1241
+ const siblings = [...parent.children].filter(c => sigOf(c) === mySig);
1242
+ const totalChildren = parent.children.length;
1243
+ const childSel = gs(el).replace(/:nth-child\\(\\d+\\)/, '');
1244
+ const parentSel = fp(parent);
1245
+
1246
+ result.ancestorAnalysis.push({
1247
+ depth,
1248
+ parentTag: parent.tagName.toLowerCase(),
1249
+ parentSelector: parentSel,
1250
+ totalChildren,
1251
+ matchingSiblings: siblings.length,
1252
+ childSelector: childSel,
1253
+ fullSelector: parentSel + ' > ' + childSel,
1254
+ });
1255
+
1256
+ // Best sibling pattern: 3+ matching siblings with text
1257
+ if (!result.siblingPattern && siblings.length >= 3) {
1258
+ const siblingData = siblings.map((s, i) => {
1259
+ const directText = [...s.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent.trim()).filter(Boolean).join(' ').substring(0, 120);
1260
+ const allText = (s.textContent || '').trim().substring(0, 200);
1261
+ const childCount = s.children?.length || 0;
1262
+ const cls = (s.className && typeof s.className === 'string') ? s.className.trim().split(/\\s+/).filter(Boolean) : [];
1263
+ const attrs = {};
1264
+ if (s.attributes) for (const a of s.attributes) {
1265
+ if (a.name !== 'class' && a.name !== 'style' && a.value) attrs[a.name] = a.value.substring(0, 100);
1266
+ }
1267
+ return { index: i, directText, allText, childCount, cls, attrs, tag: s.tagName.toLowerCase() };
1268
+ });
1269
+
1270
+ // Find common attributes across siblings
1271
+ const allAttrs = siblingData.map(s => Object.keys(s.attrs));
1272
+ const commonAttrs = allAttrs[0]?.filter(attr => allAttrs.every(a => a.includes(attr))) || [];
1273
+ // Find varying attributes (data-*, role, etc)
1274
+ const varyingAttrs = {};
1275
+ for (const attr of commonAttrs) {
1276
+ const values = siblingData.map(s => s.attrs[attr]);
1277
+ const unique = [...new Set(values)];
1278
+ if (unique.length > 1) varyingAttrs[attr] = unique.slice(0, 5);
1279
+ }
1280
+
1281
+ result.siblingPattern = {
1282
+ count: siblings.length,
1283
+ selector: parentSel + ' > ' + childSel,
1284
+ parentSelector: parentSel,
1285
+ depthFromTarget: depth,
1286
+ siblings: siblingData.slice(0, 30),
1287
+ commonAttrs,
1288
+ varyingAttrs,
1289
+ };
1290
+ }
1291
+
1292
+ el = parent;
1293
+ depth++;
1294
+ }
1295
+
1296
+ // 2. Collect subtree text nodes from target
1297
+ const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, null);
1298
+ let node;
1299
+ while ((node = walker.nextNode()) && result.subtreeTexts.length < 30) {
1300
+ const text = node.textContent.trim();
1301
+ if (text.length > 2) {
1302
+ const parentTag = node.parentElement?.tagName?.toLowerCase() || '';
1303
+ const parentCls = (node.parentElement?.className && typeof node.parentElement.className === 'string')
1304
+ ? node.parentElement.className.trim().split(/\\s+/).filter(Boolean).slice(0,3).join('.') : '';
1305
+ result.subtreeTexts.push({
1306
+ text: text.substring(0, 150),
1307
+ parentTag,
1308
+ parentCls,
1309
+ parentSelector: gs(node.parentElement),
1310
+ });
1311
+ }
1312
+ }
1313
+
1314
+ return JSON.stringify(result);
1315
+ })()`;
1316
+
1317
+ try {
1318
+ const raw = await cdp.evaluate(analyzeScript, 15000);
1319
+ let result = raw;
1320
+ if (typeof raw === 'string') { try { result = JSON.parse(raw as string); } catch { } }
1321
+ this.json(res, 200, result as Record<string, unknown>);
1322
+ } catch (e: any) {
1323
+ this.json(res, 500, { error: e.message });
1324
+ }
1325
+ }
1326
+
1327
+ private async handleFindCommon(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1328
+ const body = await this.readBody(req);
1329
+ const { include, exclude, ideType } = body;
1330
+ if (!Array.isArray(include) || include.length === 0) { this.json(res, 400, { error: 'include[] is required' }); return; }
1331
+ const cdp = this.getCdp(ideType);
1332
+ if (!cdp) { this.json(res, 503, { error: 'No CDP connection' }); return; }
1333
+
1334
+ const script = `(() => {
1335
+ const includes = ${JSON.stringify(include)};
1336
+ const excludes = ${JSON.stringify(exclude || [])};
1337
+
1338
+ function gs(el) {
1339
+ if (!el || el === document.body) return 'body';
1340
+ if (el.id) return '#' + CSS.escape(el.id);
1341
+ let s = el.tagName.toLowerCase();
1342
+ if (el.className && typeof el.className === 'string') {
1343
+ const cls = el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).slice(0, 3);
1344
+ if (cls.length) s += '.' + cls.map(c => CSS.escape(c)).join('.');
1345
+ }
1346
+ return s;
1347
+ }
1348
+ function fp(el) {
1349
+ const parts = [];
1350
+ let c = el;
1351
+ while (c && c !== document.documentElement) { parts.unshift(gs(c)); c = c.parentElement; }
1352
+ return parts.join(' > ');
1353
+ }
1354
+ function sig(el) {
1355
+ return el.tagName + '|' + ((el.className && typeof el.className === 'string') ? el.className.trim() : '');
1356
+ }
1357
+
1358
+ // Step 1: For each include, find all matching leaf elements
1359
+ const includeMatches = includes.map(text => {
1360
+ const lower = text.toLowerCase();
1361
+ const found = [];
1362
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
1363
+ acceptNode: n => n.textContent.toLowerCase().includes(lower) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
1364
+ });
1365
+ let node;
1366
+ while ((node = walker.nextNode()) && found.length < 5) {
1367
+ if (node.parentElement) found.push(node.parentElement);
1368
+ }
1369
+ return found;
1370
+ });
1371
+
1372
+ if (includeMatches.some(m => m.length === 0)) {
1373
+ const missing = includes.filter((_, i) => includeMatches[i].length === 0);
1374
+ return JSON.stringify({ results: [], message: 'Text not found: ' + missing.join(', ') });
1375
+ }
1376
+
1377
+ // Step 2: Find LCA for each combination of include elements
1378
+ // For each pair of include[0] element and include[1] element, find their LCA
1379
+ // Then within the LCA, find the direct-child subtree branch for each
1380
+ const containers = [];
1381
+ const seen = new Set();
1382
+
1383
+ function findLCA(el1, el2) {
1384
+ const ancestors1 = new Set();
1385
+ let c = el1;
1386
+ while (c) { ancestors1.add(c); c = c.parentElement; }
1387
+ c = el2;
1388
+ while (c) { if (ancestors1.has(c)) return c; c = c.parentElement; }
1389
+ return document.body;
1390
+ }
1391
+
1392
+ function findDirectChildContaining(parent, descendant) {
1393
+ let c = descendant;
1394
+ while (c && c.parentElement !== parent) c = c.parentElement;
1395
+ return c;
1396
+ }
1397
+
1398
+ // Try all combinations (first 3 matches per include)
1399
+ for (const el1 of includeMatches[0].slice(0, 3)) {
1400
+ for (let ii = 1; ii < includeMatches.length; ii++) {
1401
+ for (const el2 of includeMatches[ii].slice(0, 3)) {
1402
+ if (el1 === el2) continue;
1403
+ const lca = findLCA(el1, el2);
1404
+ if (!lca || lca === document.body || lca === document.documentElement) continue;
1405
+
1406
+ // Find which direct child of LCA contains each include element
1407
+ const child1 = findDirectChildContaining(lca, el1);
1408
+ const child2 = findDirectChildContaining(lca, el2);
1409
+ if (!child1 || !child2 || child1 === child2) continue;
1410
+
1411
+ const lcaSel = fp(lca);
1412
+ if (seen.has(lcaSel)) continue;
1413
+ seen.add(lcaSel);
1414
+
1415
+ // Check exclude
1416
+ if (excludes.length > 0) {
1417
+ const lcaText = (lca.textContent || '').toLowerCase();
1418
+ if (excludes.some(ex => lcaText.includes(ex.toLowerCase()))) continue;
1419
+ }
1420
+
1421
+ // Are child1 and child2 same tag? (relaxed — ignore classes)
1422
+ const tag1 = child1.tagName;
1423
+ const tag2 = child2.tagName;
1424
+
1425
+ // Bubble up: walk up from LCA, find the best list container
1426
+ // (the one with most repeating same-tag children)
1427
+ let container = lca;
1428
+ let bestContainer = lca;
1429
+ let bestListCount = 0;
1430
+ for (let up = 0; up < 10; up++) {
1431
+ const p = container.parentElement;
1432
+ if (!p || p === document.body || p === document.documentElement) break;
1433
+ // Check how many same-tag siblings 'container' has in parent
1434
+ const myTag = container.tagName;
1435
+ const sibCount = [...p.children].filter(c => c.tagName === myTag).length;
1436
+ if (sibCount > bestListCount) {
1437
+ bestListCount = sibCount;
1438
+ bestContainer = p;
1439
+ }
1440
+ container = p;
1441
+ }
1442
+ container = bestListCount >= 3 ? bestContainer : lca;
1443
+
1444
+ const allChildren = [...container.children];
1445
+ const childTag = tag1 === tag2 ? tag1 : (allChildren.length > 0 ? allChildren[0].tagName : '');
1446
+ const sameTagCount = allChildren.filter(c => c.tagName === childTag).length;
1447
+ const isList = sameTagCount >= 3 && sameTagCount >= allChildren.length * 0.4;
1448
+
1449
+ // Gather all same-tag children as list items
1450
+ const listItems = isList
1451
+ ? allChildren.filter(c => c.tagName === childTag)
1452
+ : allChildren;
1453
+
1454
+ // Filter rendered items (skip virtual scroll placeholders)
1455
+ const rendered = listItems.filter(c => (c.innerText || '').trim().length > 0);
1456
+ const placeholderCount = listItems.length - rendered.length;
1457
+
1458
+ const containerSel = fp(container);
1459
+ if (seen.has(containerSel)) continue;
1460
+ seen.add(containerSel);
1461
+
1462
+ const r = container.getBoundingClientRect();
1463
+ containers.push({
1464
+ selector: containerSel,
1465
+ tag: container.tagName.toLowerCase(),
1466
+ childCount: allChildren.length,
1467
+ listItemCount: listItems.length,
1468
+ renderedCount: rendered.length,
1469
+ placeholderCount,
1470
+ isList,
1471
+ rect: { w: Math.round(r.width), h: Math.round(r.height) },
1472
+ depth: containerSel.split(' > ').length,
1473
+ items: rendered.slice(0, 30).map((el, i) => {
1474
+ const fullText = (el.innerText || el.textContent || '').trim();
1475
+ // Find snippet around first matched include text
1476
+ let text = fullText.substring(0, 200);
1477
+ const matched = [];
1478
+ for (const inc of includes) {
1479
+ const idx = fullText.toLowerCase().indexOf(inc.toLowerCase());
1480
+ if (idx >= 0) {
1481
+ matched.push(inc);
1482
+ if (matched.length === 1) {
1483
+ // Show snippet around first match
1484
+ const start = Math.max(0, idx - 30);
1485
+ const end = Math.min(fullText.length, idx + inc.length + 80);
1486
+ text = (start > 0 ? '...' : '') + fullText.substring(start, end) + (end < fullText.length ? '...' : '');
1487
+ }
1488
+ }
1489
+ }
1490
+ return {
1491
+ index: i,
1492
+ tag: el.tagName.toLowerCase(),
1493
+ cls: (el.className && typeof el.className === 'string') ? el.className.trim().split(/\\s+/).slice(0, 2).join(' ') : '',
1494
+ text,
1495
+ matchedIncludes: matched,
1496
+ childCount: el.children.length,
1497
+ h: Math.round(el.getBoundingClientRect().height),
1498
+ };
1499
+ }),
1500
+ });
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ // Sort: list containers first (more items = better), then by depth
1506
+ containers.sort((a, b) => {
1507
+ if (a.isList !== b.isList) return a.isList ? -1 : 1;
1508
+ return b.listItemCount - a.listItemCount || b.depth - a.depth;
1509
+ });
1510
+
1511
+ return JSON.stringify({
1512
+ results: containers.slice(0, 10),
1513
+ includeCount: includes.length,
1514
+ excludeCount: excludes.length,
1515
+ });
1516
+ })()`;
1517
+
1518
+ try {
1519
+ const raw = await cdp.evaluate(script, 10000);
1520
+ let result = raw;
1521
+ if (typeof raw === 'string') { try { result = JSON.parse(raw as string); } catch { } }
1522
+ this.json(res, 200, result as Record<string, unknown>);
1523
+ } catch (e: any) {
1524
+ this.json(res, 500, { error: e.message });
1525
+ }
1526
+ }
1527
+
1528
+ private async handleFindByText(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1529
+ const body = await this.readBody(req);
1530
+ const { text, ideType, containerSelector } = body;
1531
+ if (!text || typeof text !== 'string') { this.json(res, 400, { error: 'text is required' }); return; }
1532
+ const cdp = this.getCdp(ideType);
1533
+ if (!cdp) { this.json(res, 503, { error: 'No CDP connection' }); return; }
1534
+
1535
+ const containerArg = containerSelector ? JSON.stringify(containerSelector) : 'null';
1536
+ const script = `(() => {
1537
+ function gs(el) {
1538
+ if (!el || el === document.body) return 'body';
1539
+ if (el.id) return '#' + CSS.escape(el.id);
1540
+ let s = el.tagName.toLowerCase();
1541
+ if (el.className && typeof el.className === 'string') {
1542
+ const cls = el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).slice(0, 3);
1543
+ if (cls.length) s += '.' + cls.map(c => CSS.escape(c)).join('.');
1544
+ }
1545
+ return s;
1546
+ }
1547
+ function fp(el) {
1548
+ const parts = [];
1549
+ let c = el;
1550
+ while (c && c !== document.documentElement) { parts.unshift(gs(c)); c = c.parentElement; }
1551
+ return parts.join(' > ');
1552
+ }
1553
+ function parentSig(el) {
1554
+ // Signature: tag+class chain up 3 levels
1555
+ const parts = [];
1556
+ let c = el;
1557
+ for (let i = 0; i < 3 && c; i++) { parts.push(gs(c)); c = c.parentElement; }
1558
+ return parts.join(' < ');
1559
+ }
1560
+
1561
+ const searchText = ${JSON.stringify(text)}.toLowerCase();
1562
+ const container = ${containerArg} ? document.querySelector(${containerArg}) : document.body;
1563
+ if (!container) return JSON.stringify({ error: 'Container not found' });
1564
+
1565
+ const matches = [];
1566
+ const seen = new Set();
1567
+
1568
+ // Find all text nodes containing the search text
1569
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
1570
+ acceptNode: n => n.textContent.toLowerCase().includes(searchText) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
1571
+ });
1572
+ let node;
1573
+ while ((node = walker.nextNode()) && matches.length < 50) {
1574
+ // Walk up to find the most specific visible element
1575
+ let el = node.parentElement;
1576
+ if (!el) continue;
1577
+
1578
+ // Skip hidden elements
1579
+ const r = el.getBoundingClientRect();
1580
+ if (r.width === 0 && r.height === 0) continue;
1581
+
1582
+ const selector = fp(el);
1583
+ if (seen.has(selector)) continue;
1584
+ seen.add(selector);
1585
+
1586
+ // Walk up parent chain — record each level's selector + sibling count
1587
+ const ancestors = [];
1588
+ let cur = el;
1589
+ let pLvl = cur.parentElement;
1590
+ for (let lvl = 0; lvl < 10 && pLvl && pLvl !== document.body; lvl++) {
1591
+ const mySig = cur.tagName + '|' + ((cur.className && typeof cur.className === 'string') ? cur.className.trim().split(/\\s+/).sort().join('.') : '');
1592
+ const sibs = [...pLvl.children].filter(c => {
1593
+ const sig = c.tagName + '|' + ((c.className && typeof c.className === 'string') ? c.className.trim().split(/\\s+/).sort().join('.') : '');
1594
+ return sig === mySig;
1595
+ });
1596
+ const childSel = gs(cur).replace(/:nth-child\\(\\d+\\)/, '');
1597
+ ancestors.push({
1598
+ parentSelector: fp(pLvl),
1599
+ childSelector: childSel,
1600
+ fullSelector: fp(pLvl) + ' > ' + childSel,
1601
+ siblingCount: sibs.length,
1602
+ parentTag: pLvl.tagName.toLowerCase(),
1603
+ });
1604
+ cur = pLvl;
1605
+ pLvl = pLvl.parentElement;
1606
+ }
1607
+
1608
+ const directText = (node.textContent || '').trim().substring(0, 200);
1609
+ const allText = (node.parentElement.textContent || '').trim().substring(0, 300);
1610
+ const tag = node.parentElement.tagName.toLowerCase();
1611
+ const cls = (node.parentElement.className && typeof node.parentElement.className === 'string')
1612
+ ? node.parentElement.className.trim().split(/\\s+/).filter(Boolean) : [];
1613
+
1614
+ matches.push({
1615
+ selector,
1616
+ tag,
1617
+ cls,
1618
+ directText,
1619
+ allText,
1620
+ ancestors,
1621
+ rect: { w: Math.round(r.width), h: Math.round(r.height) },
1622
+ depth: selector.split(' > ').length,
1623
+ });
1624
+ }
1625
+
1626
+ // Sort: prefer elements with more siblings in ancestry, then fewer depth
1627
+ matches.sort((a, b) => {
1628
+ const aMax = Math.max(1, ...a.ancestors.map(x => x.siblingCount));
1629
+ const bMax = Math.max(1, ...b.ancestors.map(x => x.siblingCount));
1630
+ return (bMax - aMax) || (a.depth - b.depth);
1631
+ });
1632
+
1633
+ return JSON.stringify({ query: ${JSON.stringify(text)}, matches, total: matches.length });
1634
+ })()`;
1635
+
1636
+ try {
1637
+ const raw = await cdp.evaluate(script, 10000);
1638
+ let result = raw;
1639
+ if (typeof raw === 'string') { try { result = JSON.parse(raw as string); } catch { } }
1640
+ this.json(res, 200, result as Record<string, unknown>);
1641
+ } catch (e: any) {
1642
+ this.json(res, 500, { error: e.message });
1643
+ }
1644
+ }
1645
+
1646
+ // ─── Phase 1: DOM Context API ───
1647
+
1648
+ private async handleDomContext(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1649
+ const body = await this.readBody(req);
1650
+ const { ideType } = body;
1651
+ const provider = this.providerLoader.resolve(type);
1652
+ if (!provider) { this.json(res, 404, { error: `Provider not found: ${type}` }); return; }
1653
+
1654
+ const cdp = this.getCdp(ideType || type);
1655
+ if (!cdp) { this.json(res, 503, { error: 'No CDP connection available. Target IDE must be running with CDP enabled.' }); return; }
1656
+
1657
+ try {
1658
+ // 1. Capture screenshot
1659
+ let screenshot: string | null = null;
1660
+ try {
1661
+ const buf = await cdp.captureScreenshot();
1662
+ if (buf) screenshot = buf.toString('base64');
1663
+ } catch { /* screenshot optional */ }
1664
+
1665
+ // 2. Collect DOM snapshot
1666
+ const domScript = `(() => {
1667
+ function gs(el) {
1668
+ if (!el || el === document.body) return 'body';
1669
+ if (el.id) return '#' + CSS.escape(el.id);
1670
+ let s = el.tagName.toLowerCase();
1671
+ if (el.className && typeof el.className === 'string') {
1672
+ const cls = el.className.trim().split(/\\s+/).filter(c => c && !c.startsWith('_')).slice(0, 3);
1673
+ if (cls.length) s += '.' + cls.map(c => CSS.escape(c)).join('.');
1674
+ }
1675
+ return s;
1676
+ }
1677
+ function fp(el) {
1678
+ const parts = [];
1679
+ let c = el;
1680
+ while (c && c !== document.documentElement) { parts.unshift(gs(c)); c = c.parentElement; }
1681
+ return parts.join(' > ');
1682
+ }
1683
+ function rect(el) {
1684
+ try { const r = el.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }; }
1685
+ catch { return null; }
1686
+ }
1687
+
1688
+ const result = { contentEditables: [], chatContainers: [], buttons: [], sidebars: [], dropdowns: [], inputs: [] };
1689
+
1690
+ // Content editables + textareas + inputs
1691
+ document.querySelectorAll('[contenteditable], textarea, input[type="text"], input:not([type])').forEach(el => {
1692
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
1693
+ result.contentEditables.push({
1694
+ selector: fp(el),
1695
+ tag: el.tagName.toLowerCase(),
1696
+ contenteditable: el.getAttribute('contenteditable'),
1697
+ role: el.getAttribute('role'),
1698
+ ariaLabel: el.getAttribute('aria-label'),
1699
+ placeholder: el.getAttribute('placeholder'),
1700
+ rect: rect(el),
1701
+ visible: el.offsetParent !== null || el.offsetWidth > 0,
1702
+ });
1703
+ });
1704
+
1705
+ // Chat containers — large divs with scroll
1706
+ document.querySelectorAll('div, section, main').forEach(el => {
1707
+ const style = getComputedStyle(el);
1708
+ const isScrollable = style.overflowY === 'auto' || style.overflowY === 'scroll';
1709
+ const r = el.getBoundingClientRect();
1710
+ if (!isScrollable || r.height < 200 || r.width < 200) return;
1711
+ const childCount = el.children.length;
1712
+ if (childCount < 2) return;
1713
+ result.chatContainers.push({
1714
+ selector: fp(el),
1715
+ childCount,
1716
+ rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
1717
+ hasScrollable: true,
1718
+ scrollTop: Math.round(el.scrollTop),
1719
+ scrollHeight: Math.round(el.scrollHeight),
1720
+ });
1721
+ });
1722
+
1723
+ // Buttons
1724
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
1725
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
1726
+ const text = (el.textContent || '').trim().substring(0, 80);
1727
+ if (!text && !el.getAttribute('aria-label')) return;
1728
+ result.buttons.push({
1729
+ text,
1730
+ ariaLabel: el.getAttribute('aria-label'),
1731
+ selector: fp(el),
1732
+ rect: rect(el),
1733
+ disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
1734
+ });
1735
+ });
1736
+
1737
+ // Sidebars — panels on left/right edges
1738
+ document.querySelectorAll('[class*="sidebar"], [class*="side-bar"], [class*="panel"], [role="complementary"], [role="navigation"], aside').forEach(el => {
1739
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
1740
+ const r = el.getBoundingClientRect();
1741
+ if (r.width < 50 || r.height < 200) return;
1742
+ result.sidebars.push({
1743
+ selector: fp(el),
1744
+ position: r.x < window.innerWidth / 3 ? 'left' : r.x > window.innerWidth * 2 / 3 ? 'right' : 'center',
1745
+ rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
1746
+ childCount: el.children.length,
1747
+ });
1748
+ });
1749
+
1750
+ // Dropdowns — select, popover, menu patterns
1751
+ document.querySelectorAll('select, [role="listbox"], [role="menu"], [role="combobox"], [class*="dropdown"], [class*="popover"]').forEach(el => {
1752
+ result.dropdowns.push({
1753
+ selector: fp(el),
1754
+ tag: el.tagName.toLowerCase(),
1755
+ role: el.getAttribute('role'),
1756
+ visible: el.offsetParent !== null || el.offsetWidth > 0,
1757
+ rect: rect(el),
1758
+ });
1759
+ });
1760
+
1761
+ return JSON.stringify(result);
1762
+ })()`;
1763
+
1764
+ const raw = await cdp.evaluate(domScript, 15000);
1765
+ let domSnapshot: any = {};
1766
+ if (typeof raw === 'string') { try { domSnapshot = JSON.parse(raw); } catch { domSnapshot = { raw }; } }
1767
+ else domSnapshot = raw;
1768
+
1769
+ this.json(res, 200, {
1770
+ screenshot: screenshot ? `base64:${screenshot}` : null,
1771
+ domSnapshot,
1772
+ pageTitle: await cdp.evaluate('document.title', 3000).catch(() => ''),
1773
+ pageUrl: await cdp.evaluate('window.location.href', 3000).catch(() => ''),
1774
+ providerType: type,
1775
+ timestamp: new Date().toISOString(),
1776
+ });
1777
+ } catch (e: any) {
1778
+ this.json(res, 500, { error: `DOM context collection failed: ${e.message}` });
1779
+ }
1780
+ }
1781
+
1782
+ // ─── Phase 2: Auto-Implement Backend ───
1783
+
1784
+ private async handleAutoImplement(type: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
1785
+ const body = await this.readBody(req);
1786
+ const { agent = 'claude-cli', functions, reference = 'antigravity', model } = body;
1787
+ if (!functions || !Array.isArray(functions) || functions.length === 0) {
1788
+ this.json(res, 400, { error: 'functions[] is required (e.g. ["readChat", "sendMessage"])' });
1789
+ return;
1790
+ }
1791
+
1792
+ if (this.autoImplStatus.running) {
1793
+ this.json(res, 409, { error: 'Auto-implement already in progress', type: this.autoImplStatus.type });
1794
+ return;
1795
+ }
1796
+
1797
+ const provider = this.providerLoader.resolve(type);
1798
+ if (!provider) { this.json(res, 404, { error: `Provider not found: ${type}` }); return; }
1799
+
1800
+ const providerDir = this.findProviderDir(type);
1801
+ if (!providerDir) { this.json(res, 404, { error: `Provider directory not found: ${type}` }); return; }
1802
+
1803
+ try {
1804
+ // 1. Collect DOM context
1805
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'analyzing', message: 'DOM 구조 수집 중...' } });
1806
+
1807
+ let domContext: any = null;
1808
+ const cdp = this.getCdp(type);
1809
+ if (cdp) {
1810
+ try {
1811
+ const domScript = `(() => {
1812
+ function fp(el) {
1813
+ if (!el || el === document.body) return 'body';
1814
+ const parts = [];
1815
+ let c = el;
1816
+ while (c && c !== document.documentElement) {
1817
+ let s = c.tagName.toLowerCase();
1818
+ if (c.id) s = '#' + c.id;
1819
+ else if (c.className && typeof c.className === 'string') {
1820
+ const cls = c.className.trim().split(/\\s+/).filter(x => x && !x.startsWith('_')).slice(0, 2);
1821
+ if (cls.length) s += '.' + cls.join('.');
1822
+ }
1823
+ parts.unshift(s);
1824
+ c = c.parentElement;
1825
+ }
1826
+ return parts.join(' > ');
1827
+ }
1828
+ const r = {};
1829
+ // Content editables
1830
+ r.editables = [...document.querySelectorAll('[contenteditable], textarea, input')].filter(e => e.offsetWidth > 0).slice(0, 10).map(e => ({
1831
+ selector: fp(e), tag: e.tagName.toLowerCase(), ce: e.getAttribute('contenteditable'), role: e.getAttribute('role'), ph: e.getAttribute('placeholder')
1832
+ }));
1833
+ // Scroll containers
1834
+ r.scrollContainers = [...document.querySelectorAll('div, section')].filter(e => {
1835
+ const s = getComputedStyle(e); const b = e.getBoundingClientRect();
1836
+ return (s.overflowY === 'auto' || s.overflowY === 'scroll') && b.height > 200 && e.children.length > 2;
1837
+ }).slice(0, 5).map(e => ({ selector: fp(e), children: e.children.length, h: Math.round(e.getBoundingClientRect().height) }));
1838
+ // Buttons
1839
+ r.buttons = [...document.querySelectorAll('button, [role="button"]')].filter(e => e.offsetWidth > 0).slice(0, 20).map(e => ({
1840
+ text: (e.textContent||'').trim().substring(0, 60), selector: fp(e), label: e.getAttribute('aria-label')
1841
+ }));
1842
+ return JSON.stringify(r);
1843
+ })()`;
1844
+ const raw = await cdp.evaluate(domScript, 10000);
1845
+ domContext = typeof raw === 'string' ? JSON.parse(raw) : raw;
1846
+ } catch (e: any) {
1847
+ this.log(`DOM context collection failed (non-fatal): ${e.message}`);
1848
+ }
1849
+ }
1850
+
1851
+ // 2. Load reference scripts
1852
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'loading_reference', message: `레퍼런스 스크립트 로드 중 (${reference})...` } });
1853
+
1854
+ let referenceScripts: Record<string, string> = {};
1855
+ const builtinDir = (this.providerLoader as any).builtinDir || path.resolve(__dirname, '../providers/_builtin');
1856
+ const refDir = path.join(builtinDir, 'ide', reference);
1857
+ if (fs.existsSync(refDir)) {
1858
+ // Find latest versioned scripts dir
1859
+ const scriptsDir = path.join(refDir, 'scripts');
1860
+ if (fs.existsSync(scriptsDir)) {
1861
+ const versions = fs.readdirSync(scriptsDir).filter((d: string) => {
1862
+ try { return fs.statSync(path.join(scriptsDir, d)).isDirectory(); } catch { return false; }
1863
+ }).sort().reverse();
1864
+ if (versions.length > 0) {
1865
+ const latestDir = path.join(scriptsDir, versions[0]);
1866
+ for (const file of fs.readdirSync(latestDir)) {
1867
+ if (file.endsWith('.js')) {
1868
+ try {
1869
+ referenceScripts[file] = fs.readFileSync(path.join(latestDir, file), 'utf-8');
1870
+ } catch { /* skip */ }
1871
+ }
1872
+ }
1873
+ }
1874
+ }
1875
+ }
1876
+
1877
+ // 3. Build the prompt
1878
+ const prompt = this.buildAutoImplPrompt(type, provider, providerDir, functions, domContext, referenceScripts);
1879
+
1880
+ // 4. Write prompt to temp file (avoids shell escaping issues with special chars)
1881
+ const tmpDir = path.join(os.tmpdir(), 'adhdev-autoimpl');
1882
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
1883
+ const promptFile = path.join(tmpDir, `prompt-${type}-${Date.now()}.md`);
1884
+ fs.writeFileSync(promptFile, prompt, 'utf-8');
1885
+ this.log(`Auto-implement prompt written to ${promptFile} (${prompt.length} chars)`);
1886
+
1887
+ // 5. Determine agent command from provider spawn config
1888
+ const agentProvider = this.providerLoader.resolve(agent) || this.providerLoader.getMeta(agent);
1889
+ const spawn = (agentProvider as any)?.spawn;
1890
+ if (!spawn?.command) {
1891
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
1892
+ this.json(res, 400, { error: `Agent '${agent}' has no spawn config. Select a CLI provider with a spawn configuration.` });
1893
+ return;
1894
+ }
1895
+
1896
+ const agentCategory = (agentProvider as any)?.category;
1897
+
1898
+ // ─── ACP Agent: use ACP SDK (JSON-RPC protocol) ───
1899
+ if (agentCategory === 'acp') {
1900
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'spawning', message: `ACP 에이전트 실행 중: ${spawn.command} ${(spawn.args || []).join(' ')}` } });
1901
+ this.autoImplStatus = { running: true, type, progress: [] };
1902
+
1903
+ // Dynamic import ACP SDK
1904
+ const { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } = await import('@agentclientprotocol/sdk');
1905
+ const { Readable, Writable } = await import('stream');
1906
+ const { spawn: spawnFn } = await import('child_process');
1907
+
1908
+ // Add model override to spawn args if specified
1909
+ const acpArgs = [...(spawn.args || [])];
1910
+ if (model) {
1911
+ acpArgs.push('--model', model);
1912
+ this.log(`Auto-implement ACP using model: ${model}`);
1913
+ }
1914
+
1915
+ const child = spawnFn(spawn.command, acpArgs, {
1916
+ cwd: providerDir,
1917
+ stdio: ['pipe', 'pipe', 'pipe'],
1918
+ shell: spawn.shell ?? false,
1919
+ env: { ...process.env, ...(spawn.env || {}) },
1920
+ });
1921
+ this.autoImplProcess = child;
1922
+
1923
+ // stderr → stream to SSE
1924
+ child.stderr?.on('data', (d: Buffer) => {
1925
+ const chunk = d.toString();
1926
+ this.sendAutoImplSSE({ event: 'output', data: { chunk, stream: 'stderr' } });
1927
+ });
1928
+
1929
+ // Setup ACP connection via SDK
1930
+ const webStdin = Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>;
1931
+ const webStdout = Readable.toWeb(child.stdout!) as ReadableStream<Uint8Array>;
1932
+ const stream = ndJsonStream(webStdin, webStdout);
1933
+
1934
+ const connection = new ClientSideConnection((_agent: any) => ({
1935
+ // Auto-approve all tool calls for auto-implement
1936
+ requestPermission: async (params: any) => {
1937
+ const allowOpt = params.options?.find((o: any) => o.kind === 'allow_once') || params.options?.[0];
1938
+ this.sendAutoImplSSE({ event: 'output', data: { chunk: `[ACP] Auto-approved: ${params.toolCall?.title || 'tool call'}\n`, stream: 'stdout' } });
1939
+ return { outcome: { outcome: 'selected', optionId: allowOpt?.optionId || '' } };
1940
+ },
1941
+ sessionUpdate: async (params: any) => {
1942
+ const update = params?.update;
1943
+ if (!update) return;
1944
+ // Stream meaningful output only (skip thought chunks — they're too verbose)
1945
+ switch (update.sessionUpdate) {
1946
+ case 'agent_message_chunk':
1947
+ if (update.content?.text) {
1948
+ this.sendAutoImplSSE({ event: 'output', data: { chunk: update.content.text, stream: 'stdout' } });
1949
+ }
1950
+ break;
1951
+ case 'tool_call':
1952
+ this.sendAutoImplSSE({ event: 'output', data: { chunk: `\n🔧 [Tool] ${update.title || 'unknown'}\n`, stream: 'stdout' } });
1953
+ break;
1954
+ case 'tool_call_update':
1955
+ if (update.status === 'completed' || update.status === 'failed') {
1956
+ const label = update.status === 'completed' ? '✅' : '❌';
1957
+ const out = update.rawOutput ? (typeof update.rawOutput === 'string' ? update.rawOutput : JSON.stringify(update.rawOutput)) : '';
1958
+ this.sendAutoImplSSE({ event: 'output', data: { chunk: `${label} Result: ${out.slice(0, 1000)}\n`, stream: 'stdout' } });
1959
+ }
1960
+ break;
1961
+ case 'agent_thought_chunk':
1962
+ // Skip — too verbose for auto-implement UI
1963
+ break;
1964
+ default:
1965
+ break;
1966
+ }
1967
+ },
1968
+ // Not used for auto-implement
1969
+ readTextFile: async () => { throw new Error('not supported'); },
1970
+ writeTextFile: async () => { throw new Error('not supported'); },
1971
+ createTerminal: async () => { throw new Error('not supported'); },
1972
+ terminalOutput: async () => { throw new Error('not supported'); },
1973
+ releaseTerminal: async () => { throw new Error('not supported'); },
1974
+ waitForTerminalExit: async () => { throw new Error('not supported'); },
1975
+ killTerminal: async () => { throw new Error('not supported'); },
1976
+ }), stream);
1977
+
1978
+ child.on('exit', (code) => {
1979
+ this.autoImplProcess = null;
1980
+ this.autoImplStatus.running = false;
1981
+ const success = code === 0;
1982
+ this.sendAutoImplSSE({ event: 'complete', data: { success, exitCode: code, functions, message: success ? '✅ ACP Auto-implement 완료' : `❌ ACP 에이전트 종료 (code: ${code})` } });
1983
+ try { this.providerLoader.reload(); } catch { /* ignore */ }
1984
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
1985
+ this.log(`Auto-implement (ACP) ${success ? 'completed' : 'failed'}: ${type} (exit: ${code})`);
1986
+ });
1987
+
1988
+ // ACP handshake flow (async, runs in background)
1989
+ (async () => {
1990
+ try {
1991
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'initializing', message: 'ACP initialize...' } });
1992
+ await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} });
1993
+
1994
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'session', message: 'ACP session 생성 중...' } });
1995
+ const session = await connection.newSession({ cwd: providerDir, mcpServers: [] });
1996
+ const sessionId = session?.sessionId;
1997
+ if (!sessionId) throw new Error('No sessionId returned from session/new');
1998
+
1999
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'prompting', message: `프롬프트 전송 중 (${prompt.length} chars)...` } });
2000
+ await connection.prompt({
2001
+ sessionId,
2002
+ prompt: [{ type: 'text', text: prompt }],
2003
+ });
2004
+
2005
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_done', status: 'complete', message: '✅ ACP 프롬프트 처리 완료' } });
2006
+ } catch (e: any) {
2007
+ this.sendAutoImplSSE({ event: 'output', data: { chunk: `[ACP Error] ${e.message}\n`, stream: 'stderr' } });
2008
+ this.log(`Auto-implement ACP error: ${e.message}`);
2009
+ // Process exit will trigger the 'complete' SSE event
2010
+ if (child.exitCode === null) { child.kill('SIGTERM'); }
2011
+ }
2012
+ })();
2013
+
2014
+ this.json(res, 202, {
2015
+ started: true, type, agent: spawn.command, functions, providerDir,
2016
+ message: 'ACP Auto-implement started. Connect to SSE for progress.',
2017
+ sseUrl: `/api/providers/${type}/auto-implement/status`,
2018
+ });
2019
+ return;
2020
+ }
2021
+
2022
+ // ─── CLI Agent: stdin pipe approach ───
2023
+ const command: string = spawn.command;
2024
+ // Strip interactive-only flags for auto-implement (non-interactive mode)
2025
+ const interactiveFlags = ['--yolo', '--interactive', '-i'];
2026
+ const baseArgs: string[] = [...(spawn.args || [])].filter((a: string) => !interactiveFlags.includes(a));
2027
+ let args: string[];
2028
+ let useStdin = true;
2029
+
2030
+ if (command === 'claude') {
2031
+ // Claude Code: --print mode, skip permissions for non-interactive auto-implement
2032
+ args = [...baseArgs, '--print', '--dangerously-skip-permissions', '--add-dir', providerDir];
2033
+ useStdin = true;
2034
+ } else if (command === 'gemini') {
2035
+ // Gemini CLI: -p (non-interactive mode) with stdin piped prompt
2036
+ // -p "" means "non-interactive mode, read prompt from stdin"
2037
+ // -y for yolo (auto-approve all), -s false for no sandbox
2038
+ args = [...baseArgs, '-p', '', '-y', '-s', 'false'];
2039
+ if (model) {
2040
+ args.push('-m', model);
2041
+ }
2042
+ useStdin = true;
2043
+ } else {
2044
+ // Codex CLI, etc: pipe prompt via stdin
2045
+ args = [...baseArgs];
2046
+ }
2047
+
2048
+ // 5.5. Check agent binary exists
2049
+ const { execSync } = await import('child_process');
2050
+ try {
2051
+ execSync(`which ${command}`, { stdio: 'pipe' });
2052
+ } catch {
2053
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
2054
+ this.json(res, 400, { error: `Agent binary '${command}' not found on PATH. Install it first: ${(agentProvider as any)?.install || 'check provider docs'}` });
2055
+ return;
2056
+ }
2057
+
2058
+ // 6. Spawn CLI agent via shell pipe (avoids Node.js stdin deadlock on large prompts)
2059
+ this.sendAutoImplSSE({ event: 'progress', data: { function: '_init', status: 'spawning', message: `에이전트 실행 중: ${command} ${args.join(' ')} (prompt: ${prompt.length} chars)` } });
2060
+
2061
+ this.autoImplStatus = { running: true, type, progress: [] };
2062
+
2063
+ const { spawn: spawnFn } = await import('child_process');
2064
+ // Shell pipe: cat promptFile | command args...
2065
+ // This avoids Node.js stdin buffer deadlock and ensures proper EOF signaling
2066
+ const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
2067
+ const shellCmd = `cat '${promptFile}' | ${command} ${escapedArgs}`;
2068
+ this.log(`Auto-implement spawn: ${shellCmd}`);
2069
+ const child = spawnFn('sh', ['-c', shellCmd], {
2070
+ cwd: providerDir,
2071
+ shell: false,
2072
+ timeout: 900000, // 15 min timeout
2073
+ stdio: ['ignore', 'pipe', 'pipe'],
2074
+ env: { ...process.env, ...(spawn.env || {}) },
2075
+ });
2076
+ this.autoImplProcess = child;
2077
+ child.on('error', (err) => {
2078
+ this.log(`Auto-implement spawn error: ${err.message}`);
2079
+ this.sendAutoImplSSE({ event: 'output', data: { chunk: `[Spawn Error] ${err.message}\n`, stream: 'stderr' } });
2080
+ });
2081
+
2082
+ let stdout = '';
2083
+ let stderr = '';
2084
+ child.stdout?.on('data', (d: Buffer) => {
2085
+ const chunk = d.toString();
2086
+ stdout += chunk;
2087
+ this.sendAutoImplSSE({ event: 'output', data: { chunk, stream: 'stdout' } });
2088
+ });
2089
+ child.stderr?.on('data', (d: Buffer) => {
2090
+ const chunk = d.toString();
2091
+ stderr += chunk;
2092
+ this.sendAutoImplSSE({ event: 'output', data: { chunk, stream: 'stderr' } });
2093
+ });
2094
+
2095
+ child.on('exit', (code) => {
2096
+ this.autoImplProcess = null;
2097
+ this.autoImplStatus.running = false;
2098
+ const success = code === 0;
2099
+ this.sendAutoImplSSE({
2100
+ event: 'complete',
2101
+ data: {
2102
+ success,
2103
+ exitCode: code,
2104
+ functions,
2105
+ message: success ? '✅ Auto-implement 완료' : `❌ 에이전트 종료 (code: ${code})`,
2106
+ },
2107
+ });
2108
+ // Reload providers to pick up new scripts
2109
+ try { this.providerLoader.reload(); } catch { /* ignore */ }
2110
+ // Cleanup temp prompt file
2111
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
2112
+ this.log(`Auto-implement ${success ? 'completed' : 'failed'}: ${type} (exit: ${code})`);
2113
+ });
2114
+
2115
+ this.json(res, 202, {
2116
+ started: true,
2117
+ type,
2118
+ agent: command,
2119
+ functions,
2120
+ providerDir,
2121
+ message: 'Auto-implement started. Connect to SSE for progress.',
2122
+ sseUrl: `/api/providers/${type}/auto-implement/status`,
2123
+ });
2124
+ } catch (e: any) {
2125
+ this.autoImplStatus.running = false;
2126
+ this.json(res, 500, { error: `Auto-implement failed: ${e.message}` });
2127
+ }
2128
+ }
2129
+
2130
+ private buildAutoImplPrompt(
2131
+ type: string,
2132
+ provider: any,
2133
+ providerDir: string,
2134
+ functions: string[],
2135
+ domContext: any,
2136
+ referenceScripts: Record<string, string>,
2137
+ ): string {
2138
+ const lines: string[] = [];
2139
+
2140
+ // ── System instructions ──
2141
+ lines.push('You are implementing browser automation scripts for an IDE provider.');
2142
+ lines.push('Be concise. Do NOT explain your reasoning. Just edit files directly.');
2143
+ lines.push('');
2144
+
2145
+ // ── Target ──
2146
+ lines.push(`# Target: ${provider.name || type} (${type})`);
2147
+ lines.push(`Provider directory: \`${providerDir}\``);
2148
+ lines.push('');
2149
+
2150
+ // ── Existing target files (inline, so no reading needed) ──
2151
+ lines.push('## Current Target Files');
2152
+ lines.push('These are the files you need to EDIT. They contain TODO stubs — replace them with working implementations.');
2153
+ lines.push('');
2154
+
2155
+ const scriptsDir = path.join(providerDir, 'scripts');
2156
+ if (fs.existsSync(scriptsDir)) {
2157
+ const versions = fs.readdirSync(scriptsDir).filter((d: string) => {
2158
+ try { return fs.statSync(path.join(scriptsDir, d)).isDirectory(); } catch { return false; }
2159
+ }).sort().reverse();
2160
+ if (versions.length > 0) {
2161
+ const vDir = path.join(scriptsDir, versions[0]);
2162
+ lines.push(`Scripts version directory: \`${vDir}\``);
2163
+ lines.push('');
2164
+ for (const file of fs.readdirSync(vDir)) {
2165
+ if (file.endsWith('.js')) {
2166
+ try {
2167
+ const content = fs.readFileSync(path.join(vDir, file), 'utf-8');
2168
+ lines.push(`### \`${file}\``);
2169
+ lines.push('```javascript');
2170
+ lines.push(content);
2171
+ lines.push('```');
2172
+ lines.push('');
2173
+ } catch { /* skip */ }
2174
+ }
2175
+ }
2176
+ }
2177
+ }
2178
+
2179
+ // ── DOM context ──
2180
+ if (domContext) {
2181
+ lines.push('## Live DOM Analysis (from CDP)');
2182
+ lines.push('Use these selectors in your implementations:');
2183
+ lines.push('```json');
2184
+ lines.push(JSON.stringify(domContext, null, 2));
2185
+ lines.push('```');
2186
+ lines.push('');
2187
+ }
2188
+
2189
+ // ── Reference implementation ──
2190
+ const funcToFile: Record<string, string> = {
2191
+ readChat: 'read_chat.js', sendMessage: 'send_message.js',
2192
+ resolveAction: 'resolve_action.js', listSessions: 'list_sessions.js',
2193
+ listChats: 'list_chats.js', switchSession: 'switch_session.js',
2194
+ newSession: 'new_session.js', focusEditor: 'focus_editor.js',
2195
+ openPanel: 'open_panel.js', listModels: 'list_models.js',
2196
+ listModes: 'list_modes.js', setModel: 'set_model.js', setMode: 'set_mode.js',
2197
+ };
2198
+
2199
+ if (Object.keys(referenceScripts).length > 0) {
2200
+ lines.push('## Reference Implementation (from Antigravity provider)');
2201
+ lines.push('These are WORKING scripts from another IDE. Adapt the PATTERNS (not selectors) for the target IDE.');
2202
+ lines.push('');
2203
+ for (const fn of functions) {
2204
+ const fileName = funcToFile[fn];
2205
+ if (fileName && referenceScripts[fileName]) {
2206
+ lines.push(`### ${fn} → \`${fileName}\``);
2207
+ lines.push('```javascript');
2208
+ lines.push(referenceScripts[fileName]);
2209
+ lines.push('```');
2210
+ lines.push('');
2211
+ }
2212
+ }
2213
+ if (referenceScripts['scripts.js']) {
2214
+ lines.push('### Router → `scripts.js`');
2215
+ lines.push('```javascript');
2216
+ lines.push(referenceScripts['scripts.js']);
2217
+ lines.push('```');
2218
+ lines.push('');
2219
+ }
2220
+ }
2221
+
2222
+ // ── Task ──
2223
+ lines.push('## Task');
2224
+ lines.push(`Edit files in \`${providerDir}\` to implement: **${functions.join(', ')}**`);
2225
+ lines.push('');
2226
+
2227
+ // ── Rules ──
2228
+ lines.push('## Rules');
2229
+ lines.push('1. **Scripts WITHOUT params** → IIFE: `(() => { ... })()`');
2230
+ lines.push('2. **Scripts WITH params** → arrow: `(params) => { ... }` — router calls `(${script})(${JSON.stringify(params)})`');
2231
+ lines.push('3. Use CSS selectors from the DOM analysis above');
2232
+ lines.push('4. Always wrap in try-catch, return `JSON.stringify(result)`');
2233
+ lines.push('5. Do NOT modify `scripts.js` router — only edit individual `*.js` files');
2234
+ lines.push('6. All scripts run in the browser (CDP evaluate) — use DOM APIs only');
2235
+ lines.push('');
2236
+
2237
+ // ── Output contracts ──
2238
+ lines.push('## Required Return Format');
2239
+ lines.push('| Function | Return JSON |');
2240
+ lines.push('|---|---|');
2241
+ lines.push('| readChat | `{ id, status, title, messages: [{role, content, index}], inputContent, activeModal }` |');
2242
+ lines.push('| sendMessage | `{ sent: false, needsTypeAndSend: true, selector }` |');
2243
+ lines.push('| resolveAction | `{ resolved: true/false, clicked? }` |');
2244
+ lines.push('| listSessions | `{ sessions: [{ id, title, active, index }] }` |');
2245
+ lines.push('| switchSession | `{ switched: true/false }` |');
2246
+ lines.push('| newSession | `{ created: true/false }` |');
2247
+ lines.push('| listModels | `{ models: [{ name, id }], current }` |');
2248
+ lines.push('| setModel | `{ success: true/false }` |');
2249
+ lines.push('| listModes | `{ modes: [{ name, id }], current }` |');
2250
+ lines.push('| setMode | `{ success: true/false }` |');
2251
+ lines.push('| focusEditor | `{ focused: true/false }` |');
2252
+ lines.push('| openPanel | `{ opened: true/false }` |');
2253
+ lines.push('');
2254
+
2255
+ lines.push('## Action');
2256
+ lines.push('1. Edit the script files to implement working code');
2257
+ lines.push('2. After editing, TEST each function using the DevConsole API (see below)');
2258
+ lines.push('3. If a test fails, fix the implementation and re-test');
2259
+ lines.push('');
2260
+
2261
+ // ── DevConsole API for verification ──
2262
+ lines.push('## DevConsole API (for testing)');
2263
+ lines.push(`The DevConsole is running at \`http://127.0.0.1:${DEV_SERVER_PORT}\`. Use these HTTP APIs to test your implementations.`);
2264
+ lines.push('');
2265
+ lines.push('### Run a script against the live IDE');
2266
+ lines.push('```bash');
2267
+ lines.push(`curl -X POST http://127.0.0.1:${DEV_SERVER_PORT}/api/providers/${type}/scripts/run \\`);
2268
+ lines.push(` -H "Content-Type: application/json" \\`);
2269
+ lines.push(` -d '{"script": "readChat"}'`);
2270
+ lines.push('```');
2271
+ lines.push('Replace `"readChat"` with any function name. For functions with params:');
2272
+ lines.push('```bash');
2273
+ lines.push(`curl -X POST http://127.0.0.1:${DEV_SERVER_PORT}/api/providers/${type}/scripts/run \\`);
2274
+ lines.push(` -H "Content-Type: application/json" \\`);
2275
+ lines.push(` -d '{"script": "sendMessage", "params": {"text": "hello"}}'`);
2276
+ lines.push('```');
2277
+ lines.push('');
2278
+ lines.push('### Evaluate raw JS in the IDE (CDP)');
2279
+ lines.push('```bash');
2280
+ lines.push(`curl -X POST http://127.0.0.1:${DEV_SERVER_PORT}/api/cdp/evaluate \\`);
2281
+ lines.push(` -H "Content-Type: application/json" \\`);
2282
+ lines.push(` -d '{"expression": "document.title", "ideType": "${type}"}'`);
2283
+ lines.push('```');
2284
+ lines.push('');
2285
+ lines.push('### Reload provider (after editing files)');
2286
+ lines.push('```bash');
2287
+ lines.push(`curl -X POST http://127.0.0.1:${DEV_SERVER_PORT}/api/providers/reload`);
2288
+ lines.push('```');
2289
+ lines.push('**IMPORTANT**: After editing script files, you MUST call reload before running scripts.');
2290
+ lines.push('');
2291
+ lines.push('### Workflow: Edit → Reload → Test → Fix');
2292
+ lines.push('1. Edit the `.js` file');
2293
+ lines.push('2. `curl POST /api/providers/reload`');
2294
+ lines.push(`3. \`curl POST /api/providers/${type}/scripts/run -d '{"script":"readChat"}'\``);
2295
+ lines.push('4. Check the response — if error, fix and repeat from step 1');
2296
+ lines.push('');
2297
+ lines.push('Start NOW. Edit files, then test each one.');
2298
+
2299
+ return lines.join('\n');
2300
+ }
2301
+
2302
+ private handleAutoImplSSE(type: string, req: http.IncomingMessage, res: http.ServerResponse): void {
2303
+ res.writeHead(200, {
2304
+ 'Content-Type': 'text/event-stream',
2305
+ 'Cache-Control': 'no-cache',
2306
+ 'Connection': 'keep-alive',
2307
+ 'Access-Control-Allow-Origin': '*',
2308
+ });
2309
+ res.write(`data: ${JSON.stringify({ type: 'connected', running: this.autoImplStatus.running, providerType: type })}\n\n`);
2310
+
2311
+ // Replay existing progress
2312
+ for (const p of this.autoImplStatus.progress) {
2313
+ res.write(`event: ${p.event}\ndata: ${JSON.stringify(p.data)}\n\n`);
2314
+ }
2315
+
2316
+ this.autoImplSSEClients.push(res);
2317
+ req.on('close', () => {
2318
+ this.autoImplSSEClients = this.autoImplSSEClients.filter(c => c !== res);
2319
+ });
2320
+ }
2321
+
2322
+ private handleAutoImplCancel(_type: string, _req: http.IncomingMessage, res: http.ServerResponse): void {
2323
+ if (this.autoImplProcess) {
2324
+ this.autoImplProcess.kill('SIGTERM');
2325
+ setTimeout(() => { if (this.autoImplProcess) this.autoImplProcess.kill('SIGKILL'); }, 3000);
2326
+ this.sendAutoImplSSE({ event: 'complete', data: { success: false, exitCode: -1, message: '⛔ 사용자에 의해 중단됨' } });
2327
+ this.autoImplProcess = null;
2328
+ this.autoImplStatus.running = false;
2329
+ this.json(res, 200, { cancelled: true });
2330
+ } else {
2331
+ this.autoImplStatus.running = false;
2332
+ this.json(res, 200, { cancelled: false, message: 'No running process' });
2333
+ }
2334
+ }
2335
+
2336
+ private sendAutoImplSSE(msg: { event: string; data: any }): void {
2337
+ this.autoImplStatus.progress.push(msg);
2338
+ const payload = `event: ${msg.event}\ndata: ${JSON.stringify(msg.data)}\n\n`;
2339
+ for (const client of this.autoImplSSEClients) {
2340
+ try { client.write(payload); } catch { /* ignore */ }
2341
+ }
2342
+ }
2343
+
2344
+ /** Get CDP manager — matching IDE when ideType specified, first connected one otherwise */
2345
+ private getCdp(ideType?: string): DaemonCdpManager | null {
2346
+ if (ideType) {
2347
+ const cdp = this.cdpManagers.get(ideType);
2348
+ if (cdp?.isConnected) return cdp;
2349
+ }
2350
+ for (const cdp of this.cdpManagers.values()) {
2351
+ if (cdp.isConnected) return cdp;
2352
+ }
2353
+ return null;
2354
+ }
2355
+
2356
+ private getAnyCdp(): DaemonCdpManager | null {
2357
+ return this.getCdp();
2358
+ }
2359
+
2360
+ private json(res: http.ServerResponse, status: number, data: any): void {
2361
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2362
+ res.end(JSON.stringify(data, null, 2));
2363
+ }
2364
+
2365
+ private async readBody(req: http.IncomingMessage): Promise<any> {
2366
+ return new Promise((resolve) => {
2367
+ let body = '';
2368
+ req.on('data', (chunk) => body += chunk);
2369
+ req.on('end', () => {
2370
+ try {
2371
+ resolve(JSON.parse(body));
2372
+ } catch {
2373
+ resolve({});
2374
+ }
2375
+ });
2376
+ });
2377
+ }
2378
+ }