dodo-mcp 1.0.0

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 (447) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +425 -0
  3. package/dist/auth/adapter.js +43 -0
  4. package/dist/auth/adapter.js.map +1 -0
  5. package/dist/auth/clientDeletion.js +45 -0
  6. package/dist/auth/clientDeletion.js.map +1 -0
  7. package/dist/auth/clients.js +55 -0
  8. package/dist/auth/clients.js.map +1 -0
  9. package/dist/auth/constants.js +10 -0
  10. package/dist/auth/constants.js.map +1 -0
  11. package/dist/auth/interactions.js +184 -0
  12. package/dist/auth/interactions.js.map +1 -0
  13. package/dist/auth/keys.js +56 -0
  14. package/dist/auth/keys.js.map +1 -0
  15. package/dist/auth/provider.js +131 -0
  16. package/dist/auth/provider.js.map +1 -0
  17. package/dist/auth/verifier.js +74 -0
  18. package/dist/auth/verifier.js.map +1 -0
  19. package/dist/cli/kill.js +124 -0
  20. package/dist/cli/kill.js.map +1 -0
  21. package/dist/cli/main.js +1393 -0
  22. package/dist/cli/main.js.map +1 -0
  23. package/dist/cli/menu.js +127 -0
  24. package/dist/cli/menu.js.map +1 -0
  25. package/dist/cli/terminal.js +30 -0
  26. package/dist/cli/terminal.js.map +1 -0
  27. package/dist/config/globalConfig.js +144 -0
  28. package/dist/config/globalConfig.js.map +1 -0
  29. package/dist/config/limits.js +42 -0
  30. package/dist/config/limits.js.map +1 -0
  31. package/dist/config/paths.js +94 -0
  32. package/dist/config/paths.js.map +1 -0
  33. package/dist/config/projectConfig.js +63 -0
  34. package/dist/config/projectConfig.js.map +1 -0
  35. package/dist/config/stateImport.js +306 -0
  36. package/dist/config/stateImport.js.map +1 -0
  37. package/dist/config/tunnelConfig.js +23 -0
  38. package/dist/config/tunnelConfig.js.map +1 -0
  39. package/dist/errors.js +82 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/evaluation/contracts.js +86 -0
  42. package/dist/evaluation/contracts.js.map +1 -0
  43. package/dist/evaluation/dodoBench.js +69 -0
  44. package/dist/evaluation/dodoBench.js.map +1 -0
  45. package/dist/index.js +26 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/ipc/authentication.js +117 -0
  48. package/dist/ipc/authentication.js.map +1 -0
  49. package/dist/ipc/client.js +86 -0
  50. package/dist/ipc/client.js.map +1 -0
  51. package/dist/ipc/installationClient.js +83 -0
  52. package/dist/ipc/installationClient.js.map +1 -0
  53. package/dist/ipc/ownerControl.js +46 -0
  54. package/dist/ipc/ownerControl.js.map +1 -0
  55. package/dist/ipc/protocol.js +2 -0
  56. package/dist/ipc/protocol.js.map +1 -0
  57. package/dist/ipc/server.js +108 -0
  58. package/dist/ipc/server.js.map +1 -0
  59. package/dist/platform/execResolve.js +64 -0
  60. package/dist/platform/execResolve.js.map +1 -0
  61. package/dist/platform/fileIdentity.js +26 -0
  62. package/dist/platform/fileIdentity.js.map +1 -0
  63. package/dist/platform/fsRetry.js +22 -0
  64. package/dist/platform/fsRetry.js.map +1 -0
  65. package/dist/platform/pathPolicy.js +29 -0
  66. package/dist/platform/pathPolicy.js.map +1 -0
  67. package/dist/platform/privateFs.js +116 -0
  68. package/dist/platform/privateFs.js.map +1 -0
  69. package/dist/platform/processTree.js +31 -0
  70. package/dist/platform/processTree.js.map +1 -0
  71. package/dist/platform/shell.js +74 -0
  72. package/dist/platform/shell.js.map +1 -0
  73. package/dist/platform/speech.js +94 -0
  74. package/dist/platform/speech.js.map +1 -0
  75. package/dist/platform/system.js +28 -0
  76. package/dist/platform/system.js.map +1 -0
  77. package/dist/platform/windowsSandbox.js +129 -0
  78. package/dist/platform/windowsSandbox.js.map +1 -0
  79. package/dist/projects/federation.js +312 -0
  80. package/dist/projects/federation.js.map +1 -0
  81. package/dist/projects/registry.js +291 -0
  82. package/dist/projects/registry.js.map +1 -0
  83. package/dist/security/accessMode.js +19 -0
  84. package/dist/security/accessMode.js.map +1 -0
  85. package/dist/security/approvals.js +56 -0
  86. package/dist/security/approvals.js.map +1 -0
  87. package/dist/security/env.js +57 -0
  88. package/dist/security/env.js.map +1 -0
  89. package/dist/security/mutationQueue.js +78 -0
  90. package/dist/security/mutationQueue.js.map +1 -0
  91. package/dist/security/policy.js +22 -0
  92. package/dist/security/policy.js.map +1 -0
  93. package/dist/security/projectAuthority.js +22 -0
  94. package/dist/security/projectAuthority.js.map +1 -0
  95. package/dist/security/redact.js +30 -0
  96. package/dist/security/redact.js.map +1 -0
  97. package/dist/server/aiAdmin.js +201 -0
  98. package/dist/server/aiAdmin.js.map +1 -0
  99. package/dist/server/appServer.js +461 -0
  100. package/dist/server/appServer.js.map +1 -0
  101. package/dist/server/bootstrap.js +185 -0
  102. package/dist/server/bootstrap.js.map +1 -0
  103. package/dist/server/configUi/app.css +347 -0
  104. package/dist/server/configUi/app.js +958 -0
  105. package/dist/server/configUi/index.html +379 -0
  106. package/dist/server/configUi/ui/alerts.js +92 -0
  107. package/dist/server/configUi/ui/dom.js +19 -0
  108. package/dist/server/configUi/ui/tooltips.js +115 -0
  109. package/dist/server/configUi/vendor/VERSION.txt +1 -0
  110. package/dist/server/configUi/vendor/sweetalert2.min.css +1 -0
  111. package/dist/server/configUi/vendor/sweetalert2.min.js +5 -0
  112. package/dist/server/configUi/workbench.css +62 -0
  113. package/dist/server/configUi/workbench.js +394 -0
  114. package/dist/server/installationRuntime.js +155 -0
  115. package/dist/server/installationRuntime.js.map +1 -0
  116. package/dist/server/instructions.js +17 -0
  117. package/dist/server/instructions.js.map +1 -0
  118. package/dist/server/ipcDispatch.js +225 -0
  119. package/dist/server/ipcDispatch.js.map +1 -0
  120. package/dist/server/localConfig.js +561 -0
  121. package/dist/server/localConfig.js.map +1 -0
  122. package/dist/server/optionalServices.js +30 -0
  123. package/dist/server/optionalServices.js.map +1 -0
  124. package/dist/server/projectLease.js +46 -0
  125. package/dist/server/projectLease.js.map +1 -0
  126. package/dist/server/stdioServer.js +75 -0
  127. package/dist/server/stdioServer.js.map +1 -0
  128. package/dist/server/version.js +13 -0
  129. package/dist/server/version.js.map +1 -0
  130. package/dist/server/workspaceHost.js +194 -0
  131. package/dist/server/workspaceHost.js.map +1 -0
  132. package/dist/services/agent/agentService.js +598 -0
  133. package/dist/services/agent/agentService.js.map +1 -0
  134. package/dist/services/agent/contracts.js +102 -0
  135. package/dist/services/agent/contracts.js.map +1 -0
  136. package/dist/services/ai/adapters.js +287 -0
  137. package/dist/services/ai/adapters.js.map +1 -0
  138. package/dist/services/ai/contracts.js +39 -0
  139. package/dist/services/ai/contracts.js.map +1 -0
  140. package/dist/services/ai/credentials.js +77 -0
  141. package/dist/services/ai/credentials.js.map +1 -0
  142. package/dist/services/ai/network.js +113 -0
  143. package/dist/services/ai/network.js.map +1 -0
  144. package/dist/services/ai/settings.js +178 -0
  145. package/dist/services/ai/settings.js.map +1 -0
  146. package/dist/services/ai/subagents.js +468 -0
  147. package/dist/services/ai/subagents.js.map +1 -0
  148. package/dist/services/assistance/analysis.js +352 -0
  149. package/dist/services/assistance/analysis.js.map +1 -0
  150. package/dist/services/assistance/contracts.js +54 -0
  151. package/dist/services/assistance/contracts.js.map +1 -0
  152. package/dist/services/assistance/verification.js +274 -0
  153. package/dist/services/assistance/verification.js.map +1 -0
  154. package/dist/services/brain/brainParser.js +97 -0
  155. package/dist/services/brain/brainParser.js.map +1 -0
  156. package/dist/services/brain/brainService.js +733 -0
  157. package/dist/services/brain/brainService.js.map +1 -0
  158. package/dist/services/brain/brainWorker.js +212 -0
  159. package/dist/services/brain/brainWorker.js.map +1 -0
  160. package/dist/services/brain/contracts.js +154 -0
  161. package/dist/services/brain/contracts.js.map +1 -0
  162. package/dist/services/brain/protocol.js +2 -0
  163. package/dist/services/brain/protocol.js.map +1 -0
  164. package/dist/services/changes/applier.js +544 -0
  165. package/dist/services/changes/applier.js.map +1 -0
  166. package/dist/services/changes/contentBudget.js +11 -0
  167. package/dist/services/changes/contentBudget.js.map +1 -0
  168. package/dist/services/changes/planner.js +284 -0
  169. package/dist/services/changes/planner.js.map +1 -0
  170. package/dist/services/changes/types.js +3 -0
  171. package/dist/services/changes/types.js.map +1 -0
  172. package/dist/services/context/contextEngine.js +746 -0
  173. package/dist/services/context/contextEngine.js.map +1 -0
  174. package/dist/services/context/contracts.js +88 -0
  175. package/dist/services/context/contracts.js.map +1 -0
  176. package/dist/services/desktop/desktopPolicy.js +76 -0
  177. package/dist/services/desktop/desktopPolicy.js.map +1 -0
  178. package/dist/services/desktop/desktopService.js +130 -0
  179. package/dist/services/desktop/desktopService.js.map +1 -0
  180. package/dist/services/desktop/nativeBackend.js +174 -0
  181. package/dist/services/desktop/nativeBackend.js.map +1 -0
  182. package/dist/services/desktop/protocol.js +32 -0
  183. package/dist/services/desktop/protocol.js.map +1 -0
  184. package/dist/services/files/readService.js +136 -0
  185. package/dist/services/files/readService.js.map +1 -0
  186. package/dist/services/git/gitService.js +369 -0
  187. package/dist/services/git/gitService.js.map +1 -0
  188. package/dist/services/intelligence/intelService.js +136 -0
  189. package/dist/services/intelligence/intelService.js.map +1 -0
  190. package/dist/services/intelligence/protocol.js +2 -0
  191. package/dist/services/intelligence/protocol.js.map +1 -0
  192. package/dist/services/intelligence/tsWorker.js +419 -0
  193. package/dist/services/intelligence/tsWorker.js.map +1 -0
  194. package/dist/services/jobs/commandInput.js +34 -0
  195. package/dist/services/jobs/commandInput.js.map +1 -0
  196. package/dist/services/jobs/jobManager.js +398 -0
  197. package/dist/services/jobs/jobManager.js.map +1 -0
  198. package/dist/services/jobs/sandbox.js +156 -0
  199. package/dist/services/jobs/sandbox.js.map +1 -0
  200. package/dist/services/jobs/sandboxWiring.js +37 -0
  201. package/dist/services/jobs/sandboxWiring.js.map +1 -0
  202. package/dist/services/jobs/spool.js +112 -0
  203. package/dist/services/jobs/spool.js.map +1 -0
  204. package/dist/services/jobs/windowsSandboxChild.js +24 -0
  205. package/dist/services/jobs/windowsSandboxChild.js.map +1 -0
  206. package/dist/services/jobs/windowsSandboxWorker.js +155 -0
  207. package/dist/services/jobs/windowsSandboxWorker.js.map +1 -0
  208. package/dist/services/lsp/documentUri.js +16 -0
  209. package/dist/services/lsp/documentUri.js.map +1 -0
  210. package/dist/services/lsp/jsonrpc.js +382 -0
  211. package/dist/services/lsp/jsonrpc.js.map +1 -0
  212. package/dist/services/lsp/lspService.js +1051 -0
  213. package/dist/services/lsp/lspService.js.map +1 -0
  214. package/dist/services/memory/contracts.js +94 -0
  215. package/dist/services/memory/contracts.js.map +1 -0
  216. package/dist/services/memory/memoryService.js +683 -0
  217. package/dist/services/memory/memoryService.js.map +1 -0
  218. package/dist/services/multimodal/browserService.js +407 -0
  219. package/dist/services/multimodal/browserService.js.map +1 -0
  220. package/dist/services/multimodal/byteRange.js +31 -0
  221. package/dist/services/multimodal/byteRange.js.map +1 -0
  222. package/dist/services/multimodal/contracts.js +59 -0
  223. package/dist/services/multimodal/contracts.js.map +1 -0
  224. package/dist/services/multimodal/gameService.js +95 -0
  225. package/dist/services/multimodal/gameService.js.map +1 -0
  226. package/dist/services/multimodal/images.js +107 -0
  227. package/dist/services/multimodal/images.js.map +1 -0
  228. package/dist/services/multimodal/mediaService.js +279 -0
  229. package/dist/services/multimodal/mediaService.js.map +1 -0
  230. package/dist/services/multimodal/mediaWorker.js +143 -0
  231. package/dist/services/multimodal/mediaWorker.js.map +1 -0
  232. package/dist/services/multimodal/multimodalService.js +51 -0
  233. package/dist/services/multimodal/multimodalService.js.map +1 -0
  234. package/dist/services/multimodal/network.js +69 -0
  235. package/dist/services/multimodal/network.js.map +1 -0
  236. package/dist/services/multimodal/operations.js +40 -0
  237. package/dist/services/multimodal/operations.js.map +1 -0
  238. package/dist/services/multimodal/storage.js +160 -0
  239. package/dist/services/multimodal/storage.js.map +1 -0
  240. package/dist/services/multimodal/workflowService.js +179 -0
  241. package/dist/services/multimodal/workflowService.js.map +1 -0
  242. package/dist/services/overview.js +284 -0
  243. package/dist/services/overview.js.map +1 -0
  244. package/dist/services/resources/casStore.js +304 -0
  245. package/dist/services/resources/casStore.js.map +1 -0
  246. package/dist/services/resources/contracts.js +75 -0
  247. package/dist/services/resources/contracts.js.map +1 -0
  248. package/dist/services/resources/mime.js +77 -0
  249. package/dist/services/resources/mime.js.map +1 -0
  250. package/dist/services/resources/providerRegistry.js +22 -0
  251. package/dist/services/resources/providerRegistry.js.map +1 -0
  252. package/dist/services/resources/providers.js +120 -0
  253. package/dist/services/resources/providers.js.map +1 -0
  254. package/dist/services/resources/resourceService.js +282 -0
  255. package/dist/services/resources/resourceService.js.map +1 -0
  256. package/dist/services/runtime/contracts.js +65 -0
  257. package/dist/services/runtime/contracts.js.map +1 -0
  258. package/dist/services/runtime/runtimeService.js +364 -0
  259. package/dist/services/runtime/runtimeService.js.map +1 -0
  260. package/dist/services/schedules/scheduleService.js +198 -0
  261. package/dist/services/schedules/scheduleService.js.map +1 -0
  262. package/dist/services/search/searchService.js +434 -0
  263. package/dist/services/search/searchService.js.map +1 -0
  264. package/dist/services/search/searchWorker.js +58 -0
  265. package/dist/services/search/searchWorker.js.map +1 -0
  266. package/dist/setup/componentProbes.js +259 -0
  267. package/dist/setup/componentProbes.js.map +1 -0
  268. package/dist/setup/download.js +139 -0
  269. package/dist/setup/download.js.map +1 -0
  270. package/dist/setup/managedTools.js +118 -0
  271. package/dist/setup/managedTools.js.map +1 -0
  272. package/dist/setup/sandboxProbe.js +172 -0
  273. package/dist/setup/sandboxProbe.js.map +1 -0
  274. package/dist/setup/setup.js +522 -0
  275. package/dist/setup/setup.js.map +1 -0
  276. package/dist/setup/windowsChromiumAcl.js +56 -0
  277. package/dist/setup/windowsChromiumAcl.js.map +1 -0
  278. package/dist/setup/windowsSandboxInstall.js +94 -0
  279. package/dist/setup/windowsSandboxInstall.js.map +1 -0
  280. package/dist/setup/windowsSpeechInstall.js +86 -0
  281. package/dist/setup/windowsSpeechInstall.js.map +1 -0
  282. package/dist/store/db.js +711 -0
  283. package/dist/store/db.js.map +1 -0
  284. package/dist/store/scopeEncoding.js +15 -0
  285. package/dist/store/scopeEncoding.js.map +1 -0
  286. package/dist/store/store.js +467 -0
  287. package/dist/store/store.js.map +1 -0
  288. package/dist/tools/agentRuntimeTools.js +216 -0
  289. package/dist/tools/agentRuntimeTools.js.map +1 -0
  290. package/dist/tools/agentTools.js +317 -0
  291. package/dist/tools/agentTools.js.map +1 -0
  292. package/dist/tools/assistanceTools.js +126 -0
  293. package/dist/tools/assistanceTools.js.map +1 -0
  294. package/dist/tools/brainTools.js +130 -0
  295. package/dist/tools/brainTools.js.map +1 -0
  296. package/dist/tools/catalog.js +30 -0
  297. package/dist/tools/catalog.js.map +1 -0
  298. package/dist/tools/changeTools.js +188 -0
  299. package/dist/tools/changeTools.js.map +1 -0
  300. package/dist/tools/context.js +243 -0
  301. package/dist/tools/context.js.map +1 -0
  302. package/dist/tools/contextTools.js +57 -0
  303. package/dist/tools/contextTools.js.map +1 -0
  304. package/dist/tools/coreCatalog.js +82 -0
  305. package/dist/tools/coreCatalog.js.map +1 -0
  306. package/dist/tools/desktopTools.js +41 -0
  307. package/dist/tools/desktopTools.js.map +1 -0
  308. package/dist/tools/directTools.js +273 -0
  309. package/dist/tools/directTools.js.map +1 -0
  310. package/dist/tools/envelope.js +71 -0
  311. package/dist/tools/envelope.js.map +1 -0
  312. package/dist/tools/extraTools.js +298 -0
  313. package/dist/tools/extraTools.js.map +1 -0
  314. package/dist/tools/gitTools.js +93 -0
  315. package/dist/tools/gitTools.js.map +1 -0
  316. package/dist/tools/jobTools.js +454 -0
  317. package/dist/tools/jobTools.js.map +1 -0
  318. package/dist/tools/memoryTools.js +102 -0
  319. package/dist/tools/memoryTools.js.map +1 -0
  320. package/dist/tools/metaTools.js +153 -0
  321. package/dist/tools/metaTools.js.map +1 -0
  322. package/dist/tools/multimodalTools.js +262 -0
  323. package/dist/tools/multimodalTools.js.map +1 -0
  324. package/dist/tools/projectTools.js +261 -0
  325. package/dist/tools/projectTools.js.map +1 -0
  326. package/dist/tools/resourceTools.js +81 -0
  327. package/dist/tools/resourceTools.js.map +1 -0
  328. package/dist/tools/runtimeTools.js +137 -0
  329. package/dist/tools/runtimeTools.js.map +1 -0
  330. package/dist/tools/scheduleTools.js +10 -0
  331. package/dist/tools/scheduleTools.js.map +1 -0
  332. package/dist/tools/semanticTools.js +141 -0
  333. package/dist/tools/semanticTools.js.map +1 -0
  334. package/dist/tools/subagentTools.js +32 -0
  335. package/dist/tools/subagentTools.js.map +1 -0
  336. package/dist/tools/surface.js +644 -0
  337. package/dist/tools/surface.js.map +1 -0
  338. package/dist/tunnel/control.js +113 -0
  339. package/dist/tunnel/control.js.map +1 -0
  340. package/dist/tunnel/credentials.js +392 -0
  341. package/dist/tunnel/credentials.js.map +1 -0
  342. package/dist/tunnel/log.js +84 -0
  343. package/dist/tunnel/log.js.map +1 -0
  344. package/dist/tunnel/runtime.js +55 -0
  345. package/dist/tunnel/runtime.js.map +1 -0
  346. package/dist/tunnel/supervisor.js +280 -0
  347. package/dist/tunnel/supervisor.js.map +1 -0
  348. package/dist/util/bytes.js +53 -0
  349. package/dist/util/bytes.js.map +1 -0
  350. package/dist/util/hash.js +53 -0
  351. package/dist/util/hash.js.map +1 -0
  352. package/dist/workspace/fs.js +305 -0
  353. package/dist/workspace/fs.js.map +1 -0
  354. package/dist/workspace/identity.js +17 -0
  355. package/dist/workspace/identity.js.map +1 -0
  356. package/dist/workspace/ignores.js +173 -0
  357. package/dist/workspace/ignores.js.map +1 -0
  358. package/dist/workspace/root.js +59 -0
  359. package/dist/workspace/root.js.map +1 -0
  360. package/docs/AGENT_RUNTIME.md +154 -0
  361. package/docs/AI_PROVIDERS.md +110 -0
  362. package/docs/ARCHITECTURE.md +331 -0
  363. package/docs/ASSISTANCE.md +31 -0
  364. package/docs/AUTH.md +59 -0
  365. package/docs/BRAIN.md +92 -0
  366. package/docs/COMPATIBILITY.md +92 -0
  367. package/docs/CONFIRMATION.md +13 -0
  368. package/docs/CONTEXT.md +87 -0
  369. package/docs/DESKTOP.md +35 -0
  370. package/docs/EVALUATION.md +86 -0
  371. package/docs/LARGE_INPUTS.md +21 -0
  372. package/docs/LSP.md +21 -0
  373. package/docs/MANUAL_ACCEPTANCE.md +283 -0
  374. package/docs/MEMORY.md +128 -0
  375. package/docs/MULTIMODAL.md +32 -0
  376. package/docs/PROJECTS.md +146 -0
  377. package/docs/RELEASE_1.0.0.md +111 -0
  378. package/docs/RELEASE_NOTES.md +92 -0
  379. package/docs/RESOURCES.md +83 -0
  380. package/docs/RUNTIME.md +125 -0
  381. package/docs/SCHEDULES.md +23 -0
  382. package/docs/SECURITY.md +250 -0
  383. package/docs/TEST_REPORT.md +312 -0
  384. package/docs/TUNNEL.md +86 -0
  385. package/docs/WEB_CLIENTS.md +43 -0
  386. package/docs/WINDOWS.md +79 -0
  387. package/docs/adr/001-one-process-one-workspace.md +22 -0
  388. package/docs/adr/002-external-tunnel.md +19 -0
  389. package/docs/adr/003-sdk-v2-legacy-stateless.md +20 -0
  390. package/docs/adr/004-embedded-oauth-provider.md +19 -0
  391. package/docs/adr/005-static-client-baseline.md +19 -0
  392. package/docs/adr/006-guarded-fs-explicit-exec.md +16 -0
  393. package/docs/adr/007-preview-hash-journal.md +18 -0
  394. package/docs/adr/008-ts-js-semantics-first.md +17 -0
  395. package/docs/adr/009-jobs-polling.md +18 -0
  396. package/docs/adr/010-sqlite-better-sqlite3.md +18 -0
  397. package/docs/adr/011-env-example-secure-default.md +19 -0
  398. package/docs/adr/012-workspace-scope-grant.md +24 -0
  399. package/docs/adr/013-loopback-proxy-trust.md +18 -0
  400. package/docs/adr/014-direct-coding-tier.md +48 -0
  401. package/docs/adr/015-coding-agent-parity.md +56 -0
  402. package/docs/adr/016-oidc-openid-consent.md +15 -0
  403. package/docs/adr/017-installation-identity-local-config.md +44 -0
  404. package/docs/adr/018-access-scope-encoding.md +11 -0
  405. package/docs/adr/019-runtime-workspace-switch.md +59 -0
  406. package/docs/adr/020-desktop-access.md +50 -0
  407. package/docs/adr/021-persistent-desktop-consent.md +39 -0
  408. package/docs/adr/022-owner-kill-preserves-login.md +30 -0
  409. package/docs/adr/023-large-coding-inputs.md +49 -0
  410. package/docs/adr/024-usage-rounds-and-schedules.md +72 -0
  411. package/docs/adr/025-owner-chat-whitelist.md +53 -0
  412. package/docs/adr/026-remove-conversation-confirmation.md +35 -0
  413. package/docs/adr/027-workspace-client-list.md +20 -0
  414. package/docs/adr/028-owner-client-deletion.md +25 -0
  415. package/docs/adr/029-compact-tool-surface.md +121 -0
  416. package/docs/adr/030-windows-native-candidate.md +71 -0
  417. package/docs/adr/031-owner-selected-tunnel-supervision.md +13 -0
  418. package/docs/adr/032-owner-project-registry.md +43 -0
  419. package/docs/adr/033-read-only-project-federation.md +61 -0
  420. package/docs/adr/034-universal-resource-cas.md +60 -0
  421. package/docs/adr/035-project-brain-incremental-index.md +51 -0
  422. package/docs/adr/036-context-engine-evidence.md +53 -0
  423. package/docs/adr/037-owner-reviewed-memory.md +46 -0
  424. package/docs/adr/038-runtime-intelligence.md +46 -0
  425. package/docs/adr/039-advanced-agent-runtime.md +57 -0
  426. package/docs/adr/040-dodobench-release-gate.md +43 -0
  427. package/docs/adr/041-local-macos-docker-linux-gates.md +32 -0
  428. package/docs/adr/042-project-directory-generation-identity.md +36 -0
  429. package/docs/adr/043-trusted-self-hosted-platform-gates.md +38 -0
  430. package/docs/adr/044-global-launcher-and-cli-menu.md +43 -0
  431. package/docs/adr/045-ai-providers-multiproject.md +56 -0
  432. package/docs/adr/046-personal-access-mode.md +32 -0
  433. package/docs/adr/README.md +64 -0
  434. package/examples/.dodo.json.example +20 -0
  435. package/examples/cloudflared.yml +23 -0
  436. package/examples/reverse-proxy-fixture.mjs +37 -0
  437. package/native/desktop-linux.py +387 -0
  438. package/native/desktop-windows.cs +180 -0
  439. package/native/desktop.swift +212 -0
  440. package/package.json +145 -0
  441. package/schemas/global-config.schema.json +497 -0
  442. package/schemas/project-config.schema.json +75 -0
  443. package/schemas/tools.compact.json +3236 -0
  444. package/schemas/tools.hybrid.json +8194 -0
  445. package/schemas/tools.json +34518 -0
  446. package/scripts/guard-publish.mjs +12 -0
  447. package/scripts/setup-multimodal.mjs +70 -0
