@prestyj/cli 5.30.0 → 5.30.2

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 (334) hide show
  1. package/README.md +54 -39
  2. package/assets/skills/bulletproof/SKILL.md +1 -1
  3. package/assets/skills/clarify/SKILL.md +1 -1
  4. package/assets/skills/code-review/SKILL.md +1 -1
  5. package/assets/skills/compliance-guard/SKILL.md +1 -1
  6. package/assets/skills/durable/SKILL.md +1 -1
  7. package/assets/skills/evidence-led-ui/SKILL.md +1 -1
  8. package/assets/skills/lean/SKILL.md +1 -1
  9. package/assets/skills/refactoring/SKILL.md +1 -1
  10. package/assets/skills/root-cause/SKILL.md +1 -1
  11. package/assets/skills/shared-language/SKILL.md +1 -1
  12. package/assets/skills/tdd/SKILL.md +1 -1
  13. package/dist/app-sidecar/app-settings.d.ts +63 -0
  14. package/dist/app-sidecar/app-settings.js +147 -0
  15. package/dist/app-sidecar/app-settings.js.map +1 -0
  16. package/dist/app-sidecar/attachments.d.ts +11 -0
  17. package/dist/app-sidecar/attachments.js +47 -0
  18. package/dist/app-sidecar/attachments.js.map +1 -0
  19. package/dist/app-sidecar/auth-routes.d.ts +3 -0
  20. package/dist/app-sidecar/auth-routes.js +293 -0
  21. package/dist/app-sidecar/auth-routes.js.map +1 -0
  22. package/dist/app-sidecar/autopilot-runner.d.ts +62 -0
  23. package/dist/app-sidecar/autopilot-runner.js +373 -0
  24. package/dist/app-sidecar/autopilot-runner.js.map +1 -0
  25. package/dist/app-sidecar/file-search.d.ts +15 -0
  26. package/dist/app-sidecar/file-search.js +103 -0
  27. package/dist/app-sidecar/file-search.js.map +1 -0
  28. package/dist/app-sidecar/hf-pull-manager.d.ts +20 -0
  29. package/dist/app-sidecar/hf-pull-manager.js +224 -0
  30. package/dist/app-sidecar/hf-pull-manager.js.map +1 -0
  31. package/dist/app-sidecar/history-routes.d.ts +3 -0
  32. package/dist/app-sidecar/history-routes.js +375 -0
  33. package/dist/app-sidecar/history-routes.js.map +1 -0
  34. package/dist/app-sidecar/history.d.ts +67 -0
  35. package/dist/app-sidecar/history.js +23 -0
  36. package/dist/app-sidecar/history.js.map +1 -0
  37. package/dist/app-sidecar/http.d.ts +5 -0
  38. package/dist/app-sidecar/http.js +27 -0
  39. package/dist/app-sidecar/http.js.map +1 -0
  40. package/dist/app-sidecar/integration-routes.d.ts +3 -0
  41. package/dist/app-sidecar/integration-routes.js +286 -0
  42. package/dist/app-sidecar/integration-routes.js.map +1 -0
  43. package/dist/app-sidecar/json-mode.d.ts +10 -0
  44. package/dist/app-sidecar/json-mode.js +73 -0
  45. package/dist/app-sidecar/json-mode.js.map +1 -0
  46. package/dist/app-sidecar/keep-awake.d.ts +4 -0
  47. package/dist/app-sidecar/keep-awake.js +5 -0
  48. package/dist/app-sidecar/keep-awake.js.map +1 -0
  49. package/dist/app-sidecar/mcp-routes.d.ts +3 -0
  50. package/dist/app-sidecar/mcp-routes.js +185 -0
  51. package/dist/app-sidecar/mcp-routes.js.map +1 -0
  52. package/dist/app-sidecar/mcp-rows.d.ts +31 -0
  53. package/dist/app-sidecar/mcp-rows.js +77 -0
  54. package/dist/app-sidecar/mcp-rows.js.map +1 -0
  55. package/dist/app-sidecar/nolan-context.d.ts +20 -0
  56. package/dist/app-sidecar/nolan-context.js +55 -0
  57. package/dist/app-sidecar/nolan-context.js.map +1 -0
  58. package/dist/app-sidecar/progress-manager.d.ts +10 -0
  59. package/dist/app-sidecar/progress-manager.js +116 -0
  60. package/dist/app-sidecar/progress-manager.js.map +1 -0
  61. package/dist/app-sidecar/prompt-routes.d.ts +3 -0
  62. package/dist/app-sidecar/prompt-routes.js +355 -0
  63. package/dist/app-sidecar/prompt-routes.js.map +1 -0
  64. package/dist/app-sidecar/repo-polls.d.ts +18 -0
  65. package/dist/app-sidecar/repo-polls.js +45 -0
  66. package/dist/app-sidecar/repo-polls.js.map +1 -0
  67. package/dist/app-sidecar/route-context.d.ts +147 -0
  68. package/dist/app-sidecar/route-context.js +2 -0
  69. package/dist/app-sidecar/route-context.js.map +1 -0
  70. package/dist/app-sidecar/run-control-routes.d.ts +3 -0
  71. package/dist/app-sidecar/run-control-routes.js +437 -0
  72. package/dist/app-sidecar/run-control-routes.js.map +1 -0
  73. package/dist/app-sidecar/session-routes.d.ts +3 -0
  74. package/dist/app-sidecar/session-routes.js +115 -0
  75. package/dist/app-sidecar/session-routes.js.map +1 -0
  76. package/dist/app-sidecar/session-types.d.ts +39 -0
  77. package/dist/app-sidecar/session-types.js +5 -0
  78. package/dist/app-sidecar/session-types.js.map +1 -0
  79. package/dist/app-sidecar/task-routes.d.ts +3 -0
  80. package/dist/app-sidecar/task-routes.js +232 -0
  81. package/dist/app-sidecar/task-routes.js.map +1 -0
  82. package/dist/app-sidecar/usage.d.ts +19 -0
  83. package/dist/app-sidecar/usage.js +153 -0
  84. package/dist/app-sidecar/usage.js.map +1 -0
  85. package/dist/app-sidecar/workspace-routes.d.ts +3 -0
  86. package/dist/app-sidecar/workspace-routes.js +374 -0
  87. package/dist/app-sidecar/workspace-routes.js.map +1 -0
  88. package/dist/app-sidecar.js +541 -3971
  89. package/dist/app-sidecar.js.map +1 -1
  90. package/dist/chat-agents/jiwa.js +2 -0
  91. package/dist/chat-agents/jiwa.js.map +1 -1
  92. package/dist/chat-agents/memory.js +2 -0
  93. package/dist/chat-agents/memory.js.map +1 -1
  94. package/dist/cli/auth.js +13 -9
  95. package/dist/cli/auth.js.map +1 -1
  96. package/dist/cli.js +10 -11
  97. package/dist/cli.js.map +1 -1
  98. package/dist/config.js +1 -1
  99. package/dist/core/acceptance-checks.js +3 -9
  100. package/dist/core/acceptance-checks.js.map +1 -1
  101. package/dist/core/agent-session/helpers.d.ts +26 -0
  102. package/dist/core/agent-session/helpers.js +47 -0
  103. package/dist/core/agent-session/helpers.js.map +1 -0
  104. package/dist/core/agent-session/mcp-tools.d.ts +68 -0
  105. package/dist/core/agent-session/mcp-tools.js +195 -0
  106. package/dist/core/agent-session/mcp-tools.js.map +1 -0
  107. package/dist/core/agent-session/run-loop-monitor.d.ts +63 -0
  108. package/dist/core/agent-session/run-loop-monitor.js +164 -0
  109. package/dist/core/agent-session/run-loop-monitor.js.map +1 -0
  110. package/dist/core/agent-session/transcript-markers.d.ts +57 -0
  111. package/dist/core/agent-session/transcript-markers.js +166 -0
  112. package/dist/core/agent-session/transcript-markers.js.map +1 -0
  113. package/dist/core/agent-session/verification-tracker.d.ts +50 -0
  114. package/dist/core/agent-session/verification-tracker.js +269 -0
  115. package/dist/core/agent-session/verification-tracker.js.map +1 -0
  116. package/dist/core/agent-session.d.ts +27 -87
  117. package/dist/core/agent-session.js +166 -713
  118. package/dist/core/agent-session.js.map +1 -1
  119. package/dist/core/auth-providers.js +3 -3
  120. package/dist/core/auth-providers.js.map +1 -1
  121. package/dist/core/autopilot-cycle.d.ts +8 -2
  122. package/dist/core/autopilot-cycle.js +22 -3
  123. package/dist/core/autopilot-cycle.js.map +1 -1
  124. package/dist/core/bundled-agents.js +6 -6
  125. package/dist/core/bundled-agents.js.map +1 -1
  126. package/dist/core/checklist-git.d.ts +19 -0
  127. package/dist/core/checklist-git.js +54 -0
  128. package/dist/core/checklist-git.js.map +1 -0
  129. package/dist/core/checklist-items.d.ts +10 -1
  130. package/dist/core/checklist-items.js +22 -0
  131. package/dist/core/checklist-items.js.map +1 -1
  132. package/dist/core/checklist-prompt.d.ts +14 -3
  133. package/dist/core/checklist-prompt.js +38 -8
  134. package/dist/core/checklist-prompt.js.map +1 -1
  135. package/dist/core/checklist-snapshot.d.ts +5 -1
  136. package/dist/core/checklist-snapshot.js +20 -3
  137. package/dist/core/checklist-snapshot.js.map +1 -1
  138. package/dist/core/checklist-store.d.ts +5 -1
  139. package/dist/core/checklist-store.js +35 -4
  140. package/dist/core/checklist-store.js.map +1 -1
  141. package/dist/core/context-limits.js +1 -1
  142. package/dist/core/context-limits.js.map +1 -1
  143. package/dist/core/event-bus.d.ts +1 -1
  144. package/dist/core/event-bus.js +39 -13
  145. package/dist/core/event-bus.js.map +1 -1
  146. package/dist/core/extensions/plugin-bundles.js +4 -2
  147. package/dist/core/extensions/plugin-bundles.js.map +1 -1
  148. package/dist/core/hashline.d.ts +10 -8
  149. package/dist/core/hashline.js.map +1 -1
  150. package/dist/core/mcp/client.js +2 -0
  151. package/dist/core/mcp/client.js.map +1 -1
  152. package/dist/core/mcp/loopback.d.ts +2 -2
  153. package/dist/core/mcp/loopback.js +2 -2
  154. package/dist/core/mcp/shared-pool.js +4 -3
  155. package/dist/core/mcp/shared-pool.js.map +1 -1
  156. package/dist/core/persistent-shell.js +2 -1
  157. package/dist/core/persistent-shell.js.map +1 -1
  158. package/dist/core/plan-handoff.d.ts +30 -0
  159. package/dist/core/plan-handoff.js +10 -0
  160. package/dist/core/plan-handoff.js.map +1 -0
  161. package/dist/core/plan-submission-gate.d.ts +28 -0
  162. package/dist/core/plan-submission-gate.js +27 -0
  163. package/dist/core/plan-submission-gate.js.map +1 -0
  164. package/dist/core/process-manager.js +9 -1
  165. package/dist/core/process-manager.js.map +1 -1
  166. package/dist/core/project-health-scan.d.ts +109 -0
  167. package/dist/core/project-health-scan.js +673 -0
  168. package/dist/core/project-health-scan.js.map +1 -0
  169. package/dist/core/project-health-score.d.ts +41 -0
  170. package/dist/core/project-health-score.js +257 -0
  171. package/dist/core/project-health-score.js.map +1 -0
  172. package/dist/core/prompt-commands.js +44 -6
  173. package/dist/core/prompt-commands.js.map +1 -1
  174. package/dist/core/session-manager.js.map +1 -1
  175. package/dist/core/session-storage.d.ts +1 -0
  176. package/dist/core/session-storage.js +1 -1
  177. package/dist/core/session-storage.js.map +1 -1
  178. package/dist/core/skills.js +6 -9
  179. package/dist/core/skills.js.map +1 -1
  180. package/dist/core/style-packs/index.js +2 -11
  181. package/dist/core/style-packs/index.js.map +1 -1
  182. package/dist/core/style-packs/packs.d.ts +3 -12
  183. package/dist/core/style-packs/packs.js +132 -173
  184. package/dist/core/style-packs/packs.js.map +1 -1
  185. package/dist/core/subagent-manager.js +11 -6
  186. package/dist/core/subagent-manager.js.map +1 -1
  187. package/dist/core/subagent-receipt.js +3 -4
  188. package/dist/core/subagent-receipt.js.map +1 -1
  189. package/dist/core/ui-adoption.js +2 -0
  190. package/dist/core/ui-adoption.js.map +1 -1
  191. package/dist/core/ui-registry.js +5 -4
  192. package/dist/core/ui-registry.js.map +1 -1
  193. package/dist/core/verification-evidence.d.ts +21 -2
  194. package/dist/core/verification-evidence.js +283 -8
  195. package/dist/core/verification-evidence.js.map +1 -1
  196. package/dist/core/verification-gate.js +4 -2
  197. package/dist/core/verification-gate.js.map +1 -1
  198. package/dist/core/verify-commands.js +2 -3
  199. package/dist/core/verify-commands.js.map +1 -1
  200. package/dist/modes/agent-home-mode.js +1 -4
  201. package/dist/modes/agent-home-mode.js.map +1 -1
  202. package/dist/modes/subagent-worker-mode.js +13 -8
  203. package/dist/modes/subagent-worker-mode.js.map +1 -1
  204. package/dist/system-prompt.js +70 -87
  205. package/dist/system-prompt.js.map +1 -1
  206. package/dist/tools/ask-user.js +13 -74
  207. package/dist/tools/ask-user.js.map +1 -1
  208. package/dist/tools/bash.d.ts +3 -1
  209. package/dist/tools/bash.js +28 -84
  210. package/dist/tools/bash.js.map +1 -1
  211. package/dist/tools/checklist.d.ts +7 -14
  212. package/dist/tools/checklist.js +22 -41
  213. package/dist/tools/checklist.js.map +1 -1
  214. package/dist/tools/code-nav.js +7 -29
  215. package/dist/tools/code-nav.js.map +1 -1
  216. package/dist/tools/edit.d.ts +4 -9
  217. package/dist/tools/edit.js +175 -65
  218. package/dist/tools/edit.js.map +1 -1
  219. package/dist/tools/enter-plan.js +2 -7
  220. package/dist/tools/enter-plan.js.map +1 -1
  221. package/dist/tools/exit-plan.js +2 -3
  222. package/dist/tools/exit-plan.js.map +1 -1
  223. package/dist/tools/find.js +3 -4
  224. package/dist/tools/find.js.map +1 -1
  225. package/dist/tools/grep.js +9 -20
  226. package/dist/tools/grep.js.map +1 -1
  227. package/dist/tools/index.js +10 -7
  228. package/dist/tools/index.js.map +1 -1
  229. package/dist/tools/ls.js +3 -3
  230. package/dist/tools/ls.js.map +1 -1
  231. package/dist/tools/prompt-hints.js +34 -47
  232. package/dist/tools/prompt-hints.js.map +1 -1
  233. package/dist/tools/read-only-bash.js +3 -0
  234. package/dist/tools/read-only-bash.js.map +1 -1
  235. package/dist/tools/read.js +8 -20
  236. package/dist/tools/read.js.map +1 -1
  237. package/dist/tools/search-code.js +4 -12
  238. package/dist/tools/search-code.js.map +1 -1
  239. package/dist/tools/skill.js +13 -7
  240. package/dist/tools/skill.js.map +1 -1
  241. package/dist/tools/subagent-control.js +7 -22
  242. package/dist/tools/subagent-control.js.map +1 -1
  243. package/dist/tools/subagent.js +1 -1
  244. package/dist/tools/subagent.js.map +1 -1
  245. package/dist/tools/task-output.js +4 -15
  246. package/dist/tools/task-output.js.map +1 -1
  247. package/dist/tools/task-send.js +5 -17
  248. package/dist/tools/task-send.js.map +1 -1
  249. package/dist/tools/task-stop.js +2 -2
  250. package/dist/tools/task-stop.js.map +1 -1
  251. package/dist/tools/tool-search.js +2 -7
  252. package/dist/tools/tool-search.js.map +1 -1
  253. package/dist/tools/tool-tiers.d.ts +21 -4
  254. package/dist/tools/tool-tiers.js +44 -17
  255. package/dist/tools/tool-tiers.js.map +1 -1
  256. package/dist/tools/web-fetch.js +7 -33
  257. package/dist/tools/web-fetch.js.map +1 -1
  258. package/dist/tools/write.js +3 -4
  259. package/dist/tools/write.js.map +1 -1
  260. package/dist/ui/App.js +20 -26
  261. package/dist/ui/App.js.map +1 -1
  262. package/dist/ui/components/Footer.js +1 -2
  263. package/dist/ui/components/Footer.js.map +1 -1
  264. package/dist/ui/components/InputArea.js +0 -2
  265. package/dist/ui/components/InputArea.js.map +1 -1
  266. package/dist/ui/components/ToolExecution.js +4 -3
  267. package/dist/ui/components/ToolExecution.js.map +1 -1
  268. package/dist/ui/hooks/useAgentLoop.js +7 -6
  269. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  270. package/dist/ui/sessions.js +1 -5
  271. package/dist/ui/sessions.js.map +1 -1
  272. package/dist/ui/utils/highlight.d.ts +0 -5
  273. package/dist/ui/utils/highlight.js +3 -49
  274. package/dist/ui/utils/highlight.js.map +1 -1
  275. package/dist/ui/utils/markdown-renderer.d.ts +6 -0
  276. package/dist/ui/utils/markdown-renderer.js +21 -5
  277. package/dist/ui/utils/markdown-renderer.js.map +1 -1
  278. package/dist/utils/process.js +3 -3
  279. package/dist/utils/process.js.map +1 -1
  280. package/dist/utils/shared-poll.d.ts +37 -0
  281. package/dist/utils/shared-poll.js +104 -0
  282. package/dist/utils/shared-poll.js.map +1 -0
  283. package/package.json +8 -8
  284. package/dist/core/api-benchmark.d.ts +0 -63
  285. package/dist/core/api-benchmark.js +0 -381
  286. package/dist/core/api-benchmark.js.map +0 -1
  287. package/dist/core/bash-spawn-benchmark.d.ts +0 -1
  288. package/dist/core/bash-spawn-benchmark.js +0 -139
  289. package/dist/core/bash-spawn-benchmark.js.map +0 -1
  290. package/dist/core/cache-warm-benchmark.d.ts +0 -21
  291. package/dist/core/cache-warm-benchmark.js +0 -170
  292. package/dist/core/cache-warm-benchmark.js.map +0 -1
  293. package/dist/core/fast-apply-benchmark.d.ts +0 -25
  294. package/dist/core/fast-apply-benchmark.js +0 -284
  295. package/dist/core/fast-apply-benchmark.js.map +0 -1
  296. package/dist/core/hashline-edit-benchmark.d.ts +0 -88
  297. package/dist/core/hashline-edit-benchmark.js +0 -518
  298. package/dist/core/hashline-edit-benchmark.js.map +0 -1
  299. package/dist/core/semantic-search-benchmark.d.ts +0 -36
  300. package/dist/core/semantic-search-benchmark.js +0 -211
  301. package/dist/core/semantic-search-benchmark.js.map +0 -1
  302. package/dist/core/speed-benchmark.d.ts +0 -132
  303. package/dist/core/speed-benchmark.js +0 -410
  304. package/dist/core/speed-benchmark.js.map +0 -1
  305. package/dist/ui/activity-phrases.d.ts +0 -10
  306. package/dist/ui/activity-phrases.js +0 -184
  307. package/dist/ui/activity-phrases.js.map +0 -1
  308. package/dist/ui/components/PlanApproval.d.ts +0 -7
  309. package/dist/ui/components/PlanApproval.js +0 -60
  310. package/dist/ui/components/PlanApproval.js.map +0 -1
  311. package/dist/ui/components/PlanBanner.d.ts +0 -5
  312. package/dist/ui/components/PlanBanner.js +0 -31
  313. package/dist/ui/components/PlanBanner.js.map +0 -1
  314. package/dist/ui/components/PlanProgress.d.ts +0 -6
  315. package/dist/ui/components/PlanProgress.js +0 -30
  316. package/dist/ui/components/PlanProgress.js.map +0 -1
  317. package/dist/ui/hooks/useMinDisplayTime.d.ts +0 -10
  318. package/dist/ui/hooks/useMinDisplayTime.js +0 -34
  319. package/dist/ui/hooks/useMinDisplayTime.js.map +0 -1
  320. package/dist/ui/hooks/useSessionManager.d.ts +0 -12
  321. package/dist/ui/hooks/useSessionManager.js +0 -43
  322. package/dist/ui/hooks/useSessionManager.js.map +0 -1
  323. package/dist/ui/hooks/useSlashCommands.d.ts +0 -6
  324. package/dist/ui/hooks/useSlashCommands.js +0 -11
  325. package/dist/ui/hooks/useSlashCommands.js.map +0 -1
  326. package/dist/ui/terminal-history-spacing.d.ts +0 -1
  327. package/dist/ui/terminal-history-spacing.js +0 -2
  328. package/dist/ui/terminal-history-spacing.js.map +0 -1
  329. package/dist/ui/utils/markdown-cache.d.ts +0 -10
  330. package/dist/ui/utils/markdown-cache.js +0 -58
  331. package/dist/ui/utils/markdown-cache.js.map +0 -1
  332. package/dist/ui/utils/token-to-ansi.d.ts +0 -8
  333. package/dist/ui/utils/token-to-ansi.js +0 -323
  334. package/dist/ui/utils/token-to-ansi.js.map +0 -1
@@ -13,548 +13,69 @@
13
13
  */
14
14
  import http from "node:http";
15
15
  import fs from "node:fs/promises";
16
- import { watch as fsWatch } from "node:fs";
17
16
  import os from "node:os";
18
17
  import path from "node:path";
19
18
  import { randomUUID } from "node:crypto";
20
- import { parseArgs } from "node:util";
21
- import { spawn } from "node:child_process";
22
- import { environmentSecrets, formatChatError, redactValue, } from "@prestyj/ai";
23
- import { createAppErrorPayload, restoreAppErrorPayload, } from "./app-error.js";
24
- import { runJsonMode } from "./modes/json-mode.js";
19
+ import { environmentSecrets, formatChatError, redactValue } from "@prestyj/ai";
20
+ import { createAppErrorPayload } from "./app-error.js";
25
21
  import { runSubagentWorkerMode } from "./modes/subagent-worker-mode.js";
26
22
  import { setStreamDiagnostic } from "@prestyj/agent";
27
23
  import { AgentSession } from "./core/agent-session.js";
28
24
  import { RunLifecycle } from "./core/run-lifecycle.js";
29
25
  import { RunClaim } from "./core/run-claim.js";
30
- import { CHAT_AGENT_IDS, chatAgentSessionsDir, createChatAgent, parseChatAgentId, switchChatAgent, } from "./chat-agents/index.js";
26
+ import { shouldOfferPendingPlan } from "./core/plan-handoff.js";
27
+ import { createChatAgent, parseChatAgentId, switchChatAgent, } from "./chat-agents/index.js";
31
28
  import { createMotionAgentSession } from "./motion-agent/motion-agent.js";
32
29
  import { buildJiwaTools, JiwaStore } from "./chat-agents/jiwa.js";
33
- import { createWorktree, removeWorktree, sweepWorktrees, worktreeStatuses, } from "./core/worktree.js";
34
- import { prepareWorktree } from "./core/worktree-setup.js";
35
30
  import { buildMemoryTools, MemoryStore } from "./chat-agents/memory.js";
36
31
  import { buildNolanSystemPrompt, buildNolanAutopilotSystemPrompt } from "./core/nolan-prompt.js";
37
- import { buildNolanDigest, buildNolanAutopilotContext, buildNolanAutopilotPlanContext, } from "./core/nolan-context.js";
38
- import { parseAutopilotVerdict } from "./core/autopilot-verdict.js";
39
- import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
40
- import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
32
+ import { countAssistantMessages } from "./core/autopilot-gate.js";
41
33
  import { describeRunVerification, describeTurnVerification } from "./core/run-status.js";
42
34
  import { validateNolanModelPref, effectiveNolanModel, } from "./core/nolan-model.js";
43
- import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeNolanTurnsForHistory, getHistoryMessageVisibility, replayMessagesInOrder, restoreUserRow, restoreAssistantTexts, resolveRestoredCommand, autopilotMarkerCopySeed, extractToolImagePaths, } from "./core/session-history.js";
44
- import { sessionToMarkdown, defaultExportFilename, } from "./core/session-export.js";
45
35
  import { AuthStorage } from "./core/auth-storage.js";
46
- import { advancesPhase, explainPullFailure, isGgufShard, isValidHfRepoId, parseOllamaPullLine, pickGgufQuant, SHARDED_MESSAGE, toHfSearchRow, } from "./hf-pull.js";
47
36
  import { cleanupToolOutputs } from "./tools/overflow.js";
48
- import { spawnedTasks } from "./tools/subagent-shared.js";
49
- import { readCappedBody } from "./utils/http-body.js";
50
- import { fetchSubscriptionUsage, SubscriptionUsageError, XIAOMI_CREDITS_KEY, dualAuthProvider, oauthStorageKey, discoverLocalModels, findProbedModel, formatLocalModelId, parseLocalModelId, probeEndpoint, toModelInfo as localModelInfo, } from "@prestyj/core";
51
- import { LocalEndpointError, addCustomEndpoint, listAllEndpoints, removeCustomEndpoint, syncEndpointCredentials, } from "./core/local-endpoint-store.js";
52
- import { loginAnthropic } from "./core/oauth/anthropic.js";
53
- import { loginOpenAI } from "./core/oauth/openai.js";
54
- import { loginGemini } from "./core/oauth/gemini.js";
55
- import { loginKimi } from "./core/oauth/kimi.js";
56
- import { loginXai } from "./core/oauth/xai.js";
37
+ import { dualAuthProvider, discoverLocalModels, formatLocalModelId, parseLocalModelId, probeEndpoint, toModelInfo as localModelInfo, } from "@prestyj/core";
38
+ import { listAllEndpoints, syncEndpointCredentials } from "./core/local-endpoint-store.js";
57
39
  import { AUTH_PROVIDERS, authPriorityNote, describeAuthMethods, } from "./core/auth-providers.js";
58
- import { ensureAppDirs, loadSavedSettings, projectScopeAllowed } from "./config.js";
40
+ import { ensureAppDirs, loadSavedSettings } from "./config.js";
59
41
  import { SettingsManager } from "./core/settings-manager.js";
