@prestyj/cli 5.29.1 → 5.30.1

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