@@ -0,0 +1,958 @@
1
+ /* DODO Local Config UI. Plain browser JS: no framework, no CDN, no telemetry.
2
+ * Everything rendered from the API goes through textContent / DOM APIs, never
3
+ * through HTML strings, so paths, client names and config values cannot
4
+ * inject markup. */
5
+ 'use strict';
6
+ (() => {
7
+ const $ = (id) => document.getElementById(id);
8
+ const TOKEN_KEY = 'dodo-config-token';
9
+ const THEME_KEY = 'dodo-theme';
10
+ const MODE_LABEL = { inspect: 'inspect', edit: 'edit', trusted: 'trusted' };
11
+ const MODE_TEXT = {
12
+ inspect: 'อ่านได้ แก้/รันต้องขออนุมัติทีละครั้ง',
13
+ edit: 'แก้ไฟล์ได้ทันที รันคำสั่งต้องขออนุมัติ',
14
+ trusted: 'ทำได้ทุกอย่าง คำสั่งรันด้วยสิทธิ์ OS ของคุณ',
15
+ };
16
+
17
+ // ---- token (fragment → sessionStorage, fragment stripped from the URL) ----
18
+ let token = '';
19
+ if (location.hash.length > 1) {
20
+ token = location.hash.slice(1);
21
+ try { sessionStorage.setItem(TOKEN_KEY, token); } catch (_e) { /* private mode */ }
22
+ history.replaceState(null, '', location.pathname);
23
+ } else {
24
+ try { token = sessionStorage.getItem(TOKEN_KEY) || ''; } catch (_e) { token = ''; }
25
+ }
26
+
27
+ // ---- theme: dark default, light, system; remembered per browser ----
28
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
29
+ const themeBtn = $('theme-toggle');
30
+ const THEME_ORDER = ['dark', 'light', 'system'];
31
+ const THEME_UI = { dark: ['◐', 'มืด'], light: ['☀', 'สว่าง'], system: ['⚙', 'ตามระบบ'] };
32
+ function readTheme() { try { const v = localStorage.getItem(THEME_KEY); return THEME_ORDER.includes(v) ? v : 'dark'; } catch (_e) { return 'dark'; } }
33
+ function applyTheme(pref) {
34
+ const effective = pref === 'system' ? (mq.matches ? 'dark' : 'light') : pref;
35
+ const root = document.documentElement;
36
+ const changed = root.dataset.theme !== effective;
37
+ root.dataset.theme = effective;
38
+ root.dataset.themePref = pref;
39
+ // Some engines don't re-resolve var()-based backgrounds for elements that
40
+ // were styled while hidden and later shown, when only :root's theme
41
+ // attribute changes. Force one full style recomputation so every element
42
+ // picks up the new palette immediately.
43
+ if (changed) { root.style.display = 'none'; void root.offsetHeight; root.style.display = ''; }
44
+ const [icon, label] = THEME_UI[pref];
45
+ themeBtn.textContent = `${icon} ${label}`;
46
+ themeBtn.setAttribute('aria-label', `สลับธีม: ${label}`);
47
+ }
48
+ applyTheme(readTheme());
49
+ mq.addEventListener('change', () => { if (readTheme() === 'system') applyTheme('system'); });
50
+ themeBtn.addEventListener('click', () => {
51
+ const next = THEME_ORDER[(THEME_ORDER.indexOf(readTheme()) + 1) % THEME_ORDER.length];
52
+ try { localStorage.setItem(THEME_KEY, next); } catch (_e) { /* ignore */ }
53
+ applyTheme(next);
54
+ });
55
+
56
+ // ---- notices (no alert()) ----
57
+ const alerts = () => {
58
+ const ui = window.DodoUI;
59
+ return ui && ui.alerts && ui.alerts.available() ? ui.alerts : null;
60
+ };
61
+ const notice = $('notice');
62
+ let noticeTimer = 0;
63
+ function notify(kind, text, sticky) {
64
+ // Backend-confirmed successes become a toast; errors stay as the sticky
65
+ // inline bar (and important callers additionally raise a modal).
66
+ if (kind === 'success' && !sticky && alerts() && alerts().toast('success', text)) return;
67
+ clearTimeout(noticeTimer);
68
+ notice.dataset.kind = kind;
69
+ notice.querySelector('.notice-icon').textContent = kind === 'success' ? '✓' : kind === 'error' ? '✕' : 'ℹ';
70
+ notice.querySelector('.notice-text').textContent = text;
71
+ notice.hidden = false;
72
+ if (!sticky && kind !== 'error') noticeTimer = setTimeout(() => { notice.hidden = true; }, 8000);
73
+ }
74
+ $('notice-close').addEventListener('click', () => { notice.hidden = true; });
75
+
76
+ // ---- confirm: SweetAlert2 (vendored) first, <dialog> then confirm() fallback ----
77
+ const dialog = $('confirm');
78
+ async function confirmAction(title, text, okLabel) {
79
+ const a = alerts();
80
+ if (a) {
81
+ const result = await a.confirm({ title, text, confirmText: okLabel || 'ยืนยัน', danger: true });
82
+ if (result !== null) return result;
83
+ }
84
+ return new Promise((resolve) => {
85
+ $('confirm-title').textContent = title;
86
+ $('confirm-text').textContent = text;
87
+ $('confirm-ok').textContent = okLabel || 'ยืนยัน';
88
+ if (typeof dialog.showModal !== 'function') { resolve(window.confirm(text)); return; }
89
+ const onClose = () => { dialog.removeEventListener('close', onClose); resolve(dialog.returnValue === 'ok'); };
90
+ dialog.addEventListener('close', onClose);
91
+ dialog.returnValue = 'cancel';
92
+ dialog.showModal();
93
+ });
94
+ }
95
+
96
+ // ---- API ----
97
+ function workspaceContext() {
98
+ const context = state && (state.controlContext || state.workspace);
99
+ return context ? { 'x-dodo-workspace': context.workspaceId, 'x-dodo-epoch': context.epoch } : {};
100
+ }
101
+ async function api(path, body, context = workspaceContext()) {
102
+ const headers = { Authorization: `Bearer ${token}`, ...context };
103
+ if (body !== undefined) Object.assign(headers, context, { 'Content-Type': 'application/json' });
104
+ let res;
105
+ try {
106
+ res = await fetch(`/api/${path}`, { method: body !== undefined ? 'POST' : 'GET', headers, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: 'omit', cache: 'no-store' });
107
+ } catch (_e) {
108
+ const err = new Error('ติดต่อ DODO ไม่ได้ — server อาจปิดอยู่ หรือกำลัง restart');
109
+ err.status = 0;
110
+ throw err;
111
+ }
112
+ let data = null;
113
+ try { data = await res.json(); } catch (_e) { data = null; }
114
+ if (!res.ok) {
115
+ const err = new Error((data && data.error) || `HTTP ${res.status}`);
116
+ err.status = res.status;
117
+ err.code = data && data.code;
118
+ err.data = data;
119
+ throw err;
120
+ }
121
+ return data;
122
+ }
123
+
124
+ // ---- busy helper: no double submits ----
125
+ async function withBusy(button, busyLabel, fn) {
126
+ if (button.disabled) return undefined;
127
+ const original = button.textContent;
128
+ button.disabled = true;
129
+ button.setAttribute('aria-busy', 'true');
130
+ if (busyLabel) button.textContent = busyLabel;
131
+ try { return await fn(); } finally {
132
+ button.disabled = false;
133
+ button.removeAttribute('aria-busy');
134
+ button.textContent = original;
135
+ }
136
+ }
137
+
138
+ // ---- copy ----
139
+ async function copyText(text, button) {
140
+ if (!text) { notify('error', 'ไม่มีค่าให้คัดลอก'); return; }
141
+ let ok = false;
142
+ try { await navigator.clipboard.writeText(text); ok = true; } catch (_e) {
143
+ const ta = document.createElement('textarea');
144
+ ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0';
145
+ document.body.append(ta); ta.select();
146
+ try { ok = document.execCommand('copy'); } catch (_e2) { ok = false; }
147
+ ta.remove();
148
+ }
149
+ if (ok) {
150
+ const original = button.textContent;
151
+ button.textContent = 'คัดลอกแล้ว ✓';
152
+ setTimeout(() => { button.textContent = original; }, 1500);
153
+ if (alerts()) alerts().toast('success', 'คัดลอกไปยังคลิปบอร์ดแล้ว');
154
+ } else {
155
+ notify('error', 'คัดลอกอัตโนมัติไม่ได้ ให้เลือกข้อความแล้วคัดลอกเอง');
156
+ }
157
+ }
158
+
159
+ // ---- card state helpers ----
160
+ function cardState(prefix, state, message) {
161
+ const loading = $(`${prefix}-loading`), body = $(`${prefix}-body`), error = $(`${prefix}-error`);
162
+ loading.hidden = state !== 'loading';
163
+ body.hidden = state !== 'ready';
164
+ error.hidden = state !== 'error';
165
+ if (state === 'error') error.textContent = `✕ ${message || 'โหลดข้อมูลไม่สำเร็จ'}`;
166
+ }
167
+ function setChip(id, state, icon, value) {
168
+ const chip = $(id);
169
+ chip.dataset.state = state;
170
+ chip.querySelector('.chip-icon').textContent = icon;
171
+ chip.querySelector('.chip-value').textContent = value;
172
+ }
173
+ function fmtRemaining(ms) {
174
+ if (!Number.isFinite(ms) || ms <= 0) return 'หมดอายุแล้ว';
175
+ const m = Math.floor(ms / 60000);
176
+ const h = Math.floor(m / 60);
177
+ return h > 0 ? `${h} ชม. ${m % 60} นาที` : `${m} นาที`;
178
+ }
179
+
180
+ // ---- render ----
181
+ let state = null;
182
+ let lastRoot = null;
183
+ let switchedOnce = false;
184
+ let lastSavedMode = null;
185
+ let lastPublicUrl = null;
186
+ let lastClientsSig = null;
187
+ let lastTunnelFormSig = null;
188
+ let projectsGeneration = 0;
189
+
190
+ /* Background refreshes must never clobber what the owner is editing:
191
+ * form sections are only re-rendered when nothing there is dirty or focused,
192
+ * unless the refresh was user-initiated (force). */
193
+ function render(s, force) {
194
+ const previousWorkspace = state && state.workspace ? `${state.workspace.workspaceId}:${state.workspace.epoch}` : null;
195
+ const nextWorkspace = s.workspace ? `${s.workspace.workspaceId}:${s.workspace.epoch}` : null;
196
+ if (state && previousWorkspace !== nextWorkspace) {
197
+ force = true;
198
+ resetClientPicker();
199
+ resetClientManager();
200
+ projectsGeneration++;
201
+ notify('info', 'Workspace เปลี่ยนแล้ว — ล้างค่าที่ยังไม่บันทึก กรุณาตรวจโปรเจกต์ใหม่ก่อนบันทึก', true);
202
+ }
203
+ state = s;
204
+ $('version').textContent = s.version ? `v${s.version}` : '';
205
+ // chips
206
+ const c = s.connection || {};
207
+ if (c.workspaceSelected === false) setChip('chip-mcp', 'warn', '⚠', 'เปิดอยู่ แต่รอเจ้าของเลือก workspace');
208
+ else if (c.mcpLocalUrl) setChip('chip-mcp', s.state === 'switching' ? 'busy' : 'ok', s.state === 'switching' ? '⟳' : '●', s.state === 'switching' ? 'กำลังสลับ workspace' : `เปิดที่ ${c.mcpLocalUrl.replace(/^http:\/\//, '')}`);
209
+ else setChip('chip-mcp', 'warn', '○', 'ไม่มี HTTP listener ใน entry นี้');
210
+ if (c.oauthConfigured === true) setChip('chip-oauth', 'ok', '●', `เปิดใช้ · ${c.activePublicUrl}`);
211
+ else if (c.oauthConfigured === false) setChip('chip-oauth', 'warn', '⚠', 'LOCKED — ยังไม่ตั้ง public URL');
212
+ else setChip('chip-oauth', 'idle', '○', 'ไม่เกี่ยวกับ entry นี้');
213
+ const remaining = (c.expiresAt || 0) - Date.now();
214
+ setChip('chip-config', remaining > 0 ? 'ok' : 'error', remaining > 0 ? '●' : '✕', remaining > 0 ? `ใช้ได้อีก ${fmtRemaining(remaining)}` : 'token หมดอายุ');
215
+ $('foot-expiry').textContent = fmtRemaining(remaining);
216
+
217
+ // workspace
218
+ const w = s.workspace;
219
+ $('ws-name').textContent = w ? (w.name || w.root) : 'ยังไม่ได้เลือกโปรเจกต์';
220
+ const rootEl = $('ws-root');
221
+ rootEl.textContent = w ? w.root : 'เลือกจาก Project Registry หรือใส่ absolute path ด้านล่าง';
222
+ rootEl.title = w ? w.root : '';
223
+ $('ws-id').textContent = w ? w.workspaceId : 'จะสร้างเมื่อเลือกโปรเจกต์';
224
+ $('ws-epoch').textContent = w ? w.epoch : '—';
225
+ $('ws-jobs').textContent = w ? (w.runningJobs > 0 ? `${w.runningJobs} งาน (สลับ workspace ไม่ได้จนกว่าจะเสร็จหรือถูกยกเลิก)` : 'ไม่มี') : 'ไม่มี workspace';
226
+ $('ws-recovery').textContent = w ? (w.recoveryRequired > 0 ? `${w.recoveryRequired} รายการ — ดูด้วย dodo recover` : 'ไม่มี') : '—';
227
+ const badge = $('ws-state-badge');
228
+ if (s.state === 'switching') { badge.textContent = '⟳ กำลังสลับ'; badge.className = 'badge info'; badge.hidden = false; }
229
+ else if (!w) { badge.textContent = '⚠ รอเลือกโปรเจกต์'; badge.className = 'badge warn'; badge.hidden = false; }
230
+ else if (w.runningJobs > 0) { badge.textContent = `▶ ${w.runningJobs} งานกำลังรัน`; badge.className = 'badge warn'; badge.hidden = false; }
231
+ else { badge.hidden = true; }
232
+ const canSwitch = Boolean(s.workspaceSwitchSupported || (w && w.switchSupported));
233
+ $('ws-switch').disabled = !canSwitch || s.state === 'switching';
234
+ $('ws-path').disabled = !canSwitch;
235
+ if (!canSwitch) $('ws-path-help').textContent = 'entry นี้ (stdio) ผูกกับโฟลเดอร์ที่ client เปิดมา การสลับ workspace ทำได้เฉพาะเมื่อรันด้วย dodo start';
236
+ if (w && lastRoot !== null && lastRoot !== w.root) switchedOnce = true;
237
+ lastRoot = w ? w.root : null;
238
+ $('ws-hint').hidden = !switchedOnce;
239
+ cardState('ws', 'ready');
240
+ void loadProjects(force === true);
241
+
242
+ const desktop = w && s.desktop && s.desktop.policy;
243
+ $('desktop-mode').textContent = desktop ? desktop.mode : 'off';
244
+ $('desktop-summary').textContent = desktop && desktop.mode !== 'off'
245
+ ? desktop.persistent
246
+ ? `อนุญาต ${desktop.allowedApps.join(', ')} — จำครั้งเดียวสำหรับ DODO ทุกโปรเจกต์จนกว่าจะปิดสิทธิ์`
247
+ : `อนุญาต ${desktop.allowedApps.join(', ')} ถึง ${new Date(desktop.expiresAt).toLocaleTimeString()} (สิทธิ์ของ workspace รอบนี้)`
248
+ : 'การอ่านและควบคุมหน้าต่างปิดอยู่';
249
+ $('desktop-disable').disabled = !desktop || desktop.mode === 'off';
250
+
251
+ // permissions — personal mode hides the per-project ACL/trust ceremony
252
+ // entirely (a short note with a tooltip replaces it); managed mode keeps
253
+ // every original control. Pure presentation: no scope/guard changes.
254
+ const p = s.permissions;
255
+ const personalMode = Boolean(w && p && p.accessMode === 'personal');
256
+ $('card-perm').hidden = personalMode;
257
+ $('card-clients').hidden = personalMode;
258
+ $('personal-note').hidden = !personalMode;
259
+ if (!w || !p) {
260
+ $('perm-saved').textContent = 'รอเลือก workspace';
261
+ $('perm-effective').textContent = 'รอเลือก workspace';
262
+ $('perm-effective-badge').hidden = false;
263
+ $('perm-effective-badge').className = 'badge warn';
264
+ $('perm-effective-badge').textContent = '⚠ ยังไม่มีสิทธิ์ที่มีผล';
265
+ $('perm-override').hidden = true;
266
+ document.querySelectorAll('#perm-form input').forEach((input) => { input.disabled = true; });
267
+ $('perm-save').disabled = true;
268
+ $('perm-help').textContent = 'เลือกโปรเจกต์ก่อนจึงจะตั้ง trust mode ได้';
269
+ cardState('perm', 'ready');
270
+ } else {
271
+ const personal = p.accessMode === 'personal';
272
+ document.querySelectorAll('#perm-form input').forEach((input) => { input.disabled = personal; });
273
+ $('perm-save').disabled = personal;
274
+ $('perm-saved').textContent = MODE_LABEL[p.savedMode] || p.savedMode;
275
+ const eff = $('perm-effective');
276
+ eff.textContent = MODE_LABEL[p.effectiveMode] || p.effectiveMode;
277
+ if (p.override) {
278
+ const b = document.createElement('span');
279
+ b.className = 'badge warn';
280
+ b.textContent = p.override === 'bypass' ? 'override: --bypass' : 'override: --allow --all';
281
+ eff.append(b);
282
+ }
283
+ const ebadge = $('perm-effective-badge');
284
+ ebadge.hidden = false;
285
+ ebadge.textContent = p.effectiveMode === 'trusted' ? '⚠ trusted: รันด้วยสิทธิ์ OS ของคุณ' : `● ${MODE_TEXT[p.effectiveMode] || p.effectiveMode}`;
286
+ ebadge.className = p.effectiveMode === 'trusted' ? 'badge warn' : 'badge ok';
287
+ const ov = $('perm-override');
288
+ if (personal) {
289
+ ov.hidden = false;
290
+ $('perm-override-text').textContent = 'โหมดส่วนตัวมีผล: โปรเจกต์ที่เจ้าของเพิ่มพร้อมอ่าน แก้ไฟล์ และรันคำสั่งทันทีตาม OAuth token/profile scopes คำสั่งใช้สิทธิ์ OS ของคุณ ส่วน sandbox, path/secret guards และ expected hash ยังทำงาน';
291
+ } else if (p.override) {
292
+ ov.hidden = false;
293
+ $('perm-override-text').textContent = p.override === 'bypass'
294
+ ? 'รอบนี้เปิดด้วย --bypass: ทุก action เป็น trusted และ command sandbox ค่าเริ่มต้นถูกปิด คำสั่งรันด้วยสิทธิ์ OS ของบัญชีคุณโดยไม่ถาม ค่าที่บันทึกด้านล่างจะมีผลก็ต่อเมื่อ restart โดยไม่ใช้ flag'
295
+ : 'รอบนี้เปิดด้วย --allow --all: ทุก action เป็น trusted คำสั่งรันด้วยสิทธิ์ OS ของบัญชีคุณโดยไม่ถาม (sandbox ตาม config) ค่าที่บันทึกด้านล่างจะมีผลก็ต่อเมื่อ restart โดยไม่ใช้ flag';
296
+ } else { ov.hidden = true; }
297
+ const checkedNow = document.querySelector('#perm-form input:checked');
298
+ const permDirty = !force && checkedNow && lastSavedMode !== null && checkedNow.value !== lastSavedMode;
299
+ if (!permDirty) {
300
+ const radio = document.querySelector(`#perm-form input[value="${p.savedMode}"]`);
301
+ if (radio) radio.checked = true;
302
+ }
303
+ lastSavedMode = p.savedMode;
304
+ $('perm-help').textContent = personal
305
+ ? 'ไม่ต้องบันทึก trust แยกต่อโปรเจกต์ เปลี่ยนเป็นโหมดแยกสิทธิ์ได้จาก Settings'
306
+ : p.override
307
+ ? 'บันทึกได้ แต่สิทธิ์ที่มีผลรอบนี้ยังเป็น trusted จาก override จนกว่าจะ restart โดยไม่ใช้ flag'
308
+ : 'มีผลกับคำขอถัดไป ไม่ยกเลิกงานที่รันอยู่แล้ว';
309
+ cardState('perm', 'ready');
310
+ }
311
+
312
+ // connection
313
+ $('conn-local').textContent = c.mcpLocalUrl || 'ไม่มีใน entry นี้';
314
+ $('conn-local-copy').disabled = !c.mcpLocalUrl;
315
+ $('conn-public').textContent = c.mcpPublicUrl || (c.publicUrl ? `${c.publicUrl}/mcp (หลัง restart)` : 'ยังไม่ตั้งค่า');
316
+ $('conn-public-copy').disabled = !(c.mcpPublicUrl || c.publicUrl);
317
+ $('conn-config').textContent = c.localConfigOrigin || location.origin;
318
+ const oauth = $('conn-oauth');
319
+ oauth.replaceChildren();
320
+ const ob = document.createElement('span');
321
+ if (c.oauthConfigured === true) { ob.className = 'badge ok'; ob.textContent = '● เปิดใช้'; oauth.append(ob, document.createTextNode(` issuer ${c.activePublicUrl}`)); }
322
+ else if (c.oauthConfigured === false) { ob.className = 'badge warn'; ob.textContent = '⚠ LOCKED'; oauth.append(ob, document.createTextNode(' ยังไม่มี public URL — MCP ปฏิเสธทุก tool call จนกว่าจะตั้งค่าและ restart')); }
323
+ else { ob.className = 'badge'; ob.textContent = '○ ไม่เกี่ยวกับ entry นี้'; oauth.append(ob); }
324
+ const originInput = $('conn-origin');
325
+ const originDirty = !force && (document.activeElement === originInput || (lastPublicUrl !== null && originInput.value !== lastPublicUrl));
326
+ if (!originDirty) originInput.value = c.publicUrl || '';
327
+ lastPublicUrl = c.publicUrl || '';
328
+ $('conn-restart').hidden = !c.restartRequired;
329
+ cardState('conn', 'ready');
330
+
331
+ // Tunnel state contains no credential. The password input is submitted
332
+ // only to the run-scoped start endpoint and is cleared after the request.
333
+ const tunnel = s.tunnel || {};
334
+ const serverFormSig = JSON.stringify([Boolean(tunnel.startWithDodo), Number(tunnel.metricsPort || 21732), Number(tunnel.maxRestarts ?? 2)]);
335
+ const browserFormSig = JSON.stringify([$('tunnel-auto').checked, Number($('tunnel-metrics').value), Number($('tunnel-restarts').value)]);
336
+ const tunnelDirty = !force && lastTunnelFormSig !== null && browserFormSig !== lastTunnelFormSig;
337
+ if (!tunnelDirty) {
338
+ $('tunnel-auto').checked = Boolean(tunnel.startWithDodo);
339
+ $('tunnel-metrics').value = String(tunnel.metricsPort || 21732);
340
+ $('tunnel-restarts').value = String(tunnel.maxRestarts ?? 2);
341
+ }
342
+ lastTunnelFormSig = serverFormSig;
343
+ const tunnelRuntime = tunnel.runtime || {};
344
+ const currentTunnel = tunnelRuntime.current;
345
+ if (currentTunnel && currentTunnel.connected) {
346
+ $('tunnel-badge').className = 'badge ok';
347
+ $('tunnel-badge').textContent = '● Tunnel เชื่อมต่อแล้ว';
348
+ } else if (currentTunnel && currentTunnel.running) {
349
+ $('tunnel-badge').className = 'badge warn';
350
+ $('tunnel-badge').textContent = `◌ Tunnel ${currentTunnel.phase || 'กำลังเริ่ม'}`;
351
+ } else {
352
+ $('tunnel-badge').className = tunnel.startWithDodo ? 'badge' : 'badge';
353
+ $('tunnel-badge').textContent = tunnel.startWithDodo ? '○ ยังไม่รัน · จะถาม token ตอนเริ่ม' : '○ Tunnel ไม่ได้รัน';
354
+ }
355
+ $('tunnel-session-start').disabled = tunnelRuntime.available !== true || Boolean(currentTunnel && currentTunnel.running);
356
+ $('tunnel-session-stop').disabled = tunnelRuntime.available !== true || !Boolean(currentTunnel && currentTunnel.running);
357
+
358
+ // clients
359
+ $('clients-root').textContent = w ? w.root : 'ยังไม่ได้เลือกโปรเจกต์';
360
+ renderClients(s.clients || [], force);
361
+ $('clients-add').hidden = !w;
362
+ $('clients-manage').hidden = !w;
363
+ cardState('clients', 'ready');
364
+ document.getElementById('main').setAttribute('aria-busy', 'false');
365
+ }
366
+
367
+ // ---- owner project registry ----
368
+ function projectStatus(project, active) {
369
+ if (active) return { className: 'badge ok', text: '● เปิดอยู่' };
370
+ const labels = {
371
+ ready: ['badge ok', '● พร้อม'],
372
+ missing: ['badge warn', '⚠ ไม่พบ path'],
373
+ symlinked: ['badge danger', '✕ พบ symbolic link'],
374
+ replaced: ['badge danger', '✕ identity เปลี่ยน'],
375
+ inaccessible: ['badge warn', '⚠ ตรวจสอบไม่ได้'],
376
+ invalid: ['badge danger', '✕ metadata ผิดปกติ'],
377
+ removed: ['badge', '○ นำออกแล้ว'],
378
+ };
379
+ const selected = labels[project.availability] || ['badge warn', `⚠ ${project.availability}`];
380
+ return { className: selected[0], text: selected[1] };
381
+ }
382
+
383
+ async function loadProjects(force) {
384
+ if (!state || !state.controlContext) return;
385
+ const generation = ++projectsGeneration;
386
+ const context = workspaceContext();
387
+ if (force) cardState('projects', 'loading');
388
+ try {
389
+ const result = await api('projects', undefined, context);
390
+ if (generation !== projectsGeneration || !state || result.workspaceId !== state.controlContext.workspaceId || result.workspaceEpoch !== state.controlContext.epoch) return;
391
+ renderProjects(result, context);
392
+ cardState('projects', 'ready');
393
+ } catch (error) {
394
+ if (generation !== projectsGeneration) return;
395
+ cardState('projects', 'error', error.message);
396
+ }
397
+ }
398
+
399
+ function renderProjects(result, context) {
400
+ const list = $('projects-list');
401
+ list.replaceChildren();
402
+ $('projects-empty').hidden = result.projects.length > 0;
403
+ $('projects-count').hidden = false;
404
+ $('projects-count').textContent = `${result.projects.length} โปรเจกต์`;
405
+ for (const project of result.projects) {
406
+ const active = Boolean(state.workspace) && project.workspaceId === result.activeWorkspaceId && project.root === state.workspace.root;
407
+ const article = document.createElement('article');
408
+ article.className = 'client project-entry';
409
+ const head = document.createElement('div'); head.className = 'client-head';
410
+ const titleWrap = document.createElement('div');
411
+ const name = document.createElement('div'); name.className = 'client-name'; name.textContent = project.displayName;
412
+ const id = document.createElement('code'); id.className = 'client-id'; id.textContent = project.projectId;
413
+ titleWrap.append(name, id);
414
+ const status = document.createElement('span');
415
+ const statusInfo = projectStatus(project, active);
416
+ status.className = statusInfo.className; status.textContent = statusInfo.text;
417
+ head.append(titleWrap, status);
418
+
419
+ const root = document.createElement('code'); root.className = 'path project-path'; root.textContent = project.root; root.title = project.root;
420
+ const description = document.createElement('p'); description.className = 'help project-status'; description.textContent = project.statusText;
421
+ const details = document.createElement('details'); details.className = 'details';
422
+ const summary = document.createElement('summary'); summary.textContent = 'รายละเอียด registry';
423
+ const dl = document.createElement('dl'); dl.className = 'dl';
424
+ for (const [label, value] of [['Workspace ID', project.workspaceId], ['Directory identity', `${project.identity.dev}:${project.identity.ino}:${project.identity.birthtimeNs}`], ['อัปเดต', new Date(project.updatedAt).toLocaleString()]]) {
425
+ const dt = document.createElement('dt'); dt.textContent = label;
426
+ const dd = document.createElement('dd'); dd.textContent = value;
427
+ dl.append(dt, dd);
428
+ }
429
+ details.append(summary, dl);
430
+
431
+ const actions = document.createElement('div'); actions.className = 'actions';
432
+ const switchButton = document.createElement('button'); switchButton.type = 'button'; switchButton.className = 'btn small'; switchButton.textContent = active ? 'กำลังเปิดอยู่' : 'เปิดโปรเจกต์นี้';
433
+ switchButton.disabled = active || !project.available || !state.workspaceSwitchSupported;
434
+ switchButton.addEventListener('click', () => withBusy(switchButton, 'กำลังสลับ…', async () => {
435
+ try {
436
+ const switched = await api('workspace/switch', { path: project.root }, context);
437
+ if (switched.changed) {
438
+ switchedOnce = true;
439
+ notify('success', `สลับไปที่ ${project.displayName} แล้ว — AI ต้องเรียก project_overview ใหม่ และ client ต้องมีสิทธิ์ใน workspace ใหม่`, true);
440
+ }
441
+ await refresh(true);
442
+ } catch (error) {
443
+ notify('error', `เปิดโปรเจกต์ไม่สำเร็จ: ${error.message}${error.data && error.data.recovery ? ` (${error.data.recovery})` : ''}`);
444
+ await refresh(true);
445
+ }
446
+ }));
447
+ const removeButton = document.createElement('button'); removeButton.type = 'button'; removeButton.className = 'btn danger-outline small'; removeButton.textContent = 'นำออกจากรายการ';
448
+ removeButton.addEventListener('click', () => withBusy(removeButton, 'กำลังนำออก…', async () => {
449
+ const warning = active
450
+ ? `${project.displayName} กำลังเปิดอยู่ การนำออกจะลบเฉพาะรายการ registry และไม่หยุด server ไม่ลบไฟล์ ประวัติ trust หรือ client ACL`
451
+ : `นำ ${project.displayName} ออกจาก registry? ไฟล์ ประวัติ trust และ client ACL จะไม่ถูกลบ`;
452
+ if (!(await confirmAction('นำโปรเจกต์ออกจาก registry?', warning, 'นำออกจากรายการ'))) return;
453
+ try {
454
+ await api('projects/remove', { projectId: project.projectId, confirmProjectId: project.projectId }, context);
455
+ notify('success', `นำ ${project.displayName} ออกจาก registry แล้ว โดยไม่ลบไฟล์หรือสิทธิ์เดิม`);
456
+ await loadProjects(true);
457
+ } catch (error) { notify('error', `นำโปรเจกต์ออกไม่สำเร็จ: ${error.message}`); }
458
+ }));
459
+ actions.append(switchButton, removeButton);
460
+ article.append(head, root, description, details, actions);
461
+ list.append(article);
462
+ }
463
+ }
464
+
465
+ $('project-add-form').addEventListener('submit', (event) => {
466
+ event.preventDefault();
467
+ const pathInput = $('project-add-path');
468
+ const nameInput = $('project-add-name');
469
+ const errorElement = $('project-add-error');
470
+ const projectPath = pathInput.value.trim();
471
+ const displayName = nameInput.value.trim();
472
+ errorElement.hidden = true; pathInput.removeAttribute('aria-invalid');
473
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
474
+ errorElement.textContent = '✕ ต้องกรอก absolute path ของโฟลเดอร์'; errorElement.hidden = false; pathInput.setAttribute('aria-invalid', 'true'); pathInput.focus(); return;
475
+ }
476
+ const context = workspaceContext();
477
+ withBusy($('project-add'), 'กำลังเพิ่ม…', async () => {
478
+ try {
479
+ const body = { path: projectPath };
480
+ if (displayName) body.displayName = displayName;
481
+ const result = await api('projects/add', body, context);
482
+ if ($('project-add-open').checked) {
483
+ const switched = await api('workspace/switch', { path: result.project.root }, context);
484
+ switchedOnce = Boolean(switched.changed);
485
+ notify('success', `เพิ่มและเปิด ${result.project.displayName} แล้ว — AI ต้องเรียก project_overview ใหม่ และ client ต้องได้รับสิทธิ์สำหรับ workspace นี้`, true);
486
+ pathInput.value = ''; nameInput.value = '';
487
+ await refresh(true);
488
+ } else {
489
+ pathInput.value = ''; nameInput.value = '';
490
+ notify(result.changed ? 'success' : 'info', result.relocated ? `อัปเดต path ของ ${result.project.displayName} แล้ว โดยไม่คัดลอกสิทธิ์ workspace เดิม` : result.changed ? `เพิ่ม ${result.project.displayName} ใน registry แล้ว` : 'โปรเจกต์นี้อยู่ใน registry แล้ว');
491
+ await loadProjects(true);
492
+ }
493
+ } catch (error) {
494
+ errorElement.textContent = `✕ ${error.message}${error.data && error.data.recovery ? ` (${error.data.recovery})` : ''}`;
495
+ errorElement.hidden = false; pathInput.setAttribute('aria-invalid', 'true');
496
+ notify('error', 'เพิ่มโปรเจกต์ไม่สำเร็จ');
497
+ }
498
+ });
499
+ });
500
+
501
+ function renderClients(clients, force) {
502
+ const context = workspaceContext();
503
+ const list = $('clients-list');
504
+ const sig = JSON.stringify(clients.map((c) => [c.id, c.name, c.public, c.scopes]));
505
+ if (!force) {
506
+ if (sig === lastClientsSig) return; // nothing changed on the server
507
+ const dirty = [...list.querySelectorAll('input[type="checkbox"]')].some((b) => b.checked !== (b.dataset.orig === '1')) || list.contains(document.activeElement);
508
+ if (dirty) return; // keep unsaved edits; the next save/reload re-syncs
509
+ }
510
+ lastClientsSig = sig;
511
+ list.replaceChildren();
512
+ const count = $('clients-count');
513
+ count.hidden = false;
514
+ count.textContent = `${clients.length} client ในโฟลเดอร์นี้`;
515
+ $('clients-empty').hidden = clients.length > 0;
516
+ const tpl = $('client-template');
517
+ for (const cl of clients) {
518
+ const node = tpl.content.firstElementChild.cloneNode(true);
519
+ node.querySelector('.client-name').textContent = cl.name || '(ไม่มีชื่อ)';
520
+ node.querySelector('.client-id').textContent = cl.id;
521
+ const kind = node.querySelector('.client-kind');
522
+ kind.textContent = cl.public ? 'public · PKCE' : 'confidential';
523
+ if (cl.scopes.length) kind.classList.add('ok');
524
+ const status = node.querySelector('.client-status');
525
+ status.textContent = cl.scopes.length ? `สิทธิ์ปัจจุบัน: ${cl.scopes.join(', ')}` : 'ยังไม่มีสิทธิ์ใน workspace นี้';
526
+ const boxes = [...node.querySelectorAll('input[type="checkbox"]')];
527
+ for (const box of boxes) {
528
+ box.checked = cl.scopes.includes(box.value);
529
+ box.dataset.orig = box.checked ? '1' : '0';
530
+ }
531
+ const saveBtn = node.querySelector('.client-save');
532
+ const revokeBtn = node.querySelector('.client-revoke');
533
+ revokeBtn.disabled = cl.scopes.length === 0;
534
+ saveBtn.addEventListener('click', () => withBusy(saveBtn, 'กำลังบันทึก…', async () => {
535
+ const scopes = boxes.filter((b) => b.checked).map((b) => b.value);
536
+ if (scopes.length === 0) {
537
+ const ok = await confirmAction('ถอนสิทธิ์ทั้งหมด?', `ไม่ได้เลือก scope ใดเลย การบันทึกจะถอนสิทธิ์ของ ${cl.name || cl.id} ใน workspace นี้ทั้งหมด`, 'ถอนสิทธิ์');
538
+ if (!ok) return;
539
+ }
540
+ await saveAccess(cl, scopes, status, context);
541
+ }));
542
+ revokeBtn.addEventListener('click', () => withBusy(revokeBtn, 'กำลังถอน…', async () => {
543
+ const ok = await confirmAction('ถอนสิทธิ์ทั้งหมด?', `${cl.name || cl.id} จะเรียก tool ใน workspace นี้ไม่ได้อีกจนกว่าจะให้สิทธิ์ใหม่ (OAuth login ของ client ยังอยู่)`, 'ถอนสิทธิ์');
544
+ if (!ok) return;
545
+ await saveAccess(cl, [], status, context);
546
+ }));
547
+ list.append(node);
548
+ }
549
+ }
550
+
551
+ async function saveAccess(cl, scopes, statusEl, context) {
552
+ try {
553
+ await api('access', { clientId: cl.id, scopes }, context);
554
+ notify('success', scopes.length ? `บันทึกสิทธิ์ของ ${cl.name || cl.id} แล้ว: ${scopes.join(', ')} (มีผลกับคำขอถัดไป)` : `ถอนสิทธิ์ของ ${cl.name || cl.id} ใน workspace นี้แล้ว`);
555
+ await refresh(true);
556
+ } catch (e) {
557
+ statusEl.textContent = `✕ ${e.message}`;
558
+ notify('error', `บันทึกสิทธิ์ไม่สำเร็จ: ${e.message}`);
559
+ }
560
+ }
561
+
562
+ // The add picker is separate from the current-root list and loaded on demand.
563
+ let clientPickerGeneration = 0;
564
+ let clientPickerContext = null;
565
+ function resetClientPicker() {
566
+ clientPickerGeneration++;
567
+ clientPickerContext = null;
568
+ $('clients-add').open = false;
569
+ $('client-add-form').reset();
570
+ $('client-add-form').hidden = true;
571
+ $('client-add-select').replaceChildren();
572
+ $('client-add-select').disabled = true;
573
+ $('client-add-status').textContent = '';
574
+ }
575
+ async function loadClientPicker() {
576
+ const generation = ++clientPickerGeneration;
577
+ const context = workspaceContext();
578
+ clientPickerContext = null;
579
+ $('client-add-form').hidden = true;
580
+ $('client-add-select').disabled = true;
581
+ $('client-add-status').textContent = 'กำลังโหลด client ที่เพิ่มได้…';
582
+ try {
583
+ const result = await api('access/available', undefined, context);
584
+ if (generation !== clientPickerGeneration || !state || result.workspaceId !== state.workspace.workspaceId || result.workspaceEpoch !== state.workspace.epoch) return;
585
+ const select = $('client-add-select');
586
+ select.replaceChildren();
587
+ const placeholder = document.createElement('option');
588
+ placeholder.value = ''; placeholder.textContent = 'เลือก client'; select.append(placeholder);
589
+ for (const client of result.clients) {
590
+ const option = document.createElement('option');
591
+ option.value = client.id; option.textContent = `${client.name || '(ไม่มีชื่อ)'} · ${client.id}`;
592
+ select.append(option);
593
+ }
594
+ clientPickerContext = context;
595
+ $('client-add-form').reset();
596
+ select.disabled = result.clients.length === 0;
597
+ $('client-add-form').hidden = result.clients.length === 0;
598
+ $('client-add-status').textContent = result.clients.length ? 'เลือก client และสิทธิ์ แล้วกดบันทึกเพื่ออนุญาต' : 'ไม่มี client อื่นให้เพิ่ม หากยังไม่เคยเชื่อมต่อ ให้ลงทะเบียน client ก่อน';
599
+ } catch (e) {
600
+ if (generation === clientPickerGeneration) $('client-add-status').textContent = `โหลดรายการไม่สำเร็จ: ${e.message}`;
601
+ }
602
+ }
603
+ $('clients-add').addEventListener('toggle', () => {
604
+ if ($('clients-add').open) void loadClientPicker();
605
+ else { clientPickerGeneration++; clientPickerContext = null; }
606
+ });
607
+ $('client-add-reload').addEventListener('click', () => withBusy($('client-add-reload'), 'กำลังโหลด…', loadClientPicker));
608
+ $('client-add-form').addEventListener('submit', (ev) => {
609
+ ev.preventDefault();
610
+ const context = clientPickerContext;
611
+ const clientId = $('client-add-select').value;
612
+ const scopes = [...$('client-add-form').querySelectorAll('input[name="scope"]:checked')].map((b) => b.value);
613
+ if (!context || !clientId || !scopes.length) { notify('error', 'เลือก client และสิทธิ์อย่างน้อยหนึ่งรายการก่อน'); return; }
614
+ withBusy($('client-add-save'), 'กำลังบันทึก…', async () => {
615
+ try {
616
+ await api('access', { clientId, scopes, addOnly: true }, context);
617
+ resetClientPicker();
618
+ await refresh(true);
619
+ notify('success', 'เพิ่ม client ให้โฟลเดอร์นี้แล้ว');
620
+ } catch (e) { notify('error', `เพิ่ม client ไม่สำเร็จ: ${e.message}`); }
621
+ });
622
+ });
623
+
624
+ let clientManagerGeneration = 0;
625
+ let clientManagerContext = null;
626
+ let clientManagerRows = [];
627
+ function updateClientSelection() {
628
+ const boxes = [...$('client-manage-list').querySelectorAll('input[type="checkbox"]')];
629
+ const count = boxes.filter(b => b.checked).length;
630
+ $('client-manage-all').checked = boxes.length > 0 && count === boxes.length;
631
+ $('client-manage-all').indeterminate = count > 0 && count < boxes.length;
632
+ const button = $('client-manage-delete');
633
+ if (!button.hasAttribute('aria-busy')) button.disabled = count === 0;
634
+ button.textContent = count ? `ล้าง client ที่เลือก (${count})` : 'ล้าง client ที่เลือก';
635
+ }
636
+ function resetClientManager() {
637
+ clientManagerGeneration++; clientManagerContext = null; clientManagerRows = [];
638
+ $('clients-manage').open = false;
639
+ $('client-manage-list').replaceChildren();
640
+ $('client-manage-all').disabled = true;
641
+ $('client-manage-status').textContent = '';
642
+ updateClientSelection();
643
+ }
644
+ async function loadClientManager() {
645
+ const generation = ++clientManagerGeneration, context = workspaceContext();
646
+ clientManagerContext = null; clientManagerRows = [];
647
+ $('client-manage-list').replaceChildren(); $('client-manage-all').disabled = true;
648
+ updateClientSelection();
649
+ $('client-manage-status').textContent = 'กำลังโหลดทะเบียน client…';
650
+ try {
651
+ const result = await api('clients/manage', undefined, context);
652
+ if (generation !== clientManagerGeneration || !state || result.workspaceId !== state.workspace.workspaceId || result.workspaceEpoch !== state.workspace.epoch) return;
653
+ clientManagerContext = context; clientManagerRows = result.clients;
654
+ for (const cl of result.clients) {
655
+ const label = document.createElement('label'); label.className = 'check client';
656
+ const box = document.createElement('input'); box.type = 'checkbox'; box.value = cl.id;
657
+ box.addEventListener('change',updateClientSelection);
658
+ const text = document.createElement('span');
659
+ const name = document.createElement('b'); name.textContent = cl.name || '(ไม่มีชื่อ)';
660
+ const id = document.createElement('small'); id.textContent = cl.id;
661
+ const impact = document.createElement('small'); impact.textContent = `มีสิทธิ์ ${cl.workspaceCount} โฟลเดอร์ · ${cl.grantCount} OAuth grant ที่ยังไม่ถอน`;
662
+ text.append(name,id,impact); label.append(box,text); $('client-manage-list').append(label);
663
+ }
664
+ $('client-manage-all').disabled = result.clients.length === 0;
665
+ $('client-manage-status').textContent = result.clients.length ? `${result.clients.length} client ในทะเบียน${result.truncated ? ' (แสดง 100 รายการแรก โหลดใหม่หลังล้างเพื่อดูที่เหลือ)' : ''}` : 'ไม่มี client ในทะเบียน';
666
+ updateClientSelection();
667
+ } catch (e) {
668
+ if (generation === clientManagerGeneration) $('client-manage-status').textContent = `โหลดทะเบียนไม่สำเร็จ: ${e.message}`;
669
+ }
670
+ }
671
+ $('clients-manage').addEventListener('toggle', () => {
672
+ if ($('clients-manage').open) void loadClientManager();
673
+ else { clientManagerGeneration++; clientManagerContext = null; }
674
+ });
675
+ $('client-manage-reload').addEventListener('click', () => withBusy($('client-manage-reload'),'กำลังโหลด…',loadClientManager));
676
+ $('client-manage-all').addEventListener('change', () => {
677
+ for (const box of $('client-manage-list').querySelectorAll('input[type="checkbox"]')) box.checked = $('client-manage-all').checked;
678
+ updateClientSelection();
679
+ });
680
+ $('client-manage-delete').addEventListener('click', async () => {
681
+ const ids = new Set([...$('client-manage-list').querySelectorAll('input:checked')].map(b => b.value));
682
+ const selected = clientManagerRows.filter(c => ids.has(c.id));
683
+ const context = clientManagerContext, generation = clientManagerGeneration;
684
+ if (!context || !selected.length) return;
685
+ await withBusy($('client-manage-delete'),'กำลังล้าง…', async () => {
686
+ const names = selected.map(c => `${c.name || '(ไม่มีชื่อ)'} (${c.id})`).join('\n');
687
+ const confirmed = await confirmAction(`ล้าง ${selected.length} client ที่เลือก?`, `${names}\n\nลบทะเบียนและถอน token/สิทธิ์ในทุกโฟลเดอร์ของ client เหล่านี้ ต้องลงทะเบียนและเชื่อมใหม่หากต้องการใช้อีก การเชื่อมต่ออื่นและไฟล์โปรเจกต์ไม่ถูกลบ`, 'ลบทะเบียนที่เลือก');
688
+ if (!confirmed) return;
689
+ if (generation !== clientManagerGeneration) { notify('error','รายการเปลี่ยนแล้ว กรุณาโหลดและเลือกใหม่'); return; }
690
+ try {
691
+ await api('clients/delete',{clients:selected.map(c => ({id:c.id,reviewHash:c.reviewHash})),confirm:'delete-selected-clients'},context);
692
+ resetClientPicker();
693
+ await refresh(true);
694
+ if (generation === clientManagerGeneration && $('clients-manage').open) await loadClientManager();
695
+ notify('success',`ล้าง ${selected.length} client แล้ว การเชื่อมต่อที่ลบใช้ token เดิมไม่ได้อีก`);
696
+ } catch (e) { notify('error',`ล้าง client ไม่สำเร็จ: ${e.message}`); }
697
+ });
698
+ updateClientSelection();
699
+ });
700
+
701
+ let scheduleSignature = '';
702
+ function renderSchedules(s) {
703
+ const signature = JSON.stringify([s.workspace && s.workspace.workspaceId,s.workspace && s.workspace.epoch,s.schedules]);
704
+ if (signature === scheduleSignature) return;
705
+ scheduleSignature = signature;
706
+ const schedules = $('schedules-list'); schedules.replaceChildren();
707
+ const text = (tag, value) => { const el = document.createElement(tag); el.textContent = value; return el; };
708
+ if (!s.workspace) {
709
+ schedules.append(text('p', 'เลือกโปรเจกต์ก่อนจึงจะดูหรือจัดการงานตั้งเวลาได้'));
710
+ return;
711
+ }
712
+ const context = {'x-dodo-workspace':s.workspace.workspaceId, 'x-dodo-epoch':s.workspace.epoch};
713
+ const action = (box, label, run) => {
714
+ const b = text('button',label); b.className = 'btn primary'; b.type = 'button';
715
+ b.addEventListener('click', () => withBusy(b,'กำลังบันทึก…', async () => {
716
+ try { await run(); await refresh(true); notify('success','บันทึกการอนุญาตแล้ว'); }
717
+ catch (e) { notify('error',e.message); }
718
+ })); box.append(b);
719
+ };
720
+ for (const job of s.schedules || []) {
721
+ const box = document.createElement('article'); box.className='client';
722
+ box.append(text('h3',job.spec.name + ' · ' + job.status),text('p','โปรเจกต์: ' + job.root),text('pre',job.spec.command));
723
+ box.append(text('p','โฟลเดอร์ทำงาน: ' + job.spec.cwd),text('p','ตาราง: ' + job.spec.cron + ' · ' + job.spec.timezone));
724
+ box.append(text('p','หมดอายุ: ' + new Date(job.spec.expiresAt).toLocaleString() + ' · จำกัดเวลารัน ' + Math.round(job.spec.timeoutMs/1000) + ' วินาที'));
725
+ box.append(text('p','OS sandbox: ' + (job.spec.sandbox ? 'ต้องเปิด' : 'ไม่ใช้') + ' · Network: ' + (job.spec.network ? 'อนุญาต' : 'ปิดใน sandbox')));
726
+ const details = document.createElement('details'); details.append(text('summary','รหัสรายการและ hash ที่อนุมัติ'),text('code',job.id + ' ' + job.digest)); box.append(details);
727
+ box.append(text('p','คำสั่งใช้โค้ดปัจจุบันในโปรเจกต์ อาจรันโค้ดใด ๆ ตามสิทธิ์ OS ไม่ได้ตรึง revision'));
728
+ if (job.status === 'pending') {
729
+ const label = document.createElement('label'); const checked = document.createElement('input'); checked.type='checkbox';
730
+ label.append(checked,document.createTextNode('อนุญาตคำสั่งนี้ตามตาราง แม้ไม่ได้เปิดแชต')); box.append(label);
731
+ action(box,'อนุมัติงานตั้งเวลา',() => { if (!checked.checked) throw new Error('กรุณายืนยันการทำงานตามตารางก่อน'); return api('schedule/approve',{id:job.id,digest:job.digest},context); });
732
+ }
733
+ if (job.status === 'approved' || job.status === 'pending' || job.status === 'paused') action(box,'ยกเลิกงานนี้',() => api('schedule/revoke',{id:job.id},context));
734
+ schedules.append(box);
735
+ }
736
+ if (!(s.schedules || []).length) schedules.append(text('p','ยังไม่มีงานตั้งเวลา'));
737
+ }
738
+
739
+ // ---- refresh ----
740
+ let refreshing = null;
741
+ async function refresh(force) {
742
+ if (refreshing) return refreshing;
743
+ refreshing = (async () => {
744
+ try {
745
+ const s = await api('state');
746
+ $('card-auth').hidden = true;
747
+ render(s, force === true);
748
+ renderSchedules(s);
749
+ } catch (e) {
750
+ if (e.status === 401) {
751
+ $('card-auth').hidden = false;
752
+ for (const p of ['ws', 'projects', 'perm', 'conn', 'clients']) cardState(p, 'error', 'ต้องเปิดจากลิงก์ส่วนตัวใน terminal');
753
+ setChip('chip-config', 'error', '✕', 'ไม่มี token หรือหมดอายุ');
754
+ } else {
755
+ for (const p of ['ws', 'projects', 'perm', 'conn', 'clients']) cardState(p, 'error', e.message);
756
+ setChip('chip-mcp', 'error', '✕', 'ติดต่อไม่ได้');
757
+ }
758
+ document.getElementById('main').setAttribute('aria-busy', 'false');
759
+ } finally { refreshing = null; }
760
+ })();
761
+ return refreshing;
762
+ }
763
+
764
+ // ---- workspace switch ----
765
+ $('ws-form').addEventListener('submit', (ev) => {
766
+ ev.preventDefault();
767
+ const input = $('ws-path');
768
+ const errEl = $('ws-path-error');
769
+ const value = input.value.trim();
770
+ errEl.hidden = true; input.removeAttribute('aria-invalid');
771
+ if (!value) { errEl.textContent = 'กรอก absolute path ของโฟลเดอร์โปรเจกต์'; errEl.hidden = false; input.setAttribute('aria-invalid', 'true'); input.focus(); return; }
772
+ if (!value.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(value)) { errEl.textContent = 'ต้องเป็น absolute path (ขึ้นต้นด้วย /)'; errEl.hidden = false; input.setAttribute('aria-invalid', 'true'); input.focus(); return; }
773
+ if (state && state.workspace && value === state.workspace.root) { notify('info', 'โปรเจกต์นี้เปิดอยู่แล้ว'); return; }
774
+ withBusy($('ws-switch'), 'กำลังสลับ…', async () => {
775
+ $('ws-progress').hidden = false;
776
+ input.disabled = true;
777
+ try {
778
+ const r = await api('workspace/switch', { path: value });
779
+ if (r.changed) {
780
+ switchedOnce = true;
781
+ notify('success', `สลับไปที่ ${r.root} แล้ว — workspace ID ${r.workspaceId}, epoch ${r.epoch} AI ต้องเรียก project_overview ใหม่ และตรวจสิทธิ์ client ด้านล่างสำหรับ path นี้`, true);
782
+ input.value = '';
783
+ } else {
784
+ notify('info', 'โปรเจกต์นี้เปิดอยู่แล้ว ไม่มีการเปลี่ยนแปลง');
785
+ }
786
+ await refresh(true);
787
+ } catch (e) {
788
+ let text = e.message;
789
+ if (e.status === 409 && /job/i.test(text)) text = `${text} — หยุดหรือรอให้งานเสร็จก่อน แล้วลองใหม่`;
790
+ else if (e.status === 409 && /in flight/i.test(text)) text = `${text} — มีคำขอ MCP ค้างอยู่ ลองใหม่อีกครั้งในไม่กี่วินาที`;
791
+ else if (e.status === 409) text = `${text}`;
792
+ else if (e.status === 400) text = `${text}`;
793
+ errEl.textContent = `✕ ${text}${e.data && e.data.recovery ? ` (${e.data.recovery})` : ''}`;
794
+ errEl.hidden = false;
795
+ input.setAttribute('aria-invalid', 'true');
796
+ notify('error', 'สลับ workspace ไม่สำเร็จ — โปรเจกต์เดิมยังใช้งานได้ตามปกติ');
797
+ await refresh(true);
798
+ } finally {
799
+ $('ws-progress').hidden = true;
800
+ input.disabled = !(state && state.workspaceSwitchSupported);
801
+ }
802
+ });
803
+ });
804
+ $('ws-copy').addEventListener('click', (ev) => copyText(state && state.workspace ? state.workspace.root : '', ev.currentTarget));
805
+
806
+ $('desktop-command-copy').addEventListener('click', (ev) => copyText($('desktop-command').textContent, ev.currentTarget));
807
+
808
+ $('desktop-disable').addEventListener('click', () => {
809
+ withBusy($('desktop-disable'), 'กำลังหยุด…', async () => {
810
+ try { await api('desktop/disable', {}); await refresh(true); notify('success', 'ปิด Desktop access แล้ว'); }
811
+ catch (e) { notify('error', `ปิด Desktop access ไม่สำเร็จ: ${e.message}`); }
812
+ }).finally(() => { $('desktop-disable').disabled = !state || !state.desktop || state.desktop.policy.mode === 'off'; });
813
+ });
814
+
815
+ // ---- permissions ----
816
+ $('perm-form').addEventListener('submit', (ev) => {
817
+ ev.preventDefault();
818
+ const context = workspaceContext();
819
+ const mode = (new FormData(ev.currentTarget).get('mode') || '').toString();
820
+ if (!MODE_LABEL[mode]) { notify('error', 'เลือกระดับสิทธิ์ก่อน'); return; }
821
+ withBusy($('perm-save'), 'กำลังบันทึก…', async () => {
822
+ try {
823
+ if (mode === 'trusted') {
824
+ const ok = await confirmAction('บันทึกเป็น trusted?', 'AI จะแก้ไฟล์และรันคำสั่งใน workspace นี้ได้โดยไม่ถาม คำสั่งรันด้วยสิทธิ์ OS ของบัญชีคุณ (เว้นแต่เปิด command sandbox) ต้องการบันทึกหรือไม่', 'บันทึก trusted');
825
+ if (!ok) {
826
+ // Put the radio back on the saved value so the form never shows an unsaved trusted selection.
827
+ const back = document.querySelector(`#perm-form input[value="${(state && state.permissions && state.permissions.savedMode) || 'inspect'}"]`);
828
+ if (back) back.checked = true;
829
+ return;
830
+ }
831
+ }
832
+ const r = await api('config', { mode }, context);
833
+ const overridden = state && state.permissions && state.permissions.override;
834
+ notify(overridden ? 'info' : 'success', overridden
835
+ ? `บันทึกค่า ${mode} แล้ว แต่รอบนี้ยังมีผลเป็น ${r.effectiveMode || 'trusted'} จาก ${overridden === 'bypass' ? '--bypass' : '--allow --all'} จนกว่าจะ restart โดยไม่ใช้ flag`
836
+ : `บันทึกสิทธิ์ ${mode} แล้ว มีผลกับคำขอถัดไป`);
837
+ await refresh(true);
838
+ } catch (e) { notify('error', `บันทึกสิทธิ์ไม่สำเร็จ: ${e.message}`); }
839
+ });
840
+ });
841
+
842
+ // ---- connection ----
843
+ $('conn-form').addEventListener('submit', (ev) => {
844
+ ev.preventDefault();
845
+ const input = $('conn-origin');
846
+ const errEl = $('conn-origin-error');
847
+ const value = input.value.trim();
848
+ errEl.hidden = true; input.removeAttribute('aria-invalid');
849
+ let parsed = null;
850
+ try { parsed = new URL(value); } catch (_e) { parsed = null; }
851
+ const loopback = parsed && (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost');
852
+ if (!parsed || (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) || parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username) {
853
+ errEl.textContent = '✕ ต้องเป็น origin แบบ https://host (ไม่มี path, query หรือ user) http:// ใช้ได้เฉพาะ 127.0.0.1/localhost สำหรับทดสอบ';
854
+ errEl.hidden = false; input.setAttribute('aria-invalid', 'true'); input.focus(); return;
855
+ }
856
+ withBusy($('conn-save'), 'กำลังบันทึก…', async () => {
857
+ try {
858
+ const r = await api('config', { publicUrl: parsed.origin });
859
+ notify(r.restartRequired ? 'info' : 'success', r.restartRequired ? `บันทึก ${parsed.origin} แล้ว — restart DODO เพื่อให้ OAuth issuer และ /mcp ใช้ origin ใหม่` : 'บันทึกแล้ว');
860
+ await refresh(true);
861
+ } catch (e) {
862
+ errEl.textContent = `✕ ${e.message}`; errEl.hidden = false; input.setAttribute('aria-invalid', 'true');
863
+ notify('error', `บันทึก origin ไม่สำเร็จ: ${e.message}`);
864
+ }
865
+ });
866
+ });
867
+ $('conn-local-copy').addEventListener('click', (ev) => copyText(state && state.connection ? state.connection.mcpLocalUrl : '', ev.currentTarget));
868
+ $('conn-public-copy').addEventListener('click', (ev) => {
869
+ const c = state && state.connection;
870
+ copyText(c ? (c.mcpPublicUrl || (c.publicUrl ? `${c.publicUrl}/mcp` : '')) : '', ev.currentTarget);
871
+ });
872
+
873
+ // ---- Cloudflare Tunnel ----
874
+ $('tunnel-session-form').addEventListener('submit', (ev) => {
875
+ ev.preventDefault();
876
+ const tokenInput = $('tunnel-session-token');
877
+ const errorElement = $('tunnel-session-error');
878
+ const tokenValue = tokenInput.value.trim();
879
+ errorElement.hidden = true;
880
+ tokenInput.removeAttribute('aria-invalid');
881
+ if (tokenValue.length < 20) {
882
+ errorElement.textContent = '✕ กรอก Cloudflare Tunnel token ที่ถูกต้อง';
883
+ errorElement.hidden = false;
884
+ tokenInput.setAttribute('aria-invalid', 'true');
885
+ tokenInput.focus();
886
+ return;
887
+ }
888
+ withBusy($('tunnel-session-start'), 'กำลังเปิด…', async () => {
889
+ try {
890
+ const result = await api('tunnel/session/start', { token: tokenValue });
891
+ tokenInput.value = '';
892
+ notify('success', result.status && result.status.connected
893
+ ? 'Tunnel เชื่อมต่อแล้วสำหรับ DODO process นี้'
894
+ : 'เริ่ม Tunnel แล้ว กำลังตรวจ readiness');
895
+ await refresh(true);
896
+ } catch (error) {
897
+ tokenInput.value = '';
898
+ errorElement.textContent = `✕ ${error.message}`;
899
+ errorElement.hidden = false;
900
+ notify('error', 'เปิด Tunnel ไม่สำเร็จ');
901
+ }
902
+ });
903
+ });
904
+
905
+ $('tunnel-session-stop').addEventListener('click', () => withBusy($('tunnel-session-stop'), 'กำลังหยุด…', async () => {
906
+ try {
907
+ await api('tunnel/session/stop', {});
908
+ $('tunnel-session-token').value = '';
909
+ notify('success', 'หยุด Tunnel ที่ DODO process นี้เป็นเจ้าของแล้ว');
910
+ await refresh(true);
911
+ } catch (error) {
912
+ notify('error', `หยุด Tunnel ไม่สำเร็จ: ${error.message}`);
913
+ }
914
+ }));
915
+
916
+ $('tunnel-form').addEventListener('submit', (ev) => {
917
+ ev.preventDefault();
918
+ const errorElement = $('tunnel-error');
919
+ errorElement.hidden = true;
920
+ const metricsPort = Number($('tunnel-metrics').value);
921
+ const maxRestarts = Number($('tunnel-restarts').value);
922
+ if (!Number.isSafeInteger(metricsPort) || metricsPort < 1024 || metricsPort > 65535 || !Number.isSafeInteger(maxRestarts) || maxRestarts < 0 || maxRestarts > 5) {
923
+ errorElement.textContent = '✕ Metrics port ต้องอยู่ระหว่าง 1024–65535 และ restart ต้องอยู่ระหว่าง 0–5';
924
+ errorElement.hidden = false;
925
+ return;
926
+ }
927
+ withBusy($('tunnel-save'), 'กำลังบันทึก…', async () => {
928
+ try {
929
+ const body = { startWithDodo: $('tunnel-auto').checked, metricsPort, maxRestarts };
930
+ const result = await api('tunnel/config', body);
931
+ notify('info', result.startWithDodo
932
+ ? 'บันทึกแล้ว — DODO จะถาม token ชั่วคราวใน terminal เมื่อเริ่มรอบถัดไป'
933
+ : 'บันทึกแล้ว — รอบถัดไปจะเปิด local MCP โดยไม่ถาม Tunnel token');
934
+ await refresh(true);
935
+ } catch (error) {
936
+ errorElement.textContent = `✕ ${error.message}`;
937
+ errorElement.hidden = false;
938
+ notify('error', 'บันทึก Tunnel ไม่สำเร็จ');
939
+ }
940
+ });
941
+ });
942
+
943
+ // ---- misc ----
944
+ $('refresh').addEventListener('click', (ev) => withBusy(ev.currentTarget, '⟳ …', () => refresh(true)));
945
+ document.addEventListener('keydown', (ev) => {
946
+ if (ev.key === 'r' && (ev.metaKey || ev.ctrlKey) && ev.shiftKey) { ev.preventDefault(); refresh(true); }
947
+ });
948
+ setInterval(() => { if (!document.hidden && !document.querySelector('.btn[aria-busy="true"]')) refresh(false); }, 30000);
949
+
950
+ if (!token) {
951
+ $('card-auth').hidden = false;
952
+ for (const p of ['ws', 'projects', 'perm', 'conn', 'clients']) cardState(p, 'error', 'ต้องเปิดจากลิงก์ส่วนตัวใน terminal');
953
+ setChip('chip-config', 'error', '✕', 'ไม่มี token');
954
+ document.getElementById('main').setAttribute('aria-busy', 'false');
955
+ } else {
956
+ refresh(true);
957
+ }
958
+ })();