60
- import { installPlugin, listInstalledPlugins, removePlugin, } from "./core/extensions/plugin-bundles.js";
61
- import { getModel, getDefaultThinkingLevel, getContextWindow, getAllModels, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
42
+ import { getModel, getDefaultThinkingLevel, getContextWindow, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
62
43
  import { resolveStartOrFallback } from "./core/resolve-start.js";
63
44
  import { getGitBranch, getGitDirtyFileCount, isGitRepo } from "./utils/git.js";
64
- import { getGitHubOpenCounts, getGitHubRepoSlug } from "./utils/github.js";
65
- import { startGitHubCIPoll } from "./utils/github-ci.js";
45
+ import { getGitHubRepoSlug } from "./utils/github.js";
46
+ import { scoreProjectHealth } from "./core/project-health-score.js";
47
+ import { createRepoPolls } from "./app-sidecar/repo-polls.js";
66
48
  import { extractPlanSteps } from "./utils/plan-steps.js";
67
- import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSupported, } from "./core/thinking-level.js";
68
- import { parseThinkingLevel } from "./cli/thinking-arg.js";
49
+ import { getSupportedThinkingLevels, isThinkingLevelSupported } from "./core/thinking-level.js";
69
50
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
70
51
  import { loadCustomCommands } from "./core/custom-commands.js";
71
- import { discoverProjects } from "./core/project-discovery.js";
72
- import { listSidecarSessions } from "./app-sidecar-sessions.js";
73
- import { loadTasksSync, saveTasksSync, pruneDoneTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/task-store.js";
74
- import { readChecklistSnapshot } from "./core/checklist-snapshot.js";
52
+ import { loadTasksSync, pruneDoneTasksSync } from "./core/task-store.js";
75
53
  import { initLogger, log } from "./core/logger.js";
76
54
  import { installTerminationHandlers } from "./core/shutdown.js";
77
- import { KeepAwake } from "./core/keep-awake.js";
78
- import { RADIO_STATIONS, getCurrentStation, getRadioVolume, playRadio, setRadioVolume, stopRadio, } from "./core/radio.js";
55
+ import { stopRadio } from "./core/radio.js";
79
56
  import { enrichProcessPath } from "./core/shell-path.js";
80
- import { downscaleForPreview, IMAGE_MEDIA_TYPES, shrinkToFit, validateVisionImage, } from "./utils/image.js";
81
- import { readFileBounded } from "./tools/operations.js";
82
- import { startServeMode } from "./modes/serve-mode.js";
83
- import { installSteroids, probeSteroids } from "./core/steroids.js";
84
- import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
85
- import { loadServers, addServer, removeServer, getServer, parseMcpAddCommand, MCPClientManager, McpOAuthStore, createElicitationBridge, } from "./core/mcp/index.js";
86
- import { askSoftDeadlineMs, createAskUserBridge, deliverLateAnswer, } from "./core/ask-user.js";
57
+ import { createElicitationBridge } from "./core/mcp/index.js";
58
+ import { askSoftDeadlineMs, createAskUserBridge, deliverLateAnswer } from "./core/ask-user.js";
87
59
  import { createAskUserTool } from "./tools/ask-user.js";
88
- import { buildSnapshot, levelForXp, rankForLevel } from "./core/progress/ranks.js";
89
- import { loadProgress, peekProgress, updateProgress } from "./core/progress/store.js";
90
- import { awardPrompt, awardCommits } from "./core/progress/engine.js";
91
- import { detectNewCommits, repoKey } from "./core/progress/git-xp.js";
92
- import { rebuildFromSessions } from "./core/progress/rebuild.js";
93
- /** App-wide idle-sleep guard: every window's runs share one OS assertion. The
94
- * `keepAwake` setting is applied at daemon start and live via /keep-awake. */
95
- const keepAwake = new KeepAwake();
96
- const AUTOMATION_PROVENANCE = {
97
- source: "runtime",
98
- kind: "automation",
99
- visibility: "hidden",
100
- };
101
- const ALL_PROVIDERS = [
102
- // US
103
- "anthropic",
104
- "openai",
105
- "gemini",
106
- "xai",
107
- // China
108
- "moonshot",
109
- "glm",
110
- "minimax",
111
- "xiaomi",
112
- "deepseek",
113
- // Open-community gateway, before the provider-agnostic one
114
- "huggingface",
115
- // Japan, then provider-agnostic gateway last
116
- "sakana",
117
- "openrouter",
118
- ];
119
- function stringArray(value) {
120
- if (!Array.isArray(value))
121
- return undefined;
122
- const out = value.filter((v) => typeof v === "string" && v.trim().length > 0);
123
- return out.length > 0 ? out : undefined;
124
- }
125
- function appSettingsFile() {
126
- return path.join(os.homedir(), ".ezcoder", "ezcoder-app.json");
127
- }
128
- function defaultProjectsRoot() {
129
- return path.join(os.homedir(), "ez-projects");
130
- }
131
- /** Normalize a project cwd to a stable settings key so trailing slashes /
132
- * relative segments collapse — the same project always maps to one entry. */
133
- function projectModelKey(cwd) {
134
- return path.resolve(cwd);
135
- }
136
- async function loadAppSettings() {
137
- try {
138
- const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
139
- return {
140
- projectsRoot: typeof raw.projectsRoot === "string" && raw.projectsRoot.trim()
141
- ? raw.projectsRoot
142
- : defaultProjectsRoot(),
143
- // Preserve the per-project map verbatim (validated + written by the
144
- // model/thinking handlers below).
145
- projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
146
- autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
147
- nolanModels: raw.nolanModels && typeof raw.nolanModels === "object" ? raw.nolanModels : undefined,
148
- projectRoots: stringArray(raw.projectRoots),
149
- hiddenProjects: stringArray(raw.hiddenProjects),
150
- };
151
- }
152
- catch {
153
- return { projectsRoot: defaultProjectsRoot() };
154
- }
155
- }
156
- async function saveAppSettings(settings) {
157
- await fs.mkdir(path.dirname(appSettingsFile()), { recursive: true });
158
- await fs.writeFile(appSettingsFile(), JSON.stringify(settings, null, 2), "utf-8");
159
- }
160
- /** Read this project's persisted model/thinking prefs, if any. */
161
- async function loadProjectModelPrefs(cwd) {
162
- const s = await loadAppSettings();
163
- return s.projectModels?.[projectModelKey(cwd)];
164
- }
165
- /** Persist this project's model/thinking prefs via read-modify-write so the rest
166
- * of the settings file (projectsRoot, other projects' entries) is preserved. */
167
- async function saveProjectModelPrefs(cwd, prefs) {
168
- const s = await loadAppSettings();
169
- const key = projectModelKey(cwd);
170
- s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
171
- await saveAppSettings(s);
172
- }
173
- /** Read this project's persisted Nolan model override, if any. */
174
- async function loadNolanModelPref(cwd) {
175
- const s = await loadAppSettings();
176
- return s.nolanModels?.[projectModelKey(cwd)];
177
- }
178
- /** Persist (or with null, clear) this project's Nolan model override via
179
- * read-modify-write so the rest of the settings file is preserved. */
180
- async function saveNolanModelPref(cwd, pref) {
181
- const s = await loadAppSettings();
182
- const key = projectModelKey(cwd);
183
- const next = { ...(s.nolanModels ?? {}) };
184
- if (pref)
185
- next[key] = pref;
186
- else
187
- delete next[key];
188
- s.nolanModels = next;
189
- await saveAppSettings(s);
190
- }
191
- /** Read this project's persisted autopilot flag (default off). */
192
- async function loadAutopilot(cwd) {
193
- const s = await loadAppSettings();
194
- return s.autopilot?.[projectModelKey(cwd)] ?? false;
195
- }
196
- /** Persist this project's autopilot flag via read-modify-write so the rest of
197
- * the settings file (projectsRoot, model map, other projects) is preserved. */
198
- async function saveAutopilot(cwd, enabled) {
199
- const s = await loadAppSettings();
200
- const key = projectModelKey(cwd);
201
- s.autopilot = { ...(s.autopilot ?? {}), [key]: enabled };
202
- await saveAppSettings(s);
203
- }
204
- /**
205
- * Persist the active model selection to ~/.ezcoder/settings.json so it survives app
206
- * restarts. Mirrors the CLI's handleModelSelect persistence (App.tsx).
207
- */
208
- async function persistModelSelection(settingsFile, provider, model) {
209
- try {
210
- const sm = new SettingsManager(settingsFile);
211
- await sm.load();
212
- await sm.set("defaultProvider", provider);
213
- await sm.set("defaultModel", model);
214
- }
215
- catch (err) {
216
- log("WARN", "app-sidecar", "failed to persist model selection", { err: String(err) });
217
- }
218
- }
219
- /**
220
- * Persist the thinking level to ~/.ezcoder/settings.json so it survives app restarts.
221
- * Mirrors the CLI's handleToggleThinking persistence (App.tsx).
222
- */
223
- async function persistThinkingLevel(settingsFile, level) {
224
- try {
225
- const sm = new SettingsManager(settingsFile);
226
- await sm.load();
227
- await sm.set("thinkingEnabled", !!level);
228
- if (level)
229
- await sm.set("thinkingLevel", level);
230
- }
231
- catch (err) {
232
- log("WARN", "app-sidecar", "failed to persist thinking level", { err: String(err) });
233
- }
234
- }
235
- /** Validate a project folder name: lowercase letters, digits, dashes only. */
236
- function isValidProjectName(name) {
237
- return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
238
- }
239
- async function prepareAttachments(cwd, attachments) {
240
- const dir = path.join(cwd, ".ezcoder", "uploads");
241
- await fs.mkdir(dir, { recursive: true }).catch(() => { });
242
- const out = [];
243
- for (const a of attachments) {
244
- // Sanitize the filename and prefix with a short timestamp to avoid clobber.
245
- const safe = a.name.replace(/[^\w.-]+/g, "_").slice(-80) || "file";
246
- const fileName = `${Date.now().toString(36)}-${safe}`;
247
- const filePath = path.join(dir, fileName);
248
- const buf = Buffer.from(a.data, "base64");
249
- // Validate and cap image attachments before they become native image content
250
- // blocks. Anthropic applies a 2000 px per-dimension cap once conversation
251
- // history contains more than 20 images, so every attachment must be safe for
252
- // later turns too. Keep the original on disk, but send the resized bytes.
253
- // Corrupt or unsupported images become plain files so they cannot reject the
254
- // entire provider request.
255
- let prepared = { ...a };
256
- if (a.kind === "image") {
257
- try {
258
- const resized = await shrinkToFit(buf, a.mediaType);
259
- const validatedType = await validateVisionImage(resized.buffer);
260
- prepared = validatedType
261
- ? {
262
- ...a,
263
- mediaType: validatedType,
264
- data: resized.buffer.toString("base64"),
265
- }
266
- : { ...a, kind: "file" };
267
- }
268
- catch {
269
- prepared = { ...a, kind: "file" };
270
- }
271
- }
272
- try {
273
- await fs.writeFile(filePath, buf);
274
- out.push({ ...prepared, path: filePath });
275
- }
276
- catch {
277
- out.push({ ...prepared });
278
- }
279
- }
280
- return out;
281
- }
282
- const FILE_SEARCH_LIMIT = 20;
283
- // Upper bound on files walked per search (baseline #8 memory cap). Far above the
284
- // 20-result output limit, so relevance/recency ranking is unaffected in practice.
285
- const FILE_SEARCH_SCAN_CAP = 50_000;
286
- /** Score a candidate path against a lowercased query. Higher is better; a
287
- * negative score means "no match". Basename hits beat path hits; prefix beats
288
- * substring; shorter paths break ties. */
289
- function scoreFile(relPath, name, query) {
290
- const lcPath = relPath.toLowerCase();
291
- const lcName = name.toLowerCase();
292
- let score = -1;
293
- if (lcName === query)
294
- score = 1000;
295
- else if (lcName.startsWith(query))
296
- score = 800;
297
- else if (lcName.includes(query))
298
- score = 600;
299
- else if (lcPath.startsWith(query))
300
- score = 400;
301
- else if (lcPath.includes(query))
302
- score = 200;
303
- else if (subsequenceMatch(lcPath, query))
304
- score = 100;
305
- if (score < 0)
306
- return -1;
307
- // Prefer shorter paths (closer to root, less nesting) on equal match class.
308
- return score - relPath.length * 0.1;
309
- }
310
- /** True when every char of `needle` appears in `haystack` in order (fuzzy). */
311
- function subsequenceMatch(haystack, needle) {
312
- let i = 0;
313
- for (const ch of haystack) {
314
- if (ch === needle[i])
315
- i++;
316
- if (i === needle.length)
317
- return true;
318
- }
319
- return needle.length === 0;
320
- }
321
- async function searchProjectFiles(cwd, rawQuery) {
322
- const fg = await import("fast-glob");
323
- const ignore = await import("ignore");
324
- const query = rawQuery.trim().toLowerCase();
325
- let gitignore = [];
326
- try {
327
- const content = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8");
328
- gitignore = content
329
- .split("\n")
330
- .map((l) => l.trim())
331
- .filter((l) => l && !l.startsWith("#"));
332
- }
333
- catch {
334
- // No .gitignore — nothing extra to ignore.
335
- }
336
- const ig = ignore.default().add(gitignore);
337
- // `stats: true` gives mtime without a second stat pass, so the empty-query
338
- // "recent files" path is a single walk. Stream + hard scan cap (baseline #8):
339
- // collecting the full result array retained ~0.9 MB per 1k files (18.5 MB at
340
- // 20k), unbounded. Bail after FILE_SEARCH_SCAN_CAP entries so a giant repo
341
- // can't balloon RSS; the newest/most-relevant matches still surface because
342
- // the cap is far above the 20-result output limit.
343
- const entries = [];
344
- const scanStream = fg.default.stream("**/*", {
345
- cwd,
346
- dot: false,
347
- onlyFiles: true,
348
- ignore: ["**/node_modules/**", "**/.git/**", "**/.ezcoder/**"],
349
- suppressErrors: true,
350
- followSymbolicLinks: false,
351
- stats: true,
352
- });
353
- for await (const entry of scanStream) {
354
- entries.push(entry);
355
- if (entries.length >= FILE_SEARCH_SCAN_CAP) {
356
- scanStream.destroy();
357
- log("DEBUG", "app-sidecar", "file search scan cap hit", {
358
- cap: String(FILE_SEARCH_SCAN_CAP),
359
- cwd,
360
- });
361
- break;
362
- }
363
- }
364
- const files = entries.filter((e) => !ig.ignores(e.path));
365
- if (!query) {
366
- return files
367
- .sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0))
368
- .slice(0, FILE_SEARCH_LIMIT)
369
- .map((e) => ({ path: e.path, name: path.posix.basename(e.path) }));
370
- }
371
- const scored = [];
372
- for (const e of files) {
373
- const name = path.posix.basename(e.path);
374
- const score = scoreFile(e.path, name, query);
375
- if (score >= 0)
376
- scored.push({ hit: { path: e.path, name }, score });
377
- }
378
- scored.sort((a, b) => b.score - a.score);
379
- return scored.slice(0, FILE_SEARCH_LIMIT).map((s) => s.hit);
380
- }
381
- /**
382
- * Detect whether a restored user message is actually an injected self-correction
383
- * hook prompt, by its distinctive opening phrase. Returns the hook kind so the
384
- * webview can render the short notice line instead of the full prompt body.
385
- */
386
- /** `?tools=` on /export. Anything unrecognised (including absent) falls back to
387
- * the human-facing `summary` default rather than dumping every payload. */
388
- function parseToolDetail(value) {
389
- return value === "none" || value === "full" ? value : "summary";
390
- }
391
- function detectHookKind(text) {
392
- const t = text.trimStart();
393
- if (t.startsWith("Ideal? Review the actual work"))
394
- return "ideal";
395
- if (t.startsWith("Stuck? You've repeated essentially"))
396
- return "loop_break";
397
- if (t.startsWith("Re-ground. The conversation was just compacted"))
398
- return "regrounding";
399
- return null;
400
- }
401
- /** A short transport summary for display (URL for http/sse, command+args for stdio). */
402
- function mcpRowSummary(config) {
403
- if (config.url)
404
- return config.url;
405
- return [config.command, ...(config.args ?? [])].filter(Boolean).join(" ");
406
- }
407
- /** Load + connect every server, returning one wire row per server. Mirrors the
408
- * CLI dashboard's buildRows (connectAllDetailed, then dispose). Empty list
409
- * short-circuits before spawning any stdio process / opening any HTTP conn.
410
- * Project-scope servers run repo-controlled commands, so unless the user
411
- * trusts them (trustProjectMcpServers) they are reported blocked WITHOUT
412
- * being connected — even a status probe would spawn the process. */
413
- async function buildMcpRows(cwd, settingsFile) {
414
- const scoped = await loadServers(cwd);
415
- if (scoped.length === 0)
416
- return [];
417
- const settings = loadSavedSettings(settingsFile);
418
- const allowProject = projectScopeAllowed(settings.trustProjectMcpServers, settings.trustedProjects, cwd);
419
- const connectable = scoped.filter((s) => allowProject || s.scope !== "project");
420
- const blocked = scoped.filter((s) => !allowProject && s.scope === "project");
421
- const manager = new MCPClientManager();
422
- try {
423
- const results = connectable.length > 0
424
- ? await manager.connectAllDetailed(connectable.map((s) => s.config))
425
- : [];
426
- return [
427
- ...connectable.map((s) => {
428
- const result = results.find((r) => r.name === s.config.name);
429
- return {
430
- name: s.config.name,
431
- scope: s.scope,
432
- ok: result?.ok ?? false,
433
- toolCount: result?.toolCount ?? 0,
434
- error: result?.error,
435
- kind: s.config.url ? "http" : "stdio",
436
- summary: mcpRowSummary(s.config),
437
- requiresAuth: result?.requiresAuth,
438
- };
439
- }),
440
- ...blocked.map((s) => ({
441
- name: s.config.name,
442
- scope: s.scope,
443
- ok: false,
444
- toolCount: 0,
445
- error: "Project-scope server not connected — this repo's .ezcoder/mcp.json runs " +
446
- "repo-controlled commands. Add or re-add a server in this project via " +
447
- "the MCP modal to trust it.",
448
- kind: (s.config.url ? "http" : "stdio"),
449
- summary: mcpRowSummary(s.config),
450
- })),
451
- ];
452
- }
453
- finally {
454
- await manager.dispose();
455
- }
456
- }
457
- /** Probe a single server's connection before persisting it. Never throws — a
458
- * failed probe returns ok:false with a human-readable error so the config can
459
- * still be saved. Mirrors the CLI's probeServer. */
460
- async function probeMcp(config) {
461
- const manager = new MCPClientManager();
462
- try {
463
- const result = await manager.probe(config);
464
- return {
465
- ok: result.ok,
466
- toolCount: result.toolCount,
467
- error: result.error,
468
- requiresAuth: result.requiresAuth,
469
- };
470
- }
471
- finally {
472
- await manager.dispose();
473
- }
474
- }
475
- /**
476
- * Sub-agents spawn the ezcoder CLI in JSON mode to run a delegated task. In the
477
- * packaged desktop app the only runnable entry is THIS bundle (there's no
478
- * sibling `cli.js`), so the subagent tool ends up spawning the sidecar itself.
479
- * Without this guard that would boot a second HTTP server, emit no NDJSON, and
480
- * hang until the 10-minute hard timeout. So when invoked with `--json`, behave
481
- * exactly like `ezcoder --json …`: stream the sub-agent run as NDJSON and exit,
482
- * never starting the HTTP/SSE server. Mirrors the `values.json` branch in cli.ts.
483
- */
484
- async function runJsonModeIfRequested() {
485
- if (!process.argv.includes("--json"))
486
- return false;
487
- const { values, positionals } = parseArgs({
488
- args: process.argv.slice(2),
489
- options: {
490
- json: { type: "boolean" },
491
- provider: { type: "string" },
492
- model: { type: "string" },
493
- "max-turns": { type: "string" },
494
- "system-prompt": { type: "string" },
495
- "agent-prompt": { type: "string" },
496
- "agent-context": { type: "string" },
497
- tools: { type: "string" },
498
- "mcp-servers": { type: "string" },
499
- "prompt-cache-key": { type: "string" },
500
- thinking: { type: "string" },
501
- },
502
- allowPositionals: true,
503
- strict: true,
504
- });
505
- const maxTurnsRaw = values["max-turns"];
506
- // Optional tool allow-list forwarded by the subagent spawner from an agent
507
- // definition's `tools:` frontmatter. Mirrors the identical parsing in
508
- // cli.ts's `values.json` branch — keep both in sync (see subagent.ts).
509
- const parsedTools = values.tools
510
- ? values.tools
511
- .split(",")
512
- .map((t) => t.trim())
513
- .filter(Boolean)
514
- : [];
515
- const allowedTools = parsedTools.length > 0 ? parsedTools : undefined;
516
- // MCP servers the agent definition asked for. Without forwarding these, an
517
- // allow-listed child connects no MCP at all and loses live code search.
518
- const parsedMcpServers = values["mcp-servers"]
519
- ? values["mcp-servers"]
520
- .split(",")
521
- .map((s) => s.trim())
522
- .filter(Boolean)
523
- : [];
524
- const allowedMcpServers = parsedMcpServers.length > 0 ? parsedMcpServers : undefined;
525
- await runJsonMode({
526
- message: positionals[0] ?? "",
527
- provider: (values.provider ?? "anthropic"),
528
- model: values.model ?? "claude-opus-5-5",
529
- cwd: process.cwd(),
530
- systemPrompt: values["system-prompt"],
531
- agentPrompt: values["agent-prompt"],
532
- agentContext: values["agent-context"] === "none" ? "none" : undefined,
533
- maxTurns: maxTurnsRaw ? parseInt(maxTurnsRaw, 10) : undefined,
534
- allowedTools,
535
- allowedMcpServers,
536
- promptCacheKey: values["prompt-cache-key"],
537
- thinkingLevel: parseThinkingLevel(values.thinking),
538
- }).catch(async (err) => {
539
- process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
540
- process.exit(1);
541
- });
542
- return true;
543
- }
544
- // ── Daemon-level HTTP helpers (shared by the session-management routes) ─────
545
- // The per-session route table has its own local copies; these serve the
546
- // daemon's own POST /session / DELETE /session routes.
547
- function daemonReadBody(req, res) {
548
- return readCappedBody(req, res);
549
- }
550
- function daemonJson(res, status, body) {
551
- // No CORS headers: only the Rust proxy (no Origin) should call this daemon.
552
- // Granting origins would let any web page read responses from loopback.
553
- res.writeHead(status, {
554
- "content-type": "application/json",
555
- });
556
- res.end(JSON.stringify(body));
557
- }
60
+ import { createUsageService } from "./app-sidecar/usage.js";
61
+ import { daemonReadBody, daemonJson, json } from "./app-sidecar/http.js";
62
+ import { runJsonModeIfRequested } from "./app-sidecar/json-mode.js";
63
+ import { parseWorkspaceMode, } from "./app-sidecar/session-types.js";
64
+ import { ALL_PROVIDERS, loadProjectModelPrefs, loadNolanModelPref, loadAutopilot, } from "./app-sidecar/app-settings.js";
65
+ import { NOLAN_ALLOWED_TOOLS } from "./app-sidecar/nolan-context.js";
66
+ import { createProgressManager } from "./app-sidecar/progress-manager.js";
67
+ import { handleSessionRoutes } from "./app-sidecar/session-routes.js";
68
+ import { handleWorkspaceRoutes } from "./app-sidecar/workspace-routes.js";
69
+ import { handleHistoryRoutes } from "./app-sidecar/history-routes.js";
70
+ import { handlePromptRoutes } from "./app-sidecar/prompt-routes.js";
71
+ import { handleTaskRoutes } from "./app-sidecar/task-routes.js";
72
+ import { handleRunControlRoutes } from "./app-sidecar/run-control-routes.js";
73
+ import { handleAuthRoutes } from "./app-sidecar/auth-routes.js";
74
+ import { handleIntegrationRoutes } from "./app-sidecar/integration-routes.js";
75
+ import { handleMcpRoutes } from "./app-sidecar/mcp-routes.js";
76
+ import { createHfPullManager } from "./app-sidecar/hf-pull-manager.js";
77
+ import { keepAwake } from "./app-sidecar/keep-awake.js";
78
+ import { createAutopilotRunner } from "./app-sidecar/autopilot-runner.js";
558
79
  async function main() {
559
80
  // Hidden persistent-worker dispatch must win before strict JSON/server parsing.
560
81
  if (process.argv.includes("--subagent-worker")) {
@@ -680,152 +201,10 @@ async function main() {
680
201
  for (const ctx of sessions.values())
681
202
  ctx.broadcast("progress", { ...snapshot, origin: ctx.id === originId });
682
203
  });
683
- const usageCache = new Map();
684
- const usageRequests = new Map();
685
- // Last snapshot that actually carried windows, per provider. Replayed while a
686
- // fetch is failing so the title meter never blinks out of existence. Bounded
687
- // by USAGE_LAST_GOOD_MAX_AGE_MS — a provider that never recovers must stop
688
- // reporting rather than freeze a percentage (and a long-past reset time) on
689
- // screen forever. The 429 backoff alone runs to 24h, far past any usefulness.
690
- const usageLastGood = new Map();
691
- const USAGE_LAST_GOOD_MAX_AGE_MS = 30 * 60_000;
692
- // 429 backoff: quota endpoints are auxiliary UI data. Honor Retry-After when
693
- // provided; otherwise retain the unavailable snapshot for 30 minutes. Clamp
694
- // the provider value so a malformed header can neither hammer the endpoint
695
- // nor suppress usage data forever.
696
- const usageRateLimitedUntil = new Map();
697
- // Providers currently in a logged rate-limit episode (cleared on success).
698
- const usageRateLimitLogged = new Set();
699
- const USAGE_RATE_LIMIT_FALLBACK_BACKOFF_MS = 30 * 60_000;
700
- const USAGE_RATE_LIMIT_MIN_BACKOFF_MS = 60_000;
701
- const USAGE_RATE_LIMIT_MAX_BACKOFF_MS = 24 * 60 * 60_000;
702
- function clearUsageRateLimit(provider) {
703
- usageRateLimitedUntil.delete(provider);
704
- if (usageRateLimitLogged.delete(provider)) {
705
- log("INFO", "app-sidecar", "subscription usage recovered", { provider });
706
- }
707
- }
708
- async function fetchUsageProvider(provider) {
709
- const displayName = provider === "anthropic" ? "Anthropic" : provider === "openai" ? "Codex" : "Kimi";
710
- // Kimi plan usage is tracked on the OAuth credential specifically — the
711
- // Moonshot platform API key is metered per-token, not per plan window.
712
- const authKey = oauthStorageKey(provider) ?? provider;
713
- if (!(await auth.hasProviderAuth(authKey))) {
714
- // Logged out: drop the replay cache so a later login on a DIFFERENT
715
- // account can never inherit the previous one's numbers.
716
- usageLastGood.delete(provider);
717
- return { provider, displayName, connected: false, windows: [], fetchedAt: Date.now() };
718
- }
719
- try {
720
- let credentials = await auth.resolveCredentials(authKey);
721
- try {
722
- const snapshot = {
723
- ...(await fetchSubscriptionUsage(provider, credentials)),
724
- connected: true,
725
- };
726
- clearUsageRateLimit(provider);
727
- return snapshot;
728
- }
729
- catch (error) {
730
- // A provider can revoke an access token before its stored expiry. Refresh
731
- // once on 401, matching inference auth recovery, then retry the usage call.
732
- if (error instanceof SubscriptionUsageError && error.status === 401) {
733
- // Name the rejected token. This poller runs on a timer alongside live
734
- // agent runs, so an unconditional refresh here would invalidate the
735
- // token those runs are holding and fail them mid-turn — a background
736
- // status check must never be able to log out the foreground.
737
- credentials = await auth.resolveCredentials(authKey, {
738
- forceRefresh: true,
739
- rejectedToken: credentials.accessToken,
740
- });
741
- const snapshot = {
742
- ...(await fetchSubscriptionUsage(provider, credentials)),
743
- connected: true,
744
- };
745
- clearUsageRateLimit(provider);
746
- return snapshot;
747
- }
748
- throw error;
749
- }
750
- }
751
- catch (error) {
752
- const message = error instanceof Error ? error.message : String(error);
753
- if (error instanceof SubscriptionUsageError && error.status === 429) {
754
- const backoffMs = Math.min(USAGE_RATE_LIMIT_MAX_BACKOFF_MS, Math.max(USAGE_RATE_LIMIT_MIN_BACKOFF_MS, error.retryAfterMs ?? USAGE_RATE_LIMIT_FALLBACK_BACKOFF_MS));
755
- usageRateLimitedUntil.set(provider, Date.now() + backoffMs);
756
- // Rate limits on this auxiliary endpoint are expected (every GG process
757
- // on the machine polls it) and already handled by the backoff plus the
758
- // last-good replay. Log the transition, not every retry.
759
- if (!usageRateLimitLogged.has(provider)) {
760
- usageRateLimitLogged.add(provider);
761
- log("INFO", "app-sidecar", "subscription usage rate-limited; backing off", {
762
- provider,
763
- backoffMs: String(backoffMs),
764
- });
765
- }
766
- }
767
- else {
768
- log("WARN", "app-sidecar", "subscription usage fetch failed", { provider, message });
769
- }
770
- const connected = await auth.hasProviderAuth(authKey);
771
- // Transient failures (notably the 429s these auxiliary quota endpoints
772
- // hand out) must not blank the meter. Keep serving the last good
773
- // snapshot, flagged `stale`, so the bar stays put instead of flickering
774
- // out for the whole backoff window and back in on the next success.
775
- // Past the max age it's dropped — no data beats confidently wrong data.
776
- const lastGood = usageLastGood.get(provider);
777
- if (lastGood && Date.now() - lastGood.fetchedAt >= USAGE_LAST_GOOD_MAX_AGE_MS) {
778
- usageLastGood.delete(provider);
779
- }
780
- else if (connected && lastGood) {
781
- return { ...lastGood, stale: true };
782
- }
783
- return {
784
- provider,
785
- displayName,
786
- connected,
787
- windows: [],
788
- fetchedAt: Date.now(),
789
- error: connected ? "Usage is temporarily unavailable." : undefined,
790
- };
791
- }
792
- }
793
- async function subscriptionUsage(provider) {
794
- const cached = usageCache.get(provider);
795
- if (cached && cached.expiresAt > Date.now())
796
- return cached.result;
797
- const inFlight = usageRequests.get(provider);
798
- if (inFlight)
799
- return inFlight;
800
- const request = fetchUsageProvider(provider);
801
- usageRequests.set(provider, request);
802
- try {
803
- const result = await request;
804
- // Never re-store a replay — it would keep its original `fetchedAt`, but
805
- // writing it back muddies the "last GOOD" contract for no gain.
806
- if (result.connected && !result.error && !result.stale && result.windows.length > 0) {
807
- usageLastGood.set(provider, result);
808
- }
809
- // Anthropic can return utilization before it assigns reset timestamps
810
- // (notably before the account's first active request). Retry that partial
811
- // snapshot quickly; complete snapshots keep the normal one-minute cache.
812
- const missingReset = result.connected &&
813
- result.windows.length > 0 &&
814
- result.windows.some((window) => window.resetsAt === undefined);
815
- const rateLimitedUntil = usageRateLimitedUntil.get(provider) ?? 0;
816
- usageCache.set(provider, {
817
- result,
818
- expiresAt: rateLimitedUntil > Date.now()
819
- ? rateLimitedUntil
820
- : Date.now() + (missingReset ? 10_000 : 60_000),
821
- });
822
- return result;
823
- }
824
- finally {
825
- if (usageRequests.get(provider) === request)
826
- usageRequests.delete(provider);
827
- }
828
- }
204
+ const { subscriptionUsage } = createUsageService(auth);
205
+ // git status / GitHub counts / CI pollers, one per repo however many windows
206
+ // have it open (each window used to run its own).
207
+ const repoPolls = createRepoPolls();
829
208
  /** Resolve the target session id: the `x-ez-session` header, else a
830
209
  * `?session=` query param (used by the SSE /events connection). */
831
210
  function sessionIdFromReq(req, url) {
@@ -891,6 +270,7 @@ async function main() {
891
270
  jiwaStore,
892
271
  broadcastAll,
893
272
  oauthInFlightProviders,
273
+ repoPolls,
894
274
  }, { id, mode, chatAgent, cwd: sessionCwd, sessionPath });
895
275
  sessions.set(id, ctx);
896
276
  log("INFO", "app-sidecar", "session created", {
@@ -1045,168 +425,6 @@ async function main() {
1045
425
  }, 1_000);
1046
426
  parentWatch.unref?.();
1047
427
  }
1048
- /** Nolan's read-only tool allow-list. Excludes every mutating tool (write/edit/
1049
- * bash/tasks/subagent/generate_image/enter_plan/exit_plan/task_*) so the mentor
1050
- * agent can research + see, but never change the repo. */
1051
- const NOLAN_ALLOWED_TOOLS = [
1052
- "read",
1053
- "grep",
1054
- "find",
1055
- "ls",
1056
- "source_path",
1057
- "web_fetch",
1058
- "web_search",
1059
- "screenshot",
1060
- // Local corpus of real repos: lets Nolan verify against code that ships
1061
- // instead of assuming — core to how he's meant to work. Read-only.
1062
- "steroids",
1063
- ];
1064
- /** Extract the plain text of the most recent assistant message (Nolan's reply).
1065
- * Strips tool-call / image blocks, returning just the prose Nolan streamed. */
1066
- function lastAssistantText(messages) {
1067
- for (let i = messages.length - 1; i >= 0; i--) {
1068
- const m = messages[i];
1069
- if (m.role !== "assistant")
1070
- continue;
1071
- if (typeof m.content === "string")
1072
- return m.content;
1073
- return m.content
1074
- .map((c) => (c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : ""))
1075
- .join("");
1076
- }
1077
- return "";
1078
- }
1079
- /**
1080
- * Assemble Nolan's context digest for one `@Nolan` question: git/env + the build
1081
- * session's compaction summary + recent activity. Prepended to the user's
1082
- * question as Nolan's prompt body each turn. Project docs (CLAUDE.md/AGENTS.md)
1083
- * are NOT here — they're folded into Nolan's cached system prompt once per
1084
- * session instead (see nolan-prompt.ts), so they hit the provider prompt cache
1085
- * instead of being re-sent uncached on every question. Workflow commands +
1086
- * autopilot-injected prompts are passed through so the digest labels them as
1087
- * what they are instead of user-authored asks.
1088
- */
1089
- function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
1090
- return buildNolanDigest({
1091
- question,
1092
- cwd,
1093
- gitBranch,
1094
- messages: buildSession.getMessages(),
1095
- verificationEvidence: buildSession.getVerificationEvidence(),
1096
- verificationProblem: buildSession.getVerificationProblem(),
1097
- workflowCommands,
1098
- injectedPrompts,
1099
- });
1100
- }
1101
- async function createProgressManager(agentDir, broadcastAll) {
1102
- // Boot: recovery chain main → backup → coding + chat session rebuild → empty.
1103
- const coderSessionsDir = path.join(agentDir, "sessions");
1104
- let file = await loadProgress({
1105
- rebuild: () => rebuildFromSessions([
1106
- coderSessionsDir,
1107
- ...CHAT_AGENT_IDS.map((agentId) => chatAgentSessionsDir(coderSessionsDir, agentId)),
1108
- ]),
1109
- });
1110
- // Don't re-celebrate an old levelUp event on boot.
1111
- let lastSeenNonce = file.lastEvent?.nonce ?? null;
1112
- log("INFO", "app-sidecar", "progress loaded", {
1113
- xp: String(file.xp),
1114
- level: String(levelForXp(file.xp)),
1115
- });
1116
- function snapshot() {
1117
- return buildSnapshot(file);
1118
- }
1119
- async function awardRun(cwd, runStartedAt, originId) {
1120
- try {
1121
- const now = Date.now();
1122
- const updated = await updateProgress(async (f) => {
1123
- const levelBefore = levelForXp(f.xp);
1124
- awardPrompt(f, now, cwd);
1125
- // Commit XP: probe repo root + HEAD, then score lastHead..HEAD bounded
1126
- // by the run window. First sight of a repo records HEAD, scores nothing.
1127
- const probe = await detectNewCommits(cwd, undefined, runStartedAt);
1128
- if (probe) {
1129
- const key = repoKey(probe.repoRoot);
1130
- const lastHead = f.repos[key]?.lastHead;
1131
- if (lastHead && lastHead !== probe.head) {
1132
- const detected = await detectNewCommits(cwd, lastHead, runStartedAt);
1133
- if (detected && detected.commits.length > 0) {
1134
- awardCommits(f, detected.commits, now, cwd);
1135
- }
1136
- }
1137
- f.repos[key] = { lastHead: probe.head };
1138
- }
1139
- // One combined lastEvent per run so other windows celebrate exactly once.
1140
- const levelAfter = levelForXp(f.xp);
1141
- const levelUp = levelAfter > levelBefore
1142
- ? { from: levelBefore, to: levelAfter, rankName: rankForLevel(levelAfter).name }
1143
- : null;
1144
- f.lastEvent = { nonce: randomUUID(), levelUp };
1145
- return { file: f, levelledUp: levelUp !== null };
1146
- });
1147
- file = updated;
1148
- lastSeenNonce = updated.lastEvent?.nonce ?? null;
1149
- broadcastAll(buildSnapshot(updated), originId);
1150
- }
1151
- catch (err) {
1152
- log("DEBUG", "app-sidecar", "progress award failed", {
1153
- message: err instanceof Error ? err.message : String(err),
1154
- });
1155
- }
1156
- }
1157
- // Watch ~/.ezcoder (dir watch survives the atomic tmp+rename) for progress.json
1158
- // writes from other daemon processes; debounce, reload read-only, dedupe by nonce.
1159
- let watchDebounce = null;
1160
- let progressWatcher = null;
1161
- try {
1162
- const watcher = fsWatch(agentDir, (_event, filename) => {
1163
- if (filename !== "progress.json")
1164
- return;
1165
- if (watchDebounce)
1166
- clearTimeout(watchDebounce);
1167
- watchDebounce = setTimeout(() => {
1168
- void (async () => {
1169
- const reloaded = await peekProgress();
1170
- if (!reloaded)
1171
- return;
1172
- const nonce = reloaded.lastEvent?.nonce ?? null;
1173
- if (nonce === lastSeenNonce)
1174
- return;
1175
- file = reloaded;
1176
- lastSeenNonce = nonce;
1177
- broadcastAll(buildSnapshot(reloaded));
1178
- })();
1179
- }, 150);
1180
- });
1181
- watcher.unref();
1182
- progressWatcher = watcher;
1183
- }
1184
- catch (err) {
1185
- log("DEBUG", "app-sidecar", "progress watch unavailable", {
1186
- message: err instanceof Error ? err.message : String(err),
1187
- });
1188
- }
1189
- // Dispose closes the fs.watch handle (baseline #8: it was previously never
1190
- // closed — a per-daemon leak) and clears any pending debounce timer.
1191
- function dispose() {
1192
- if (watchDebounce) {
1193
- clearTimeout(watchDebounce);
1194
- watchDebounce = null;
1195
- }
1196
- try {
1197
- progressWatcher?.close();
1198
- }
1199
- catch {
1200
- // Already closed / never opened — nothing to do.
1201
- }
1202
- progressWatcher = null;
1203
- }
1204
- return { snapshot, awardRun, dispose };
1205
- }
1206
- /** Unknown or missing modes fall back to the coding agent. */
1207
- function parseWorkspaceMode(value) {
1208
- return value === "chat" || value === "motion" ? value : "code";
1209
- }
1210
428
  /**
1211
429
  * Build one in-process agent session: its AgentSession, SSE client set, event
1212
430
  * bridge, task runner, auth/login bridge, and the full HTTP route table exposed
@@ -1215,7 +433,7 @@ function parseWorkspaceMode(value) {
1215
433
  * logger, PATH, shared auth file, and radio live at the daemon level.
1216
434
  */
1217
435
  async function createSession(deps, opts) {
1218
- const { auth, progress, memoryStore, jiwaStore, broadcastAll, oauthInFlightProviders } = deps;
436
+ const { auth, progress, memoryStore, jiwaStore, broadcastAll, oauthInFlightProviders, repoPolls, } = deps;
1219
437
  const paths = deps.paths;
1220
438
  const mode = opts.mode;
1221
439
  let chatAgent = opts.chatAgent;
@@ -1434,6 +652,8 @@ async function createSession(deps, opts) {
1434
652
  session = new AgentSession({
1435
653
  ...baseSessionOptions,
1436
654
  additionalTools: [askUserTool],
655
+ // A run must not end mid-plan with nothing submitted: nobody could review it.
656
+ planSubmissionGate: true,
1437
657
  // Plan mode belongs only to the coding agent.
1438
658
  onEnterPlan: async (reason) => {
1439
659
  // Unattended task runs cannot approve plans; implement directly instead.
@@ -1520,204 +740,15 @@ async function createSession(deps, opts) {
1520
740
  })),
1521
741
  };
1522
742
  }
1523
- let hfPull = null;
1524
- const hfPullPayload = (s) => ({
1525
- repo: s.repo,
1526
- model: s.model,
1527
- tag: s.tag,
1528
- file: s.file,
1529
- sizeBytes: s.sizeBytes,
1530
- phase: s.phase,
1531
- percent: s.percent,
1532
- ...(s.detail ? { detail: s.detail } : {}),
1533
- ...(s.error ? { error: s.error } : {}),
743
+ // ── Hugging Face → Ollama pulls (see app-sidecar/hf-pull-manager.ts) ──
744
+ const hf = createHfPullManager({
745
+ paths,
746
+ broadcast,
747
+ broadcastAll,
748
+ scanLocalModels: (force) => scanLocalModels(force),
749
+ localStatePayload: () => localStatePayload(),
1534
750
  });
1535
- /** Stored HF token, if the user connected the huggingface provider. */
1536
- async function hfToken() {
1537
- try {
1538
- const auth = new AuthStorage(paths.authFile);
1539
- const creds = await auth.resolveCredentials("huggingface");
1540
- return creds.accessToken || undefined;
1541
- }
1542
- catch {
1543
- return undefined;
1544
- }
1545
- }
1546
- async function hfHubJson(pathname) {
1547
- const token = await hfToken();
1548
- const res = await fetch(`https://huggingface.co${pathname}`, {
1549
- headers: token ? { authorization: `Bearer ${token}` } : {},
1550
- signal: AbortSignal.timeout(10_000),
1551
- });
1552
- if (!res.ok)
1553
- throw new Error(`Hugging Face responded ${res.status}`);
1554
- return res.json();
1555
- }
1556
- /** Search the Hub for GGUF repos (what Ollama can pull). */
1557
- async function hfSearch(query) {
1558
- // `filter=gguf` (the tag the Hub applies to repos that actually contain
1559
- // GGUF files) — `library=gguf` also matches safetensors-only base repos,
1560
- // which then fail at pull time. `expand[]=gguf` confirms per row.
1561
- const params = new URLSearchParams({
1562
- search: query,
1563
- filter: "gguf",
1564
- sort: "downloads",
1565
- direction: "-1",
1566
- limit: "12",
1567
- });
1568
- for (const field of ["gguf", "downloads", "likes", "lastModified"]) {
1569
- params.append("expand[]", field);
1570
- }
1571
- const data = (await hfHubJson(`/api/models?${params.toString()}`));
1572
- return (Array.isArray(data) ? data : [])
1573
- .map(toHfSearchRow)
1574
- .filter((r) => r !== null);
1575
- }
1576
- /**
1577
- * Start `ollama pull hf.co/<repo>[:quant]`. Resolves once the child is
1578
- * spawned; progress streams as `hf_pull` events. The chosen quant comes from
1579
- * the repo's real file list — the client only ever sends a repo id, so there
1580
- * is no injection surface into argv.
1581
- */
1582
- async function startHfPull(repo) {
1583
- if (hfPull?.child) {
1584
- throw Object.assign(new Error("A download is already running."), { status: 409 });
1585
- }
1586
- // `recursive=true`: many repos (unsloth, mradermacher) keep quants in
1587
- // per-quant subfolders, and a flat listing reports them as GGUF-less.
1588
- const tree = (await hfHubJson(`/api/models/${repo}/tree/main?recursive=true`));
1589
- const files = (Array.isArray(tree) ? tree : []).flatMap((entry) => {
1590
- const e = entry;
1591
- // `type` guards a directory entry (size 0) from beating real files in the
1592
- // size fallback; the Hub marks folders as "directory".
1593
- if (e.type !== undefined && e.type !== "file")
1594
- return [];
1595
- if (typeof e.path !== "string" || !e.path.toLowerCase().endsWith(".gguf"))
1596
- return [];
1597
- const size = Number(e.lfs?.size ?? e.size ?? 0);
1598
- return [{ path: e.path, sizeBytes: Number.isFinite(size) ? size : 0 }];
1599
- });
1600
- const choice = pickGgufQuant(files);
1601
- if (!choice) {
1602
- const sharded = files.some((f) => isGgufShard(f.path));
1603
- throw Object.assign(new Error(sharded ? SHARDED_MESSAGE : "That repo has no GGUF file for Ollama to pull."), { status: 400 });
1604
- }
1605
- const model = `hf.co/${repo}${choice.tag ? `:${choice.tag}` : ""}`;
1606
- const token = await hfToken();
1607
- const state = {
1608
- repo,
1609
- model,
1610
- tag: choice.tag,
1611
- file: choice.file.path,
1612
- sizeBytes: choice.file.sizeBytes,
1613
- phase: "preparing",
1614
- percent: 0,
1615
- child: null,
1616
- };
1617
- hfPull = state;
1618
- broadcast("hf_pull", hfPullPayload(state));
1619
- // ollama prints progress to stderr (stdout on some builds); parse both.
1620
- let stderrTail = "";
1621
- let lastBroadcast = "";
1622
- const feed = (chunk) => {
1623
- // Once the pull is terminal (success, failure, or user cancel) stop
1624
- // parsing: a killed ollama dumps a burst of stderr that would otherwise
1625
- // spam the modal with garbage frames after the outcome is already shown.
1626
- if (state.phase === "success" || state.phase === "error")
1627
- return;
1628
- for (const line of chunk.split(/\r\n|\r|\n/)) {
1629
- const parsed = parseOllamaPullLine(line);
1630
- if (!parsed)
1631
- continue;
1632
- // A redrawn frame repeats `pulling manifest` next to live progress; it
1633
- // must not drag the modal back to "Contacting Ollama…".
1634
- if (!advancesPhase(state.phase, parsed.phase))
1635
- continue;
1636
- if (parsed.phase !== "error") {
1637
- state.phase = parsed.phase;
1638
- if (parsed.percent !== undefined)
1639
- state.percent = parsed.percent;
1640
- state.detail = parsed.detail;
1641
- }
1642
- // ollama redraws its TUI frame several times a second, mostly with
1643
- // identical numbers. Broadcasting each one re-rendered the modal for no
1644
- // visible change; only a frame that actually reads differently ships.
1645
- const next = JSON.stringify(hfPullPayload(state));
1646
- if (next === lastBroadcast)
1647
- continue;
1648
- lastBroadcast = next;
1649
- broadcast("hf_pull", hfPullPayload(state));
1650
- }
1651
- };
1652
- try {
1653
- const child = spawn("ollama", ["pull", model], {
1654
- env: { ...process.env, ...(token ? { HF_TOKEN: token } : {}) },
1655
- stdio: ["ignore", "pipe", "pipe"],
1656
- });
1657
- state.child = child;
1658
- child.stdout?.setEncoding("utf8");
1659
- child.stderr?.setEncoding("utf8");
1660
- child.stdout?.on("data", feed);
1661
- child.stderr?.on("data", (d) => {
1662
- stderrTail = (stderrTail + d).slice(-2000);
1663
- feed(d);
1664
- });
1665
- child.on("error", (err) => {
1666
- state.child = null;
1667
- state.phase = "error";
1668
- state.error =
1669
- err.code === "ENOENT"
1670
- ? "Ollama isn't installed (or isn't on PATH). Install it from ollama.com, then retry."
1671
- : `Could not start Ollama: ${err.message}`;
1672
- broadcast("hf_pull", hfPullPayload(state));
1673
- });
1674
- child.on("close", (code) => {
1675
- state.child = null;
1676
- if (state.phase === "success" || state.phase === "error")
1677
- return; // cancelled
1678
- if (code === 0) {
1679
- state.phase = "success";
1680
- state.percent = 100;
1681
- state.detail = undefined;
1682
- broadcast("hf_pull", hfPullPayload(state));
1683
- // The new model only exists to the app once Ollama lists it. Ollama's
1684
- // library is machine-wide, like the shared auth file, so every window
1685
- // gets the refresh — not just the one that ran the download.
1686
- void scanLocalModels(true)
1687
- .then(() => broadcastAll("models_change", { local: localStatePayload() }))
1688
- .catch(() => undefined);
1689
- }
1690
- else {
1691
- state.phase = "error";
1692
- state.error = state.error ?? explainPullFailure(stderrTail);
1693
- broadcast("hf_pull", hfPullPayload(state));
1694
- }
1695
- });
1696
- }
1697
- catch (err) {
1698
- state.child = null;
1699
- state.phase = "error";
1700
- state.error = `Could not start Ollama: ${err instanceof Error ? err.message : String(err)}`;
1701
- broadcast("hf_pull", hfPullPayload(state));
1702
- }
1703
- return hfPullPayload(state);
1704
- }
1705
- function cancelHfPull() {
1706
- const child = hfPull?.child;
1707
- if (!child)
1708
- return false;
1709
- // Terminal state FIRST, then the kill: the child's death rattle must not
1710
- // overwrite the clean "cancelled" outcome with raw stderr.
1711
- if (hfPull) {
1712
- hfPull.phase = "error";
1713
- hfPull.error = "Download cancelled.";
1714
- hfPull.detail = undefined;
1715
- hfPull.child = null;
1716
- broadcast("hf_pull", hfPullPayload(hfPull));
1717
- }
1718
- child.kill("SIGTERM");
1719
- return true;
1720
- }
751
+ const { hfPullPayload, hfSearch, startHfPull, cancelHfPull } = hf;
1721
752
  /**
1722
753
  * Why `modelId` can't be selected right now, or `undefined` when it can.
1723
754
  * Two real footguns get a clear answer instead of a mid-run provider error:
@@ -1785,6 +816,23 @@ async function createSession(deps, opts) {
1785
816
  let gitHubIssues = null;
1786
817
  let gitHubPRs = null;
1787
818
  let gitHubCI = null;
819
+ // Latest Project Health scan (code mode only); scored with the live CI result.
820
+ let projectHealthScan = null;
821
+ // Footer extras go out on every git/CI/task change; score only when the scan
822
+ // or CI result actually changed.
823
+ let scoredHealth = null;
824
+ function currentProjectHealth() {
825
+ if (!projectHealthScan)
826
+ return null;
827
+ if (scoredHealth?.scan !== projectHealthScan || scoredHealth.ci !== gitHubCI) {
828
+ scoredHealth = {
829
+ scan: projectHealthScan,
830
+ ci: gitHubCI,
831
+ health: scoreProjectHealth(projectHealthScan, gitHubCI),
832
+ };
833
+ }
834
+ return scoredHealth.health;
835
+ }
1788
836
  function currentContextWindow() {
1789
837
  const st = session.getState();
1790
838
  return getContextWindow(st.model, { provider: st.provider, accountId: st.accountId });
@@ -1801,6 +849,7 @@ async function createSession(deps, opts) {
1801
849
  gitHubPRs,
1802
850
  gitHubCI,
1803
851
  gitHubRepoUrl: gitHubSlug ? `https://github.com/${gitHubSlug}` : null,
852
+ projectHealth: currentProjectHealth(),
1804
853
  tasks: session.listBackgroundProcesses(),
1805
854
  // Roots added with /add-dir — the header shows a badge when non-empty.
1806
855
  additionalRoots: session.getAdditionalRoots(),
@@ -1819,20 +868,6 @@ async function createSession(deps, opts) {
1819
868
  error: err instanceof Error ? err.message : String(err),
1820
869
  });
1821
870
  });
1822
- // Refresh the GitHub counts and broadcast only on change. Transient failures
1823
- // keep the last-known numbers so the chips don't flicker off on a timeout.
1824
- async function refreshGitHubCounts() {
1825
- if (!gitHubSlug)
1826
- return;
1827
- const counts = await getGitHubOpenCounts(gitHubSlug);
1828
- if (!counts)
1829
- return;
1830
- if (counts.issues !== gitHubIssues || counts.prs !== gitHubPRs) {
1831
- gitHubIssues = counts.issues;
1832
- gitHubPRs = counts.prs;
1833
- broadcast("extras", footerExtras());
1834
- }
1835
- }
1836
871
  // tool_call_end carries no tool name (only the id), so remember each call's
1837
872
  // name from tool_call_start to log a useful line on completion. Mirrors the
1838
873
  // CLI's logging so the app sidecar's ~/.ezcoder/ezcoder-app-sidecar.log records tool
@@ -2114,6 +1149,47 @@ async function createSession(deps, opts) {
2114
1149
  pendingPlanContent = "";
2115
1150
  planGeneration++;
2116
1151
  }
1152
+ // Plan feedback (Nolan's or the user's) is revised back in read-only plan
1153
+ // mode. exit_plan leaves plan mode, so without this a "revise the plan" run
1154
+ // had full write access and could start implementing before any approval.
1155
+ async function reenterPlanModeForRevision() {
1156
+ if (session.getPlanMode())
1157
+ return;
1158
+ deactivateApprovedPlan();
1159
+ await session.setPlanMode(true);
1160
+ broadcast("plan_mode", { active: true });
1161
+ log("INFO", "app-sidecar", "plan mode re-entered for revision");
1162
+ }
1163
+ // Plan generation last handed to the human review box (-1: none yet).
1164
+ let offeredPlanGeneration = -1;
1165
+ // The plan the human review box should show right now, or null. Only once
1166
+ // everything has settled — see shouldOfferPendingPlan.
1167
+ function pendingPlanForHuman(offeredGeneration) {
1168
+ const offer = shouldOfferPendingPlan({
1169
+ planPath: pendingPlanPath,
1170
+ generation: planGeneration,
1171
+ offeredGeneration,
1172
+ running,
1173
+ starting: runClaim.active,
1174
+ autopilotActive,
1175
+ queued: session.getQueuedCount(),
1176
+ });
1177
+ return offer && pendingPlanPath !== null
1178
+ ? { planPath: pendingPlanPath, content: pendingPlanContent }
1179
+ : null;
1180
+ }
1181
+ // Open the Accept / Feedback / Reject box once nothing else will act on the
1182
+ // submitted plan. Called at every settle point (turn end, queue drain, task
1183
+ // run end). Without it, a plan Nolan didn't approve — handed back, review
1184
+ // failed, capped, cancelled, or skipped — sat pending with no way to act.
1185
+ function offerPendingPlan() {
1186
+ const plan = pendingPlanForHuman(offeredPlanGeneration);
1187
+ if (!plan)
1188
+ return;
1189
+ offeredPlanGeneration = planGeneration;
1190
+ log("INFO", "app-sidecar", "plan handed to user for review", { planPath: plan.planPath });
1191
+ broadcast("plan_review", plan);
1192
+ }
2117
1193
  // Workflow (prompt-template) commands: built-in + the project's custom
2118
1194
  // `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
2119
1195
  // expanded templates in Nolan's digests. Loaded fresh so a newly added custom
@@ -2347,7 +1423,11 @@ async function createSession(deps, opts) {
2347
1423
  }
2348
1424
  finally {
2349
1425
  const cancelled = runLifecycle.isCancellationRequested(generation);
2350
- const verificationProblem = cancelled ? null : session.getVerificationProblem();
1426
+ // A plain reply has no real end (the user keeps building), so checks are
1427
+ // left to the commit and unchecked edits are not "Unverified". Only an
1428
+ // Autopilot cycle, which does end, reports unchecked work. A check that
1429
+ // ran and failed still shows as failed (from the evidence).
1430
+ const verificationProblem = cancelled || !autopilotActive ? null : session.getVerificationProblem();
2351
1431
  if (runSucceeded && verificationProblem && ownsGeneration) {
2352
1432
  // Expected control outcome: run_end and the journal already carry Unverified.
2353
1433
  // Do not format it as a crash or persist a misleading error marker.
@@ -2370,8 +1450,9 @@ async function createSession(deps, opts) {
2370
1450
  ]);
2371
1451
  // A run may have opened/closed issues or PRs — refresh fire-and-forget so
2372
1452
  // teardown isn't delayed by the network. Broadcasts itself on change.
2373
- void refreshGitHubCounts();
2374
- void ciPoll.refresh();
1453
+ gitHubCountsPoll?.refresh();
1454
+ ciPoll.refresh();
1455
+ healthPoll?.refresh();
2375
1456
  // Serialize behind any marker/tool-triggered refresh so the terminal
2376
1457
  // progress snapshot uses the live plan file. Once every canonical step
2377
1458
  // is complete, remove the approved plan from future system prompts and
@@ -2433,233 +1514,11 @@ async function createSession(deps, opts) {
2433
1514
  broadcast("extras", footerExtras());
2434
1515
  }
2435
1516
  }
2436
- // ── Autopilot orchestration ─────────────────────────────────
2437
- // One review = prompt the nolanAuto session with the review digest, read its
2438
- // final assistant text, parse a verdict. Returns null on failure (surfaced as
2439
- // an autopilot_error frame) so the cycle stops rather than looping blind.
2440
- // `originalRequest` is the user prompt that started the turn under review —
2441
- // pinned in the digest so it can't scroll out during multi-round cycles.
2442
- async function runAutopilotReview(originalRequest) {
2443
- autopilotReviewing = true;
2444
- broadcast("autopilot_review_start", {});
2445
- try {
2446
- const nolanAgent = await ensureNolanAutoSession();
2447
- const digest = buildNolanAutopilotContext({
2448
- cwd,
2449
- gitBranch,
2450
- messages: session.getMessages(),
2451
- verificationEvidence: session.getVerificationEvidence(),
2452
- verificationProblem: session.getVerificationProblem(),
2453
- originalRequest,
2454
- injectedPrompts: [...injectedAutopilotPrompts],
2455
- workflowCommands: await loadWorkflowCommandSpecs(),
2456
- });
2457
- await nolanAgent.prompt(digest);
2458
- return parseAutopilotVerdict(lastAssistantText(nolanAgent.getMessages()));
2459
- }
2460
- catch (err) {
2461
- if (!autopilotCancelled)
2462
- broadcastError("autopilot_error", "autopilot review failed", err);
2463
- return null;
2464
- }
2465
- finally {
2466
- autopilotReviewing = false;
2467
- // Apply any model switch that landed mid-review.
2468
- const pending = pendingNolanAutoModel;
2469
- pendingNolanAutoModel = null;
2470
- if (pending)
2471
- await syncNolanAutoModel(pending.provider, pending.model);
2472
- }
2473
- }
2474
- // One PLAN review: like runAutopilotReview but the digest carries the
2475
- // submitted plan's markdown (`## Plan under review`) and the plan-review
2476
- // instruction — Nolan judges the plan itself, not finished work. Returns null
2477
- // on failure; a failure caused by the user's own action racing the review
2478
- // (cancel or a manual Accept/Reject that bumped planGeneration) stays
2479
- // SILENT — no autopilot_error — because the user's decision already won.
2480
- async function runAutopilotPlanReview(originalRequest) {
2481
- const planPath = pendingPlanPath;
2482
- if (planPath === null)
2483
- return null;
2484
- const genAtStart = planGeneration;
2485
- autopilotReviewing = true;
2486
- broadcast("autopilot_review_start", {});
2487
- try {
2488
- const nolanAgent = await ensureNolanAutoSession();
2489
- // Re-read the plan file (the run may have revised it in place); fall
2490
- // back to the content captured at exit_plan time.
2491
- const planContent = await fs.readFile(planPath, "utf-8").catch(() => pendingPlanContent);
2492
- const digest = buildNolanAutopilotPlanContext({
2493
- cwd,
2494
- gitBranch,
2495
- messages: session.getMessages(),
2496
- verificationEvidence: session.getVerificationEvidence(),
2497
- verificationProblem: session.getVerificationProblem(),
2498
- originalRequest,
2499
- injectedPrompts: [...injectedAutopilotPrompts],
2500
- workflowCommands: await loadWorkflowCommandSpecs(),
2501
- planContent,
2502
- });
2503
- await nolanAgent.prompt(digest);
2504
- if (autopilotCancelled || planGeneration !== genAtStart)
2505
- return null;
2506
- return parseAutopilotVerdict(lastAssistantText(nolanAgent.getMessages()));
2507
- }
2508
- catch (err) {
2509
- // User action mid-review (manual Accept aborts the nolanAuto run): drop
2510
- // the review silently — the user's decision supersedes Nolan's.
2511
- if (autopilotCancelled || planGeneration !== genAtStart)
2512
- return null;
2513
- broadcastError("autopilot_error", "autopilot plan review failed", err);
2514
- return null;
2515
- }
2516
- finally {
2517
- autopilotReviewing = false;
2518
- // Apply any model switch that landed mid-review.
2519
- const pending = pendingNolanAutoModel;
2520
- pendingNolanAutoModel = null;
2521
- if (pending)
2522
- await syncNolanAutoModel(pending.provider, pending.model);
2523
- }
2524
- }
2525
1517
  // The prompt fed to the fresh session after a plan is accepted — the SAME
2526
1518
  // string the webview sends on a manual Accept (see PlanReviewModal's accept
2527
1519
  // handler in ezcoder-app/src/App.tsx). Keep the two in lockstep so auto- and
2528
1520
  // manual approval produce identical implementation turns.
2529
1521
  const IMPLEMENT_PLAN_PROMPT = "The plan has been approved. Implement it now, following each step in order.";
2530
- // Drive the review→prompt→review loop for one finished user turn. Only ever
2531
- // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
2532
- // the stranded-queue drain) — never from the task runner, resume, /nolan, or
2533
- // error paths, so there's no recursion and no guard tangle. The loop's
2534
- // control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
2535
- // every exit path is unit-tested; this only wires the real dependencies.
2536
- async function runAutopilotCycle(originalRequest) {
2537
- if (!autopilot || autopilotCancelled || session.getVerificationProblem())
2538
- return;
2539
- const generation = runLifecycle.begin(abortOwnedWork).generation;
2540
- pendingCancelDrain = null;
2541
- autopilotActive = true;
2542
- // Generation captured by the last plan review; acceptPlan re-checks it so
2543
- // a user Accept/Reject landing mid-review always wins.
2544
- let planGenAtReview = -1;
2545
- try {
2546
- await driveAutopilotCycle({
2547
- // A plan-pending cycle needs extra rounds: approve+implement and the
2548
- // post-implement work review each consume one, so +2 keeps a real fix
2549
- // round available.
2550
- maxRounds: pendingPlanPath !== null ? MAX_AUTOPILOT_ROUNDS + 2 : MAX_AUTOPILOT_ROUNDS,
2551
- isCancelled: () => autopilotCancelled,
2552
- verificationProblem: () => session.getVerificationProblem(),
2553
- // An injected run entering plan mode WITHOUT submitting (enter_plan,
2554
- // no exit_plan) halts the cycle — Nolan never prompts into a read-only
2555
- // plan-mode session. A submitted plan takes the planPending branch.
2556
- isPlanMode: () => session.getPlanMode(),
2557
- planPending: () => pendingPlanPath !== null,
2558
- reviewPlan: async () => {
2559
- planGenAtReview = planGeneration;
2560
- return runAutopilotPlanReview(originalRequest);
2561
- },
2562
- // Auto-accept: the inlined POST /plan/accept body. Returns false when
2563
- // the plan generation moved since the review (user acted) — the cycle
2564
- // exits silently and the user's action stands.
2565
- acceptPlan: async (reason) => {
2566
- if (pendingPlanPath === null || planGeneration !== planGenAtReview)
2567
- return false;
2568
- const planPath = pendingPlanPath;
2569
- let planTotal;
2570
- try {
2571
- await session.newSession(true);
2572
- injectedAutopilotPrompts = [];
2573
- planTotal = await activateApprovedPlan(planPath);
2574
- }
2575
- catch (err) {
2576
- broadcastError("autopilot_error", "autopilot plan accept failed", err);
2577
- return false;
2578
- }
2579
- clearPendingPlan();
2580
- // Keep the approval marker ahead of the reset, then seed the reset
2581
- // with the sidecar's canonical count from the actual plan file.
2582
- broadcast("autopilot_plan_accepted", reason ? { reason } : {});
2583
- broadcast("session_reset", { planTotal });
2584
- broadcast("plan_progress", planProgressPayload());
2585
- // Persisted into the NEW session so a resume shows the marker.
2586
- void session.persistAutopilotMarker("plan_approved", { reason });
2587
- return true;
2588
- },
2589
- runImplement: () => {
2590
- // Autopilot-injected run: frame it so EZ Coder knows no human is
2591
- // watching the implementation. Record the framed string so Nolan's
2592
- // digest labels it as injected, not as the user's ask. The run_start
2593
- // label stays the clean prompt.
2594
- const framed = frameAutopilotInjection(IMPLEMENT_PLAN_PROMPT);
2595
- injectedAutopilotPrompts.push(framed);
2596
- return runAgent(IMPLEMENT_PLAN_PROMPT, () => session.prompt(framed, AUTOMATION_PROVENANCE));
2597
- },
2598
- // Lean context per user turn: wipe prior review history so each new
2599
- // turn starts cheap, while within this cycle the few review messages
2600
- // persist so Nolan remembers what he already asked EZ Coder to fix.
2601
- resetReviewer: async () => {
2602
- await nolanAutoSession?.newSession().catch(() => { });
2603
- },
2604
- review: () => runAutopilotReview(originalRequest),
2605
- // prompt → record the injected body (so later digests label it as
2606
- // Nolan's, not the user's), show a compact Nolan-tinted marker (not the
2607
- // prompt body), then feed EZ Coder bracketed by runAgent so the run
2608
- // streams normally; the shared finally never re-triggers autopilot,
2609
- // so this can't recurse.
2610
- onInjected: (body, round) => {
2611
- // A revision injection supersedes the pending plan — if the run
2612
- // resubmits via exit_plan, onExitPlan re-sets it (no-op for work-
2613
- // branch injections, where nothing is pending).
2614
- clearPendingPlan();
2615
- // Record the FRAMED string (what actually lands in the build session,
2616
- // see runPrompt) so Nolan's digest matches and labels it as injected.
2617
- // The webview marker + persisted body stay the CLEAN prompt so the UI
2618
- // shows Nolan's actual instruction, not the autopilot preamble.
2619
- injectedAutopilotPrompts.push(frameAutopilotInjection(body));
2620
- broadcast("autopilot_prompted", { round, body });
2621
- void session.persistAutopilotMarker("prompted", { body });
2622
- },
2623
- // Autopilot-injected run: EZ Coder receives the framed prompt (no human
2624
- // is watching this turn) while run_start keeps the clean label.
2625
- runPrompt: (body) => runAgent(body, () => session.prompt(frameAutopilotInjection(body), AUTOMATION_PROVENANCE)),
2626
- emit: (event) => {
2627
- // Persist the terminal verdict marker so a resumed session renders the
2628
- // same Nolan bubble the live run showed instead of dropping it or
2629
- // falling back to the raw verdict text (e.g. ALL_CLEAR).
2630
- if (event.type === "autopilot_done") {
2631
- // Broadcast the SAME copySeed the persisted marker will produce on
2632
- // resume, so the live all-clear wording matches the resumed one.
2633
- // Must use the PERSISTED count — that's what persistAutopilotMarker
2634
- // anchors against, and it trails the in-memory list after a run
2635
- // whose messages never made it to disk.
2636
- const seed = autopilotMarkerCopySeed({
2637
- version: 1,
2638
- phase: "done",
2639
- afterMessageCount: session.getPersistedTranscriptCount(),
2640
- ...event.data,
2641
- });
2642
- broadcast(event.type, { ...event.data, copySeed: seed });
2643
- void session.persistAutopilotMarker("done", event.data);
2644
- return;
2645
- }
2646
- broadcast(event.type, event.data);
2647
- if (event.type === "autopilot_human") {
2648
- void session.persistAutopilotMarker("human", { reason: event.data.reason });
2649
- }
2650
- else if (event.type === "autopilot_capped") {
2651
- void session.persistAutopilotMarker("capped");
2652
- }
2653
- // autopilot_ignored renders nothing live, so nothing is persisted either.
2654
- },
2655
- });
2656
- }
2657
- finally {
2658
- autopilotActive = false;
2659
- finishOwnedGeneration(generation, true, session.getVerificationProblem() ? "unverified" : "completed");
2660
- queueMicrotask(() => void runStrandedQueue());
2661
- }
2662
- }
2663
1522
  // ── Stranded-queue drain ───────────────────────────────
2664
1523
  // A prompt POSTed while an autopilot cycle is between injected runs (build
2665
1524
  // idle, Nolan reviewing) queues — but the queue only drains INTO a running
@@ -2670,71 +1529,6 @@ async function createSession(deps, opts) {
2670
1529
  // gated review). Also covers the non-autopilot tail window: a message queued
2671
1530
  // after the run's last steering drain but before run_end.
2672
1531
  let drainingStrandedQueue = false;
2673
- async function runStrandedQueue() {
2674
- if (drainingStrandedQueue)
2675
- return;
2676
- drainingStrandedQueue = true;
2677
- try {
2678
- for (;;) {
2679
- if (running || autopilotActive)
2680
- return;
2681
- const next = session.takeNextQueuedMessage();
2682
- if (!next)
2683
- return;
2684
- broadcast("queued", {
2685
- count: session.getQueuedCount(),
2686
- messages: session.listQueuedMessages(),
2687
- });
2688
- if (!next.text.trim() && next.attachments.length === 0)
2689
- continue;
2690
- // A queued message draining as a fresh turn supersedes any pending
2691
- // plan, exactly like a direct POST /prompt turn.
2692
- clearPendingPlan();
2693
- const workflowCommand = next.attachments.length === 0 &&
2694
- isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
2695
- const assistantsBefore = countAssistantMessages(session.getMessages());
2696
- const messagesBefore = session.getMessages().length;
2697
- await runAgent(next.text, async () => {
2698
- if (next.attachments.length > 0) {
2699
- await session.promptWithAttachments(next.text, next.attachments);
2700
- }
2701
- else {
2702
- await session.prompt(next.text);
2703
- }
2704
- }, () => autopilot);
2705
- const decision = shouldStartAutopilotCycle({
2706
- enabled: autopilot,
2707
- cancelled: autopilotCancelled,
2708
- planMode: session.getPlanMode(),
2709
- // A submitted plan (exit_plan fired) routes into the PLAN review
2710
- // branch — the cycle reviews the plan itself instead of skipping.
2711
- planPending: pendingPlanPath !== null,
2712
- workflowCommand,
2713
- assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
2714
- // Skip the review API call outright for turns that only started a
2715
- // background process (dev server/watcher), ran a read-only lookup, or
2716
- // committed/pushed — Nolan's autopilot contract already IGNOREs these,
2717
- // so there's no reason to pay for that verdict.
2718
- mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
2719
- });
2720
- if (decision.start) {
2721
- log("INFO", "app-sidecar", "autopilot cycle starting (queued turn)", {
2722
- kind: decision.kind,
2723
- });
2724
- await runAutopilotCycle(next.text);
2725
- }
2726
- else {
2727
- broadcast("autopilot_ignored", { reason: decision.reason });
2728
- log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
2729
- reason: decision.reason,
2730
- });
2731
- }
2732
- }
2733
- }
2734
- finally {
2735
- drainingStrandedQueue = false;
2736
- }
2737
- }
2738
1532
  // ── Task runner (project task list → sessions) ──────────────
2739
1533
  // Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
2740
1534
  // completion hint instructing the agent to mark the task done via the tasks
@@ -2744,70 +1538,180 @@ async function createSession(deps, opts) {
2744
1538
  // callbacks read this to decline plan mode when no human is present to
2745
1539
  // approve a plan, so tasks implement directly instead of hanging on review.
2746
1540
  let taskRunActive = false;
2747
- async function runTaskById(taskId) {
2748
- const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
2749
- if (!task)
2750
- return false;
2751
- taskRunActive = true;
2752
- try {
2753
- return await runTaskByIdInner(task);
2754
- }
2755
- finally {
2756
- taskRunActive = false;
2757
- }
2758
- }
2759
- async function runTaskByIdInner(task) {
2760
- // Fresh session per task so one task's context never bleeds into the next.
2761
- await session.newSession();
2762
- deactivateApprovedPlan();
2763
- injectedAutopilotPrompts = [];
2764
- clearPendingPlan();
2765
- broadcast("session_reset", {});
2766
- markTaskInProgress(cwd, task.id);
2767
- broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
2768
- broadcast("task_start", { id: task.id, title: task.title });
2769
- // Persist the task header so a resumed task session shows what ran.
2770
- void session.persistAppMarker("task", { title: task.title }).catch(() => { });
2771
- const shortId = task.id.slice(0, 8);
2772
- const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
2773
- `tasks({ action: "done", id: "${shortId}" })`;
2774
- await runAgent(task.title, () => session.prompt(task.prompt + completionHint, AUTOMATION_PROVENANCE));
2775
- // The agent typically marks the task done via the tasks tool during the run;
2776
- // prune completed tasks and push the refreshed list so the modal drops them.
2777
- broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
2778
- return true;
2779
- }
2780
- async function runTasks(startId, all) {
2781
- taskRunAll = all;
2782
- let currentId = startId ?? getNextPendingTask(cwd)?.id ?? null;
2783
- while (currentId) {
2784
- const ran = await runTaskById(currentId);
2785
- if (!ran || !taskRunAll)
2786
- break;
2787
- const next = getNextPendingTask(cwd);
2788
- currentId = next ? next.id : null;
2789
- // Brief pause between tasks (mirrors the CLI cadence).
2790
- if (currentId)
2791
- await new Promise((resolve) => setTimeout(resolve, 500));
2792
- }
2793
- taskRunAll = false;
2794
- broadcast("tasks_run_done", {});
2795
- }
2796
- // ── Provider auth (login) bridge ───────────────────────────
2797
- // OAuth login functions are interactive (open a URL, sometimes prompt for a
2798
- // pasted code). We run one at a time and surface every step over SSE so the
2799
- // webview can open the URL and collect a code via a modal. `pendingCode`
2800
- // resolves when the webview POSTs /auth/oauth/code.
2801
- let oauthInFlight = false;
2802
- let pendingCode = null;
2803
- function authCallbacks() {
2804
- return {
2805
- onOpenUrl: (url) => broadcast("auth_url", { url }),
2806
- onStatus: (message) => broadcast("auth_status", { message }),
2807
- onPromptCode: (message) => new Promise((resolve) => {
2808
- pendingCode = resolve;
2809
- broadcast("auth_need_code", { message });
2810
- }),
1541
+ // Moved to app-sidecar/autopilot-runner.ts
1542
+ const { runAutopilotCycle, runStrandedQueue, runTasks } = createAutopilotRunner({
1543
+ get taskRunActive() {
1544
+ return taskRunActive;
1545
+ },
1546
+ set taskRunActive(value) {
1547
+ taskRunActive = value;
1548
+ },
1549
+ get autopilotReviewing() {
1550
+ return autopilotReviewing;
1551
+ },
1552
+ set autopilotReviewing(v) {
1553
+ autopilotReviewing = v;
1554
+ },
1555
+ get broadcast() {
1556
+ return broadcast;
1557
+ },
1558
+ get ensureNolanAutoSession() {
1559
+ return ensureNolanAutoSession;
1560
+ },
1561
+ get cwd() {
1562
+ return cwd;
1563
+ },
1564
+ get gitBranch() {
1565
+ return gitBranch;
1566
+ },
1567
+ set gitBranch(v) {
1568
+ gitBranch = v;
1569
+ },
1570
+ get session() {
1571
+ return session;
1572
+ },
1573
+ set session(v) {
1574
+ session = v;
1575
+ },
1576
+ get injectedAutopilotPrompts() {
1577
+ return injectedAutopilotPrompts;
1578
+ },
1579
+ set injectedAutopilotPrompts(v) {
1580
+ injectedAutopilotPrompts = v;
1581
+ },
1582
+ get loadWorkflowCommandSpecs() {
1583
+ return loadWorkflowCommandSpecs;
1584
+ },
1585
+ get autopilotCancelled() {
1586
+ return autopilotCancelled;
1587
+ },
1588
+ set autopilotCancelled(v) {
1589
+ autopilotCancelled = v;
1590
+ },
1591
+ get broadcastError() {
1592
+ return broadcastError;
1593
+ },
1594
+ get pendingNolanAutoModel() {
1595
+ return pendingNolanAutoModel;
1596
+ },
1597
+ set pendingNolanAutoModel(v) {
1598
+ pendingNolanAutoModel = v;
1599
+ },
1600
+ get syncNolanAutoModel() {
1601
+ return syncNolanAutoModel;
1602
+ },
1603
+ get pendingPlanPath() {
1604
+ return pendingPlanPath;
1605
+ },
1606
+ set pendingPlanPath(v) {
1607
+ pendingPlanPath = v;
1608
+ },
1609
+ get planGeneration() {
1610
+ return planGeneration;
1611
+ },
1612
+ set planGeneration(v) {
1613
+ planGeneration = v;
1614
+ },
1615
+ get pendingPlanContent() {
1616
+ return pendingPlanContent;
1617
+ },
1618
+ set pendingPlanContent(v) {
1619
+ pendingPlanContent = v;
1620
+ },
1621
+ get autopilot() {
1622
+ return autopilot;
1623
+ },
1624
+ set autopilot(v) {
1625
+ autopilot = v;
1626
+ },
1627
+ get runLifecycle() {
1628
+ return runLifecycle;
1629
+ },
1630
+ get abortOwnedWork() {
1631
+ return abortOwnedWork;
1632
+ },
1633
+ get pendingCancelDrain() {
1634
+ return pendingCancelDrain;
1635
+ },
1636
+ set pendingCancelDrain(v) {
1637
+ pendingCancelDrain = v;
1638
+ },
1639
+ get autopilotActive() {
1640
+ return autopilotActive;
1641
+ },
1642
+ set autopilotActive(v) {
1643
+ autopilotActive = v;
1644
+ },
1645
+ get MAX_AUTOPILOT_ROUNDS() {
1646
+ return MAX_AUTOPILOT_ROUNDS;
1647
+ },
1648
+ get activateApprovedPlan() {
1649
+ return activateApprovedPlan;
1650
+ },
1651
+ get clearPendingPlan() {
1652
+ return clearPendingPlan;
1653
+ },
1654
+ get planProgressPayload() {
1655
+ return planProgressPayload;
1656
+ },
1657
+ get IMPLEMENT_PLAN_PROMPT() {
1658
+ return IMPLEMENT_PLAN_PROMPT;
1659
+ },
1660
+ get runAgent() {
1661
+ return runAgent;
1662
+ },
1663
+ get nolanAutoSession() {
1664
+ return nolanAutoSession;
1665
+ },
1666
+ set nolanAutoSession(v) {
1667
+ nolanAutoSession = v;
1668
+ },
1669
+ get reenterPlanModeForRevision() {
1670
+ return reenterPlanModeForRevision;
1671
+ },
1672
+ get finishOwnedGeneration() {
1673
+ return finishOwnedGeneration;
1674
+ },
1675
+ get drainingStrandedQueue() {
1676
+ return drainingStrandedQueue;
1677
+ },
1678
+ set drainingStrandedQueue(v) {
1679
+ drainingStrandedQueue = v;
1680
+ },
1681
+ get running() {
1682
+ return running;
1683
+ },
1684
+ set running(v) {
1685
+ running = v;
1686
+ },
1687
+ get offerPendingPlan() {
1688
+ return offerPendingPlan;
1689
+ },
1690
+ get deactivateApprovedPlan() {
1691
+ return deactivateApprovedPlan;
1692
+ },
1693
+ get taskRunAll() {
1694
+ return taskRunAll;
1695
+ },
1696
+ set taskRunAll(v) {
1697
+ taskRunAll = v;
1698
+ },
1699
+ });
1700
+ // ── Provider auth (login) bridge ───────────────────────────
1701
+ // OAuth login functions are interactive (open a URL, sometimes prompt for a
1702
+ // pasted code). We run one at a time and surface every step over SSE so the
1703
+ // webview can open the URL and collect a code via a modal. `pendingCode`
1704
+ // resolves when the webview POSTs /auth/oauth/code.
1705
+ let oauthInFlight = false;
1706
+ let pendingCode = null;
1707
+ function authCallbacks() {
1708
+ return {
1709
+ onOpenUrl: (url) => broadcast("auth_url", { url }),
1710
+ onStatus: (message) => broadcast("auth_status", { message }),
1711
+ onPromptCode: (message) => new Promise((resolve) => {
1712
+ pendingCode = resolve;
1713
+ broadcast("auth_need_code", { message });
1714
+ }),
2811
1715
  };
2812
1716
  }
2813
1717
  async function authStatusPayload() {
@@ -2887,2596 +1791,265 @@ async function createSession(deps, opts) {
2887
1791
  tasksPoll.unref?.();
2888
1792
  };
2889
1793
  scheduleTasksPoll(1500);
2890
- // Files can change outside the agent (editor saves, terminal commits), so keep
2891
- // the dirty count current while idle. Branch/repo state already refreshes after
2892
- // agent runs; polling only the count avoids spawning three git processes per tick.
2893
- let gitPoll;
2894
- let gitPollStopped = false;
2895
- const scheduleGitPoll = (delay) => {
2896
- if (gitPollStopped)
2897
- return;
2898
- gitPoll = setTimeout(() => {
2899
- void getGitDirtyFileCount(cwd)
2900
- .catch(() => gitDirtyFileCount)
2901
- .then((nextDirtyFileCount) => {
2902
- if (gitPollStopped)
2903
- return;
2904
- if (nextDirtyFileCount !== gitDirtyFileCount) {
2905
- gitDirtyFileCount = nextDirtyFileCount;
2906
- broadcast("extras", footerExtras());
2907
- }
2908
- scheduleGitPoll(5000);
2909
- });
2910
- }, delay);
2911
- gitPoll.unref?.();
2912
- };
2913
- scheduleGitPoll(5000);
2914
- // GitHub issue/PR counts change outside the app (web UI, teammates), so poll
2915
- // on a slow cadence. Network-bound, so keep it well under the search API's
2916
- // rate budget (2 calls per tick). No-op when the origin isn't a GitHub repo.
2917
- let gitHubPoll;
2918
- let gitHubPollStopped = false;
2919
- const scheduleGitHubPoll = (delay) => {
2920
- if (gitHubPollStopped)
1794
+ // Keep the dirty count, GitHub issue/PR counts and CI current while idle
1795
+ // (they change outside the agent: editor saves, commits, teammates). Branch/
1796
+ // repo state already refreshes after agent runs. The pollers are shared with
1797
+ // every other window on the same repo; each window broadcasts only changes.
1798
+ const repoKey = path.resolve(cwd);
1799
+ const dirtyFilesPoll = repoPolls.dirtyFiles.subscribe(repoKey, (next) => {
1800
+ if (next === gitDirtyFileCount)
1801
+ return;
1802
+ gitDirtyFileCount = next;
1803
+ broadcast("extras", footerExtras());
1804
+ });
1805
+ // Failed checks publish nothing, so the chips keep their last-known numbers
1806
+ // instead of flickering off on a timeout. None when origin isn't GitHub.
1807
+ const gitHubCountsPoll = gitHubSlug
1808
+ ? repoPolls.gitHubCounts.subscribe(gitHubSlug, (counts) => {
1809
+ if (counts.issues === gitHubIssues && counts.prs === gitHubPRs)
1810
+ return;
1811
+ gitHubIssues = counts.issues;
1812
+ gitHubPRs = counts.prs;
1813
+ broadcast("extras", footerExtras());
1814
+ })
1815
+ : null;
1816
+ const ciPoll = repoPolls.gitHubCI.subscribe(repoKey, (next) => {
1817
+ if (JSON.stringify(next) === JSON.stringify(gitHubCI))
2921
1818
  return;
2922
- gitHubPoll = setTimeout(() => {
2923
- void refreshGitHubCounts().finally(() => scheduleGitHubPoll(60_000));
2924
- }, delay);
2925
- gitHubPoll.unref?.();
2926
- };
2927
- scheduleGitHubPoll(2000);
2928
- const ciPoll = startGitHubCIPoll(cwd, (next) => {
2929
1819
  gitHubCI = next;
2930
1820
  broadcast("extras", footerExtras());
2931
1821
  });
2932
- function readBody(req, res) {
2933
- return readCappedBody(req, res);
2934
- }
2935
- function json(res, status, body) {
2936
- const payload = JSON.stringify(redactValue(body, { secrets: environmentSecrets(process.env) }));
2937
- res.writeHead(status, {
2938
- "content-type": "application/json",
2939
- });
2940
- res.end(payload);
2941
- }
1822
+ // Project Health is a code-workspace signal; chat and motion windows skip the scan.
1823
+ const healthPoll = mode === "code"
1824
+ ? repoPolls.projectHealth.subscribe(repoKey, (next) => {
1825
+ if (JSON.stringify(next) === JSON.stringify(projectHealthScan))
1826
+ return;
1827
+ projectHealthScan = next;
1828
+ broadcast("extras", footerExtras());
1829
+ })
1830
+ : null;
1831
+ const routeCtx = {
1832
+ get session() {
1833
+ return session;
1834
+ },
1835
+ set session(v) {
1836
+ session = v;
1837
+ },
1838
+ mode,
1839
+ get chatAgent() {
1840
+ return chatAgent;
1841
+ },
1842
+ set chatAgent(v) {
1843
+ chatAgent = v;
1844
+ },
1845
+ get running() {
1846
+ return running;
1847
+ },
1848
+ set running(v) {
1849
+ running = v;
1850
+ },
1851
+ runLifecycle,
1852
+ get autopilot() {
1853
+ return autopilot;
1854
+ },
1855
+ set autopilot(v) {
1856
+ autopilot = v;
1857
+ },
1858
+ nolanStatePayload,
1859
+ footerExtras,
1860
+ progress,
1861
+ memoryStore,
1862
+ jiwaStore,
1863
+ get clientSeq() {
1864
+ return clientSeq;
1865
+ },
1866
+ set clientSeq(v) {
1867
+ clientSeq = v;
1868
+ },
1869
+ clients,
1870
+ get autopilotActive() {
1871
+ return autopilotActive;
1872
+ },
1873
+ set autopilotActive(v) {
1874
+ autopilotActive = v;
1875
+ },
1876
+ pendingPlanForHuman,
1877
+ paths,
1878
+ host,
1879
+ cwd,
1880
+ asks,
1881
+ runClaim,
1882
+ broadcast,
1883
+ get scheduledRunActive() {
1884
+ return scheduledRunActive;
1885
+ },
1886
+ set scheduledRunActive(v) {
1887
+ scheduledRunActive = v;
1888
+ },
1889
+ loadWorkflowCommandSpecs,
1890
+ get autopilotCancelled() {
1891
+ return autopilotCancelled;
1892
+ },
1893
+ set autopilotCancelled(v) {
1894
+ autopilotCancelled = v;
1895
+ },
1896
+ clearPendingPlan,
1897
+ runAgent,
1898
+ reenterPlanModeForRevision,
1899
+ get pendingPlanPath() {
1900
+ return pendingPlanPath;
1901
+ },
1902
+ set pendingPlanPath(v) {
1903
+ pendingPlanPath = v;
1904
+ },
1905
+ runAutopilotCycle,
1906
+ runStrandedQueue,
1907
+ offerPendingPlan,
1908
+ get nolanRunning() {
1909
+ return nolanRunning;
1910
+ },
1911
+ set nolanRunning(v) {
1912
+ nolanRunning = v;
1913
+ },
1914
+ ensureNolanSession,
1915
+ get gitBranch() {
1916
+ return gitBranch;
1917
+ },
1918
+ set gitBranch(v) {
1919
+ gitBranch = v;
1920
+ },
1921
+ get injectedAutopilotPrompts() {
1922
+ return injectedAutopilotPrompts;
1923
+ },
1924
+ set injectedAutopilotPrompts(v) {
1925
+ injectedAutopilotPrompts = v;
1926
+ },
1927
+ broadcastError,
1928
+ get pendingNolanModel() {
1929
+ return pendingNolanModel;
1930
+ },
1931
+ set pendingNolanModel(v) {
1932
+ pendingNolanModel = v;
1933
+ },
1934
+ syncNolanModel,
1935
+ get nolanAbort() {
1936
+ return nolanAbort;
1937
+ },
1938
+ set nolanAbort(v) {
1939
+ nolanAbort = v;
1940
+ },
1941
+ get nolanSession() {
1942
+ return nolanSession;
1943
+ },
1944
+ set nolanSession(v) {
1945
+ nolanSession = v;
1946
+ },
1947
+ runTasks,
1948
+ auth,
1949
+ get localProbes() {
1950
+ return localProbes;
1951
+ },
1952
+ set localProbes(v) {
1953
+ localProbes = v;
1954
+ },
1955
+ localModelBlocker,
1956
+ get nolanModelOverride() {
1957
+ return nolanModelOverride;
1958
+ },
1959
+ set nolanModelOverride(v) {
1960
+ nolanModelOverride = v;
1961
+ },
1962
+ syncNolanAutoModel,
1963
+ get autopilotReviewing() {
1964
+ return autopilotReviewing;
1965
+ },
1966
+ set autopilotReviewing(v) {
1967
+ autopilotReviewing = v;
1968
+ },
1969
+ get taskRunAll() {
1970
+ return taskRunAll;
1971
+ },
1972
+ set taskRunAll(v) {
1973
+ taskRunAll = v;
1974
+ },
1975
+ get nolanAutoAbort() {
1976
+ return nolanAutoAbort;
1977
+ },
1978
+ set nolanAutoAbort(v) {
1979
+ nolanAutoAbort = v;
1980
+ },
1981
+ get nolanAutoSession() {
1982
+ return nolanAutoSession;
1983
+ },
1984
+ set nolanAutoSession(v) {
1985
+ nolanAutoSession = v;
1986
+ },
1987
+ get pendingCancelDrain() {
1988
+ return pendingCancelDrain;
1989
+ },
1990
+ set pendingCancelDrain(v) {
1991
+ pendingCancelDrain = v;
1992
+ },
1993
+ CANCEL_TIMEOUT_MS,
1994
+ deactivateApprovedPlan,
1995
+ activateApprovedPlan,
1996
+ planProgressPayload,
1997
+ authStatusPayload,
1998
+ broadcastAll,
1999
+ get oauthInFlight() {
2000
+ return oauthInFlight;
2001
+ },
2002
+ set oauthInFlight(v) {
2003
+ oauthInFlight = v;
2004
+ },
2005
+ oauthInFlightProviders,
2006
+ authCallbacks,
2007
+ get pendingCode() {
2008
+ return pendingCode;
2009
+ },
2010
+ set pendingCode(v) {
2011
+ pendingCode = v;
2012
+ },
2013
+ elicitations,
2014
+ localStatePayload,
2015
+ scanLocalModels,
2016
+ get hfPull() {
2017
+ return hf.hfPull;
2018
+ },
2019
+ set hfPull(v) {
2020
+ hf.hfPull = v;
2021
+ },
2022
+ hfPullPayload,
2023
+ hfSearch,
2024
+ startHfPull,
2025
+ cancelHfPull,
2026
+ get serveController() {
2027
+ return serveController;
2028
+ },
2029
+ set serveController(v) {
2030
+ serveController = v;
2031
+ },
2032
+ };
2942
2033
  // OPTIONS/CORS preflight is handled at the daemon level before delegation.
2943
2034
  function handle(req, res, url, method) {
2944
- if (method === "GET" && url === "/state") {
2945
- const st = session.getState();
2946
- json(res, 200, {
2947
- ...st,
2948
- mode,
2949
- chatAgent,
2950
- running,
2951
- runState: runLifecycle.state,
2952
- ready: true,
2953
- thinkingLevel: session.getThinkingLevel() ?? null,
2954
- supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
2955
- supportsVideo: getModel(st.model)?.supportsVideo ?? false,
2956
- autopilot,
2957
- cacheExpiry: session.getCacheExpiryStatus(),
2958
- ...nolanStatePayload(),
2959
- ...footerExtras(),
2960
- });
2961
- return;
2962
- }
2963
- if (method === "GET" && url === "/progress") {
2964
- json(res, 200, progress.snapshot());
2965
- return;
2966
- }
2967
- if (method === "GET" && url === "/memories") {
2968
- void memoryStore
2969
- .snapshot()
2970
- .then((snapshot) => json(res, 200, snapshot))
2971
- .catch((error) => {
2972
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2973
- });
2974
- return;
2975
- }
2976
- if (method === "DELETE" && url.startsWith("/memories/")) {
2977
- const id = decodeURIComponent(url.slice("/memories/".length));
2978
- if (!id) {
2979
- json(res, 400, { error: "memory id is required" });
2980
- return;
2981
- }
2982
- void memoryStore
2983
- .forget(id)
2984
- .then(() => memoryStore.snapshot())
2985
- .then((snapshot) => json(res, 200, snapshot))
2986
- .catch((error) => {
2987
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2988
- });
2989
- return;
2990
- }
2991
- if (method === "GET" && url === "/jiwa") {
2992
- void jiwaStore
2993
- .snapshot()
2994
- .then((snapshot) => json(res, 200, snapshot))
2995
- .catch((error) => {
2996
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2997
- });
2998
- return;
2999
- }
3000
- if (method === "DELETE" && url.startsWith("/jiwa/")) {
3001
- const id = decodeURIComponent(url.slice("/jiwa/".length));
3002
- if (!id) {
3003
- json(res, 400, { error: "Jiwa entry id is required" });
3004
- return;
3005
- }
3006
- void jiwaStore
3007
- .forget(id)
3008
- .then(() => jiwaStore.snapshot())
3009
- .then((snapshot) => json(res, 200, snapshot))
3010
- .catch((error) => {
3011
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
3012
- });
3013
- return;
3014
- }
3015
- if (method === "GET" && (url === "/events" || url.startsWith("/events?"))) {
3016
- res.writeHead(200, {
3017
- "content-type": "text/event-stream",
3018
- "cache-control": "no-cache",
3019
- connection: "keep-alive",
3020
- });
3021
- res.write(`retry: 1000\n\n`);
3022
- const client = { id: ++clientSeq, res };
3023
- clients.add(client);
3024
- const st = session.getState();
3025
- res.write(`data: ${JSON.stringify({
3026
- type: "ready",
3027
- data: {
3028
- ...st,
3029
- mode,
3030
- chatAgent,
3031
- running,
3032
- reviewPending: autopilotActive,
3033
- runState: runLifecycle.state,
3034
- thinkingLevel: session.getThinkingLevel() ?? null,
3035
- supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
3036
- supportsVideo: getModel(st.model)?.supportsVideo ?? false,
3037
- autopilot,
3038
- ...nolanStatePayload(),
3039
- ...footerExtras(),
3040
- },
3041
- })}\n\n`);
3042
- const keepAlive = setInterval(() => res.write(`: ping\n\n`), 15000);
3043
- req.on("close", () => {
3044
- clearInterval(keepAlive);
3045
- clients.delete(client);
3046
- });
3047
- return;
3048
- }
3049
- if (method === "GET" && url === "/plugins") {
3050
- void listInstalledPlugins(paths.extensionsDir)
3051
- .then((plugins) => json(res, 200, { plugins }))
3052
- .catch((error) => {
3053
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
3054
- });
2035
+ if (handleSessionRoutes(routeCtx, req, res, url, method))
3055
2036
  return;
3056
- }
3057
- if (method === "POST" && url === "/plugins/install") {
3058
- void readBody(req, res).then(async (raw) => {
3059
- if (raw === null)
3060
- return;
3061
- try {
3062
- const bundlePath = JSON.parse(raw).bundlePath;
3063
- if (typeof bundlePath !== "string" || !path.isAbsolute(bundlePath)) {
3064
- json(res, 400, { error: "bundlePath must be an absolute path" });
3065
- return;
3066
- }
3067
- const plugin = await installPlugin(bundlePath, paths.extensionsDir);
3068
- json(res, 200, { plugin, restartRequired: true });
3069
- }
3070
- catch (error) {
3071
- json(res, 400, { error: error instanceof Error ? error.message : String(error) });
3072
- }
3073
- });
2037
+ if (handleWorkspaceRoutes(routeCtx, req, res, url, method))
3074
2038
  return;
3075
- }
3076
- if (method === "DELETE" && url.startsWith("/plugins/")) {
3077
- const pluginId = decodeURIComponent(url.slice("/plugins/".length));
3078
- void removePlugin(pluginId, paths.extensionsDir)
3079
- .then(() => json(res, 200, { removed: pluginId, restartRequired: true }))
3080
- .catch((error) => {
3081
- json(res, 400, { error: error instanceof Error ? error.message : String(error) });
3082
- });
2039
+ if (handleHistoryRoutes(routeCtx, req, res, url, method))
3083
2040
  return;
3084
- }
3085
- if (method === "GET" && url === "/settings") {
3086
- // `configured` is true only when the user explicitly saved a projects root
3087
- // (the ezcoder-app.json file exists with a value) — not when we fall back to the
3088
- // default. The home screen gates "Your Projects" on this.
3089
- void (async () => {
3090
- const s = await loadAppSettings();
3091
- let configured;
3092
- try {
3093
- const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
3094
- configured = typeof raw.projectsRoot === "string" && raw.projectsRoot.trim().length > 0;
3095
- }
3096
- catch {
3097
- configured = false;
3098
- }
3099
- // Only projectsRoot + configured flag are webview-facing; the
3100
- // per-project model map is internal persistence, never shipped out.
3101
- json(res, 200, { projectsRoot: s.projectsRoot, configured });
3102
- })();
2041
+ if (handlePromptRoutes(routeCtx, req, res, url, method))
3103
2042
  return;
3104
- }
3105
- if (method === "POST" && url === "/settings") {
3106
- void readBody(req, res).then(async (raw) => {
3107
- if (raw === null)
3108
- return;
3109
- let projectsRoot;
3110
- try {
3111
- projectsRoot = JSON.parse(raw).projectsRoot ?? "";
3112
- }
3113
- catch {
3114
- json(res, 400, { error: "invalid JSON body" });
3115
- return;
3116
- }
3117
- if (!projectsRoot.trim()) {
3118
- json(res, 400, { error: "projectsRoot is required" });
3119
- return;
3120
- }
3121
- // Read-modify-write so the per-project model map survives a projectsRoot
3122
- // change (a naive overwrite would drop every window's saved model).
3123
- const s = await loadAppSettings();
3124
- s.projectsRoot = projectsRoot;
3125
- await saveAppSettings(s);
3126
- json(res, 200, { projectsRoot });
3127
- });
2043
+ if (handleTaskRoutes(routeCtx, req, res, url, method))
3128
2044
  return;
3129
- }
3130
- if (method === "POST" && url === "/create-project") {
3131
- void readBody(req, res).then(async (raw) => {
3132
- if (raw === null)
3133
- return;
3134
- let name;
3135
- try {
3136
- name = JSON.parse(raw).name ?? "";
3137
- }
3138
- catch {
3139
- json(res, 400, { error: "invalid JSON body" });
3140
- return;
3141
- }
3142
- name = name.trim();
3143
- if (!isValidProjectName(name)) {
3144
- json(res, 400, {
3145
- error: "Project name must be lowercase letters, digits, and dashes (e.g. my-project).",
3146
- });
3147
- return;
3148
- }
3149
- const { projectsRoot } = await loadAppSettings();
3150
- const dir = path.join(projectsRoot, name);
3151
- try {
3152
- // Refuse to clobber an existing directory.
3153
- const exists = await fs
3154
- .stat(dir)
3155
- .then(() => true)
3156
- .catch(() => false);
3157
- if (exists) {
3158
- json(res, 409, { error: `A folder named "${name}" already exists.` });
3159
- return;
3160
- }
3161
- await fs.mkdir(dir, { recursive: true });
3162
- json(res, 200, { path: dir });
3163
- }
3164
- catch (err) {
3165
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
3166
- }
3167
- });
2045
+ if (handleRunControlRoutes(routeCtx, req, res, url, method))
3168
2046
  return;
3169
- }
3170
- // Create an isolated git worktree for a project so a second window can work
3171
- // on the same repo without fighting the first over the working tree. The
3172
- // caller passes the project cwd; the module resolves the repo's MAIN root
3173
- // itself, so opening this from an already-linked worktree still branches
3174
- // off the real repo rather than nesting.
3175
- //
3176
- // A dirty main checkout is fine and expected here: a second window is only
3177
- // ever opened while the first window's agent is mid-edit. The copy forks
3178
- // from the last commit and `git worktree add` leaves the main tree
3179
- // untouched, so nothing is stranded.
3180
- //
3181
- // The response waits for `prepareWorktree`, which can run a package install
3182
- // and take minutes. That is deliberate — an agent handed a copy with no
3183
- // node_modules reports a broken project — so the callers set a long
3184
- // timeout. A failed install is reported IN the 200 body, not as an error:
3185
- // the worktree exists either way and destroying it would lose the branch.
3186
- if (method === "POST" && url === "/worktree") {
3187
- void readBody(req, res).then(async (raw) => {
3188
- if (raw === null)
3189
- return;
3190
- let body;
3191
- try {
3192
- body = JSON.parse(raw);
3193
- }
3194
- catch {
3195
- json(res, 400, { error: "invalid JSON body" });
3196
- return;
3197
- }
3198
- const repoDir = body.cwd?.trim();
3199
- if (!repoDir) {
3200
- json(res, 400, { error: "cwd is required" });
3201
- return;
3202
- }
3203
- try {
3204
- const created = await createWorktree({
3205
- repoDir,
3206
- branch: body.branch?.trim() || undefined,
3207
- baseRef: body.baseRef?.trim() || undefined,
3208
- });
3209
- const setup = await prepareWorktree({
3210
- mainRoot: created.mainRoot,
3211
- worktreePath: created.path,
3212
- skipInstall: body.skipInstall === true,
3213
- });
3214
- json(res, 200, { ...created, setup });
3215
- }
3216
- catch (err) {
3217
- const message = err instanceof Error ? err.message : String(err);
3218
- // Not-a-repo is the user's to resolve, not a bug.
3219
- const expected = message.includes("Not a git repository");
3220
- json(res, expected ? 409 : 500, { error: message });
3221
- }
3222
- });
2047
+ if (handleAuthRoutes(routeCtx, req, res, url, method))
3223
2048
  return;
3224
- }
3225
- // Every managed copy of a repo with the state the picker needs to decide
3226
- // what to offer: branch, unsaved files, unmerged commits, and why a copy
3227
- // cannot be reclaimed. `busy` paths come from the caller (the app knows its
3228
- // own windows; the daemon does not), repeated as `busy=` params.
3229
- if (method === "GET" && (url === "/worktrees" || url.startsWith("/worktrees?"))) {
3230
- const params = new URL(url, `http://${host}`).searchParams;
3231
- const target = params.get("cwd")?.trim() || cwd;
3232
- void worktreeStatuses(target, params.getAll("busy"))
3233
- .then((worktrees) => json(res, 200, { worktrees }))
3234
- .catch((err) => {
3235
- // Not-a-repo is the common case here, not a fault: answer "none".
3236
- log("INFO", "app-sidecar", "worktreeStatuses failed", {
3237
- message: err instanceof Error ? err.message : String(err),
3238
- });
3239
- json(res, 200, { worktrees: [] });
3240
- });
2049
+ if (handleIntegrationRoutes(routeCtx, req, res, url, method))
3241
2050
  return;
3242
- }
3243
- // Remove one copy. `force` is the user having seen what would be lost and
3244
- // said yes anyway, so it is never defaulted on.
3245
- //
3246
- // `path` arrives from the client, and a request naming a path is not
3247
- // permission to delete it: `removeWorktree` re-derives the managed root
3248
- // from the repo itself and refuses anything outside it, or reached through
3249
- // a symlink. The check lives there so no caller can skip it.
3250
- if (method === "DELETE" && url === "/worktree") {
3251
- void readBody(req, res).then(async (raw) => {
3252
- if (raw === null)
3253
- return;
3254
- let body;
3255
- try {
3256
- body = JSON.parse(raw);
3257
- }
3258
- catch {
3259
- json(res, 400, { error: "invalid JSON body" });
3260
- return;
3261
- }
3262
- const worktreePath = body.path?.trim();
3263
- if (!worktreePath) {
3264
- json(res, 400, { error: "path is required" });
3265
- return;
3266
- }
3267
- const release = await removeWorktree({
3268
- repoDir: body.cwd?.trim() || cwd,
3269
- worktreePath,
3270
- force: body.force === true,
3271
- });
3272
- // removeWorktree never throws; a refusal is a 409 the UI shows as
3273
- // written, matching the dirty-checkout gate on POST above.
3274
- json(res, release.freed ? 200 : 409, release);
3275
- });
2051
+ if (handleMcpRoutes(routeCtx, req, res, url, method))
3276
2052
  return;
3277
- }
3278
- // Reclaim every copy that provably holds no work. Never forces: anything
3279
- // with unsaved or unmerged changes comes back under `kept` with a reason
3280
- // and waits for a human. Called on window close and on daemon startup.
3281
- if (method === "POST" && url === "/worktrees/sweep") {
3282
- void readBody(req, res).then(async (raw) => {
3283
- if (raw === null)
3284
- return;
3285
- let body;
3286
- try {
3287
- body = JSON.parse(raw);
3288
- }
3289
- catch {
3290
- json(res, 400, { error: "invalid JSON body" });
3291
- return;
3292
- }
3293
- const busy = Array.isArray(body.busyPaths)
3294
- ? body.busyPaths.filter((p) => typeof p === "string")
3295
- : [];
3296
- json(res, 200, await sweepWorktrees(body.cwd?.trim() || cwd, busy));
3297
- });
3298
- return;
3299
- }
3300
- // Dismiss a project from the picker (or restore it with hidden:false). The
3301
- // path is kept rather than the row: sessions in scratch dirs keep
3302
- // re-surfacing, so the user's decision has to outlive any one scan.
3303
- if (method === "POST" && url === "/projects/hidden") {
3304
- void readBody(req, res).then(async (raw) => {
3305
- if (raw === null)
3306
- return;
3307
- let body;
3308
- try {
3309
- body = JSON.parse(raw);
3310
- }
3311
- catch {
3312
- json(res, 400, { error: "invalid JSON body" });
3313
- return;
3314
- }
3315
- const target = body.path?.trim();
3316
- if (!target) {
3317
- json(res, 400, { error: "path is required" });
3318
- return;
3319
- }
3320
- const key = path.resolve(target);
3321
- try {
3322
- const settings = await loadAppSettings();
3323
- const current = new Set((settings.hiddenProjects ?? []).map((p) => path.resolve(p)));
3324
- if (body.hidden === false)
3325
- current.delete(key);
3326
- else
3327
- current.add(key);
3328
- settings.hiddenProjects = current.size > 0 ? Array.from(current) : undefined;
3329
- await saveAppSettings(settings);
3330
- json(res, 200, { hidden: Array.from(current) });
3331
- }
3332
- catch (err) {
3333
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
3334
- }
3335
- });
3336
- return;
3337
- }
3338
- if (method === "GET" && url === "/projects") {
3339
- // Session stores plus configured project folders, including unopened projects.
3340
- void loadAppSettings()
3341
- .then(({ projectsRoot, projectRoots, hiddenProjects }) => discoverProjects({
3342
- projectsRoot,
3343
- extraRoots: projectRoots,
3344
- hiddenPaths: hiddenProjects,
3345
- }))
3346
- .then((projects) => json(res, 200, { projects }))
3347
- .catch((err) => {
3348
- log("ERROR", "app-sidecar", "discoverProjects failed", {
3349
- message: err instanceof Error ? err.message : String(err),
3350
- });
3351
- json(res, 200, { projects: [] });
3352
- });
3353
- return;
3354
- }
3355
- if (method === "GET" && url.startsWith("/sessions")) {
3356
- const target = new URL(url, `http://${host}`).searchParams.get("cwd");
3357
- if (!target) {
3358
- json(res, 400, { error: "missing cwd query param" });
3359
- return;
3360
- }
3361
- const requestedAgent = new URL(url, `http://${host}`).searchParams.get("chatAgent");
3362
- // An omitted chatAgent means coding history; chat callers identify one
3363
- // agent or request the combined, recency-sorted "all" listing; the
3364
- // reserved value "motion" lists Motion sessions.
3365
- void listSidecarSessions(target, requestedAgent, paths.sessionsDir)
3366
- .then((sessions) => json(res, 200, { sessions }))
3367
- .catch(() => json(res, 200, { sessions: [] }));
3368
- return;
3369
- }
3370
- if (method === "GET" && url.startsWith("/files")) {
3371
- const q = new URL(url, `http://${host}`).searchParams.get("q") ?? "";
3372
- void searchProjectFiles(cwd, q)
3373
- .then((files) => json(res, 200, { files }))
3374
- .catch((err) => {
3375
- log("ERROR", "app-sidecar", "searchProjectFiles failed", {
3376
- message: err instanceof Error ? err.message : String(err),
3377
- });
3378
- json(res, 200, { files: [] });
3379
- });
3380
- return;
3381
- }
3382
- // Markdown transcript export for the app's download button. Serialized
3383
- // here rather than in the webview because the webview's transcript model
3384
- // deliberately keeps tool activity in the LiveToolPanel — exporting from
3385
- // there would hand the user a coding session with the coding missing.
3386
- // `?name=1` asks for the suggested filename only (the save dialog needs it
3387
- // before there is a path), so the markdown never crosses IPC twice.
3388
- if (method === "GET" && (url === "/export" || url.startsWith("/export?"))) {
3389
- const query = new URLSearchParams(url.slice(url.indexOf("?") + 1));
3390
- const st = session.getState();
3391
- const filename = defaultExportFilename(mode);
3392
- if (query.get("name") === "1") {
3393
- json(res, 200, { filename });
3394
- return;
3395
- }
3396
- const markdown = sessionToMarkdown({
3397
- mode,
3398
- cwd,
3399
- provider: st.provider,
3400
- model: st.model,
3401
- ...(st.sessionId ? { sessionId: st.sessionId } : {}),
3402
- }, session.getMessages(), { toolDetail: parseToolDetail(query.get("tools")) });
3403
- json(res, 200, { filename, markdown });
3404
- return;
3405
- }
3406
- if (method === "GET" && url === "/history") {
3407
- // Reconstruct the transcript from persisted messages so resume is 1:1 with
3408
- // the live SSE stream. Walks ALL message types (not just user/assistant):
3409
- // tool result messages carry ImageContent blocks (screenshots,
3410
- // generate_image) that must re-render inline, and assistant tool_call
3411
- // blocks carry sub-agent delegations that must re-appear as group items.
3412
- //
3413
- // The `details` object (imagePreviews with path + downscaled preview) is
3414
- // event-only and never persisted — we reconstruct from the raw
3415
- // ImageContent in the tool result, downsampling on the sidecar side and
3416
- // extracting the path from the text block ("Generated image → /path").
3417
- void (async () => {
3418
- const commandCandidates = [...PROMPT_COMMANDS, ...(await loadCustomCommands(cwd))];
3419
- const messages = session.getMessages();
3420
- // An Ideal hook is injected immediately after the assistant's candidate
3421
- // no-tool response. That response is review scratch, not a user-visible
3422
- // final answer. Live SSE drops it when the hook event arrives; mark the
3423
- // same assistant message here so resumed history stays identical.
3424
- const hiddenIdealDrafts = new Set();
3425
- for (let i = 0; i < messages.length - 1; i++) {
3426
- const draft = messages[i];
3427
- const hookPrompt = messages[i + 1];
3428
- if (draft?.role !== "assistant" || hookPrompt?.role !== "user")
3429
- continue;
3430
- const restored = restoreUserRow(hookPrompt.content);
3431
- if (detectHookKind(restored.text) === "ideal")
3432
- hiddenIdealDrafts.add(draft);
3433
- }
3434
- // Pre-index tool results by toolCallId so we can pair tool calls with
3435
- // their results (for sub-agent status + image extraction).
3436
- const toolResultMap = new Map();
3437
- // Tool name per call, so restore only trusts generate_image's own text
3438
- // when it reads extra images from disk (an MCP server could fake it).
3439
- const toolNameById = new Map();
3440
- for (const msg of messages) {
3441
- if (msg.role === "assistant" && typeof msg.content !== "string") {
3442
- for (const c of msg.content) {
3443
- if (c.type === "tool_call")
3444
- toolNameById.set(c.id, c.name);
3445
- }
3446
- }
3447
- if (msg.role !== "tool")
3448
- continue;
3449
- for (const tr of msg.content) {
3450
- toolResultMap.set(tr.toolCallId, {
3451
- content: tr.content,
3452
- isError: tr.isError ?? false,
3453
- });
3454
- }
3455
- }
3456
- const history = [];
3457
- // Nolan (mentor) turns to interleave: group by the non-system message count
3458
- // they were recorded after, so each lands right after that message. A
3459
- // turn becomes two wire rows: the `@Nolan` question (user) + Nolan's reply
3460
- // (assistant), both flagged `nolan` so the webview tints them.
3461
- // Deduped; stale anchors are clamped to the last message (Nolan turns
3462
- // carry real conversation, so they render at the end instead of
3463
- // vanishing).
3464
- const nolanByCount = new Map();
3465
- for (const turn of normalizeNolanTurnsForHistory(session.getNolanTurns(), messages.filter((m) => m.role !== "system").length)) {
3466
- const list = nolanByCount.get(turn.afterMessageCount) ?? [];
3467
- list.push(turn);
3468
- nolanByCount.set(turn.afterMessageCount, list);
3469
- }
3470
- const flushNolan = (count) => {
3471
- const turns = nolanByCount.get(count);
3472
- if (!turns)
3473
- return;
3474
- nolanByCount.delete(count);
3475
- for (const turn of turns) {
3476
- history.push({ role: "user", text: `@Nolan ${turn.question}`, nolan: true });
3477
- history.push({ role: "assistant", text: turn.reply, nolan: true });
3478
- }
3479
- };
3480
- // Autopilot verdict markers to interleave, same anchor scheme as Nolan
3481
- // turns — each becomes a single assistant row the webview renders
3482
- // exactly like the live `autopilot` item (never a raw verdict string).
3483
- // Normalization pulls anchors left over from an unrebased compaction
3484
- // back to where the marker was actually written, so stale all-clear
3485
- // bubbles no longer bunch at the bottom of a reopened session.
3486
- const restoredMessageCount = messages.filter((m) => m.role !== "system").length;
3487
- const autopilotByCount = new Map();
3488
- for (const marker of normalizeAutopilotMarkersForHistory(session.getAutopilotMarkers(), restoredMessageCount)) {
3489
- const list = autopilotByCount.get(marker.afterMessageCount) ?? [];
3490
- list.push(marker);
3491
- autopilotByCount.set(marker.afterMessageCount, list);
3492
- }
3493
- const flushAutopilot = (count) => {
3494
- const markers = autopilotByCount.get(count);
3495
- if (!markers)
3496
- return;
3497
- autopilotByCount.delete(count);
3498
- for (const marker of markers) {
3499
- history.push({
3500
- role: "assistant",
3501
- text: "",
3502
- autopilot: {
3503
- phase: marker.phase,
3504
- ...(marker.reason !== undefined ? { reason: marker.reason } : {}),
3505
- ...(marker.body !== undefined ? { body: marker.body } : {}),
3506
- copySeed: marker.copySeed,
3507
- },
3508
- });
3509
- }
3510
- };
3511
- // App transcript markers (plan banner / task header / error rows /
3512
- // user-bubble hints), same anchor scheme. user_hint markers don't
3513
- // become rows — they decorate the user row at their anchor instead.
3514
- const appMarkersByCount = new Map();
3515
- const userHintByCount = new Map();
3516
- // Compaction-count markers pair with compacted summary rows in file
3517
- // order (FIFO), not by anchor — the summary user message is what
3518
- // positions the notice.
3519
- const compactionCounts = [];
3520
- for (const marker of normalizeAppMarkersForHistory(session.getAppMarkers(), restoredMessageCount)) {
3521
- if (marker.kind === "user_hint") {
3522
- userHintByCount.set(marker.afterMessageCount, marker.data);
3523
- continue;
3524
- }
3525
- if (marker.kind === "compaction") {
3526
- const d = marker.data;
3527
- if (typeof d.originalCount === "number" && typeof d.newCount === "number") {
3528
- compactionCounts.push({ originalCount: d.originalCount, newCount: d.newCount });
3529
- }
3530
- continue;
3531
- }
3532
- const list = appMarkersByCount.get(marker.afterMessageCount) ?? [];
3533
- list.push(marker);
3534
- appMarkersByCount.set(marker.afterMessageCount, list);
3535
- }
3536
- const flushAppMarkers = (count) => {
3537
- const markers = appMarkersByCount.get(count);
3538
- if (!markers)
3539
- return;
3540
- appMarkersByCount.delete(count);
3541
- for (const marker of markers) {
3542
- const d = marker.data;
3543
- if (marker.kind === "plan") {
3544
- history.push({
3545
- role: "assistant",
3546
- text: "",
3547
- plan: { reason: typeof d.reason === "string" ? d.reason : "" },
3548
- });
3549
- }
3550
- else if (marker.kind === "task") {
3551
- history.push({
3552
- role: "assistant",
3553
- text: "",
3554
- task: { title: typeof d.title === "string" ? d.title : "" },
3555
- });
3556
- }
3557
- else if (marker.kind === "error" && typeof d.headline === "string") {
3558
- const error = restoreAppErrorPayload(d);
3559
- if (error)
3560
- history.push({ role: "assistant", text: "", error });
3561
- }
3562
- else if (marker.kind === "interrupted_run") {
3563
- // Rendered as an error row: the run's tools already changed the
3564
- // repo, so the user needs to see it and decide what to do. We
3565
- // never replay it — that would duplicate those changes.
3566
- history.push({
3567
- role: "assistant",
3568
- text: "",
3569
- error: {
3570
- scope: "interrupted_run",
3571
- headline: "A run was interrupted",
3572
- message: "EZ Coder stopped mid-run, so this turn is incomplete. Any files its tools already changed are still on disk.",
3573
- guidance: "Review the working tree, then re-send the request if you still want it.",
3574
- },
3575
- });
3576
- }
3577
- }
3578
- };
3579
- let nonSystemCount = 0;
3580
- // Turns/markers recorded before any build message (anchor 0) render at
3581
- // the top.
3582
- flushNolan(0);
3583
- flushAutopilot(0);
3584
- flushAppMarkers(0);
3585
- await replayMessagesInOrder(messages, async (msg, count) => {
3586
- nonSystemCount = count;
3587
- const visibility = getHistoryMessageVisibility(msg);
3588
- if (visibility === "hidden")
3589
- return;
3590
- if (msg.role === "tool") {
3591
- // Tool result messages: check for ImageContent blocks (screenshots,
3592
- // generated images) and emit a toolImages entry.
3593
- for (const tr of msg.content) {
3594
- if (typeof tr.content === "string")
3595
- continue;
3596
- const imageBlocks = tr.content.filter((c) => c.type === "image");
3597
- if (imageBlocks.length === 0)
3598
- continue;
3599
- // Extract the path from the text block so the restored image
3600
- // stays clickable (read / screenshot / generate_image formats).
3601
- const textBlock = tr.content.find((c) => c.type === "text" && "text" in c && typeof c.text === "string");
3602
- const textContent = textBlock && textBlock.type === "text" ? textBlock.text : "";
3603
- const imgPaths = extractToolImagePaths(textContent);
3604
- // Downscale each image for the webview preview. Paths pair with
3605
- // image blocks in order (a tool saves one file per image).
3606
- const toolImages = [];
3607
- let blockIndex = 0;
3608
- for (const block of imageBlocks) {
3609
- if (block.type !== "image")
3610
- continue;
3611
- const imgPath = imgPaths[blockIndex++];
3612
- const previewBuf = await downscaleForPreview(Buffer.from(block.data, "base64"));
3613
- toolImages.push({
3614
- src: `data:${block.mediaType};base64,${previewBuf.toString("base64")}`,
3615
- path: imgPath,
3616
- });
3617
- }
3618
- // generate_image persists only its first image's pixels; the
3619
- // rest exist only on disk, so preview them from their files.
3620
- // Skip silently if a file was moved or deleted since.
3621
- const extraPaths = toolNameById.get(tr.toolCallId) === "generate_image"
3622
- ? imgPaths.slice(blockIndex)
3623
- : [];
3624
- for (const extraPath of extraPaths) {
3625
- const mediaType = IMAGE_MEDIA_TYPES[path.extname(extraPath).toLowerCase()];
3626
- if (!mediaType)
3627
- continue;
3628
- const raw = await readFileBounded(extraPath).catch(() => null);
3629
- if (!raw)
3630
- continue;
3631
- const previewBuf = await downscaleForPreview(raw);
3632
- toolImages.push({
3633
- src: `data:${mediaType};base64,${previewBuf.toString("base64")}`,
3634
- path: extraPath,
3635
- });
3636
- }
3637
- if (toolImages.length > 0) {
3638
- history.push({
3639
- role: "assistant",
3640
- text: "",
3641
- toolImages,
3642
- });
3643
- }
3644
- }
3645
- return;
3646
- }
3647
- // User or assistant message — text/hook/command/compacted extraction,
3648
- // plus sub-agent group detection for assistant tool_calls.
3649
- if (msg.role === "user") {
3650
- // Rebuild the live bubble: strip the steering wrapper, drop
3651
- // attachment/file notes the model saw but the bubble never showed.
3652
- const restored = restoreUserRow(msg.content, msg.provenance);
3653
- const text = restored.text;
3654
- const hook = msg.provenance ? null : detectHookKind(text);
3655
- const compacted = visibility === "summary" ||
3656
- (!msg.provenance && !hook && text.startsWith("[Previous conversation summary]"));
3657
- const hint = userHintByCount.get(nonSystemCount);
3658
- // The typed invocation persisted alongside the prompt is
3659
- // authoritative. Reversing the expanded body only works while the
3660
- // template is byte-identical, and templates drift (edited
3661
- // `.ezcoder/commands/*.md`, reworded built-ins, app-vs-CLI phrasing) —
3662
- // after which the resumed session dumped the raw multi-KB body
3663
- // instead of the `/name` chip. Older sessions have no hint, so the
3664
- // body match stays as the fallback.
3665
- const command = !hook && !compacted
3666
- ? resolveRestoredCommand(typeof hint?.command === "string" ? hint.command : null, text, commandCandidates)
3667
- : null;
3668
- // Autopilot injected this turn — live showed only the Nolan-tinted
3669
- // marker for it, never a user bubble. Emitting one here would print
3670
- // the injected instruction a second time, unstyled.
3671
- //
3672
- // Pushed background-status updates are skipped for the same reason:
3673
- // the live run rendered no bubble for them, so showing them here
3674
- // would fill a reopened session with machine-facing status lines
3675
- // the user never saw while working.
3676
- if ((!msg.provenance || (!restored.autopilotInjected && !restored.notification)) &&
3677
- (text.trim() || restored.images.length > 0)) {
3678
- history.push({
3679
- role: "user",
3680
- text: command ?? text,
3681
- images: restored.images,
3682
- hook,
3683
- command: command !== null,
3684
- compacted,
3685
- // Markers accumulate across continuation files (each rewrite
3686
- // re-persists prior ones) but only the LATEST summary row
3687
- // survives compaction — so consume from the newest end.
3688
- ...(compacted && compactionCounts.length > 0
3689
- ? { compactionCounts: compactionCounts.pop() }
3690
- : {}),
3691
- ...(hint?.nolanSent === true ? { nolanSent: true } : {}),
3692
- ...(Array.isArray(hint?.enhancements) ? { enhancements: hint.enhancements } : {}),
3693
- });
3694
- // Live showed the video-capability warning right after the bubble.
3695
- if (restored.videoWarning) {
3696
- history.push({ role: "assistant", text: "", infoKind: "video_warning" });
3697
- }
3698
- }
3699
- }
3700
- else if (!hiddenIdealDrafts.has(msg)) {
3701
- // Assistant: one wire row per persisted text block — live streaming
3702
- // splits bubbles at server_tool_call boundaries, and the persisted
3703
- // content keeps those blocks separate. Ideal-review candidate drafts
3704
- // are intentionally omitted to match the live pre-final hook flow.
3705
- for (const blockText of restoreAssistantTexts(msg.content)) {
3706
- history.push({
3707
- role: "assistant",
3708
- text: blockText,
3709
- images: [],
3710
- hook: null,
3711
- command: false,
3712
- compacted: false,
3713
- });
3714
- }
3715
- }
3716
- // Assistant tool_call blocks: detect sub-agent delegations.
3717
- if (msg.role === "assistant" && typeof msg.content !== "string") {
3718
- const subagentCalls = msg.content.filter((c) => c.type === "tool_call" && (c.name === "subagent" || c.name === "spawn_agent"));
3719
- if (subagentCalls.length > 0) {
3720
- const agents = subagentCalls.flatMap((c) => {
3721
- const result = toolResultMap.get(c.id);
3722
- // Async workers are intentionally non-resumable; restored rows are historical.
3723
- const status = result?.isError ? "error" : "done";
3724
- if (c.name === "spawn_agent") {
3725
- // One row per child: a batch call starts several.
3726
- return spawnedTasks(c.args).map((spawn) => ({
3727
- agentName: spawn.task_name ?? spawn.agent,
3728
- status,
3729
- toolUseCount: 0,
3730
- }));
3731
- }
3732
- return [
3733
- {
3734
- agentName: typeof c.args?.agent === "string" ? c.args.agent : undefined,
3735
- status,
3736
- toolUseCount: 0,
3737
- },
3738
- ];
3739
- });
3740
- history.push({
3741
- role: "assistant",
3742
- text: "",
3743
- subagentGroup: agents,
3744
- });
3745
- }
3746
- }
3747
- }, (count) => {
3748
- // Markers flush after every physical message, including tools and
3749
- // provenance-hidden runtime context.
3750
- flushNolan(count);
3751
- flushAutopilot(count);
3752
- flushAppMarkers(count);
3753
- });
3754
- // Flush remaining Nolan turns whose anchor is at/after the message count so
3755
- // none are dropped. Autopilot/app markers beyond the restored message
3756
- // count were already filtered above; any remaining marker here is valid.
3757
- for (const count of [...nolanByCount.keys()].sort((a, b) => a - b))
3758
- flushNolan(count);
3759
- for (const count of [...autopilotByCount.keys()].sort((a, b) => a - b))
3760
- flushAutopilot(count);
3761
- for (const count of [...appMarkersByCount.keys()].sort((a, b) => a - b))
3762
- flushAppMarkers(count);
3763
- json(res, 200, { history });
3764
- })();
3765
- return;
3766
- }
3767
- if (method === "GET" && url === "/commands") {
3768
- if (mode !== "code") {
3769
- json(res, 200, { commands: [] });
3770
- return;
3771
- }
3772
- // Workflow commands with agent functionality: built-in prompt templates +
3773
- // the user's own `.ezcoder/commands/*.md`. UI commands (model/quit/etc.) are
3774
- // handled webview-side and intentionally excluded.
3775
- void (async () => {
3776
- const builtins = PROMPT_COMMANDS.map((c) => ({
3777
- name: c.name,
3778
- aliases: c.aliases,
3779
- description: c.description,
3780
- source: "built-in",
3781
- }));
3782
- // Desktop-safe registry actions belong in the same picker as workflows.
3783
- // Most registry commands have dedicated app controls or TUI-only flows;
3784
- // multi-root management has no other affordance, so expose only these.
3785
- const workspaceActions = [
3786
- {
3787
- name: "add-dir",
3788
- aliases: ["adddir"],
3789
- description: "Add another project folder to this workspace",
3790
- source: "built-in",
3791
- },
3792
- {
3793
- name: "remove-dir",
3794
- aliases: ["removedir"],
3795
- description: "Remove an added project folder from this workspace",
3796
- source: "built-in",
3797
- },
3798
- ];
3799
- const custom = (await loadCustomCommands(cwd))
3800
- // A custom command can't shadow a built-in name or app action.
3801
- .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name) &&
3802
- !workspaceActions.some((action) => action.name === c.name))
3803
- .map((c) => ({
3804
- name: c.name,
3805
- aliases: [],
3806
- description: c.description,
3807
- source: "custom",
3808
- }));
3809
- json(res, 200, { commands: [...workspaceActions, ...builtins, ...custom] });
3810
- })();
3811
- return;
3812
- }
3813
- if (method === "POST" && url === "/prompt") {
3814
- // Per-request: only the prompt that actually claimed the start may release
3815
- // it. A bare release would let an early-returning request (bad JSON, or a
3816
- // prompt that queued) clear a claim another request is still holding.
3817
- let claimedStart = false;
3818
- void readBody(req, res)
3819
- .then(async (raw) => {
3820
- if (raw === null)
3821
- return;
3822
- let text;
3823
- let attachments;
3824
- let meta;
3825
- try {
3826
- const body = JSON.parse(raw);
3827
- text = body.text ?? "";
3828
- attachments = Array.isArray(body.attachments) ? body.attachments : [];
3829
- meta = typeof body.meta === "object" && body.meta !== null ? body.meta : undefined;
3830
- }
3831
- catch {
3832
- json(res, 400, { error: "invalid JSON body" });
3833
- return;
3834
- }
3835
- if (!text.trim() && attachments.length === 0) {
3836
- json(res, 400, { error: "empty prompt" });
3837
- return;
3838
- }
3839
- // A typed prompt supersedes any question parked on the user: they
3840
- // answered with a message of their own. Release the blocked tool call
3841
- // NOW — otherwise it waits out its ten-minute timeout while this very
3842
- // message sits behind it as steering that only drains once the tool
3843
- // returns, so the turn looks frozen. The webview closes the band on
3844
- // send; a racing /ask POST just 409s.
3845
- asks.cancelAll({ action: "cancel", superseded: true });
3846
- if (runLifecycle.running &&
3847
- runLifecycle.isCancellationRequested(runLifecycle.generation)) {
3848
- json(res, 409, {
3849
- error: runLifecycle.state === "cancelling" ? "run_cancelling" : "cancel_failed",
3850
- runState: runLifecycle.state,
3851
- });
3852
- return;
3853
- }
3854
- // `runClaim` covers the gap before `runAgent` flips `running`: a
3855
- // prompt arriving in that window must queue, not start a second run.
3856
- if (running || runClaim.active || autopilotActive) {
3857
- // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
3858
- // an autopilot cycle is active but between injected runs (build idle,
3859
- // Nolan reviewing) so the message never starts a run that collides with
3860
- // an injected one on the same session. Attachments are persisted to
3861
- // .ezcoder/uploads first so the queued media rides the same native-block
3862
- // path as a non-queued attachment prompt when it drains.
3863
- const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
3864
- const count = session.queueMessage(text, prepared);
3865
- broadcast("queued", { count, messages: session.listQueuedMessages() });
3866
- json(res, 202, { queued: true, count });
3867
- return;
3868
- }
3869
- // Claim the run NOW, synchronously. Everything below this line may
3870
- // yield, and `running` does not flip until runAgent begins.
3871
- claimedStart = runClaim.claim();
3872
- // A scheduled prompt has nobody watching: short ask deadline.
3873
- scheduledRunActive = meta?.scheduled === true;
3874
- json(res, 202, { accepted: true });
3875
- // Gate inputs captured around the run: whether this turn is a workflow
3876
- // slash command (attachment prompts skip slash expansion entirely), and
3877
- // how many assistant messages the run actually adds. Computed even when
3878
- // autopilot is currently off — the toggle can flip ON mid-run, and the
3879
- // gate reads the post-run value.
3880
- const workflowCommand = attachments.length === 0 &&
3881
- isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
3882
- // Does this input actually expand into a persisted user message? Asked
3883
- // of the session itself, because only it knows whether the command
3884
- // resolves here (name/alias casing, custom `.ezcoder/commands`, non-coder
3885
- // agents that don't expand at all). A looser guess would anchor the
3886
- // hint at +1 with no message to land on — decorating an unrelated
3887
- // later bubble with the wrong `/name`.
3888
- const expandsToTemplate = attachments.length === 0 && (await session.willExpandPromptTemplate(text));
3889
- // Webview display hint for this prompt's user bubble (nolanSent shimmer
3890
- // label / enhancer highlight segments / the `/name` a command was typed
3891
- // as). Anchored +1 so it attaches to the user message the prompt below
3892
- // is about to push. Queued prompts skip this (their position in the run
3893
- // is unpredictable).
3894
- //
3895
- // Recording the invocation matters because the agent persists the
3896
- // EXPANDED template as the user message. Resume used to recover
3897
- // `/name` by matching that body against the current templates, which
3898
- // silently fails the moment a template is edited or reworded — the
3899
- // reopened session then rendered the raw multi-KB prompt instead of
3900
- // the command chip.
3901
- if (expandsToTemplate ||
3902
- (meta && (meta.nolanSent === true || Array.isArray(meta.enhancements)))) {
3903
- void session
3904
- .persistAppMarker("user_hint", {
3905
- ...(expandsToTemplate ? { command: text.trim() } : {}),
3906
- ...(meta?.nolanSent === true ? { nolanSent: true } : {}),
3907
- ...(Array.isArray(meta?.enhancements) ? { enhancements: meta.enhancements } : {}),
3908
- }, 1)
3909
- .catch(() => { });
3910
- }
3911
- // Fresh user turn: clear any cancel flag left from a prior cycle so this
3912
- // turn's autopilot review can run.
3913
- autopilotCancelled = false;
3914
- // A typed message while a plan modal/review is pending (reject,
3915
- // feedback, anything) supersedes the pending plan — the bump also
3916
- // invalidates any in-flight Nolan plan review.
3917
- clearPendingPlan();
3918
- const assistantsBefore = countAssistantMessages(session.getMessages());
3919
- const messagesBefore = session.getMessages().length;
3920
- await runAgent(text, async () => {
3921
- if (attachments.length > 0) {
3922
- // Persist each attachment under .ezcoder/uploads so files are inspectable
3923
- // by the agent's tools, then prompt with the media as native blocks.
3924
- const prepared = await prepareAttachments(cwd, attachments);
3925
- await session.promptWithAttachments(text, prepared);
3926
- }
3927
- else {
3928
- // Pass the raw text straight through. AgentSession.prompt() is the
3929
- // single source of truth for slash-command expansion (built-in +
3930
- // `.ezcoder/commands/*.md` custom), so the agent gets the right body
3931
- // while the webview keeps showing the short `/name`.
3932
- await session.prompt(text);
3933
- }
3934
- }, () => autopilot);
3935
- // After the user's run settles, kick off Nolan's auto-review loop — but
3936
- // only when the turn is actually reviewable (shouldStartAutopilotCycle):
3937
- // workflow commands (/compare, /expand, …) end with reports or
3938
- // A/B/C choices reserved for the USER; registry commands (/help) and
3939
- // failed runs add no assistant work to judge; a turn that ended in plan
3940
- // mode has a pending Accept/Reject modal Nolan must not preempt. This is
3941
- // the ONLY entry point into the cycle besides the stranded-queue drain —
3942
- // it drives any follow-up EZ Coder runs itself, so the shared runAgent
3943
- // finally never recurses.
3944
- const decision = shouldStartAutopilotCycle({
3945
- enabled: autopilot,
3946
- cancelled: autopilotCancelled,
3947
- planMode: session.getPlanMode(),
3948
- // A submitted plan (exit_plan fired) routes into the PLAN review
3949
- // branch — the cycle reviews the plan itself instead of skipping.
3950
- planPending: pendingPlanPath !== null,
3951
- workflowCommand,
3952
- assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
3953
- // Skip the review API call outright for turns that only started a
3954
- // background process (dev server/watcher), ran a read-only lookup, or
3955
- // committed/pushed — Nolan's autopilot contract already IGNOREs these,
3956
- // so there's no reason to pay for that verdict.
3957
- mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
3958
- });
3959
- if (decision.start) {
3960
- log("INFO", "app-sidecar", "autopilot cycle starting", { kind: decision.kind });
3961
- await runAutopilotCycle(text);
3962
- }
3963
- else {
3964
- broadcast("autopilot_ignored", { reason: decision.reason });
3965
- log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
3966
- }
3967
- // A prompt sent while Nolan was reviewing (build idle) queued but had no
3968
- // run to steer into — run it now as a fresh turn so it never strands.
3969
- await runStrandedQueue();
3970
- })
3971
- .finally(() => {
3972
- if (claimedStart) {
3973
- scheduledRunActive = false;
3974
- runClaim.release();
3975
- }
3976
- });
3977
- return;
3978
- }
3979
- // Nolan Grout (mentor): an independent read-only advisory run on the nolanSession.
3980
- // Runs concurrently with a build run — its events are nolan_-prefixed so the
3981
- // webview keeps the bubbles separate. The context digest is assembled fresh
3982
- // from the BUILD session's transcript each turn (one-way mirror).
3983
- if (method === "POST" && url === "/nolan/prompt") {
3984
- if (mode !== "code") {
3985
- json(res, 404, {
3986
- error: `Nolan is not available in EZ ${mode === "chat" ? "Chat" : "Motion"}.`,
3987
- });
3988
- return;
3989
- }
3990
- void readBody(req, res).then(async (raw) => {
3991
- if (raw === null)
3992
- return;
3993
- let text;
3994
- try {
3995
- text = JSON.parse(raw).text ?? "";
3996
- }
3997
- catch {
3998
- json(res, 400, { error: "invalid JSON body" });
3999
- return;
4000
- }
4001
- if (!text.trim()) {
4002
- json(res, 400, { error: "empty prompt" });
4003
- return;
4004
- }
4005
- if (nolanRunning) {
4006
- json(res, 409, { error: "Nolan is already thinking — wait for his reply." });
4007
- return;
4008
- }
4009
- json(res, 202, { accepted: true });
4010
- nolanRunning = true;
4011
- const releaseNolanAwake = keepAwake.acquire("nolan");
4012
- broadcast("nolan_run_start", { text });
4013
- try {
4014
- const nolanAgent = await ensureNolanSession();
4015
- const digest = await buildNolanContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
4016
- await nolanAgent.prompt(digest);
4017
- // Record the turn against the BUILD session so it persists + survives
4018
- // resume (advisory custom entry, never an LLM message). Reply is Nolan's
4019
- // last assistant message; skip persistence if he produced nothing.
4020
- const reply = lastAssistantText(nolanAgent.getMessages());
4021
- if (reply.trim())
4022
- await session.persistNolanTurn(text, reply);
4023
- }
4024
- catch (err) {
4025
- broadcastError("nolan_error", "nolan run failed", err);
4026
- }
4027
- finally {
4028
- releaseNolanAwake();
4029
- nolanRunning = false;
4030
- broadcast("nolan_run_end", {});
4031
- const pending = pendingNolanModel;
4032
- pendingNolanModel = null;
4033
- if (pending)
4034
- await syncNolanModel(pending.provider, pending.model);
4035
- }
4036
- });
4037
- return;
4038
- }
4039
- if (method === "POST" && url === "/nolan/cancel") {
4040
- nolanAbort.abort();
4041
- nolanAbort = new AbortController();
4042
- nolanSession?.setSignal(nolanAbort.signal);
4043
- nolanRunning = false;
4044
- broadcast("nolan_run_end", { cancelled: true });
4045
- json(res, 200, { cancelled: true });
4046
- return;
4047
- }
4048
- if (method === "POST" && url === "/autopilot") {
4049
- if (mode !== "code") {
4050
- json(res, 404, {
4051
- error: `Autopilot is not available in EZ ${mode === "chat" ? "Chat" : "Motion"}.`,
4052
- });
4053
- return;
4054
- }
4055
- void readBody(req, res).then(async (raw) => {
4056
- if (raw === null)
4057
- return;
4058
- let enabled;
4059
- try {
4060
- enabled = Boolean(JSON.parse(raw).enabled);
4061
- }
4062
- catch {
4063
- json(res, 400, { error: "invalid JSON body" });
4064
- return;
4065
- }
4066
- autopilot = enabled;
4067
- await saveAutopilot(cwd, enabled);
4068
- log("INFO", "app-sidecar", "autopilot toggled", { enabled: String(enabled) });
4069
- broadcast("autopilot", { autopilot: enabled });
4070
- json(res, 200, { autopilot: enabled });
4071
- });
4072
- return;
4073
- }
4074
- if (method === "POST" && url === "/enhance") {
4075
- void readBody(req, res).then(async (raw) => {
4076
- if (raw === null)
4077
- return;
4078
- let text;
4079
- try {
4080
- text = JSON.parse(raw).text ?? "";
4081
- }
4082
- catch {
4083
- json(res, 400, { error: "invalid JSON body" });
4084
- return;
4085
- }
4086
- if (!text.trim()) {
4087
- json(res, 400, { error: "empty prompt" });
4088
- return;
4089
- }
4090
- // An independent read-only LLM call — touches no session state, so it's
4091
- // allowed even while a run is in flight.
4092
- try {
4093
- const result = await session.enhancePrompt(text);
4094
- json(res, 200, result);
4095
- }
4096
- catch (err) {
4097
- const message = err instanceof Error ? err.message : String(err);
4098
- log("ERROR", "app-sidecar", "enhance failed", { message });
4099
- json(res, 500, { error: message });
4100
- }
4101
- });
4102
- return;
4103
- }
4104
- if (method === "GET" && url === "/tasks") {
4105
- json(res, 200, { tasks: pruneDoneTasksSync(cwd) });
4106
- return;
4107
- }
4108
- if (method === "GET" && url === "/checklist") {
4109
- const controller = new AbortController();
4110
- res.once("close", () => controller.abort());
4111
- void (async () => {
4112
- try {
4113
- const result = await readChecklistSnapshot(cwd, new Date(), controller.signal);
4114
- if (res.destroyed)
4115
- return;
4116
- if (!result.ok) {
4117
- log("WARN", "app-sidecar", "checklist read failed", { error: result.error });
4118
- json(res, 500, { error: result.error });
4119
- return;
4120
- }
4121
- json(res, 200, result.value);
4122
- }
4123
- catch (error) {
4124
- log("WARN", "app-sidecar", "checklist snapshot failed", { error: String(error) });
4125
- if (!res.destroyed)
4126
- json(res, 500, { error: "Could not read the project checklist" });
4127
- }
4128
- })();
4129
- return;
4130
- }
4131
- // ── Radio (app-wide) ──────────────────────────────────────
4132
- // Radio is now APP-WIDE: all windows share one daemon process, and the
4133
- // player lives in `core/radio.ts` module-level singletons (one stream for
4134
- // the whole app). Any window's /radio reads/controls that single stream —
4135
- // starting a station in one window replaces whatever was playing, and every
4136
- // window's footer reflects the same `current`. This intentionally prevents
4137
- // duplicate audio across windows (the original per-window goal), now for
4138
- // free. (To restore per-window radio, key playback by sessionId.)
4139
- if (method === "GET" && url === "/radio") {
4140
- json(res, 200, {
4141
- stations: RADIO_STATIONS,
4142
- current: getCurrentStation(),
4143
- volume: getRadioVolume(),
4144
- });
4145
- return;
4146
- }
4147
- if (method === "POST" && url === "/radio/volume") {
4148
- void readBody(req, res).then((raw) => {
4149
- if (raw === null)
4150
- return;
4151
- let volume;
4152
- try {
4153
- volume = Number(JSON.parse(raw).volume);
4154
- }
4155
- catch {
4156
- json(res, 400, { error: "invalid JSON body" });
4157
- return;
4158
- }
4159
- if (!Number.isFinite(volume)) {
4160
- json(res, 400, { error: "volume must be a number" });
4161
- return;
4162
- }
4163
- const result = setRadioVolume(volume);
4164
- if (!result.ok) {
4165
- json(res, 400, { error: result.error ?? "Radio volume failed to update." });
4166
- return;
4167
- }
4168
- json(res, 200, { current: getCurrentStation(), volume: getRadioVolume() });
4169
- });
4170
- return;
4171
- }
4172
- if (method === "POST" && url === "/radio") {
4173
- void readBody(req, res).then((raw) => {
4174
- if (raw === null)
4175
- return;
4176
- let station;
4177
- try {
4178
- station = JSON.parse(raw).station ?? "";
4179
- }
4180
- catch {
4181
- json(res, 400, { error: "invalid JSON body" });
4182
- return;
4183
- }
4184
- if (!station || station === "off") {
4185
- stopRadio();
4186
- json(res, 200, { current: null });
4187
- return;
4188
- }
4189
- const result = playRadio(station);
4190
- if (!result.ok) {
4191
- json(res, 400, { error: result.error ?? "Radio failed to start." });
4192
- return;
4193
- }
4194
- json(res, 200, { current: getCurrentStation() });
4195
- });
4196
- return;
4197
- }
4198
- if (method === "POST" && url === "/tasks/run") {
4199
- void readBody(req, res).then((raw) => {
4200
- if (raw === null)
4201
- return;
4202
- let id;
4203
- let all;
4204
- try {
4205
- const body = JSON.parse(raw);
4206
- id = body.id ?? null;
4207
- all = Boolean(body.all);
4208
- }
4209
- catch {
4210
- json(res, 400, { error: "invalid JSON body" });
4211
- return;
4212
- }
4213
- if (running) {
4214
- json(res, 409, { error: "cannot run a task while the agent is running" });
4215
- return;
4216
- }
4217
- json(res, 202, { accepted: true });
4218
- void runTasks(id, all);
4219
- });
4220
- return;
4221
- }
4222
- if (method === "POST" && url === "/tasks/add") {
4223
- void readBody(req, res).then((raw) => {
4224
- if (raw === null)
4225
- return;
4226
- let title;
4227
- let prompt;
4228
- try {
4229
- const body = JSON.parse(raw);
4230
- title = (body.title ?? "").trim();
4231
- prompt = (body.prompt ?? "").trim();
4232
- }
4233
- catch {
4234
- json(res, 400, { error: "invalid JSON body" });
4235
- return;
4236
- }
4237
- if (!title) {
4238
- json(res, 400, { error: "missing task title" });
4239
- return;
4240
- }
4241
- const tasks = loadTasksSync(cwd);
4242
- const newTask = {
4243
- id: randomUUID(),
4244
- title,
4245
- prompt: prompt || title,
4246
- status: "pending",
4247
- createdAt: new Date().toISOString(),
4248
- };
4249
- const next = [...tasks, newTask];
4250
- saveTasksSync(cwd, next);
4251
- broadcast("tasks_list", { tasks: next });
4252
- json(res, 200, { tasks: next });
4253
- });
4254
- return;
4255
- }
4256
- if (method === "POST" && url === "/tasks/update") {
4257
- void readBody(req, res).then((raw) => {
4258
- if (raw === null)
4259
- return;
4260
- let id;
4261
- let status;
4262
- let title;
4263
- let prompt;
4264
- try {
4265
- const body = JSON.parse(raw);
4266
- id = (body.id ?? "").trim();
4267
- status = body.status;
4268
- title = body.title;
4269
- prompt = body.prompt;
4270
- }
4271
- catch {
4272
- json(res, 400, { error: "invalid JSON body" });
4273
- return;
4274
- }
4275
- if (!id) {
4276
- json(res, 400, { error: "missing task id" });
4277
- return;
4278
- }
4279
- if (status && status !== "pending" && status !== "in-progress" && status !== "done") {
4280
- json(res, 400, { error: `invalid status: ${status}` });
4281
- return;
4282
- }
4283
- const tasks = loadTasksSync(cwd);
4284
- const task = tasks.find((t) => t.id === id || t.id.startsWith(id));
4285
- if (!task) {
4286
- json(res, 404, { error: `no task found matching id "${id}"` });
4287
- return;
4288
- }
4289
- const next = tasks.map((t) => t.id === task.id
4290
- ? {
4291
- ...t,
4292
- ...(status ? { status: status } : {}),
4293
- ...(typeof title === "string" && title.trim() ? { title: title.trim() } : {}),
4294
- ...(typeof prompt === "string" && prompt.trim() ? { prompt: prompt.trim() } : {}),
4295
- }
4296
- : t);
4297
- saveTasksSync(cwd, next);
4298
- broadcast("tasks_list", { tasks: next });
4299
- json(res, 200, { tasks: next });
4300
- });
4301
- return;
4302
- }
4303
- if (method === "POST" && url === "/tasks/delete") {
4304
- void readBody(req, res).then((raw) => {
4305
- if (raw === null)
4306
- return;
4307
- let id;
4308
- try {
4309
- id = JSON.parse(raw).id ?? "";
4310
- }
4311
- catch {
4312
- json(res, 400, { error: "invalid JSON body" });
4313
- return;
4314
- }
4315
- if (!id.trim()) {
4316
- json(res, 400, { error: "missing task id" });
4317
- return;
4318
- }
4319
- const remaining = loadTasksSync(cwd).filter((t) => t.id !== id && !t.id.startsWith(id));
4320
- saveTasksSync(cwd, remaining);
4321
- broadcast("tasks_list", { tasks: remaining });
4322
- json(res, 200, { tasks: remaining });
4323
- });
4324
- return;
4325
- }
4326
- if (method === "GET" && url === "/models") {
4327
- void (async () => {
4328
- // The desktop app writes API keys natively (Rust → auth.json), bypassing
4329
- // this daemon's AuthStorage cache. Re-read from disk so a provider the
4330
- // user just logged in for (e.g. Xiaomi/MiMo) shows up in the list.
4331
- await auth.reload();
4332
- const loggedIn = [];
4333
- for (const p of ALL_PROVIDERS) {
4334
- if (await auth.hasProviderAuth(p))
4335
- loggedIn.push(p);
4336
- }
4337
- // Just the names, grouped by provider in registry order — the UI shows
4338
- // a clean multi-column list of model ids. Local models come from the
4339
- // runtime registry (populated by the background scan) and are always
4340
- // listed: their "login" is the endpoint answering a probe.
4341
- const models = getAllModels()
4342
- .filter((m) => m.provider === "local" || loggedIn.includes(m.provider))
4343
- .map((m) => {
4344
- if (m.provider !== "local") {
4345
- return { id: m.id, name: m.name, provider: m.provider };
4346
- }
4347
- const probed = findProbedModel(localProbes, m.id);
4348
- return {
4349
- id: m.id,
4350
- name: m.name,
4351
- provider: m.provider,
4352
- local: true,
4353
- endpoint: probed?.endpoint.label ?? parseLocalModelId(m.id)?.endpointId,
4354
- // A local model that can't call tools can't run the agent — the UI
4355
- // renders it disabled rather than hiding it, so the user learns why.
4356
- supportsTools: probed?.model.supportsTools ?? true,
4357
- contextWindow: m.contextWindow,
4358
- contextWindowKnown: probed?.model.contextWindowKnown ?? false,
4359
- supportsThinking: m.supportsThinking,
4360
- };
4361
- });
4362
- json(res, 200, { models });
4363
- })();
4364
- return;
4365
- }
4366
- if (method === "POST" && url === "/model") {
4367
- void readBody(req, res).then(async (raw) => {
4368
- if (raw === null)
4369
- return;
4370
- let modelId;
4371
- try {
4372
- modelId = JSON.parse(raw).model ?? "";
4373
- }
4374
- catch {
4375
- json(res, 400, { error: "invalid JSON body" });
4376
- return;
4377
- }
4378
- const target = getModel(modelId);
4379
- if (!target) {
4380
- json(res, 404, { error: `unknown model: ${modelId}` });
4381
- return;
4382
- }
4383
- if (running) {
4384
- json(res, 409, { error: "cannot switch model while running" });
4385
- return;
4386
- }
4387
- // Pick up any natively-written keys (desktop app) before resolving the
4388
- // new model's credentials, so switching to a just-added provider works.
4389
- await auth.reload();
4390
- if (target.provider === "local") {
4391
- const problem = await localModelBlocker(target.id);
4392
- if (problem) {
4393
- json(res, 409, { error: problem });
4394
- return;
4395
- }
4396
- }
4397
- await session.switchModel(target.provider, target.id);
4398
- // Nolan follows EZ Coder's model only while un-pinned; a user-set Nolan
4399
- // override survives EZ Coder model switches untouched.
4400
- if (!nolanModelOverride) {
4401
- await syncNolanModel(target.provider, target.id);
4402
- await syncNolanAutoModel(target.provider, target.id);
4403
- }
4404
- // Clamp the reasoning level to what the new model supports (mirrors the
4405
- // CLI): keep thinking on at the first supported tier if it was on but
4406
- // the prior level is unsupported here; leave it off if it was off.
4407
- const prevLevel = session.getThinkingLevel();
4408
- if (prevLevel && !isThinkingLevelSupported(target.provider, target.id, prevLevel)) {
4409
- session.setThinkingLevel(getNextThinkingLevel(target.provider, target.id, undefined));
4410
- }
4411
- // Persist per-project so THIS window/project restores its own model on
4412
- // restart (not the single global slot every window shares). Keep the
4413
- // global write too as a "last used" fallback for never-opened projects
4414
- // and so the CLI stays in sync.
4415
- await saveProjectModelPrefs(cwd, {
4416
- provider: target.provider,
4417
- model: target.id,
4418
- thinkingEnabled: !!session.getThinkingLevel(),
4419
- thinkingLevel: session.getThinkingLevel() ?? undefined,
4420
- });
4421
- await persistModelSelection(paths.settingsFile, target.provider, target.id);
4422
- await persistThinkingLevel(paths.settingsFile, session.getThinkingLevel());
4423
- const payload = {
4424
- thinkingLevel: session.getThinkingLevel() ?? null,
4425
- supportedThinkingLevels: getSupportedThinkingLevels(target.provider, target.id),
4426
- };
4427
- // model_change is emitted by switchModel; follow with thinking_change so
4428
- // the footer toggle reflects the new model's supported levels.
4429
- broadcast("thinking_change", payload);
4430
- // Un-pinned Nolan just followed the switch — update his footer chip too.
4431
- // When Nolan is pinned, his effective model did not change, so skip the
4432
- // no-op event (keeps footer/event tests from treating an EZ Coder
4433
- // switch as a Nolan switch).
4434
- if (!nolanModelOverride)
4435
- broadcast("nolan_model_change", nolanStatePayload());
4436
- // The new model usually has a different context window — push extras so
4437
- // the footer's context meter rescales immediately.
4438
- broadcast("extras", footerExtras());
4439
- json(res, 200, { provider: target.provider, model: target.id, ...payload });
4440
- });
4441
- return;
4442
- }
4443
- // Set or clear Nolan's model pin. Body: { model: "<id>" } to pin, or
4444
- // { model: null } / "" to clear (Nolan resumes following EZ Coder). Applies
4445
- // to BOTH Nolan sessions (chat + autopilot reviewer); a switch landing while
4446
- // either is mid-run defers via the pending-model mechanics.
4447
- if (method === "POST" && url === "/nolan/model") {
4448
- void readBody(req, res).then(async (raw) => {
4449
- if (raw === null)
4450
- return;
4451
- let modelId;
4452
- try {
4453
- const parsed = JSON.parse(raw).model;
4454
- modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
4455
- }
4456
- catch {
4457
- json(res, 400, { error: "invalid JSON body" });
4458
- return;
4459
- }
4460
- // Same lock as POST /model, for the same reason: this retargets Nolan's
4461
- // chat session AND the autopilot reviewer, and `switchModel` on a
4462
- // session mid-turn races the stream it is already consuming. The
4463
- // footer picker is disabled to match; this is the enforcement.
4464
- if (running || nolanRunning || autopilotReviewing) {
4465
- json(res, 409, { error: "cannot switch Nolan's model while running" });
4466
- return;
4467
- }
4468
- if (modelId === null) {
4469
- // Clear the pin → follow EZ Coder again, syncing both sessions back.
4470
- nolanModelOverride = null;
4471
- await saveNolanModelPref(cwd, null);
4472
- const st = session.getState();
4473
- await syncNolanModel(st.provider, st.model);
4474
- await syncNolanAutoModel(st.provider, st.model);
4475
- log("INFO", "app-sidecar", "Nolan model pin cleared — following EZ Coder", {
4476
- provider: st.provider,
4477
- model: st.model,
4478
- });
4479
- }
4480
- else {
4481
- const target = getModel(modelId);
4482
- if (!target) {
4483
- json(res, 404, { error: `unknown model: ${modelId}` });
4484
- return;
4485
- }
4486
- nolanModelOverride = { provider: target.provider, model: target.id };
4487
- await saveNolanModelPref(cwd, nolanModelOverride);
4488
- await syncNolanModel(target.provider, target.id);
4489
- await syncNolanAutoModel(target.provider, target.id);
4490
- log("INFO", "app-sidecar", "nolan model pinned", {
4491
- provider: target.provider,
4492
- model: target.id,
4493
- });
4494
- }
4495
- const payload = nolanStatePayload();
4496
- broadcast("nolan_model_change", payload);
4497
- json(res, 200, payload);
4498
- });
4499
- return;
4500
- }
4501
- // Pending queued steering, for the composer's cancel affordance.
4502
- if (method === "GET" && url === "/queued") {
4503
- json(res, 200, { queued: session.listQueuedMessages() });
4504
- return;
4505
- }
4506
- // Cancel one pending queued message by id.
4507
- if (method === "POST" && url === "/queued/cancel") {
4508
- void readBody(req, res).then((raw) => {
4509
- if (raw === null)
4510
- return;
4511
- let id;
4512
- try {
4513
- id = JSON.parse(raw).id ?? "";
4514
- }
4515
- catch {
4516
- json(res, 400, { error: "invalid JSON body" });
4517
- return;
4518
- }
4519
- if (!id.trim()) {
4520
- json(res, 400, { error: "missing queued message id" });
4521
- return;
4522
- }
4523
- // `false` means it already drained into the run between render and
4524
- // click. That is a race, not an error, so report it as a normal result
4525
- // and let the client reconcile through the ordered event stream.
4526
- const cancelled = session.cancelQueuedMessage(id);
4527
- const queued = session.listQueuedMessages();
4528
- broadcast("queued", {
4529
- count: queued.length,
4530
- messages: queued,
4531
- ...(cancelled ? { cancelledId: id } : {}),
4532
- });
4533
- json(res, 200, { cancelled, queued });
4534
- });
4535
- return;
4536
- }
4537
- if (method === "POST" && url === "/kill") {
4538
- void readBody(req, res).then(async (raw) => {
4539
- if (raw === null)
4540
- return;
4541
- let id;
4542
- try {
4543
- id = JSON.parse(raw).id ?? "";
4544
- }
4545
- catch {
4546
- json(res, 400, { error: "invalid JSON body" });
4547
- return;
4548
- }
4549
- if (!id.trim()) {
4550
- json(res, 400, { error: "missing task id" });
4551
- return;
4552
- }
4553
- const message = await session.killBackgroundProcess(id);
4554
- // Push the updated task list right away rather than waiting for the poll.
4555
- broadcast("tasks", { tasks: session.listBackgroundProcesses() });
4556
- json(res, 200, { message });
4557
- });
4558
- return;
4559
- }
4560
- // Import a Claude Code / Codex / Cursor transcript into a resumable GG
4561
- // Coder session. The importer never throws — it returns a typed failure so
4562
- // the app can show the reason verbatim.
4563
- if (method === "POST" && url === "/import-transcript") {
4564
- void readBody(req, res).then(async (raw) => {
4565
- if (raw === null)
4566
- return;
4567
- let body;
4568
- try {
4569
- body = JSON.parse(raw);
4570
- }
4571
- catch {
4572
- json(res, 400, { error: "invalid JSON body" });
4573
- return;
4574
- }
4575
- const filePath = body.path?.trim();
4576
- if (!filePath) {
4577
- json(res, 400, { error: "missing transcript path" });
4578
- return;
4579
- }
4580
- const result = await session.importForeignTranscript(filePath, {
4581
- ...(body.cwd ? { cwd: body.cwd } : {}),
4582
- });
4583
- json(res, result.ok ? 200 : 400, result);
4584
- });
4585
- return;
4586
- }
4587
- if (method === "POST" && url === "/thinking") {
4588
- // The in-flight request already carries its reasoning config, so a
4589
- // mid-run cycle cannot affect the turn the user is watching — it just
4590
- // persists a level the footer then reports for a run that never used it.
4591
- // Locked like POST /model; the footer button is disabled to match.
4592
- if (running) {
4593
- json(res, 409, { error: "cannot change reasoning level while running" });
4594
- return;
4595
- }
4596
- const st = session.getState();
4597
- const next = getNextThinkingLevel(st.provider, st.model, session.getThinkingLevel());
4598
- session.setThinkingLevel(next);
4599
- // Persist per-project so THIS window restores its thinking state on
4600
- // restart; keep the global write as a fallback (mirrors the CLI).
4601
- void saveProjectModelPrefs(cwd, {
4602
- provider: st.provider,
4603
- model: st.model,
4604
- thinkingEnabled: !!next,
4605
- thinkingLevel: next ?? undefined,
4606
- }).then(() => persistThinkingLevel(paths.settingsFile, next));
4607
- const payload = {
4608
- thinkingLevel: next ?? null,
4609
- supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
4610
- };
4611
- broadcast("thinking_change", payload);
4612
- json(res, 200, payload);
4613
- return;
4614
- }
4615
- if (method === "POST" && url === "/prewarm") {
4616
- // Best-effort Anthropic cache prewarm on the composer's first keystroke.
4617
- // Fire-and-forget: AgentSession.prewarm() applies its own gating (provider,
4618
- // setting, history size, TTL, in-flight) and swallows request errors.
4619
- if (!running) {
4620
- void session.prewarm().catch((err) => {
4621
- log("WARN", "app-sidecar", "prewarm failed", { err: String(err) });
4622
- });
4623
- }
4624
- json(res, 202, { accepted: !running });
4625
- return;
4626
- }
4627
- if (method === "POST" && url === "/cancel") {
4628
- void (async () => {
4629
- // Even between task runs, cancellation stops the sweep. Active provider
4630
- // ownership invokes the full abort hook exactly once through lifecycle.
4631
- taskRunAll = false;
4632
- autopilotCancelled = true;
4633
- if (!runLifecycle.running) {
4634
- nolanAutoAbort.abort();
4635
- nolanAutoAbort = new AbortController();
4636
- nolanAutoSession?.setSignal(nolanAutoAbort.signal);
4637
- }
4638
- const generation = runLifecycle.generation;
4639
- if (!pendingCancelDrain || pendingCancelDrain.generation !== generation) {
4640
- pendingCancelDrain = { generation, text: session.drainQueue() };
4641
- broadcast("queued", { count: 0, messages: [] });
4642
- }
4643
- const result = await runLifecycle.cancel(CANCEL_TIMEOUT_MS);
4644
- const drained = pendingCancelDrain.text;
4645
- if (result.status === "failed") {
4646
- broadcast("cancel_failed", {
4647
- error: "cancel_failed",
4648
- reason: result.reason,
4649
- runState: runLifecycle.state,
4650
- });
4651
- json(res, 504, {
4652
- error: "cancel_failed",
4653
- reason: result.reason,
4654
- runState: runLifecycle.state,
4655
- drained,
4656
- });
4657
- return;
4658
- }
4659
- json(res, 200, {
4660
- cancelled: result.status === "cancelled",
4661
- runState: runLifecycle.state,
4662
- drained,
4663
- });
4664
- })().catch((error) => {
4665
- broadcast("cancel_failed", { error: "cancel_failed", runState: runLifecycle.state });
4666
- json(res, 500, {
4667
- error: "cancel_failed",
4668
- message: error instanceof Error ? error.message : String(error),
4669
- runState: runLifecycle.state,
4670
- });
4671
- });
4672
- return;
4673
- }
4674
- if (method === "POST" && url === "/new-session") {
4675
- if (running) {
4676
- json(res, 409, { error: "cannot start a new session while running" });
4677
- return;
4678
- }
4679
- void session
4680
- .newSession()
4681
- .then(async () => {
4682
- if (mode === "chat") {
4683
- await session.persistAppMarker("agent_handoff", { chatAgent });
4684
- }
4685
- deactivateApprovedPlan();
4686
- injectedAutopilotPrompts = [];
4687
- clearPendingPlan();
4688
- broadcast("session_reset", {});
4689
- json(res, 200, { ok: true });
4690
- })
4691
- .catch((err) => {
4692
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
4693
- });
4694
- return;
4695
- }
4696
- // Accept an approved plan and begin implementation in a FRESH session
4697
- // (mirrors the CLI's handleApprovePlan). The plan-mode conversation — all the
4698
- // research, file reads, and exploration done while drafting — must NOT bleed
4699
- // into the build, or it bloats the context and distracts the model. So:
4700
- // 1. newSession() wipes history + starts a new session file.
4701
- // 2. setApprovedPlan() bakes the plan into the fresh system prompt so the
4702
- // model emits `[DONE:n]` markers the plan-progress widget reads.
4703
- // 3. session_reset tells the webview to clear its transcript; it then runs
4704
- // the "implement it now" prompt in the clean session.
4705
- if (method === "POST" && url === "/plan/accept") {
4706
- void readBody(req, res).then(async (raw) => {
4707
- if (raw === null)
4708
- return;
4709
- let planPath;
4710
- try {
4711
- planPath = JSON.parse(raw).planPath || undefined;
4712
- }
4713
- catch {
4714
- json(res, 400, { error: "invalid JSON body" });
4715
- return;
4716
- }
4717
- if (running) {
4718
- json(res, 409, { error: "cannot accept a plan while the agent is running" });
4719
- return;
4720
- }
4721
- // Manual accept, possibly racing Nolan's autopilot plan review: the user
4722
- // always wins. Bump the plan generation (invalidates any in-flight
4723
- // review's verdict), stop the cycle, abort a mid-prompt review on the
4724
- // nolanAuto session, and clear the spinner — autopilot_ignored renders
4725
- // nothing, so no stale "approve or reject" bubble ever lands. The
4726
- // webview's follow-up "implement" /prompt arrives as a fresh turn
4727
- // (resetting autopilotCancelled), so the implementation still gets its
4728
- // normal post-run review; if it lands while the cycle is winding down
4729
- // it queues and runStrandedQueue drains it as a fresh turn.
4730
- clearPendingPlan();
4731
- autopilotCancelled = true;
4732
- nolanAutoAbort.abort();
4733
- nolanAutoAbort = new AbortController();
4734
- nolanAutoSession?.setSignal(nolanAutoAbort.signal);
4735
- if (autopilotReviewing) {
4736
- autopilotReviewing = false;
4737
- broadcast("autopilot_ignored", {});
4738
- }
4739
- try {
4740
- await session.newSession(true);
4741
- injectedAutopilotPrompts = [];
4742
- const planTotal = await activateApprovedPlan(planPath);
4743
- broadcast("session_reset", { planTotal });
4744
- broadcast("plan_progress", planProgressPayload());
4745
- json(res, 200, { ok: true, planTotal });
4746
- }
4747
- catch (err) {
4748
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
4749
- }
4750
- });
4751
- return;
4752
- }
4753
- // ── Provider auth (login) ───────────────────────────────
4754
- if (method === "GET" && url === "/auth/status") {
4755
- void authStatusPayload().then((payload) => json(res, 200, payload));
4756
- return;
4757
- }
4758
- if (method === "POST" && url === "/auth/apikey") {
4759
- void readBody(req, res).then(async (raw) => {
4760
- if (raw === null)
4761
- return;
4762
- let provider = "";
4763
- let key;
4764
- let variant;
4765
- try {
4766
- const body = JSON.parse(raw);
4767
- provider = body.provider ?? "";
4768
- key = (body.key ?? "").trim();
4769
- variant = body.variant;
4770
- }
4771
- catch {
4772
- json(res, 400, { error: "invalid JSON body" });
4773
- return;
4774
- }
4775
- const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
4776
- if (!meta || !meta.methods.includes("apikey")) {
4777
- json(res, 400, { error: "provider does not support API key auth" });
4778
- return;
4779
- }
4780
- if (!key) {
4781
- json(res, 400, { error: "API key is required" });
4782
- return;
4783
- }
4784
- // Providers with multiple API-key variants (currently only Xiaomi: Token
4785
- // Plan vs. API Credits) store under the chosen variant's key/baseUrl,
4786
- // defaulting to the first variant. Single-variant providers fall back to
4787
- // the legacy provider-id storage key + flat apiKeyBaseUrl.
4788
- const chosenVariant = meta.apiKeyVariants?.find((v) => v.key === variant) ?? meta.apiKeyVariants?.[0];
4789
- const storageKey = chosenVariant?.key ?? provider;
4790
- const baseUrl = chosenVariant?.baseUrl ?? meta.apiKeyBaseUrl;
4791
- const creds = {
4792
- accessToken: key,
4793
- refreshToken: "",
4794
- expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
4795
- ...(baseUrl ? { baseUrl } : {}),
4796
- };
4797
- await auth.setCredentials(storageKey, creds);
4798
- // auth.json is shared by every window, so this is a global change:
4799
- // close their login modals and refresh their provider lists too.
4800
- broadcastAll("auth_done", { provider });
4801
- // `auth_done` means "a login succeeded" (modals close on it).
4802
- // `auth_change` means "auth.json changed" — which a DISCONNECT also is,
4803
- // so connection state has one signal that covers both directions.
4804
- broadcastAll("auth_change", { provider });
4805
- // A newly connected provider unlocks its models. `/models` filters on
4806
- // who is logged in, so every window's picker is now stale — without
4807
- // this the new models don't appear until the session is reopened.
4808
- broadcastAll("models_change", {});
4809
- json(res, 200, { ok: true });
4810
- });
4811
- return;
4812
- }
4813
- if (method === "POST" && url === "/auth/oauth/start") {
4814
- void readBody(req, res).then((raw) => {
4815
- if (raw === null)
4816
- return;
4817
- let provider = "";
4818
- try {
4819
- provider = JSON.parse(raw).provider ?? "";
4820
- }
4821
- catch {
4822
- json(res, 400, { error: "invalid JSON body" });
4823
- return;
4824
- }
4825
- const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
4826
- if (!meta || !meta.methods.includes("oauth")) {
4827
- json(res, 400, { error: "provider does not support OAuth" });
4828
- return;
4829
- }
4830
- if (oauthInFlight) {
4831
- json(res, 409, { error: "a login is already in progress" });
4832
- return;
4833
- }
4834
- // A login writes the shared ~/.ezcoder/auth.json, so two windows racing the
4835
- // same provider means two browser tabs and two token exchanges whose
4836
- // writes clobber each other. The per-session flag above cannot see
4837
- // that — guard the provider daemon-wide as well.
4838
- if (oauthInFlightProviders.has(provider)) {
4839
- json(res, 409, {
4840
- error: `a ${meta.label} login is already in progress in another window`,
4841
- });
4842
- return;
4843
- }
4844
- oauthInFlight = true;
4845
- oauthInFlightProviders.add(provider);
4846
- json(res, 202, { accepted: true });
4847
- void (async () => {
4848
- const cb = authCallbacks();
4849
- try {
4850
- let creds;
4851
- let storageKey = provider;
4852
- if (provider === "anthropic")
4853
- creds = await loginAnthropic(cb);
4854
- else if (provider === "openai")
4855
- creds = await loginOpenAI(cb);
4856
- else if (provider === "gemini")
4857
- creds = await loginGemini(cb);
4858
- else if (provider === "moonshot" || provider === "xai") {
4859
- // Subscription OAuth (Kimi plan / SuperGrok-X Premium) stores under
4860
- // a distinct key so it can coexist with the provider's API key.
4861
- creds = provider === "moonshot" ? await loginKimi(cb) : await loginXai(cb);
4862
- storageKey = dualAuthProvider(provider).oauthKey;
4863
- }
4864
- else {
4865
- throw new Error(`OAuth not implemented for ${provider}`);
4866
- }
4867
- await auth.setCredentials(storageKey, creds);
4868
- // Terminal outcome of a GLOBAL change: every window's login modal
4869
- // should close and its provider list refresh, not just the one
4870
- // that started the flow.
4871
- broadcastAll("auth_done", { provider });
4872
- broadcastAll("auth_change", { provider });
4873
- // The OAuth provider's models just became selectable everywhere.
4874
- broadcastAll("models_change", {});
4875
- }
4876
- catch (err) {
4877
- // Deliberately session-scoped: this is the outcome of ONE window's
4878
- // attempt. Another window that never pressed Connect has nothing to
4879
- // show an error about, and its modal correctly still offers login.
4880
- broadcast("auth_error", {
4881
- provider,
4882
- message: err instanceof Error ? err.message : String(err),
4883
- });
4884
- }
4885
- finally {
4886
- oauthInFlight = false;
4887
- oauthInFlightProviders.delete(provider);
4888
- pendingCode = null;
4889
- }
4890
- })();
4891
- });
4892
- return;
4893
- }
4894
- if (method === "POST" && url === "/auth/oauth/code") {
4895
- void readBody(req, res).then((raw) => {
4896
- if (raw === null)
4897
- return;
4898
- let code;
4899
- try {
4900
- code = JSON.parse(raw).code ?? "";
4901
- }
4902
- catch {
4903
- json(res, 400, { error: "invalid JSON body" });
4904
- return;
4905
- }
4906
- if (!pendingCode) {
4907
- json(res, 409, { error: "no login is awaiting a code" });
4908
- return;
4909
- }
4910
- pendingCode(code.trim());
4911
- pendingCode = null;
4912
- json(res, 200, { ok: true });
4913
- });
4914
- return;
4915
- }
4916
- if (method === "POST" && url.startsWith("/mcp/elicit/")) {
4917
- const id = decodeURIComponent(url.slice("/mcp/elicit/".length));
4918
- void readBody(req, res).then((raw) => {
4919
- if (raw === null)
4920
- return;
4921
- let result;
4922
- try {
4923
- const parsed = JSON.parse(raw);
4924
- if (parsed.action !== "accept" &&
4925
- parsed.action !== "decline" &&
4926
- parsed.action !== "cancel") {
4927
- json(res, 400, { error: "action must be accept, decline, or cancel" });
4928
- return;
4929
- }
4930
- result =
4931
- parsed.action === "accept"
4932
- ? { action: "accept", content: parsed.content ?? {} }
4933
- : { action: parsed.action };
4934
- }
4935
- catch {
4936
- json(res, 400, { error: "invalid JSON body" });
4937
- return;
4938
- }
4939
- // Unknown id means it already timed out or was cancelled by an abort —
4940
- // the tool call has moved on, so the answer has nowhere to go.
4941
- if (!elicitations.settle(id, result)) {
4942
- json(res, 409, { error: "no elicitation is awaiting a response" });
4943
- return;
4944
- }
4945
- json(res, 200, { ok: true });
4946
- });
4947
- return;
4948
- }
4949
- // Answer (or dismiss) an `ask_user` question band. The turn is blocked on
4950
- // this, so both paths must land: "answer" carries the picked values,
4951
- // "cancel" releases the tool call with no answer.
4952
- if (method === "POST" && url.startsWith("/ask/")) {
4953
- const id = decodeURIComponent(url.slice("/ask/".length));
4954
- void readBody(req, res).then((raw) => {
4955
- if (raw === null)
4956
- return;
4957
- let result;
4958
- try {
4959
- const parsed = JSON.parse(raw);
4960
- if (parsed.action !== "answer" && parsed.action !== "cancel") {
4961
- json(res, 400, { error: "action must be answer or cancel" });
4962
- return;
4963
- }
4964
- if (parsed.action === "cancel") {
4965
- result = { action: "cancel" };
4966
- }
4967
- else {
4968
- // Only strings and string arrays are answers; anything else is a
4969
- // malformed client, not a value to hand the model.
4970
- const answers = {};
4971
- for (const [key, value] of Object.entries(parsed.answers ?? {})) {
4972
- if (typeof value === "string")
4973
- answers[key] = value;
4974
- else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
4975
- answers[key] = value;
4976
- }
4977
- }
4978
- result = { action: "answer", answers };
4979
- }
4980
- }
4981
- catch {
4982
- json(res, 400, { error: "invalid JSON body" });
4983
- return;
4984
- }
4985
- if (!asks.settle(id, result)) {
4986
- json(res, 409, { error: "no question is awaiting an answer" });
4987
- return;
4988
- }
4989
- json(res, 200, { ok: true });
4990
- });
4991
- return;
4992
- }
4993
- if (method === "POST" && url === "/auth/logout") {
4994
- void readBody(req, res).then(async (raw) => {
4995
- if (raw === null)
4996
- return;
4997
- let provider;
4998
- let logoutMethod;
4999
- try {
5000
- const body = JSON.parse(raw);
5001
- provider = body.provider ?? "";
5002
- logoutMethod =
5003
- body.method === "oauth" || body.method === "apikey" ? body.method : undefined;
5004
- }
5005
- catch {
5006
- json(res, 400, { error: "invalid JSON body" });
5007
- return;
5008
- }
5009
- const dual = dualAuthProvider(provider);
5010
- // A dual-auth provider can be disconnected one method at a time: dropping
5011
- // a spent API key should not sign the user out of their subscription, and
5012
- // vice versa. Omitting `method` still clears everything (the plain
5013
- // "Disconnect" action).
5014
- if (logoutMethod !== "apikey") {
5015
- await auth.clearCredentials(dual ? dual.oauthKey : provider);
5016
- }
5017
- if (logoutMethod !== "oauth") {
5018
- // Non-dual providers store their only credential under the provider id,
5019
- // so this covers both them and a dual provider's API key.
5020
- await auth.clearCredentials(provider);
5021
- // Xiaomi's API Credits credential lives under a distinct key — clear it
5022
- // too so "disconnect" fully removes both the Token Plan and Credits keys.
5023
- if (provider === "xiaomi")
5024
- await auth.clearCredentials(XIAOMI_CREDITS_KEY);
5025
- }
5026
- broadcast("auth_done", { provider });
5027
- json(res, 200, { ok: true });
5028
- });
5029
- return;
5030
- }
5031
- // ── Local models ──────────────────────────────────────
5032
- // GET returns the last scan (cheap, no probing) so opening the modal is
5033
- // instant; POST /local/scan is the explicit refresh.
5034
- if (method === "GET" && url === "/local") {
5035
- json(res, 200, localStatePayload());
5036
- return;
5037
- }
5038
- if (method === "POST" && url === "/local/scan") {
5039
- void scanLocalModels(true)
5040
- .then(() => {
5041
- broadcast("models_change", { local: localStatePayload() });
5042
- json(res, 200, localStatePayload());
5043
- })
5044
- .catch((err) => {
5045
- broadcastError("error", "local model scan failed", err);
5046
- json(res, 500, { error: "Local model scan failed — see the sidecar log." });
5047
- });
5048
- return;
5049
- }
5050
- if (method === "POST" && url === "/local/endpoints") {
5051
- void readBody(req, res).then(async (raw) => {
5052
- if (raw === null)
5053
- return;
5054
- let body;
5055
- try {
5056
- body = JSON.parse(raw);
5057
- }
5058
- catch {
5059
- json(res, 400, { error: "invalid JSON body" });
5060
- return;
5061
- }
5062
- try {
5063
- const endpoint = await addCustomEndpoint({
5064
- baseUrl: body.baseUrl ?? "",
5065
- ...(body.label ? { label: body.label } : {}),
5066
- ...(body.apiKey ? { apiKey: body.apiKey } : {}),
5067
- });
5068
- await scanLocalModels(true);
5069
- broadcast("models_change", { local: localStatePayload() });
5070
- json(res, 200, {
5071
- endpoint: { id: endpoint.id, label: endpoint.label },
5072
- ...localStatePayload(),
5073
- });
5074
- }
5075
- catch (err) {
5076
- // Validation errors are the user's typo, not a system fault — 400 with
5077
- // the exact reason, and nothing in the transcript.
5078
- if (err instanceof LocalEndpointError) {
5079
- json(res, 400, { error: err.message });
5080
- return;
5081
- }
5082
- broadcastError("error", "add local endpoint failed", err);
5083
- json(res, 500, { error: "Could not save the endpoint — see the sidecar log." });
5084
- }
5085
- });
5086
- return;
5087
- }
5088
- if (method === "DELETE" && url.startsWith("/local/endpoints/")) {
5089
- const id = decodeURIComponent(url.slice("/local/endpoints/".length));
5090
- void (async () => {
5091
- try {
5092
- await removeCustomEndpoint(id);
5093
- clearRuntimeModels((m) => m.provider === "local" && parseLocalModelId(m.id)?.endpointId === id);
5094
- await scanLocalModels(true);
5095
- broadcast("models_change", { local: localStatePayload() });
5096
- json(res, 200, localStatePayload());
5097
- }
5098
- catch (err) {
5099
- if (err instanceof LocalEndpointError) {
5100
- json(res, 400, { error: err.message });
5101
- return;
5102
- }
5103
- broadcastError("error", "remove local endpoint failed", err);
5104
- json(res, 500, { error: "Could not remove the endpoint — see the sidecar log." });
5105
- }
5106
- })();
5107
- return;
5108
- }
5109
- // ── Hugging Face search & pull (the "Add from Hugging Face" modal) ──
5110
- if (method === "GET" && url === "/hf/pull") {
5111
- json(res, 200, hfPull ? { active: hfPullPayload(hfPull) } : { active: null });
5112
- return;
5113
- }
5114
- if (method === "POST" && url === "/hf/search") {
5115
- void readBody(req, res).then(async (raw) => {
5116
- if (raw === null)
5117
- return;
5118
- let body;
5119
- try {
5120
- body = JSON.parse(raw);
5121
- }
5122
- catch {
5123
- json(res, 400, { error: "invalid JSON body" });
5124
- return;
5125
- }
5126
- // Strip control characters and cap length — this goes into a URL query.
5127
- const query = String(body.query ?? "")
5128
- // eslint-disable-next-line no-control-regex -- stripping control characters is the point
5129
- .replace(/[\u0000-\u001f]/g, "")
5130
- .trim()
5131
- .slice(0, 100);
5132
- if (!query) {
5133
- json(res, 400, { error: "Type something to search for." });
5134
- return;
5135
- }
5136
- try {
5137
- json(res, 200, { models: await hfSearch(query) });
5138
- }
5139
- catch (err) {
5140
- broadcastError("error", "hugging face search failed", err);
5141
- json(res, 502, {
5142
- error: `Hugging Face search failed — ${err instanceof Error ? err.message : "network error"}.`,
5143
- });
5144
- }
5145
- });
5146
- return;
5147
- }
5148
- if (method === "POST" && url === "/hf/pull") {
5149
- void readBody(req, res).then(async (raw) => {
5150
- if (raw === null)
5151
- return;
5152
- let body;
5153
- try {
5154
- body = JSON.parse(raw);
5155
- }
5156
- catch {
5157
- json(res, 400, { error: "invalid JSON body" });
5158
- return;
5159
- }
5160
- const repo = String(body.repo ?? "").trim();
5161
- if (!isValidHfRepoId(repo)) {
5162
- json(res, 400, { error: 'Expected a Hugging Face repo like "org/model".' });
5163
- return;
5164
- }
5165
- try {
5166
- json(res, 200, await startHfPull(repo));
5167
- }
5168
- catch (err) {
5169
- const status = err instanceof Error && "status" in err && typeof err.status === "number"
5170
- ? err.status
5171
- : 502;
5172
- if (status >= 500)
5173
- broadcastError("error", "hugging face pull failed", err);
5174
- json(res, status, { error: err instanceof Error ? err.message : "Pull failed." });
5175
- }
5176
- });
5177
- return;
5178
- }
5179
- if (method === "POST" && url === "/hf/pull/cancel") {
5180
- json(res, 200, { ok: cancelHfPull() });
5181
- return;
5182
- }
5183
- // ── Telegram config (mirrors `ezcoder telegram`) ─────────
5184
- if (method === "GET" && url === "/telegram") {
5185
- void loadTelegramConfig().then((cfg) => {
5186
- if (!cfg) {
5187
- json(res, 200, { configured: false });
5188
- return;
5189
- }
5190
- // Never return the raw token to the webview — a short masked preview is
5191
- // enough to show "already set".
5192
- const t = cfg.botToken;
5193
- const tokenPreview = t.length > 14 ? `${t.slice(0, 10)}\u2026${t.slice(-4)}` : "set";
5194
- json(res, 200, { configured: true, userId: cfg.userId, tokenPreview });
5195
- });
5196
- return;
5197
- }
5198
- if (method === "POST" && url === "/telegram") {
5199
- void readBody(req, res).then(async (raw) => {
5200
- if (raw === null)
5201
- return;
5202
- let botTokenInput;
5203
- let userIdInput;
5204
- try {
5205
- const body = JSON.parse(raw);
5206
- botTokenInput = (body.botToken ?? "").trim();
5207
- userIdInput = String(body.userId ?? "").trim();
5208
- }
5209
- catch {
5210
- json(res, 400, { error: "invalid JSON body" });
5211
- return;
5212
- }
5213
- // Keep the existing token when the field is left blank (the webview shows
5214
- // a masked preview, not the real token).
5215
- const existing = await loadTelegramConfig();
5216
- const botToken = botTokenInput || existing?.botToken || "";
5217
- if (!botToken) {
5218
- json(res, 400, { error: "Bot token is required." });
5219
- return;
5220
- }
5221
- const userId = userIdInput ? parseInt(userIdInput, 10) : existing?.userId;
5222
- if (!userId || Number.isNaN(userId)) {
5223
- json(res, 400, { error: "A numeric Telegram user ID is required." });
5224
- return;
5225
- }
5226
- const verified = await verifyBotToken(botToken);
5227
- if (!verified.ok) {
5228
- json(res, 400, { error: "Invalid bot token — Telegram rejected it." });
5229
- return;
5230
- }
5231
- await saveTelegramConfig({ botToken, userId });
5232
- json(res, 200, { ok: true, userId, username: verified.username ?? null });
5233
- });
5234
- return;
5235
- }
5236
- // ── Serve lifecycle (mirrors `ezcoder serve`) ───────────
5237
- if (method === "GET" && url === "/serve") {
5238
- void loadTelegramConfig().then((cfg) => json(res, 200, { running: serveController !== null, configured: cfg !== null }));
5239
- return;
5240
- }
5241
- if (method === "POST" && url === "/serve/start") {
5242
- void (async () => {
5243
- if (serveController) {
5244
- json(res, 200, { running: true });
5245
- return;
5246
- }
5247
- const cfg = await loadTelegramConfig();
5248
- if (!cfg) {
5249
- json(res, 400, { error: "Telegram isn't set up yet. Open Serve settings first." });
5250
- return;
5251
- }
5252
- const st = session.getState();
5253
- try {
5254
- serveController = await startServeMode({
5255
- provider: st.provider,
5256
- model: st.model,
5257
- cwd,
5258
- version: "app",
5259
- thinkingLevel: session.getThinkingLevel() ?? undefined,
5260
- telegram: { botToken: cfg.botToken, userId: cfg.userId },
5261
- embedded: true,
5262
- });
5263
- broadcast("serve_change", { running: true });
5264
- log("INFO", "app-sidecar", "serve started", { userId: cfg.userId });
5265
- json(res, 200, { running: true });
5266
- }
5267
- catch (err) {
5268
- serveController = null;
5269
- json(res, 400, { error: err instanceof Error ? err.message : String(err) });
5270
- }
5271
- })();
5272
- return;
5273
- }
5274
- if (method === "POST" && url === "/serve/stop") {
5275
- void (async () => {
5276
- if (serveController) {
5277
- await serveController.stop().catch(() => { });
5278
- serveController = null;
5279
- broadcast("serve_change", { running: false });
5280
- log("INFO", "app-sidecar", "serve stopped");
5281
- }
5282
- json(res, 200, { running: false });
5283
- })();
5284
- return;
5285
- }
5286
- // ── Agent Steroids (local code corpus) ───────────────────────────────
5287
- if (method === "GET" && url === "/steroids") {
5288
- void probeSteroids().then((status) => json(res, 200, status));
5289
- return;
5290
- }
5291
- if (method === "POST" && url === "/steroids/install") {
5292
- void installSteroids()
5293
- .then((status) => {
5294
- broadcast("steroids_change", status);
5295
- log("INFO", "app-sidecar", "steroids installed", { version: status.version });
5296
- json(res, 200, status);
5297
- })
5298
- .catch((err) => {
5299
- json(res, 400, { error: err instanceof Error ? err.message : String(err) });
5300
- });
5301
- return;
5302
- }
5303
- // ── MCP server management (mirrors `ezcoder mcp`) ──────────────────
5304
- // `targetCwd` (project scope) overrides the window cwd so a server can be
5305
- // added/removed for ANY discovered project, not just this window's. Global
5306
- // scope ignores it (always ~/.ezcoder/mcp.json).
5307
- if (method === "GET" && (url === "/mcp" || url.startsWith("/mcp?"))) {
5308
- const targetCwd = new URL(url, `http://${host}`).searchParams.get("cwd") ?? cwd;
5309
- void buildMcpRows(targetCwd, paths.settingsFile)
5310
- .then((servers) => json(res, 200, { servers }))
5311
- .catch((err) => {
5312
- log("ERROR", "app-sidecar", "buildMcpRows failed", {
5313
- message: err instanceof Error ? err.message : String(err),
5314
- });
5315
- json(res, 200, { servers: [] });
5316
- });
5317
- return;
5318
- }
5319
- if (method === "POST" && url === "/mcp/add") {
5320
- void readBody(req, res).then(async (raw) => {
5321
- if (raw === null)
5322
- return;
5323
- let line;
5324
- let scopeValue;
5325
- let bodyCwd;
5326
- try {
5327
- const body = JSON.parse(raw);
5328
- line = body.line ?? "";
5329
- scopeValue = body.scope ?? "global";
5330
- bodyCwd = body.cwd;
5331
- }
5332
- catch {
5333
- json(res, 400, { error: "invalid JSON body" });
5334
- return;
5335
- }
5336
- const scope = scopeValue === "project" ? "project" : "global";
5337
- if (scope === "project" && !bodyCwd) {
5338
- json(res, 400, { error: "project scope requires a project (cwd)." });
5339
- return;
5340
- }
5341
- const targetCwd = bodyCwd ?? cwd;
5342
- const parsed = parseMcpAddCommand(line);
5343
- if (!parsed.ok) {
5344
- json(res, 400, { error: parsed.error });
5345
- return;
5346
- }
5347
- const config = parsed.value.config;
5348
- try {
5349
- // Best-effort probe — never blocks the save. A failed connect is
5350
- // surfaced to the UI but the config is still persisted (mirrors the
5351
- // CLI). probeMcp swallows connect errors; the try/catch guards the
5352
- // persist step so a write failure returns a 500 instead of becoming
5353
- // an unhandled rejection that would crash the sidecar.
5354
- const probe = await probeMcp(config);
5355
- const saved = await addServer(config, scope, targetCwd, true);
5356
- if (!saved.ok) {
5357
- json(res, 400, { error: saved.error });
5358
- return;
5359
- }
5360
- // Adding a project-scope server is an explicit trust signal — the
5361
- // user chose to put a server in this repo's .ezcoder/mcp.json. Auto-trust
5362
- // the project so all project-scope servers connect on next load.
5363
- if (scope === "project") {
5364
- await session.trustProject(targetCwd);
5365
- }
5366
- json(res, 200, {
5367
- ok: true,
5368
- name: config.name,
5369
- connected: probe.ok,
5370
- toolCount: probe.toolCount,
5371
- error: probe.error,
5372
- requiresAuth: probe.requiresAuth,
5373
- });
5374
- }
5375
- catch (err) {
5376
- json(res, 500, {
5377
- error: err instanceof Error ? err.message : String(err),
5378
- });
5379
- }
5380
- });
5381
- return;
5382
- }
5383
- if (method === "POST" && url === "/mcp/remove") {
5384
- void readBody(req, res).then(async (raw) => {
5385
- if (raw === null)
5386
- return;
5387
- let name;
5388
- let scopeValue;
5389
- let bodyCwd;
5390
- try {
5391
- const body = JSON.parse(raw);
5392
- name = body.name ?? "";
5393
- scopeValue = body.scope ?? "global";
5394
- bodyCwd = body.cwd;
5395
- }
5396
- catch {
5397
- json(res, 400, { error: "invalid JSON body" });
5398
- return;
5399
- }
5400
- if (!name.trim()) {
5401
- json(res, 400, { error: "missing server name" });
5402
- return;
5403
- }
5404
- const scope = scopeValue === "project" ? "project" : "global";
5405
- if (scope === "project" && !bodyCwd) {
5406
- json(res, 400, { error: "project scope requires a project (cwd)." });
5407
- return;
5408
- }
5409
- const targetCwd = bodyCwd ?? cwd;
5410
- const removed = await removeServer(name, scope, targetCwd);
5411
- // Drop any saved OAuth tokens for this server so a re-add starts clean.
5412
- await new McpOAuthStore().clear(name).catch(() => { });
5413
- json(res, 200, { removed });
5414
- });
5415
- return;
5416
- }
5417
- // Interactive OAuth login for a remote (HTTP) MCP server. The browser is
5418
- // opened by the webview in response to the broadcast `mcp_auth_url` event;
5419
- // progress + outcome stream via `mcp_auth_status` / `mcp_auth_done` /
5420
- // `mcp_auth_error`. Responds 202 immediately and runs the flow in the
5421
- // background (the browser round-trip can take a while).
5422
- if (method === "POST" && url === "/mcp/login") {
5423
- void readBody(req, res).then(async (raw) => {
5424
- if (raw === null)
5425
- return;
5426
- let name;
5427
- let scopeValue;
5428
- let bodyCwd;
5429
- try {
5430
- const body = JSON.parse(raw);
5431
- name = body.name ?? "";
5432
- scopeValue = body.scope ?? "global";
5433
- bodyCwd = body.cwd;
5434
- }
5435
- catch {
5436
- json(res, 400, { error: "invalid JSON body" });
5437
- return;
5438
- }
5439
- if (!name.trim()) {
5440
- json(res, 400, { error: "missing server name" });
5441
- return;
5442
- }
5443
- const scope = scopeValue === "project" ? "project" : "global";
5444
- const targetCwd = bodyCwd ?? cwd;
5445
- const scoped = await getServer(name, targetCwd);
5446
- if (!scoped || scoped.scope !== scope) {
5447
- json(res, 404, { error: `No "${name}" server found.` });
5448
- return;
5449
- }
5450
- if (!scoped.config.url) {
5451
- json(res, 400, { error: "Login is only supported for HTTP MCP servers." });
5452
- return;
5453
- }
5454
- json(res, 202, { accepted: true });
5455
- broadcast("mcp_auth_status", { name, message: "Starting login\u2026" });
5456
- const manager = new MCPClientManager();
5457
- try {
5458
- const result = await manager.login(scoped.config, (authUrl) => {
5459
- broadcast("mcp_auth_url", { name, url: authUrl });
5460
- });
5461
- if (result.ok) {
5462
- broadcast("mcp_auth_done", { name, toolCount: result.toolCount });
5463
- }
5464
- else {
5465
- broadcast("mcp_auth_error", { name, message: result.error ?? "Login failed." });
5466
- }
5467
- }
5468
- catch (err) {
5469
- broadcast("mcp_auth_error", {
5470
- name,
5471
- message: err instanceof Error ? err.message : String(err),
5472
- });
5473
- }
5474
- finally {
5475
- await manager.dispose().catch(() => { });
5476
- }
5477
- });
5478
- return;
5479
- }
5480
2053
  json(res, 404, { error: "not found" });
5481
2054
  }
5482
2055
  async function dispose() {
@@ -5489,13 +2062,10 @@ async function createSession(deps, opts) {
5489
2062
  tasksPollStopped = true;
5490
2063
  if (tasksPoll)
5491
2064
  clearTimeout(tasksPoll);
5492
- gitPollStopped = true;
5493
- if (gitPoll)
5494
- clearTimeout(gitPoll);
5495
- gitHubPollStopped = true;
5496
- if (gitHubPoll)
5497
- clearTimeout(gitHubPoll);
5498
- ciPoll.stop();
2065
+ dirtyFilesPoll.unsubscribe();
2066
+ gitHubCountsPoll?.unsubscribe();
2067
+ ciPoll.unsubscribe();
2068
+ healthPoll?.unsubscribe();
5499
2069
  // Stop the Telegram serve loop + dispose its per-chat sessions.
5500
2070
  if (serveController)
5501
2071
  await serveController.stop().catch(() => { });