@kenkaiiii/ggcoder 5.74.2 → 5.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +1 -1
  2. package/assets/skills/bulletproof/SKILL.md +1 -1
  3. package/assets/skills/clarify/SKILL.md +1 -1
  4. package/assets/skills/code-review/SKILL.md +1 -1
  5. package/assets/skills/compliance-guard/SKILL.md +1 -1
  6. package/assets/skills/durable/SKILL.md +1 -1
  7. package/assets/skills/evidence-led-ui/SKILL.md +1 -1
  8. package/assets/skills/lean/SKILL.md +1 -1
  9. package/assets/skills/refactoring/SKILL.md +1 -1
  10. package/assets/skills/root-cause/SKILL.md +1 -1
  11. package/assets/skills/shared-language/SKILL.md +1 -1
  12. package/assets/skills/tdd/SKILL.md +1 -1
  13. package/dist/app-sidecar/app-settings.d.ts +65 -0
  14. package/dist/app-sidecar/app-settings.js +147 -0
  15. package/dist/app-sidecar/app-settings.js.map +1 -0
  16. package/dist/app-sidecar/attachments.d.ts +11 -0
  17. package/dist/app-sidecar/attachments.js +47 -0
  18. package/dist/app-sidecar/attachments.js.map +1 -0
  19. package/dist/app-sidecar/auth-routes.d.ts +3 -0
  20. package/dist/app-sidecar/auth-routes.js +290 -0
  21. package/dist/app-sidecar/auth-routes.js.map +1 -0
  22. package/dist/app-sidecar/autopilot-runner.d.ts +61 -0
  23. package/dist/app-sidecar/autopilot-runner.js +367 -0
  24. package/dist/app-sidecar/autopilot-runner.js.map +1 -0
  25. package/dist/app-sidecar/file-search.d.ts +15 -0
  26. package/dist/app-sidecar/file-search.js +103 -0
  27. package/dist/app-sidecar/file-search.js.map +1 -0
  28. package/dist/app-sidecar/hf-pull-manager.d.ts +20 -0
  29. package/dist/app-sidecar/hf-pull-manager.js +224 -0
  30. package/dist/app-sidecar/hf-pull-manager.js.map +1 -0
  31. package/dist/app-sidecar/history-routes.d.ts +3 -0
  32. package/dist/app-sidecar/history-routes.js +375 -0
  33. package/dist/app-sidecar/history-routes.js.map +1 -0
  34. package/dist/app-sidecar/history.d.ts +67 -0
  35. package/dist/app-sidecar/history.js +23 -0
  36. package/dist/app-sidecar/history.js.map +1 -0
  37. package/dist/app-sidecar/http.d.ts +5 -0
  38. package/dist/app-sidecar/http.js +27 -0
  39. package/dist/app-sidecar/http.js.map +1 -0
  40. package/dist/app-sidecar/integration-routes.d.ts +3 -0
  41. package/dist/app-sidecar/integration-routes.js +286 -0
  42. package/dist/app-sidecar/integration-routes.js.map +1 -0
  43. package/dist/app-sidecar/json-mode.d.ts +10 -0
  44. package/dist/app-sidecar/json-mode.js +73 -0
  45. package/dist/app-sidecar/json-mode.js.map +1 -0
  46. package/dist/app-sidecar/keep-awake.d.ts +4 -0
  47. package/dist/app-sidecar/keep-awake.js +5 -0
  48. package/dist/app-sidecar/keep-awake.js.map +1 -0
  49. package/dist/app-sidecar/ken-context.d.ts +20 -0
  50. package/dist/app-sidecar/ken-context.js +55 -0
  51. package/dist/app-sidecar/ken-context.js.map +1 -0
  52. package/dist/app-sidecar/mcp-routes.d.ts +3 -0
  53. package/dist/app-sidecar/mcp-routes.js +185 -0
  54. package/dist/app-sidecar/mcp-routes.js.map +1 -0
  55. package/dist/app-sidecar/mcp-rows.d.ts +31 -0
  56. package/dist/app-sidecar/mcp-rows.js +77 -0
  57. package/dist/app-sidecar/mcp-rows.js.map +1 -0
  58. package/dist/app-sidecar/progress-manager.d.ts +10 -0
  59. package/dist/app-sidecar/progress-manager.js +116 -0
  60. package/dist/app-sidecar/progress-manager.js.map +1 -0
  61. package/dist/app-sidecar/prompt-routes.d.ts +3 -0
  62. package/dist/app-sidecar/prompt-routes.js +354 -0
  63. package/dist/app-sidecar/prompt-routes.js.map +1 -0
  64. package/dist/app-sidecar/route-context.d.ts +145 -0
  65. package/dist/app-sidecar/route-context.js +2 -0
  66. package/dist/app-sidecar/route-context.js.map +1 -0
  67. package/dist/app-sidecar/run-control-routes.d.ts +3 -0
  68. package/dist/app-sidecar/run-control-routes.js +430 -0
  69. package/dist/app-sidecar/run-control-routes.js.map +1 -0
  70. package/dist/app-sidecar/session-routes.d.ts +3 -0
  71. package/dist/app-sidecar/session-routes.js +115 -0
  72. package/dist/app-sidecar/session-routes.js.map +1 -0
  73. package/dist/app-sidecar/session-types.d.ts +39 -0
  74. package/dist/app-sidecar/session-types.js +5 -0
  75. package/dist/app-sidecar/session-types.js.map +1 -0
  76. package/dist/app-sidecar/task-routes.d.ts +3 -0
  77. package/dist/app-sidecar/task-routes.js +149 -0
  78. package/dist/app-sidecar/task-routes.js.map +1 -0
  79. package/dist/app-sidecar/usage.d.ts +19 -0
  80. package/dist/app-sidecar/usage.js +153 -0
  81. package/dist/app-sidecar/usage.js.map +1 -0
  82. package/dist/app-sidecar/workspace-routes.d.ts +3 -0
  83. package/dist/app-sidecar/workspace-routes.js +244 -0
  84. package/dist/app-sidecar/workspace-routes.js.map +1 -0
  85. package/dist/app-sidecar.js +482 -3695
  86. package/dist/app-sidecar.js.map +1 -1
  87. package/dist/cli.js +2 -2
  88. package/dist/cli.js.map +1 -1
  89. package/dist/config.js +1 -1
  90. package/dist/core/acceptance-checks.js +3 -9
  91. package/dist/core/acceptance-checks.js.map +1 -1
  92. package/dist/core/agent-session/helpers.d.ts +26 -0
  93. package/dist/core/agent-session/helpers.js +47 -0
  94. package/dist/core/agent-session/helpers.js.map +1 -0
  95. package/dist/core/agent-session/mcp-tools.d.ts +68 -0
  96. package/dist/core/agent-session/mcp-tools.js +195 -0
  97. package/dist/core/agent-session/mcp-tools.js.map +1 -0
  98. package/dist/core/agent-session/run-loop-monitor.d.ts +63 -0
  99. package/dist/core/agent-session/run-loop-monitor.js +164 -0
  100. package/dist/core/agent-session/run-loop-monitor.js.map +1 -0
  101. package/dist/core/agent-session/transcript-markers.d.ts +57 -0
  102. package/dist/core/agent-session/transcript-markers.js +166 -0
  103. package/dist/core/agent-session/transcript-markers.js.map +1 -0
  104. package/dist/core/agent-session/verification-tracker.d.ts +46 -0
  105. package/dist/core/agent-session/verification-tracker.js +246 -0
  106. package/dist/core/agent-session/verification-tracker.js.map +1 -0
  107. package/dist/core/agent-session.d.ts +27 -87
  108. package/dist/core/agent-session.js +166 -709
  109. package/dist/core/agent-session.js.map +1 -1
  110. package/dist/core/auth-providers.js +3 -3
  111. package/dist/core/auth-providers.js.map +1 -1
  112. package/dist/core/autopilot-cycle.d.ts +6 -2
  113. package/dist/core/autopilot-cycle.js +1 -1
  114. package/dist/core/autopilot-cycle.js.map +1 -1
  115. package/dist/core/bundled-agents.js +6 -6
  116. package/dist/core/bundled-agents.js.map +1 -1
  117. package/dist/core/checklist-git.d.ts +19 -0
  118. package/dist/core/checklist-git.js +54 -0
  119. package/dist/core/checklist-git.js.map +1 -0
  120. package/dist/core/checklist-items.d.ts +10 -1
  121. package/dist/core/checklist-items.js +22 -0
  122. package/dist/core/checklist-items.js.map +1 -1
  123. package/dist/core/checklist-prompt.d.ts +5 -1
  124. package/dist/core/checklist-prompt.js +16 -5
  125. package/dist/core/checklist-prompt.js.map +1 -1
  126. package/dist/core/checklist-snapshot.d.ts +5 -1
  127. package/dist/core/checklist-snapshot.js +19 -2
  128. package/dist/core/checklist-snapshot.js.map +1 -1
  129. package/dist/core/context-limits.js +1 -1
  130. package/dist/core/context-limits.js.map +1 -1
  131. package/dist/core/plan-handoff.d.ts +30 -0
  132. package/dist/core/plan-handoff.js +10 -0
  133. package/dist/core/plan-handoff.js.map +1 -0
  134. package/dist/core/plan-submission-gate.d.ts +28 -0
  135. package/dist/core/plan-submission-gate.js +27 -0
  136. package/dist/core/plan-submission-gate.js.map +1 -0
  137. package/dist/core/skills.js +6 -9
  138. package/dist/core/skills.js.map +1 -1
  139. package/dist/core/style-packs/index.js +2 -11
  140. package/dist/core/style-packs/index.js.map +1 -1
  141. package/dist/core/style-packs/packs.d.ts +3 -12
  142. package/dist/core/style-packs/packs.js +132 -173
  143. package/dist/core/style-packs/packs.js.map +1 -1
  144. package/dist/system-prompt.js +55 -78
  145. package/dist/system-prompt.js.map +1 -1
  146. package/dist/tools/ask-user.js +13 -74
  147. package/dist/tools/ask-user.js.map +1 -1
  148. package/dist/tools/bash.js +13 -78
  149. package/dist/tools/bash.js.map +1 -1
  150. package/dist/tools/checklist.d.ts +2 -10
  151. package/dist/tools/checklist.js +7 -39
  152. package/dist/tools/checklist.js.map +1 -1
  153. package/dist/tools/code-nav.js +7 -29
  154. package/dist/tools/code-nav.js.map +1 -1
  155. package/dist/tools/edit.d.ts +3 -9
  156. package/dist/tools/edit.js +66 -65
  157. package/dist/tools/edit.js.map +1 -1
  158. package/dist/tools/enter-plan.js +2 -7
  159. package/dist/tools/enter-plan.js.map +1 -1
  160. package/dist/tools/exit-plan.js +2 -3
  161. package/dist/tools/exit-plan.js.map +1 -1
  162. package/dist/tools/find.js +3 -4
  163. package/dist/tools/find.js.map +1 -1
  164. package/dist/tools/grep.js +6 -18
  165. package/dist/tools/grep.js.map +1 -1
  166. package/dist/tools/ls.js +3 -3
  167. package/dist/tools/ls.js.map +1 -1
  168. package/dist/tools/prompt-hints.js +34 -47
  169. package/dist/tools/prompt-hints.js.map +1 -1
  170. package/dist/tools/read.js +8 -20
  171. package/dist/tools/read.js.map +1 -1
  172. package/dist/tools/search-code.js +4 -12
  173. package/dist/tools/search-code.js.map +1 -1
  174. package/dist/tools/skill.js +13 -7
  175. package/dist/tools/skill.js.map +1 -1
  176. package/dist/tools/subagent-control.js +7 -22
  177. package/dist/tools/subagent-control.js.map +1 -1
  178. package/dist/tools/subagent.js +4 -7
  179. package/dist/tools/subagent.js.map +1 -1
  180. package/dist/tools/task-output.js +4 -15
  181. package/dist/tools/task-output.js.map +1 -1
  182. package/dist/tools/task-send.js +5 -17
  183. package/dist/tools/task-send.js.map +1 -1
  184. package/dist/tools/task-stop.js +2 -2
  185. package/dist/tools/task-stop.js.map +1 -1
  186. package/dist/tools/tool-search.js +2 -7
  187. package/dist/tools/tool-search.js.map +1 -1
  188. package/dist/tools/tool-tiers.d.ts +17 -4
  189. package/dist/tools/tool-tiers.js +38 -15
  190. package/dist/tools/tool-tiers.js.map +1 -1
  191. package/dist/tools/web-fetch.js +7 -33
  192. package/dist/tools/web-fetch.js.map +1 -1
  193. package/dist/tools/write.js +3 -4
  194. package/dist/tools/write.js.map +1 -1
  195. package/dist/ui/components/Footer.js +1 -2
  196. package/dist/ui/components/Footer.js.map +1 -1
  197. package/package.json +7 -4
  198. package/dist/core/api-benchmark.d.ts +0 -63
  199. package/dist/core/api-benchmark.js +0 -381
  200. package/dist/core/api-benchmark.js.map +0 -1
  201. package/dist/core/bash-spawn-benchmark.d.ts +0 -1
  202. package/dist/core/bash-spawn-benchmark.js +0 -139
  203. package/dist/core/bash-spawn-benchmark.js.map +0 -1
  204. package/dist/core/cache-warm-benchmark.d.ts +0 -21
  205. package/dist/core/cache-warm-benchmark.js +0 -170
  206. package/dist/core/cache-warm-benchmark.js.map +0 -1
  207. package/dist/core/fast-apply-benchmark.d.ts +0 -25
  208. package/dist/core/fast-apply-benchmark.js +0 -284
  209. package/dist/core/fast-apply-benchmark.js.map +0 -1
  210. package/dist/core/hashline-edit-benchmark.d.ts +0 -88
  211. package/dist/core/hashline-edit-benchmark.js +0 -518
  212. package/dist/core/hashline-edit-benchmark.js.map +0 -1
  213. package/dist/core/semantic-search-benchmark.d.ts +0 -36
  214. package/dist/core/semantic-search-benchmark.js +0 -211
  215. package/dist/core/semantic-search-benchmark.js.map +0 -1
  216. package/dist/core/speed-benchmark.d.ts +0 -132
  217. package/dist/core/speed-benchmark.js +0 -410
  218. 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, formatChatError, redactValue, } from "@kenkaiiii/gg-ai";
23
- import { createAppErrorPayload, restoreAppErrorPayload, } from "./app-error.js";
24
- import { runJsonMode } from "./modes/json-mode.js";
19
+ import { environmentSecrets, formatChatError, redactValue } from "@kenkaiiii/gg-ai";
20
+ import { createAppErrorPayload } from "./app-error.js";
25
21
  import { runSubagentWorkerMode } from "./modes/subagent-worker-mode.js";
26
22
  import { setStreamDiagnostic } from "@kenkaiiii/gg-agent";
27
23
  import { AgentSession } from "./core/agent-session.js";
28
24
  import { RunLifecycle } from "./core/run-lifecycle.js";
29
25
  import { RunClaim } from "./core/run-claim.js";
30
- import { CHAT_AGENT_IDS, chatAgentSessionsDir, createChatAgent, parseChatAgentId, switchChatAgent, } from "./chat-agents/index.js";
26
+ import { shouldOfferPendingPlan } from "./core/plan-handoff.js";
27
+ import { createChatAgent, parseChatAgentId, switchChatAgent, } from "./chat-agents/index.js";
31
28
  import { createMotionAgentSession } from "./motion-agent/motion-agent.js";
32
29
  import { buildJiwaTools, JiwaStore } from "./chat-agents/jiwa.js";
33
30
  import { buildMemoryTools, MemoryStore } from "./chat-agents/memory.js";
34
31
  import { buildKenSystemPrompt, buildKenAutopilotSystemPrompt } from "./core/ken-prompt.js";
35
- import { buildKenDigest, buildKenAutopilotContext, buildKenAutopilotPlanContext, } from "./core/ken-context.js";
36
- import { parseAutopilotVerdict } from "./core/autopilot-verdict.js";
37
- import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
38
- import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
32
+ import { countAssistantMessages } from "./core/autopilot-gate.js";
39
33
  import { describeRunVerification, describeTurnVerification } from "./core/run-status.js";
40
34
  import { validateKenModelPref, effectiveKenModel } from "./core/ken-model.js";
41
- import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeKenTurnsForHistory, getHistoryMessageVisibility, replayMessagesInOrder, restoreUserRow, restoreAssistantTexts, resolveRestoredCommand, autopilotMarkerCopySeed, extractToolImagePaths, } from "./core/session-history.js";
42
- import { sessionToMarkdown, defaultExportFilename, } from "./core/session-export.js";
43
35
  import { AuthStorage } from "./core/auth-storage.js";
44
- import { advancesPhase, explainPullFailure, isGgufShard, isValidHfRepoId, parseOllamaPullLine, pickGgufQuant, SHARDED_MESSAGE, toHfSearchRow, } from "./hf-pull.js";
45
36
  import { cleanupToolOutputs } from "./tools/overflow.js";
46
- import { spawnedTasks } from "./tools/subagent-shared.js";
47
- import { readCappedBody } from "./utils/http-body.js";
48
- import { fetchSubscriptionUsage, SubscriptionUsageError, XIAOMI_CREDITS_KEY, dualAuthProvider, oauthStorageKey, discoverLocalModels, findProbedModel, formatLocalModelId, parseLocalModelId, probeEndpoint, toModelInfo as localModelInfo, } from "@kenkaiiii/gg-core";
49
- import { LocalEndpointError, addCustomEndpoint, listAllEndpoints, removeCustomEndpoint, syncEndpointCredentials, } from "./core/local-endpoint-store.js";
50
- import { loginAnthropic } from "./core/oauth/anthropic.js";
51
- import { loginOpenAI } from "./core/oauth/openai.js";
52
- import { loginGemini } from "./core/oauth/gemini.js";
53
- import { loginKimi } from "./core/oauth/kimi.js";
54
- import { loginXai } from "./core/oauth/xai.js";
37
+ import { dualAuthProvider, discoverLocalModels, formatLocalModelId, parseLocalModelId, probeEndpoint, toModelInfo as localModelInfo, } from "@kenkaiiii/gg-core";
38
+ import { listAllEndpoints, syncEndpointCredentials } from "./core/local-endpoint-store.js";
55
39
  import { AUTH_PROVIDERS, authPriorityNote, describeAuthMethods, } from "./core/auth-providers.js";
56
- import { ensureAppDirs, loadSavedSettings, projectScopeAllowed } from "./config.js";
40
+ import { ensureAppDirs, loadSavedSettings } from "./config.js";
57
41
  import { SettingsManager } from "./core/settings-manager.js";
58
- import { installPlugin, listInstalledPlugins, removePlugin, } from "./core/extensions/plugin-bundles.js";
59
- import { getModel, getDefaultThinkingLevel, getContextWindow, getAllModels, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
42
+ import { getModel, getDefaultThinkingLevel, getContextWindow, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
60
43
  import { resolveStartOrFallback } from "./core/resolve-start.js";
61
44
  import { getGitBranch, getGitDirtyFileCount, isGitRepo } from "./utils/git.js";
62
45
  import { getGitHubOpenCounts, getGitHubRepoSlug } from "./utils/github.js";
63
46
  import { startGitHubCIPoll } from "./utils/github-ci.js";
64
47
  import { extractPlanSteps } from "./utils/plan-steps.js";
65
- import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSupported, } from "./core/thinking-level.js";
66
- import { parseThinkingLevel } from "./cli/thinking-arg.js";
48
+ import { getSupportedThinkingLevels, isThinkingLevelSupported } from "./core/thinking-level.js";
67
49
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
68
50
  import { loadCustomCommands } from "./core/custom-commands.js";
69
- import { discoverProjects } from "./core/project-discovery.js";
70
- import { listSidecarSessions } from "./app-sidecar-sessions.js";
71
- import { loadTasksSync, saveTasksSync, pruneDoneTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/tasks-store.js";
72
- import { readChecklistSnapshot } from "./core/checklist-snapshot.js";
51
+ import { pruneDoneTasksSync } from "./core/tasks-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(), ".gg", "gg-app.json");
125
- }
126
- function defaultProjectsRoot() {
127
- return path.join(os.homedir(), "gg-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
- kenModels: raw.kenModels && typeof raw.kenModels === "object" ? raw.kenModels : 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 Ken model override, if any. */
172
- async function loadKenModelPref(cwd) {
173
- const s = await loadAppSettings();
174
- return s.kenModels?.[projectModelKey(cwd)];
175
- }
176
- /** Persist (or with null, clear) this project's Ken model override via
177
- * read-modify-write so the rest of the settings file is preserved. */
178
- async function saveKenModelPref(cwd, pref) {
179
- const s = await loadAppSettings();
180
- const key = projectModelKey(cwd);
181
- const next = { ...(s.kenModels ?? {}) };
182
- if (pref)
183
- next[key] = pref;
184
- else
185
- delete next[key];
186
- s.kenModels = 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 ~/.gg/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 ~/.gg/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, ".gg", "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/**", "**/.gg/**"],
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 .gg/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 ggcoder 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 `ggcoder --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, loadKenModelPref, loadAutopilot, } from "./app-sidecar/app-settings.js";
64
+ import { KEN_ALLOWED_TOOLS } from "./app-sidecar/ken-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,7 @@ 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);
827
204
  /** Resolve the target session id: the `x-gg-session` header, else a
828
205
  * `?session=` query param (used by the SSE /events connection). */
829
206
  function sessionIdFromReq(req, url) {
@@ -1046,168 +423,6 @@ async function main() {
1046
423
  }, 1_000);
1047
424
  parentWatch.unref?.();
1048
425
  }
1049
- /** Ken's read-only tool allow-list. Excludes every mutating tool (write/edit/
1050
- * bash/tasks/subagent/generate_image/enter_plan/exit_plan/task_*) so the mentor
1051
- * agent can research + see, but never change the repo. */
1052
- const KEN_ALLOWED_TOOLS = [
1053
- "read",
1054
- "grep",
1055
- "find",
1056
- "ls",
1057
- "source_path",
1058
- "web_fetch",
1059
- "web_search",
1060
- "screenshot",
1061
- // Local corpus of real repos: lets Ken verify against code that ships
1062
- // instead of assuming — core to how he's meant to work. Read-only.
1063
- "steroids",
1064
- ];
1065
- /** Extract the plain text of the most recent assistant message (Ken's reply).
1066
- * Strips tool-call / image blocks, returning just the prose Ken streamed. */
1067
- function lastAssistantText(messages) {
1068
- for (let i = messages.length - 1; i >= 0; i--) {
1069
- const m = messages[i];
1070
- if (m.role !== "assistant")
1071
- continue;
1072
- if (typeof m.content === "string")
1073
- return m.content;
1074
- return m.content
1075
- .map((c) => (c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : ""))
1076
- .join("");
1077
- }
1078
- return "";
1079
- }
1080
- /**
1081
- * Assemble Ken's context digest for one `@Ken` question: git/env + the build
1082
- * session's compaction summary + recent activity. Prepended to the user's
1083
- * question as Ken's prompt body each turn. Project docs (CLAUDE.md/AGENTS.md)
1084
- * are NOT here — they're folded into Ken's cached system prompt once per
1085
- * session instead (see ken-prompt.ts), so they hit the provider prompt cache
1086
- * instead of being re-sent uncached on every question. Workflow commands +
1087
- * autopilot-injected prompts are passed through so the digest labels them as
1088
- * what they are instead of user-authored asks.
1089
- */
1090
- function buildKenContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
1091
- return buildKenDigest({
1092
- question,
1093
- cwd,
1094
- gitBranch,
1095
- messages: buildSession.getMessages(),
1096
- verificationEvidence: buildSession.getVerificationEvidence(),
1097
- verificationProblem: buildSession.getVerificationProblem(),
1098
- workflowCommands,
1099
- injectedPrompts,
1100
- });
1101
- }
1102
- async function createProgressManager(agentDir, broadcastAll) {
1103
- // Boot: recovery chain main → backup → coding + chat session rebuild → empty.
1104
- const coderSessionsDir = path.join(agentDir, "sessions");
1105
- let file = await loadProgress({
1106
- rebuild: () => rebuildFromSessions([
1107
- coderSessionsDir,
1108
- ...CHAT_AGENT_IDS.map((agentId) => chatAgentSessionsDir(coderSessionsDir, agentId)),
1109
- ]),
1110
- });
1111
- // Don't re-celebrate an old levelUp event on boot.
1112
- let lastSeenNonce = file.lastEvent?.nonce ?? null;
1113
- log("INFO", "app-sidecar", "progress loaded", {
1114
- xp: String(file.xp),
1115
- level: String(levelForXp(file.xp)),
1116
- });
1117
- function snapshot() {
1118
- return buildSnapshot(file);
1119
- }
1120
- async function awardRun(cwd, runStartedAt, originId) {
1121
- try {
1122
- const now = Date.now();
1123
- const updated = await updateProgress(async (f) => {
1124
- const levelBefore = levelForXp(f.xp);
1125
- awardPrompt(f, now, cwd);
1126
- // Commit XP: probe repo root + HEAD, then score lastHead..HEAD bounded
1127
- // by the run window. First sight of a repo records HEAD, scores nothing.
1128
- const probe = await detectNewCommits(cwd, undefined, runStartedAt);
1129
- if (probe) {
1130
- const key = repoKey(probe.repoRoot);
1131
- const lastHead = f.repos[key]?.lastHead;
1132
- if (lastHead && lastHead !== probe.head) {
1133
- const detected = await detectNewCommits(cwd, lastHead, runStartedAt);
1134
- if (detected && detected.commits.length > 0) {
1135
- awardCommits(f, detected.commits, now, cwd);
1136
- }
1137
- }
1138
- f.repos[key] = { lastHead: probe.head };
1139
- }
1140
- // One combined lastEvent per run so other windows celebrate exactly once.
1141
- const levelAfter = levelForXp(f.xp);
1142
- const levelUp = levelAfter > levelBefore
1143
- ? { from: levelBefore, to: levelAfter, rankName: rankForLevel(levelAfter).name }
1144
- : null;
1145
- f.lastEvent = { nonce: randomUUID(), levelUp };
1146
- return { file: f, levelledUp: levelUp !== null };
1147
- });
1148
- file = updated;
1149
- lastSeenNonce = updated.lastEvent?.nonce ?? null;
1150
- broadcastAll(buildSnapshot(updated), originId);
1151
- }
1152
- catch (err) {
1153
- log("DEBUG", "app-sidecar", "progress award failed", {
1154
- message: err instanceof Error ? err.message : String(err),
1155
- });
1156
- }
1157
- }
1158
- // Watch ~/.gg (dir watch survives the atomic tmp+rename) for progress.json
1159
- // writes from other daemon processes; debounce, reload read-only, dedupe by nonce.
1160
- let watchDebounce = null;
1161
- let progressWatcher = null;
1162
- try {
1163
- const watcher = fsWatch(agentDir, (_event, filename) => {
1164
- if (filename !== "progress.json")
1165
- return;
1166
- if (watchDebounce)
1167
- clearTimeout(watchDebounce);
1168
- watchDebounce = setTimeout(() => {
1169
- void (async () => {
1170
- const reloaded = await peekProgress();
1171
- if (!reloaded)
1172
- return;
1173
- const nonce = reloaded.lastEvent?.nonce ?? null;
1174
- if (nonce === lastSeenNonce)
1175
- return;
1176
- file = reloaded;
1177
- lastSeenNonce = nonce;
1178
- broadcastAll(buildSnapshot(reloaded));
1179
- })();
1180
- }, 150);
1181
- });
1182
- watcher.unref();
1183
- progressWatcher = watcher;
1184
- }
1185
- catch (err) {
1186
- log("DEBUG", "app-sidecar", "progress watch unavailable", {
1187
- message: err instanceof Error ? err.message : String(err),
1188
- });
1189
- }
1190
- // Dispose closes the fs.watch handle (baseline #8: it was previously never
1191
- // closed — a per-daemon leak) and clears any pending debounce timer.
1192
- function dispose() {
1193
- if (watchDebounce) {
1194
- clearTimeout(watchDebounce);
1195
- watchDebounce = null;
1196
- }
1197
- try {
1198
- progressWatcher?.close();
1199
- }
1200
- catch {
1201
- // Already closed / never opened — nothing to do.
1202
- }
1203
- progressWatcher = null;
1204
- }
1205
- return { snapshot, awardRun, dispose };
1206
- }
1207
- /** Unknown or missing modes fall back to the coding agent. */
1208
- function parseWorkspaceMode(value) {
1209
- return value === "chat" || value === "motion" ? value : "code";
1210
- }
1211
426
  /**
1212
427
  * Build one in-process agent session: its AgentSession, SSE client set, event
1213
428
  * bridge, task runner, auth/login bridge, and the full HTTP route table exposed
@@ -1435,6 +650,8 @@ async function createSession(deps, opts) {
1435
650
  session = new AgentSession({
1436
651
  ...baseSessionOptions,
1437
652
  additionalTools: [askUserTool],
653
+ // A run must not end mid-plan with nothing submitted: nobody could review it.
654
+ planSubmissionGate: true,
1438
655
  // Plan mode belongs only to the coding agent.
1439
656
  onEnterPlan: async (reason) => {
1440
657
  deactivateApprovedPlan();
@@ -1513,204 +730,15 @@ async function createSession(deps, opts) {
1513
730
  })),
1514
731
  };
1515
732
  }
1516
- let hfPull = null;
1517
- const hfPullPayload = (s) => ({
1518
- repo: s.repo,
1519
- model: s.model,
1520
- tag: s.tag,
1521
- file: s.file,
1522
- sizeBytes: s.sizeBytes,
1523
- phase: s.phase,
1524
- percent: s.percent,
1525
- ...(s.detail ? { detail: s.detail } : {}),
1526
- ...(s.error ? { error: s.error } : {}),
733
+ // ── Hugging Face → Ollama pulls (see app-sidecar/hf-pull-manager.ts) ──
734
+ const hf = createHfPullManager({
735
+ paths,
736
+ broadcast,
737
+ broadcastAll,
738
+ scanLocalModels: (force) => scanLocalModels(force),
739
+ localStatePayload: () => localStatePayload(),
1527
740
  });
1528
- /** Stored HF token, if the user connected the huggingface provider. */
1529
- async function hfToken() {
1530
- try {
1531
- const auth = new AuthStorage(paths.authFile);
1532
- const creds = await auth.resolveCredentials("huggingface");
1533
- return creds.accessToken || undefined;
1534
- }
1535
- catch {
1536
- return undefined;
1537
- }
1538
- }
1539
- async function hfHubJson(pathname) {
1540
- const token = await hfToken();
1541
- const res = await fetch(`https://huggingface.co${pathname}`, {
1542
- headers: token ? { authorization: `Bearer ${token}` } : {},
1543
- signal: AbortSignal.timeout(10_000),
1544
- });
1545
- if (!res.ok)
1546
- throw new Error(`Hugging Face responded ${res.status}`);
1547
- return res.json();
1548
- }
1549
- /** Search the Hub for GGUF repos (what Ollama can pull). */
1550
- async function hfSearch(query) {
1551
- // `filter=gguf` (the tag the Hub applies to repos that actually contain
1552
- // GGUF files) — `library=gguf` also matches safetensors-only base repos,
1553
- // which then fail at pull time. `expand[]=gguf` confirms per row.
1554
- const params = new URLSearchParams({
1555
- search: query,
1556
- filter: "gguf",
1557
- sort: "downloads",
1558
- direction: "-1",
1559
- limit: "12",
1560
- });
1561
- for (const field of ["gguf", "downloads", "likes", "lastModified"]) {
1562
- params.append("expand[]", field);
1563
- }
1564
- const data = (await hfHubJson(`/api/models?${params.toString()}`));
1565
- return (Array.isArray(data) ? data : [])
1566
- .map(toHfSearchRow)
1567
- .filter((r) => r !== null);
1568
- }
1569
- /**
1570
- * Start `ollama pull hf.co/<repo>[:quant]`. Resolves once the child is
1571
- * spawned; progress streams as `hf_pull` events. The chosen quant comes from
1572
- * the repo's real file list — the client only ever sends a repo id, so there
1573
- * is no injection surface into argv.
1574
- */
1575
- async function startHfPull(repo) {
1576
- if (hfPull?.child) {
1577
- throw Object.assign(new Error("A download is already running."), { status: 409 });
1578
- }
1579
- // `recursive=true`: many repos (unsloth, mradermacher) keep quants in
1580
- // per-quant subfolders, and a flat listing reports them as GGUF-less.
1581
- const tree = (await hfHubJson(`/api/models/${repo}/tree/main?recursive=true`));
1582
- const files = (Array.isArray(tree) ? tree : []).flatMap((entry) => {
1583
- const e = entry;
1584
- // `type` guards a directory entry (size 0) from beating real files in the
1585
- // size fallback; the Hub marks folders as "directory".
1586
- if (e.type !== undefined && e.type !== "file")
1587
- return [];
1588
- if (typeof e.path !== "string" || !e.path.toLowerCase().endsWith(".gguf"))
1589
- return [];
1590
- const size = Number(e.lfs?.size ?? e.size ?? 0);
1591
- return [{ path: e.path, sizeBytes: Number.isFinite(size) ? size : 0 }];
1592
- });
1593
- const choice = pickGgufQuant(files);
1594
- if (!choice) {
1595
- const sharded = files.some((f) => isGgufShard(f.path));
1596
- throw Object.assign(new Error(sharded ? SHARDED_MESSAGE : "That repo has no GGUF file for Ollama to pull."), { status: 400 });
1597
- }
1598
- const model = `hf.co/${repo}${choice.tag ? `:${choice.tag}` : ""}`;
1599
- const token = await hfToken();
1600
- const state = {
1601
- repo,
1602
- model,
1603
- tag: choice.tag,
1604
- file: choice.file.path,
1605
- sizeBytes: choice.file.sizeBytes,
1606
- phase: "preparing",
1607
- percent: 0,
1608
- child: null,
1609
- };
1610
- hfPull = state;
1611
- broadcast("hf_pull", hfPullPayload(state));
1612
- // ollama prints progress to stderr (stdout on some builds); parse both.
1613
- let stderrTail = "";
1614
- let lastBroadcast = "";
1615
- const feed = (chunk) => {
1616
- // Once the pull is terminal (success, failure, or user cancel) stop
1617
- // parsing: a killed ollama dumps a burst of stderr that would otherwise
1618
- // spam the modal with garbage frames after the outcome is already shown.
1619
- if (state.phase === "success" || state.phase === "error")
1620
- return;
1621
- for (const line of chunk.split(/\r\n|\r|\n/)) {
1622
- const parsed = parseOllamaPullLine(line);
1623
- if (!parsed)
1624
- continue;
1625
- // A redrawn frame repeats `pulling manifest` next to live progress; it
1626
- // must not drag the modal back to "Contacting Ollama…".
1627
- if (!advancesPhase(state.phase, parsed.phase))
1628
- continue;
1629
- if (parsed.phase !== "error") {
1630
- state.phase = parsed.phase;
1631
- if (parsed.percent !== undefined)
1632
- state.percent = parsed.percent;
1633
- state.detail = parsed.detail;
1634
- }
1635
- // ollama redraws its TUI frame several times a second, mostly with
1636
- // identical numbers. Broadcasting each one re-rendered the modal for no
1637
- // visible change; only a frame that actually reads differently ships.
1638
- const next = JSON.stringify(hfPullPayload(state));
1639
- if (next === lastBroadcast)
1640
- continue;
1641
- lastBroadcast = next;
1642
- broadcast("hf_pull", hfPullPayload(state));
1643
- }
1644
- };
1645
- try {
1646
- const child = spawn("ollama", ["pull", model], {
1647
- env: { ...process.env, ...(token ? { HF_TOKEN: token } : {}) },
1648
- stdio: ["ignore", "pipe", "pipe"],
1649
- });
1650
- state.child = child;
1651
- child.stdout?.setEncoding("utf8");
1652
- child.stderr?.setEncoding("utf8");
1653
- child.stdout?.on("data", feed);
1654
- child.stderr?.on("data", (d) => {
1655
- stderrTail = (stderrTail + d).slice(-2000);
1656
- feed(d);
1657
- });
1658
- child.on("error", (err) => {
1659
- state.child = null;
1660
- state.phase = "error";
1661
- state.error =
1662
- err.code === "ENOENT"
1663
- ? "Ollama isn't installed (or isn't on PATH). Install it from ollama.com, then retry."
1664
- : `Could not start Ollama: ${err.message}`;
1665
- broadcast("hf_pull", hfPullPayload(state));
1666
- });
1667
- child.on("close", (code) => {
1668
- state.child = null;
1669
- if (state.phase === "success" || state.phase === "error")
1670
- return; // cancelled
1671
- if (code === 0) {
1672
- state.phase = "success";
1673
- state.percent = 100;
1674
- state.detail = undefined;
1675
- broadcast("hf_pull", hfPullPayload(state));
1676
- // The new model only exists to the app once Ollama lists it. Ollama's
1677
- // library is machine-wide, like the shared auth file, so every window
1678
- // gets the refresh — not just the one that ran the download.
1679
- void scanLocalModels(true)
1680
- .then(() => broadcastAll("models_change", { local: localStatePayload() }))
1681
- .catch(() => undefined);
1682
- }
1683
- else {
1684
- state.phase = "error";
1685
- state.error = state.error ?? explainPullFailure(stderrTail);
1686
- broadcast("hf_pull", hfPullPayload(state));
1687
- }
1688
- });
1689
- }
1690
- catch (err) {
1691
- state.child = null;
1692
- state.phase = "error";
1693
- state.error = `Could not start Ollama: ${err instanceof Error ? err.message : String(err)}`;
1694
- broadcast("hf_pull", hfPullPayload(state));
1695
- }
1696
- return hfPullPayload(state);
1697
- }
1698
- function cancelHfPull() {
1699
- const child = hfPull?.child;
1700
- if (!child)
1701
- return false;
1702
- // Terminal state FIRST, then the kill: the child's death rattle must not
1703
- // overwrite the clean "cancelled" outcome with raw stderr.
1704
- if (hfPull) {
1705
- hfPull.phase = "error";
1706
- hfPull.error = "Download cancelled.";
1707
- hfPull.detail = undefined;
1708
- hfPull.child = null;
1709
- broadcast("hf_pull", hfPullPayload(hfPull));
1710
- }
1711
- child.kill("SIGTERM");
1712
- return true;
1713
- }
741
+ const { hfPullPayload, hfSearch, startHfPull, cancelHfPull } = hf;
1714
742
  /**
1715
743
  * Why `modelId` can't be selected right now, or `undefined` when it can.
1716
744
  * Two real footguns get a clear answer instead of a mid-run provider error:
@@ -2101,6 +1129,47 @@ async function createSession(deps, opts) {
2101
1129
  pendingPlanContent = "";
2102
1130
  planGeneration++;
2103
1131
  }
1132
+ // Plan feedback (Ken's or the user's) is revised back in read-only plan
1133
+ // mode. exit_plan leaves plan mode, so without this a "revise the plan" run
1134
+ // had full write access and could start implementing before any approval.
1135
+ async function reenterPlanModeForRevision() {
1136
+ if (session.getPlanMode())
1137
+ return;
1138
+ deactivateApprovedPlan();
1139
+ await session.setPlanMode(true);
1140
+ broadcast("plan_mode", { active: true });
1141
+ log("INFO", "app-sidecar", "plan mode re-entered for revision");
1142
+ }
1143
+ // Plan generation last handed to the human review box (-1: none yet).
1144
+ let offeredPlanGeneration = -1;
1145
+ // The plan the human review box should show right now, or null. Only once
1146
+ // everything has settled — see shouldOfferPendingPlan.
1147
+ function pendingPlanForHuman(offeredGeneration) {
1148
+ const offer = shouldOfferPendingPlan({
1149
+ planPath: pendingPlanPath,
1150
+ generation: planGeneration,
1151
+ offeredGeneration,
1152
+ running,
1153
+ starting: runClaim.active,
1154
+ autopilotActive,
1155
+ queued: session.getQueuedCount(),
1156
+ });
1157
+ return offer && pendingPlanPath !== null
1158
+ ? { planPath: pendingPlanPath, content: pendingPlanContent }
1159
+ : null;
1160
+ }
1161
+ // Open the Accept / Feedback / Reject box once nothing else will act on the
1162
+ // submitted plan. Called at every settle point (turn end, queue drain, task
1163
+ // run end). Without it, a plan Ken didn't approve — handed back, review
1164
+ // failed, capped, cancelled, or skipped — sat pending with no way to act.
1165
+ function offerPendingPlan() {
1166
+ const plan = pendingPlanForHuman(offeredPlanGeneration);
1167
+ if (!plan)
1168
+ return;
1169
+ offeredPlanGeneration = planGeneration;
1170
+ log("INFO", "app-sidecar", "plan handed to user for review", { planPath: plan.planPath });
1171
+ broadcast("plan_review", plan);
1172
+ }
2104
1173
  // Workflow (prompt-template) commands: built-in + the project's custom
2105
1174
  // `.gg/commands/*.md`. Used to gate autopilot off command turns and to label
2106
1175
  // expanded templates in Ken's digests. Loaded fresh so a newly added custom
@@ -2422,233 +1491,11 @@ async function createSession(deps, opts) {
2422
1491
  broadcast("extras", footerExtras());
2423
1492
  }
2424
1493
  }
2425
- // ── Autopilot orchestration ─────────────────────────────────
2426
- // One review = prompt the kenAuto session with the review digest, read its
2427
- // final assistant text, parse a verdict. Returns null on failure (surfaced as
2428
- // an autopilot_error frame) so the cycle stops rather than looping blind.
2429
- // `originalRequest` is the user prompt that started the turn under review —
2430
- // pinned in the digest so it can't scroll out during multi-round cycles.
2431
- async function runAutopilotReview(originalRequest) {
2432
- autopilotReviewing = true;
2433
- broadcast("autopilot_review_start", {});
2434
- try {
2435
- const ken = await ensureKenAutoSession();
2436
- const digest = buildKenAutopilotContext({
2437
- cwd,
2438
- gitBranch,
2439
- messages: session.getMessages(),
2440
- verificationEvidence: session.getVerificationEvidence(),
2441
- verificationProblem: session.getVerificationProblem(),
2442
- originalRequest,
2443
- injectedPrompts: [...injectedAutopilotPrompts],
2444
- workflowCommands: await loadWorkflowCommandSpecs(),
2445
- });
2446
- await ken.prompt(digest);
2447
- return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
2448
- }
2449
- catch (err) {
2450
- if (!autopilotCancelled)
2451
- broadcastError("autopilot_error", "autopilot review failed", err);
2452
- return null;
2453
- }
2454
- finally {
2455
- autopilotReviewing = false;
2456
- // Apply any model switch that landed mid-review.
2457
- const pending = pendingKenAutoModel;
2458
- pendingKenAutoModel = null;
2459
- if (pending)
2460
- await syncKenAutoModel(pending.provider, pending.model);
2461
- }
2462
- }
2463
- // One PLAN review: like runAutopilotReview but the digest carries the
2464
- // submitted plan's markdown (`## Plan under review`) and the plan-review
2465
- // instruction — Ken judges the plan itself, not finished work. Returns null
2466
- // on failure; a failure caused by the user's own action racing the review
2467
- // (cancel or a manual Accept/Reject that bumped planGeneration) stays
2468
- // SILENT — no autopilot_error — because the user's decision already won.
2469
- async function runAutopilotPlanReview(originalRequest) {
2470
- const planPath = pendingPlanPath;
2471
- if (planPath === null)
2472
- return null;
2473
- const genAtStart = planGeneration;
2474
- autopilotReviewing = true;
2475
- broadcast("autopilot_review_start", {});
2476
- try {
2477
- const ken = await ensureKenAutoSession();
2478
- // Re-read the plan file (the run may have revised it in place); fall
2479
- // back to the content captured at exit_plan time.
2480
- const planContent = await fs.readFile(planPath, "utf-8").catch(() => pendingPlanContent);
2481
- const digest = buildKenAutopilotPlanContext({
2482
- cwd,
2483
- gitBranch,
2484
- messages: session.getMessages(),
2485
- verificationEvidence: session.getVerificationEvidence(),
2486
- verificationProblem: session.getVerificationProblem(),
2487
- originalRequest,
2488
- injectedPrompts: [...injectedAutopilotPrompts],
2489
- workflowCommands: await loadWorkflowCommandSpecs(),
2490
- planContent,
2491
- });
2492
- await ken.prompt(digest);
2493
- if (autopilotCancelled || planGeneration !== genAtStart)
2494
- return null;
2495
- return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
2496
- }
2497
- catch (err) {
2498
- // User action mid-review (manual Accept aborts the kenAuto run): drop
2499
- // the review silently — the user's decision supersedes Ken's.
2500
- if (autopilotCancelled || planGeneration !== genAtStart)
2501
- return null;
2502
- broadcastError("autopilot_error", "autopilot plan review failed", err);
2503
- return null;
2504
- }
2505
- finally {
2506
- autopilotReviewing = false;
2507
- // Apply any model switch that landed mid-review.
2508
- const pending = pendingKenAutoModel;
2509
- pendingKenAutoModel = null;
2510
- if (pending)
2511
- await syncKenAutoModel(pending.provider, pending.model);
2512
- }
2513
- }
2514
1494
  // The prompt fed to the fresh session after a plan is accepted — the SAME
2515
1495
  // string the webview sends on a manual Accept (see PlanReviewModal's accept
2516
1496
  // handler in gg-app/src/App.tsx). Keep the two in lockstep so auto- and
2517
1497
  // manual approval produce identical implementation turns.
2518
1498
  const IMPLEMENT_PLAN_PROMPT = "The plan has been approved. Implement it now, following each step in order.";
2519
- // Drive the review→prompt→review loop for one finished user turn. Only ever
2520
- // called after shouldStartAutopilotCycle approves the turn (POST /prompt or
2521
- // the stranded-queue drain) — never from the task runner, resume, /ken, or
2522
- // error paths, so there's no recursion and no guard tangle. The loop's
2523
- // control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
2524
- // every exit path is unit-tested; this only wires the real dependencies.
2525
- async function runAutopilotCycle(originalRequest) {
2526
- if (!autopilot || autopilotCancelled || session.getVerificationProblem())
2527
- return;
2528
- const generation = runLifecycle.begin(abortOwnedWork).generation;
2529
- pendingCancelDrain = null;
2530
- autopilotActive = true;
2531
- // Generation captured by the last plan review; acceptPlan re-checks it so
2532
- // a user Accept/Reject landing mid-review always wins.
2533
- let planGenAtReview = -1;
2534
- try {
2535
- await driveAutopilotCycle({
2536
- // A plan-pending cycle needs extra rounds: approve+implement and the
2537
- // post-implement work review each consume one, so +2 keeps a real fix
2538
- // round available.
2539
- maxRounds: pendingPlanPath !== null ? MAX_AUTOPILOT_ROUNDS + 2 : MAX_AUTOPILOT_ROUNDS,
2540
- isCancelled: () => autopilotCancelled,
2541
- verificationProblem: () => session.getVerificationProblem(),
2542
- // An injected run entering plan mode WITHOUT submitting (enter_plan,
2543
- // no exit_plan) halts the cycle — Ken never prompts into a read-only
2544
- // plan-mode session. A submitted plan takes the planPending branch.
2545
- isPlanMode: () => session.getPlanMode(),
2546
- planPending: () => pendingPlanPath !== null,
2547
- reviewPlan: async () => {
2548
- planGenAtReview = planGeneration;
2549
- return runAutopilotPlanReview(originalRequest);
2550
- },
2551
- // Auto-accept: the inlined POST /plan/accept body. Returns false when
2552
- // the plan generation moved since the review (user acted) — the cycle
2553
- // exits silently and the user's action stands.
2554
- acceptPlan: async (reason) => {
2555
- if (pendingPlanPath === null || planGeneration !== planGenAtReview)
2556
- return false;
2557
- const planPath = pendingPlanPath;
2558
- let planTotal;
2559
- try {
2560
- await session.newSession(true);
2561
- injectedAutopilotPrompts = [];
2562
- planTotal = await activateApprovedPlan(planPath);
2563
- }
2564
- catch (err) {
2565
- broadcastError("autopilot_error", "autopilot plan accept failed", err);
2566
- return false;
2567
- }
2568
- clearPendingPlan();
2569
- // Keep the approval marker ahead of the reset, then seed the reset
2570
- // with the sidecar's canonical count from the actual plan file.
2571
- broadcast("autopilot_plan_accepted", reason ? { reason } : {});
2572
- broadcast("session_reset", { planTotal });
2573
- broadcast("plan_progress", planProgressPayload());
2574
- // Persisted into the NEW session so a resume shows the marker.
2575
- void session.persistAutopilotMarker("plan_approved", { reason });
2576
- return true;
2577
- },
2578
- runImplement: () => {
2579
- // Autopilot-injected run: frame it so GG Coder knows no human is
2580
- // watching the implementation. Record the framed string so Ken's
2581
- // digest labels it as injected, not as the user's ask. The run_start
2582
- // label stays the clean prompt.
2583
- const framed = frameAutopilotInjection(IMPLEMENT_PLAN_PROMPT);
2584
- injectedAutopilotPrompts.push(framed);
2585
- return runAgent(IMPLEMENT_PLAN_PROMPT, () => session.prompt(framed, AUTOMATION_PROVENANCE));
2586
- },
2587
- // Lean context per user turn: wipe prior review history so each new
2588
- // turn starts cheap, while within this cycle the few review messages
2589
- // persist so Ken remembers what he already asked GG Coder to fix.
2590
- resetReviewer: async () => {
2591
- await kenAutoSession?.newSession().catch(() => { });
2592
- },
2593
- review: () => runAutopilotReview(originalRequest),
2594
- // prompt → record the injected body (so later digests label it as
2595
- // Ken's, not the user's), show a compact Ken-tinted marker (not the
2596
- // prompt body), then feed GG Coder bracketed by runAgent so the run
2597
- // streams normally; the shared finally never re-triggers autopilot,
2598
- // so this can't recurse.
2599
- onInjected: (body, round) => {
2600
- // A revision injection supersedes the pending plan — if the run
2601
- // resubmits via exit_plan, onExitPlan re-sets it (no-op for work-
2602
- // branch injections, where nothing is pending).
2603
- clearPendingPlan();
2604
- // Record the FRAMED string (what actually lands in the build session,
2605
- // see runPrompt) so Ken's digest matches and labels it as injected.
2606
- // The webview marker + persisted body stay the CLEAN prompt so the UI
2607
- // shows Ken's actual instruction, not the autopilot preamble.
2608
- injectedAutopilotPrompts.push(frameAutopilotInjection(body));
2609
- broadcast("autopilot_prompted", { round, body });
2610
- void session.persistAutopilotMarker("prompted", { body });
2611
- },
2612
- // Autopilot-injected run: GG Coder receives the framed prompt (no human
2613
- // is watching this turn) while run_start keeps the clean label.
2614
- runPrompt: (body) => runAgent(body, () => session.prompt(frameAutopilotInjection(body), AUTOMATION_PROVENANCE)),
2615
- emit: (event) => {
2616
- // Persist the terminal verdict marker so a resumed session renders the
2617
- // same Ken bubble the live run showed instead of dropping it or
2618
- // falling back to the raw verdict text (e.g. ALL_CLEAR).
2619
- if (event.type === "autopilot_done") {
2620
- // Broadcast the SAME copySeed the persisted marker will produce on
2621
- // resume, so the live all-clear wording matches the resumed one.
2622
- // Must use the PERSISTED count — that's what persistAutopilotMarker
2623
- // anchors against, and it trails the in-memory list after a run
2624
- // whose messages never made it to disk.
2625
- const seed = autopilotMarkerCopySeed({
2626
- version: 1,
2627
- phase: "done",
2628
- afterMessageCount: session.getPersistedTranscriptCount(),
2629
- ...event.data,
2630
- });
2631
- broadcast(event.type, { ...event.data, copySeed: seed });
2632
- void session.persistAutopilotMarker("done", event.data);
2633
- return;
2634
- }
2635
- broadcast(event.type, event.data);
2636
- if (event.type === "autopilot_human") {
2637
- void session.persistAutopilotMarker("human", { reason: event.data.reason });
2638
- }
2639
- else if (event.type === "autopilot_capped") {
2640
- void session.persistAutopilotMarker("capped");
2641
- }
2642
- // autopilot_ignored renders nothing live, so nothing is persisted either.
2643
- },
2644
- });
2645
- }
2646
- finally {
2647
- autopilotActive = false;
2648
- finishOwnedGeneration(generation, true, session.getVerificationProblem() ? "unverified" : "completed");
2649
- queueMicrotask(() => void runStrandedQueue());
2650
- }
2651
- }
2652
1499
  // ── Stranded-queue drain ───────────────────────────────
2653
1500
  // A prompt POSTed while an autopilot cycle is between injected runs (build
2654
1501
  // idle, Ken reviewing) queues — but the queue only drains INTO a running
@@ -2659,149 +1506,197 @@ async function createSession(deps, opts) {
2659
1506
  // gated review). Also covers the non-autopilot tail window: a message queued
2660
1507
  // after the run's last steering drain but before run_end.
2661
1508
  let drainingStrandedQueue = false;
2662
- async function runStrandedQueue() {
2663
- if (drainingStrandedQueue)
2664
- return;
2665
- drainingStrandedQueue = true;
2666
- try {
2667
- for (;;) {
2668
- if (running || autopilotActive)
2669
- return;
2670
- const next = session.takeNextQueuedMessage();
2671
- if (!next)
2672
- return;
2673
- broadcast("queued", {
2674
- count: session.getQueuedCount(),
2675
- messages: session.listQueuedMessages(),
2676
- });
2677
- if (!next.text.trim() && next.attachments.length === 0)
2678
- continue;
2679
- // A queued message draining as a fresh turn supersedes any pending
2680
- // plan, exactly like a direct POST /prompt turn.
2681
- clearPendingPlan();
2682
- const workflowCommand = next.attachments.length === 0 &&
2683
- isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
2684
- const assistantsBefore = countAssistantMessages(session.getMessages());
2685
- const messagesBefore = session.getMessages().length;
2686
- await runAgent(next.text, async () => {
2687
- if (next.attachments.length > 0) {
2688
- await session.promptWithAttachments(next.text, next.attachments);
2689
- }
2690
- else {
2691
- await session.prompt(next.text);
2692
- }
2693
- }, () => autopilot);
2694
- const decision = shouldStartAutopilotCycle({
2695
- enabled: autopilot,
2696
- cancelled: autopilotCancelled,
2697
- planMode: session.getPlanMode(),
2698
- // A submitted plan (exit_plan fired) routes into the PLAN review
2699
- // branch — the cycle reviews the plan itself instead of skipping.
2700
- planPending: pendingPlanPath !== null,
2701
- workflowCommand,
2702
- assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
2703
- // Skip the review API call outright for turns that only started a
2704
- // background process (dev server/watcher), ran a read-only lookup, or
2705
- // committed/pushed — Ken's autopilot contract already IGNOREs these,
2706
- // so there's no reason to pay for that verdict.
2707
- mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
2708
- });
2709
- if (decision.start) {
2710
- log("INFO", "app-sidecar", "autopilot cycle starting (queued turn)", {
2711
- kind: decision.kind,
2712
- });
2713
- await runAutopilotCycle(next.text);
2714
- }
2715
- else {
2716
- broadcast("autopilot_ignored", { reason: decision.reason });
2717
- log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
2718
- reason: decision.reason,
2719
- });
2720
- }
2721
- }
2722
- }
2723
- finally {
2724
- drainingStrandedQueue = false;
2725
- }
2726
- }
2727
1509
  // ── Task runner (project task list → sessions) ──────────────
2728
1510
  // Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
2729
1511
  // completion hint instructing the agent to mark the task done via the tasks
2730
1512
  // tool. Run-all advances to the next pending task after each run finishes.
2731
1513
  let taskRunAll = false;
2732
- async function runTaskById(taskId) {
2733
- const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
2734
- if (!task)
2735
- return false;
2736
- // Fresh session per task so one task's context never bleeds into the next.
2737
- await session.newSession();
2738
- deactivateApprovedPlan();
2739
- injectedAutopilotPrompts = [];
2740
- clearPendingPlan();
2741
- broadcast("session_reset", {});
2742
- markTaskInProgress(cwd, task.id);
2743
- broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
2744
- broadcast("task_start", { id: task.id, title: task.title });
2745
- // Persist the task header so a resumed task session shows what ran.
2746
- void session.persistAppMarker("task", { title: task.title }).catch(() => { });
2747
- const shortId = task.id.slice(0, 8);
2748
- const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
2749
- `tasks({ action: "done", id: "${shortId}" })`;
2750
- await runAgent(task.title, () => session.prompt(task.prompt + completionHint, AUTOMATION_PROVENANCE));
2751
- // The agent typically marks the task done via the tasks tool during the run;
2752
- // prune completed tasks and push the refreshed list so the modal drops them.
2753
- broadcast("tasks_list", { tasks: pruneDoneTasksSync(cwd) });
2754
- return true;
2755
- }
2756
- async function runTasks(startId, all) {
2757
- taskRunAll = all;
2758
- let currentId = startId ?? getNextPendingTask(cwd)?.id ?? null;
2759
- while (currentId) {
2760
- const ran = await runTaskById(currentId);
2761
- if (!ran || !taskRunAll)
2762
- break;
2763
- const next = getNextPendingTask(cwd);
2764
- currentId = next ? next.id : null;
2765
- // Brief pause between tasks (mirrors the CLI cadence).
2766
- if (currentId)
2767
- await new Promise((resolve) => setTimeout(resolve, 500));
2768
- }
2769
- taskRunAll = false;
2770
- broadcast("tasks_run_done", {});
2771
- }
2772
- // ── Provider auth (login) bridge ───────────────────────────
2773
- // OAuth login functions are interactive (open a URL, sometimes prompt for a
2774
- // pasted code). We run one at a time and surface every step over SSE so the
2775
- // webview can open the URL and collect a code via a modal. `pendingCode`
2776
- // resolves when the webview POSTs /auth/oauth/code.
2777
- let oauthInFlight = false;
2778
- let pendingCode = null;
2779
- function authCallbacks() {
2780
- return {
2781
- onOpenUrl: (url) => broadcast("auth_url", { url }),
2782
- onStatus: (message) => broadcast("auth_status", { message }),
2783
- onPromptCode: (message) => new Promise((resolve) => {
2784
- pendingCode = resolve;
2785
- broadcast("auth_need_code", { message });
2786
- }),
2787
- };
2788
- }
2789
- async function authStatusPayload() {
2790
- const providers = await Promise.all(AUTH_PROVIDERS.map(async (p) => {
2791
- const dual = dualAuthProvider(p.value);
2792
- // Which methods hold a credential right now. Dual-auth providers can hold
2793
- // both at once, and the app needs them separately: one "connected" bit
2794
- // cannot express "OAuth signed in, key also on file as backup", nor offer
2795
- // a per-method disconnect.
2796
- const oauthKey = dual?.oauthKey;
2797
- const hasOAuth = oauthKey ? await auth.hasCredentials(oauthKey) : false;
2798
- const apiKeyKeys = p.apiKeyVariants?.map((v) => v.key) ?? [p.value];
2799
- const hasApiKey = p.methods.includes("apikey")
2800
- ? (await Promise.all(apiKeyKeys.map((k) => auth.hasCredentials(k)))).some(Boolean)
2801
- : false;
2802
- const connectedMethods = [];
2803
- // OAuth-only providers (Anthropic/OpenAI/Gemini) store under the provider
2804
- // id itself, so `hasProviderAuth` is what proves their OAuth connection.
1514
+ // Moved to app-sidecar/autopilot-runner.ts
1515
+ const { runAutopilotCycle, runStrandedQueue, runTasks } = createAutopilotRunner({
1516
+ get autopilotReviewing() {
1517
+ return autopilotReviewing;
1518
+ },
1519
+ set autopilotReviewing(v) {
1520
+ autopilotReviewing = v;
1521
+ },
1522
+ get broadcast() {
1523
+ return broadcast;
1524
+ },
1525
+ get ensureKenAutoSession() {
1526
+ return ensureKenAutoSession;
1527
+ },
1528
+ get cwd() {
1529
+ return cwd;
1530
+ },
1531
+ get gitBranch() {
1532
+ return gitBranch;
1533
+ },
1534
+ set gitBranch(v) {
1535
+ gitBranch = v;
1536
+ },
1537
+ get session() {
1538
+ return session;
1539
+ },
1540
+ set session(v) {
1541
+ session = v;
1542
+ },
1543
+ get injectedAutopilotPrompts() {
1544
+ return injectedAutopilotPrompts;
1545
+ },
1546
+ set injectedAutopilotPrompts(v) {
1547
+ injectedAutopilotPrompts = v;
1548
+ },
1549
+ get loadWorkflowCommandSpecs() {
1550
+ return loadWorkflowCommandSpecs;
1551
+ },
1552
+ get autopilotCancelled() {
1553
+ return autopilotCancelled;
1554
+ },
1555
+ set autopilotCancelled(v) {
1556
+ autopilotCancelled = v;
1557
+ },
1558
+ get broadcastError() {
1559
+ return broadcastError;
1560
+ },
1561
+ get pendingKenAutoModel() {
1562
+ return pendingKenAutoModel;
1563
+ },
1564
+ set pendingKenAutoModel(v) {
1565
+ pendingKenAutoModel = v;
1566
+ },
1567
+ get syncKenAutoModel() {
1568
+ return syncKenAutoModel;
1569
+ },
1570
+ get pendingPlanPath() {
1571
+ return pendingPlanPath;
1572
+ },
1573
+ set pendingPlanPath(v) {
1574
+ pendingPlanPath = v;
1575
+ },
1576
+ get planGeneration() {
1577
+ return planGeneration;
1578
+ },
1579
+ set planGeneration(v) {
1580
+ planGeneration = v;
1581
+ },
1582
+ get pendingPlanContent() {
1583
+ return pendingPlanContent;
1584
+ },
1585
+ set pendingPlanContent(v) {
1586
+ pendingPlanContent = v;
1587
+ },
1588
+ get autopilot() {
1589
+ return autopilot;
1590
+ },
1591
+ set autopilot(v) {
1592
+ autopilot = v;
1593
+ },
1594
+ get runLifecycle() {
1595
+ return runLifecycle;
1596
+ },
1597
+ get abortOwnedWork() {
1598
+ return abortOwnedWork;
1599
+ },
1600
+ get pendingCancelDrain() {
1601
+ return pendingCancelDrain;
1602
+ },
1603
+ set pendingCancelDrain(v) {
1604
+ pendingCancelDrain = v;
1605
+ },
1606
+ get autopilotActive() {
1607
+ return autopilotActive;
1608
+ },
1609
+ set autopilotActive(v) {
1610
+ autopilotActive = v;
1611
+ },
1612
+ get MAX_AUTOPILOT_ROUNDS() {
1613
+ return MAX_AUTOPILOT_ROUNDS;
1614
+ },
1615
+ get activateApprovedPlan() {
1616
+ return activateApprovedPlan;
1617
+ },
1618
+ get clearPendingPlan() {
1619
+ return clearPendingPlan;
1620
+ },
1621
+ get planProgressPayload() {
1622
+ return planProgressPayload;
1623
+ },
1624
+ get IMPLEMENT_PLAN_PROMPT() {
1625
+ return IMPLEMENT_PLAN_PROMPT;
1626
+ },
1627
+ get runAgent() {
1628
+ return runAgent;
1629
+ },
1630
+ get kenAutoSession() {
1631
+ return kenAutoSession;
1632
+ },
1633
+ set kenAutoSession(v) {
1634
+ kenAutoSession = v;
1635
+ },
1636
+ get reenterPlanModeForRevision() {
1637
+ return reenterPlanModeForRevision;
1638
+ },
1639
+ get finishOwnedGeneration() {
1640
+ return finishOwnedGeneration;
1641
+ },
1642
+ get drainingStrandedQueue() {
1643
+ return drainingStrandedQueue;
1644
+ },
1645
+ set drainingStrandedQueue(v) {
1646
+ drainingStrandedQueue = v;
1647
+ },
1648
+ get running() {
1649
+ return running;
1650
+ },
1651
+ set running(v) {
1652
+ running = v;
1653
+ },
1654
+ get offerPendingPlan() {
1655
+ return offerPendingPlan;
1656
+ },
1657
+ get deactivateApprovedPlan() {
1658
+ return deactivateApprovedPlan;
1659
+ },
1660
+ get taskRunAll() {
1661
+ return taskRunAll;
1662
+ },
1663
+ set taskRunAll(v) {
1664
+ taskRunAll = v;
1665
+ },
1666
+ });
1667
+ // ── Provider auth (login) bridge ───────────────────────────
1668
+ // OAuth login functions are interactive (open a URL, sometimes prompt for a
1669
+ // pasted code). We run one at a time and surface every step over SSE so the
1670
+ // webview can open the URL and collect a code via a modal. `pendingCode`
1671
+ // resolves when the webview POSTs /auth/oauth/code.
1672
+ let oauthInFlight = false;
1673
+ let pendingCode = null;
1674
+ function authCallbacks() {
1675
+ return {
1676
+ onOpenUrl: (url) => broadcast("auth_url", { url }),
1677
+ onStatus: (message) => broadcast("auth_status", { message }),
1678
+ onPromptCode: (message) => new Promise((resolve) => {
1679
+ pendingCode = resolve;
1680
+ broadcast("auth_need_code", { message });
1681
+ }),
1682
+ };
1683
+ }
1684
+ async function authStatusPayload() {
1685
+ const providers = await Promise.all(AUTH_PROVIDERS.map(async (p) => {
1686
+ const dual = dualAuthProvider(p.value);
1687
+ // Which methods hold a credential right now. Dual-auth providers can hold
1688
+ // both at once, and the app needs them separately: one "connected" bit
1689
+ // cannot express "OAuth signed in, key also on file as backup", nor offer
1690
+ // a per-method disconnect.
1691
+ const oauthKey = dual?.oauthKey;
1692
+ const hasOAuth = oauthKey ? await auth.hasCredentials(oauthKey) : false;
1693
+ const apiKeyKeys = p.apiKeyVariants?.map((v) => v.key) ?? [p.value];
1694
+ const hasApiKey = p.methods.includes("apikey")
1695
+ ? (await Promise.all(apiKeyKeys.map((k) => auth.hasCredentials(k)))).some(Boolean)
1696
+ : false;
1697
+ const connectedMethods = [];
1698
+ // OAuth-only providers (Anthropic/OpenAI/Gemini) store under the provider
1699
+ // id itself, so `hasProviderAuth` is what proves their OAuth connection.
2805
1700
  if (p.methods.includes("oauth") &&
2806
1701
  (hasOAuth || (!dual && (await auth.hasCredentials(p.value)))))
2807
1702
  connectedMethods.push("oauth");
@@ -2902,2336 +1797,228 @@ async function createSession(deps, opts) {
2902
1797
  gitHubCI = next;
2903
1798
  broadcast("extras", footerExtras());
2904
1799
  });
2905
- function readBody(req, res) {
2906
- return readCappedBody(req, res);
2907
- }
2908
- function json(res, status, body) {
2909
- const payload = JSON.stringify(redactValue(body, { secrets: environmentSecrets(process.env) }));
2910
- res.writeHead(status, {
2911
- "content-type": "application/json",
2912
- });
2913
- res.end(payload);
2914
- }
1800
+ const routeCtx = {
1801
+ get session() {
1802
+ return session;
1803
+ },
1804
+ set session(v) {
1805
+ session = v;
1806
+ },
1807
+ mode,
1808
+ get chatAgent() {
1809
+ return chatAgent;
1810
+ },
1811
+ set chatAgent(v) {
1812
+ chatAgent = v;
1813
+ },
1814
+ get running() {
1815
+ return running;
1816
+ },
1817
+ set running(v) {
1818
+ running = v;
1819
+ },
1820
+ runLifecycle,
1821
+ get autopilot() {
1822
+ return autopilot;
1823
+ },
1824
+ set autopilot(v) {
1825
+ autopilot = v;
1826
+ },
1827
+ kenStatePayload,
1828
+ footerExtras,
1829
+ progress,
1830
+ memoryStore,
1831
+ jiwaStore,
1832
+ get clientSeq() {
1833
+ return clientSeq;
1834
+ },
1835
+ set clientSeq(v) {
1836
+ clientSeq = v;
1837
+ },
1838
+ clients,
1839
+ get autopilotActive() {
1840
+ return autopilotActive;
1841
+ },
1842
+ set autopilotActive(v) {
1843
+ autopilotActive = v;
1844
+ },
1845
+ pendingPlanForHuman,
1846
+ paths,
1847
+ host,
1848
+ cwd,
1849
+ asks,
1850
+ runClaim,
1851
+ broadcast,
1852
+ get scheduledRunActive() {
1853
+ return scheduledRunActive;
1854
+ },
1855
+ set scheduledRunActive(v) {
1856
+ scheduledRunActive = v;
1857
+ },
1858
+ loadWorkflowCommandSpecs,
1859
+ get autopilotCancelled() {
1860
+ return autopilotCancelled;
1861
+ },
1862
+ set autopilotCancelled(v) {
1863
+ autopilotCancelled = v;
1864
+ },
1865
+ clearPendingPlan,
1866
+ runAgent,
1867
+ reenterPlanModeForRevision,
1868
+ get pendingPlanPath() {
1869
+ return pendingPlanPath;
1870
+ },
1871
+ set pendingPlanPath(v) {
1872
+ pendingPlanPath = v;
1873
+ },
1874
+ runAutopilotCycle,
1875
+ runStrandedQueue,
1876
+ offerPendingPlan,
1877
+ get kenRunning() {
1878
+ return kenRunning;
1879
+ },
1880
+ set kenRunning(v) {
1881
+ kenRunning = v;
1882
+ },
1883
+ ensureKenSession,
1884
+ get gitBranch() {
1885
+ return gitBranch;
1886
+ },
1887
+ set gitBranch(v) {
1888
+ gitBranch = v;
1889
+ },
1890
+ get injectedAutopilotPrompts() {
1891
+ return injectedAutopilotPrompts;
1892
+ },
1893
+ set injectedAutopilotPrompts(v) {
1894
+ injectedAutopilotPrompts = v;
1895
+ },
1896
+ broadcastError,
1897
+ get pendingKenModel() {
1898
+ return pendingKenModel;
1899
+ },
1900
+ set pendingKenModel(v) {
1901
+ pendingKenModel = v;
1902
+ },
1903
+ syncKenModel,
1904
+ get kenAbort() {
1905
+ return kenAbort;
1906
+ },
1907
+ set kenAbort(v) {
1908
+ kenAbort = v;
1909
+ },
1910
+ get kenSession() {
1911
+ return kenSession;
1912
+ },
1913
+ set kenSession(v) {
1914
+ kenSession = v;
1915
+ },
1916
+ runTasks,
1917
+ auth,
1918
+ get localProbes() {
1919
+ return localProbes;
1920
+ },
1921
+ set localProbes(v) {
1922
+ localProbes = v;
1923
+ },
1924
+ localModelBlocker,
1925
+ get kenModelOverride() {
1926
+ return kenModelOverride;
1927
+ },
1928
+ set kenModelOverride(v) {
1929
+ kenModelOverride = v;
1930
+ },
1931
+ syncKenAutoModel,
1932
+ get autopilotReviewing() {
1933
+ return autopilotReviewing;
1934
+ },
1935
+ set autopilotReviewing(v) {
1936
+ autopilotReviewing = v;
1937
+ },
1938
+ get taskRunAll() {
1939
+ return taskRunAll;
1940
+ },
1941
+ set taskRunAll(v) {
1942
+ taskRunAll = v;
1943
+ },
1944
+ get kenAutoAbort() {
1945
+ return kenAutoAbort;
1946
+ },
1947
+ set kenAutoAbort(v) {
1948
+ kenAutoAbort = v;
1949
+ },
1950
+ get kenAutoSession() {
1951
+ return kenAutoSession;
1952
+ },
1953
+ set kenAutoSession(v) {
1954
+ kenAutoSession = v;
1955
+ },
1956
+ get pendingCancelDrain() {
1957
+ return pendingCancelDrain;
1958
+ },
1959
+ set pendingCancelDrain(v) {
1960
+ pendingCancelDrain = v;
1961
+ },
1962
+ CANCEL_TIMEOUT_MS,
1963
+ deactivateApprovedPlan,
1964
+ activateApprovedPlan,
1965
+ planProgressPayload,
1966
+ authStatusPayload,
1967
+ broadcastAll,
1968
+ get oauthInFlight() {
1969
+ return oauthInFlight;
1970
+ },
1971
+ set oauthInFlight(v) {
1972
+ oauthInFlight = v;
1973
+ },
1974
+ oauthInFlightProviders,
1975
+ authCallbacks,
1976
+ get pendingCode() {
1977
+ return pendingCode;
1978
+ },
1979
+ set pendingCode(v) {
1980
+ pendingCode = v;
1981
+ },
1982
+ elicitations,
1983
+ localStatePayload,
1984
+ scanLocalModels,
1985
+ get hfPull() {
1986
+ return hf.hfPull;
1987
+ },
1988
+ set hfPull(v) {
1989
+ hf.hfPull = v;
1990
+ },
1991
+ hfPullPayload,
1992
+ hfSearch,
1993
+ startHfPull,
1994
+ cancelHfPull,
1995
+ get serveController() {
1996
+ return serveController;
1997
+ },
1998
+ set serveController(v) {
1999
+ serveController = v;
2000
+ },
2001
+ };
2915
2002
  // OPTIONS/CORS preflight is handled at the daemon level before delegation.
2916
2003
  function handle(req, res, url, method) {
2917
- if (method === "GET" && url === "/state") {
2918
- const st = session.getState();
2919
- json(res, 200, {
2920
- ...st,
2921
- mode,
2922
- chatAgent,
2923
- running,
2924
- runState: runLifecycle.state,
2925
- ready: true,
2926
- thinkingLevel: session.getThinkingLevel() ?? null,
2927
- supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
2928
- supportsVideo: getModel(st.model)?.supportsVideo ?? false,
2929
- autopilot,
2930
- cacheExpiry: session.getCacheExpiryStatus(),
2931
- ...kenStatePayload(),
2932
- ...footerExtras(),
2933
- });
2934
- return;
2935
- }
2936
- if (method === "GET" && url === "/progress") {
2937
- json(res, 200, progress.snapshot());
2938
- return;
2939
- }
2940
- if (method === "GET" && url === "/memories") {
2941
- void memoryStore
2942
- .snapshot()
2943
- .then((snapshot) => json(res, 200, snapshot))
2944
- .catch((error) => {
2945
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2946
- });
2947
- return;
2948
- }
2949
- if (method === "DELETE" && url.startsWith("/memories/")) {
2950
- const id = decodeURIComponent(url.slice("/memories/".length));
2951
- if (!id) {
2952
- json(res, 400, { error: "memory id is required" });
2953
- return;
2954
- }
2955
- void memoryStore
2956
- .forget(id)
2957
- .then(() => memoryStore.snapshot())
2958
- .then((snapshot) => json(res, 200, snapshot))
2959
- .catch((error) => {
2960
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2961
- });
2962
- return;
2963
- }
2964
- if (method === "GET" && url === "/jiwa") {
2965
- void jiwaStore
2966
- .snapshot()
2967
- .then((snapshot) => json(res, 200, snapshot))
2968
- .catch((error) => {
2969
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2970
- });
2971
- return;
2972
- }
2973
- if (method === "DELETE" && url.startsWith("/jiwa/")) {
2974
- const id = decodeURIComponent(url.slice("/jiwa/".length));
2975
- if (!id) {
2976
- json(res, 400, { error: "Jiwa entry id is required" });
2977
- return;
2978
- }
2979
- void jiwaStore
2980
- .forget(id)
2981
- .then(() => jiwaStore.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 === "GET" && (url === "/events" || url.startsWith("/events?"))) {
2989
- res.writeHead(200, {
2990
- "content-type": "text/event-stream",
2991
- "cache-control": "no-cache",
2992
- connection: "keep-alive",
2993
- });
2994
- res.write(`retry: 1000\n\n`);
2995
- const client = { id: ++clientSeq, res };
2996
- clients.add(client);
2997
- const st = session.getState();
2998
- res.write(`data: ${JSON.stringify({
2999
- type: "ready",
3000
- data: {
3001
- ...st,
3002
- mode,
3003
- chatAgent,
3004
- running,
3005
- reviewPending: autopilotActive,
3006
- runState: runLifecycle.state,
3007
- thinkingLevel: session.getThinkingLevel() ?? null,
3008
- supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
3009
- supportsVideo: getModel(st.model)?.supportsVideo ?? false,
3010
- autopilot,
3011
- ...kenStatePayload(),
3012
- ...footerExtras(),
3013
- },
3014
- })}\n\n`);
3015
- const keepAlive = setInterval(() => res.write(`: ping\n\n`), 15000);
3016
- req.on("close", () => {
3017
- clearInterval(keepAlive);
3018
- clients.delete(client);
3019
- });
3020
- return;
3021
- }
3022
- if (method === "GET" && url === "/plugins") {
3023
- void listInstalledPlugins(paths.extensionsDir)
3024
- .then((plugins) => json(res, 200, { plugins }))
3025
- .catch((error) => {
3026
- json(res, 500, { error: error instanceof Error ? error.message : String(error) });
3027
- });
3028
- return;
3029
- }
3030
- if (method === "POST" && url === "/plugins/install") {
3031
- void readBody(req, res).then(async (raw) => {
3032
- if (raw === null)
3033
- return;
3034
- try {
3035
- const bundlePath = JSON.parse(raw).bundlePath;
3036
- if (typeof bundlePath !== "string" || !path.isAbsolute(bundlePath)) {
3037
- json(res, 400, { error: "bundlePath must be an absolute path" });
3038
- return;
3039
- }
3040
- const plugin = await installPlugin(bundlePath, paths.extensionsDir);
3041
- json(res, 200, { plugin, restartRequired: true });
3042
- }
3043
- catch (error) {
3044
- json(res, 400, { error: error instanceof Error ? error.message : String(error) });
3045
- }
3046
- });
3047
- return;
3048
- }
3049
- if (method === "DELETE" && url.startsWith("/plugins/")) {
3050
- const pluginId = decodeURIComponent(url.slice("/plugins/".length));
3051
- void removePlugin(pluginId, paths.extensionsDir)
3052
- .then(() => json(res, 200, { removed: pluginId, restartRequired: true }))
3053
- .catch((error) => {
3054
- json(res, 400, { error: error instanceof Error ? error.message : String(error) });
3055
- });
3056
- return;
3057
- }
3058
- if (method === "GET" && url === "/settings") {
3059
- // `configured` is true only when the user explicitly saved a projects root
3060
- // (the gg-app.json file exists with a value) — not when we fall back to the
3061
- // default. The home screen gates "Your Projects" on this.
3062
- void (async () => {
3063
- const s = await loadAppSettings();
3064
- let configured;
3065
- try {
3066
- const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
3067
- configured = typeof raw.projectsRoot === "string" && raw.projectsRoot.trim().length > 0;
3068
- }
3069
- catch {
3070
- configured = false;
3071
- }
3072
- // Only projectsRoot + configured flag are webview-facing; the
3073
- // per-project model map is internal persistence, never shipped out.
3074
- json(res, 200, { projectsRoot: s.projectsRoot, configured });
3075
- })();
2004
+ if (handleSessionRoutes(routeCtx, req, res, url, method))
3076
2005
  return;
3077
- }
3078
- if (method === "POST" && url === "/settings") {
3079
- void readBody(req, res).then(async (raw) => {
3080
- if (raw === null)
3081
- return;
3082
- let projectsRoot;
3083
- try {
3084
- projectsRoot = JSON.parse(raw).projectsRoot ?? "";
3085
- }
3086
- catch {
3087
- json(res, 400, { error: "invalid JSON body" });
3088
- return;
3089
- }
3090
- if (!projectsRoot.trim()) {
3091
- json(res, 400, { error: "projectsRoot is required" });
3092
- return;
3093
- }
3094
- // Read-modify-write so the per-project model map survives a projectsRoot
3095
- // change (a naive overwrite would drop every window's saved model).
3096
- const s = await loadAppSettings();
3097
- s.projectsRoot = projectsRoot;
3098
- await saveAppSettings(s);
3099
- json(res, 200, { projectsRoot });
3100
- });
2006
+ if (handleWorkspaceRoutes(routeCtx, req, res, url, method))
3101
2007
  return;
3102
- }
3103
- if (method === "POST" && url === "/create-project") {
3104
- void readBody(req, res).then(async (raw) => {
3105
- if (raw === null)
3106
- return;
3107
- let name;
3108
- try {
3109
- name = JSON.parse(raw).name ?? "";
3110
- }
3111
- catch {
3112
- json(res, 400, { error: "invalid JSON body" });
3113
- return;
3114
- }
3115
- name = name.trim();
3116
- if (!isValidProjectName(name)) {
3117
- json(res, 400, {
3118
- error: "Project name must be lowercase letters, digits, and dashes (e.g. my-project).",
3119
- });
3120
- return;
3121
- }
3122
- const { projectsRoot } = await loadAppSettings();
3123
- const dir = path.join(projectsRoot, name);
3124
- try {
3125
- // Refuse to clobber an existing directory.
3126
- const exists = await fs
3127
- .stat(dir)
3128
- .then(() => true)
3129
- .catch(() => false);
3130
- if (exists) {
3131
- json(res, 409, { error: `A folder named "${name}" already exists.` });
3132
- return;
3133
- }
3134
- await fs.mkdir(dir, { recursive: true });
3135
- json(res, 200, { path: dir });
3136
- }
3137
- catch (err) {
3138
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
3139
- }
3140
- });
2008
+ if (handleHistoryRoutes(routeCtx, req, res, url, method))
3141
2009
  return;
3142
- }
3143
- // Dismiss a project from the picker (or restore it with hidden:false). The
3144
- // path is kept rather than the row: sessions in scratch dirs keep
3145
- // re-surfacing, so the user's decision has to outlive any one scan.
3146
- if (method === "POST" && url === "/projects/hidden") {
3147
- void readBody(req, res).then(async (raw) => {
3148
- if (raw === null)
3149
- return;
3150
- let body;
3151
- try {
3152
- body = JSON.parse(raw);
3153
- }
3154
- catch {
3155
- json(res, 400, { error: "invalid JSON body" });
3156
- return;
3157
- }
3158
- const target = body.path?.trim();
3159
- if (!target) {
3160
- json(res, 400, { error: "path is required" });
3161
- return;
3162
- }
3163
- const key = path.resolve(target);
3164
- try {
3165
- const settings = await loadAppSettings();
3166
- const current = new Set((settings.hiddenProjects ?? []).map((p) => path.resolve(p)));
3167
- if (body.hidden === false)
3168
- current.delete(key);
3169
- else
3170
- current.add(key);
3171
- settings.hiddenProjects = current.size > 0 ? Array.from(current) : undefined;
3172
- await saveAppSettings(settings);
3173
- json(res, 200, { hidden: Array.from(current) });
3174
- }
3175
- catch (err) {
3176
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
3177
- }
3178
- });
2010
+ if (handlePromptRoutes(routeCtx, req, res, url, method))
3179
2011
  return;
3180
- }
3181
- if (method === "GET" && url === "/projects") {
3182
- // Session stores (ggcoder + Claude Code + Codex) for projects with
3183
- // history, plus a filesystem scan of the configured projects folder so
3184
- // projects you have not opened yet are still listed.
3185
- void loadAppSettings()
3186
- .then(({ projectsRoot, projectRoots, hiddenProjects }) => discoverProjects({
3187
- projectsRoot,
3188
- extraRoots: projectRoots,
3189
- hiddenPaths: hiddenProjects,
3190
- }))
3191
- .then((projects) => json(res, 200, { projects }))
3192
- .catch((err) => {
3193
- log("ERROR", "app-sidecar", "discoverProjects failed", {
3194
- message: err instanceof Error ? err.message : String(err),
3195
- });
3196
- json(res, 200, { projects: [] });
3197
- });
2012
+ if (handleTaskRoutes(routeCtx, req, res, url, method))
3198
2013
  return;
3199
- }
3200
- if (method === "GET" && url.startsWith("/sessions")) {
3201
- const target = new URL(url, `http://${host}`).searchParams.get("cwd");
3202
- if (!target) {
3203
- json(res, 400, { error: "missing cwd query param" });
3204
- return;
3205
- }
3206
- const requestedAgent = new URL(url, `http://${host}`).searchParams.get("chatAgent");
3207
- // An omitted chatAgent means coding history; chat callers identify one
3208
- // agent or request the combined, recency-sorted "all" listing; the
3209
- // reserved value "motion" lists Motion sessions.
3210
- void listSidecarSessions(target, requestedAgent, paths.sessionsDir)
3211
- .then((sessions) => json(res, 200, { sessions }))
3212
- .catch(() => json(res, 200, { sessions: [] }));
2014
+ if (handleRunControlRoutes(routeCtx, req, res, url, method))
3213
2015
  return;
3214
- }
3215
- if (method === "GET" && url.startsWith("/files")) {
3216
- const q = new URL(url, `http://${host}`).searchParams.get("q") ?? "";
3217
- void searchProjectFiles(cwd, q)
3218
- .then((files) => json(res, 200, { files }))
3219
- .catch((err) => {
3220
- log("ERROR", "app-sidecar", "searchProjectFiles failed", {
3221
- message: err instanceof Error ? err.message : String(err),
3222
- });
3223
- json(res, 200, { files: [] });
3224
- });
2016
+ if (handleAuthRoutes(routeCtx, req, res, url, method))
3225
2017
  return;
3226
- }
3227
- // Markdown transcript export for the app's download button. Serialized
3228
- // here rather than in the webview because the webview's transcript model
3229
- // deliberately keeps tool activity in the LiveToolPanel — exporting from
3230
- // there would hand the user a coding session with the coding missing.
3231
- // `?name=1` asks for the suggested filename only (the save dialog needs it
3232
- // before there is a path), so the markdown never crosses IPC twice.
3233
- if (method === "GET" && (url === "/export" || url.startsWith("/export?"))) {
3234
- const query = new URLSearchParams(url.slice(url.indexOf("?") + 1));
3235
- const st = session.getState();
3236
- const filename = defaultExportFilename(mode);
3237
- if (query.get("name") === "1") {
3238
- json(res, 200, { filename });
3239
- return;
3240
- }
3241
- const markdown = sessionToMarkdown({
3242
- mode,
3243
- cwd,
3244
- provider: st.provider,
3245
- model: st.model,
3246
- ...(st.sessionId ? { sessionId: st.sessionId } : {}),
3247
- }, session.getMessages(), { toolDetail: parseToolDetail(query.get("tools")) });
3248
- json(res, 200, { filename, markdown });
2018
+ if (handleIntegrationRoutes(routeCtx, req, res, url, method))
3249
2019
  return;
3250
- }
3251
- if (method === "GET" && url === "/history") {
3252
- // Reconstruct the transcript from persisted messages so resume is 1:1 with
3253
- // the live SSE stream. Walks ALL message types (not just user/assistant):
3254
- // tool result messages carry ImageContent blocks (screenshots,
3255
- // generate_image) that must re-render inline, and assistant tool_call
3256
- // blocks carry sub-agent delegations that must re-appear as group items.
3257
- //
3258
- // The `details` object (imagePreviews with path + downscaled preview) is
3259
- // event-only and never persisted — we reconstruct from the raw
3260
- // ImageContent in the tool result, downsampling on the sidecar side and
3261
- // extracting the path from the text block ("Generated image → /path").
3262
- void (async () => {
3263
- const commandCandidates = [...PROMPT_COMMANDS, ...(await loadCustomCommands(cwd))];
3264
- const messages = session.getMessages();
3265
- // An Ideal hook is injected immediately after the assistant's candidate
3266
- // no-tool response. That response is review scratch, not a user-visible
3267
- // final answer. Live SSE drops it when the hook event arrives; mark the
3268
- // same assistant message here so resumed history stays identical.
3269
- const hiddenIdealDrafts = new Set();
3270
- for (let i = 0; i < messages.length - 1; i++) {
3271
- const draft = messages[i];
3272
- const hookPrompt = messages[i + 1];
3273
- if (draft?.role !== "assistant" || hookPrompt?.role !== "user")
3274
- continue;
3275
- const restored = restoreUserRow(hookPrompt.content);
3276
- if (detectHookKind(restored.text) === "ideal")
3277
- hiddenIdealDrafts.add(draft);
3278
- }
3279
- // Pre-index tool results by toolCallId so we can pair tool calls with
3280
- // their results (for sub-agent status + image extraction).
3281
- const toolResultMap = new Map();
3282
- // Tool name per call, so restore only trusts generate_image's own text
3283
- // when it reads extra images from disk (an MCP server could fake it).
3284
- const toolNameById = new Map();
3285
- for (const msg of messages) {
3286
- if (msg.role === "assistant" && typeof msg.content !== "string") {
3287
- for (const c of msg.content) {
3288
- if (c.type === "tool_call")
3289
- toolNameById.set(c.id, c.name);
3290
- }
3291
- }
3292
- if (msg.role !== "tool")
3293
- continue;
3294
- for (const tr of msg.content) {
3295
- toolResultMap.set(tr.toolCallId, {
3296
- content: tr.content,
3297
- isError: tr.isError ?? false,
3298
- });
3299
- }
3300
- }
3301
- const history = [];
3302
- // Ken (mentor) turns to interleave: group by the non-system message count
3303
- // they were recorded after, so each lands right after that message. A
3304
- // turn becomes two wire rows: the `@Ken` question (user) + Ken's reply
3305
- // (assistant), both flagged `ken` so the webview tints them.
3306
- // Deduped; stale anchors are clamped to the last message (Ken turns
3307
- // carry real conversation, so they render at the end instead of
3308
- // vanishing).
3309
- const kenByCount = new Map();
3310
- for (const turn of normalizeKenTurnsForHistory(session.getKenTurns(), messages.filter((m) => m.role !== "system").length)) {
3311
- const list = kenByCount.get(turn.afterMessageCount) ?? [];
3312
- list.push(turn);
3313
- kenByCount.set(turn.afterMessageCount, list);
3314
- }
3315
- const flushKen = (count) => {
3316
- const turns = kenByCount.get(count);
3317
- if (!turns)
3318
- return;
3319
- kenByCount.delete(count);
3320
- for (const turn of turns) {
3321
- history.push({ role: "user", text: `@Ken ${turn.question}`, ken: true });
3322
- history.push({ role: "assistant", text: turn.reply, ken: true });
3323
- }
3324
- };
3325
- // Autopilot verdict markers to interleave, same anchor scheme as Ken
3326
- // turns — each becomes a single assistant row the webview renders
3327
- // exactly like the live `autopilot` item (never a raw verdict string).
3328
- // Normalization pulls anchors left over from an unrebased compaction
3329
- // back to where the marker was actually written, so stale all-clear
3330
- // bubbles no longer bunch at the bottom of a reopened session.
3331
- const restoredMessageCount = messages.filter((m) => m.role !== "system").length;
3332
- const autopilotByCount = new Map();
3333
- for (const marker of normalizeAutopilotMarkersForHistory(session.getAutopilotMarkers(), restoredMessageCount)) {
3334
- const list = autopilotByCount.get(marker.afterMessageCount) ?? [];
3335
- list.push(marker);
3336
- autopilotByCount.set(marker.afterMessageCount, list);
3337
- }
3338
- const flushAutopilot = (count) => {
3339
- const markers = autopilotByCount.get(count);
3340
- if (!markers)
3341
- return;
3342
- autopilotByCount.delete(count);
3343
- for (const marker of markers) {
3344
- history.push({
3345
- role: "assistant",
3346
- text: "",
3347
- autopilot: {
3348
- phase: marker.phase,
3349
- ...(marker.reason !== undefined ? { reason: marker.reason } : {}),
3350
- ...(marker.body !== undefined ? { body: marker.body } : {}),
3351
- copySeed: marker.copySeed,
3352
- },
3353
- });
3354
- }
3355
- };
3356
- // App transcript markers (plan banner / task header / error rows /
3357
- // user-bubble hints), same anchor scheme. user_hint markers don't
3358
- // become rows — they decorate the user row at their anchor instead.
3359
- const appMarkersByCount = new Map();
3360
- const userHintByCount = new Map();
3361
- // Compaction-count markers pair with compacted summary rows in file
3362
- // order (FIFO), not by anchor — the summary user message is what
3363
- // positions the notice.
3364
- const compactionCounts = [];
3365
- for (const marker of normalizeAppMarkersForHistory(session.getAppMarkers(), restoredMessageCount)) {
3366
- if (marker.kind === "user_hint") {
3367
- userHintByCount.set(marker.afterMessageCount, marker.data);
3368
- continue;
3369
- }
3370
- if (marker.kind === "compaction") {
3371
- const d = marker.data;
3372
- if (typeof d.originalCount === "number" && typeof d.newCount === "number") {
3373
- compactionCounts.push({ originalCount: d.originalCount, newCount: d.newCount });
3374
- }
3375
- continue;
3376
- }
3377
- const list = appMarkersByCount.get(marker.afterMessageCount) ?? [];
3378
- list.push(marker);
3379
- appMarkersByCount.set(marker.afterMessageCount, list);
3380
- }
3381
- const flushAppMarkers = (count) => {
3382
- const markers = appMarkersByCount.get(count);
3383
- if (!markers)
3384
- return;
3385
- appMarkersByCount.delete(count);
3386
- for (const marker of markers) {
3387
- const d = marker.data;
3388
- if (marker.kind === "plan") {
3389
- history.push({
3390
- role: "assistant",
3391
- text: "",
3392
- plan: { reason: typeof d.reason === "string" ? d.reason : "" },
3393
- });
3394
- }
3395
- else if (marker.kind === "task") {
3396
- history.push({
3397
- role: "assistant",
3398
- text: "",
3399
- task: { title: typeof d.title === "string" ? d.title : "" },
3400
- });
3401
- }
3402
- else if (marker.kind === "error" && typeof d.headline === "string") {
3403
- const error = restoreAppErrorPayload(d);
3404
- if (error)
3405
- history.push({ role: "assistant", text: "", error });
3406
- }
3407
- else if (marker.kind === "interrupted_run") {
3408
- // Rendered as an error row: the run's tools already changed the
3409
- // repo, so the user needs to see it and decide what to do. We
3410
- // never replay it — that would duplicate those changes.
3411
- history.push({
3412
- role: "assistant",
3413
- text: "",
3414
- error: {
3415
- scope: "interrupted_run",
3416
- headline: "A run was interrupted",
3417
- message: "GG Coder stopped mid-run, so this turn is incomplete. Any files its tools already changed are still on disk.",
3418
- guidance: "Review the working tree, then re-send the request if you still want it.",
3419
- },
3420
- });
3421
- }
3422
- }
3423
- };
3424
- let nonSystemCount = 0;
3425
- // Turns/markers recorded before any build message (anchor 0) render at
3426
- // the top.
3427
- flushKen(0);
3428
- flushAutopilot(0);
3429
- flushAppMarkers(0);
3430
- await replayMessagesInOrder(messages, async (msg, count) => {
3431
- nonSystemCount = count;
3432
- const visibility = getHistoryMessageVisibility(msg);
3433
- if (visibility === "hidden")
3434
- return;
3435
- if (msg.role === "tool") {
3436
- // Tool result messages: check for ImageContent blocks (screenshots,
3437
- // generated images) and emit a toolImages entry.
3438
- for (const tr of msg.content) {
3439
- if (typeof tr.content === "string")
3440
- continue;
3441
- const imageBlocks = tr.content.filter((c) => c.type === "image");
3442
- if (imageBlocks.length === 0)
3443
- continue;
3444
- // Extract the path from the text block so the restored image
3445
- // stays clickable (read / screenshot / generate_image formats).
3446
- const textBlock = tr.content.find((c) => c.type === "text" && "text" in c && typeof c.text === "string");
3447
- const textContent = textBlock && textBlock.type === "text" ? textBlock.text : "";
3448
- const imgPaths = extractToolImagePaths(textContent);
3449
- // Downscale each image for the webview preview. Paths pair with
3450
- // image blocks in order (a tool saves one file per image).
3451
- const toolImages = [];
3452
- let blockIndex = 0;
3453
- for (const block of imageBlocks) {
3454
- if (block.type !== "image")
3455
- continue;
3456
- const imgPath = imgPaths[blockIndex++];
3457
- const previewBuf = await downscaleForPreview(Buffer.from(block.data, "base64"));
3458
- toolImages.push({
3459
- src: `data:${block.mediaType};base64,${previewBuf.toString("base64")}`,
3460
- path: imgPath,
3461
- });
3462
- }
3463
- // generate_image persists only its first image's pixels; the
3464
- // rest exist only on disk, so preview them from their files.
3465
- // Skip silently if a file was moved or deleted since.
3466
- const extraPaths = toolNameById.get(tr.toolCallId) === "generate_image"
3467
- ? imgPaths.slice(blockIndex)
3468
- : [];
3469
- for (const extraPath of extraPaths) {
3470
- const mediaType = IMAGE_MEDIA_TYPES[path.extname(extraPath).toLowerCase()];
3471
- if (!mediaType)
3472
- continue;
3473
- const raw = await readFileBounded(extraPath).catch(() => null);
3474
- if (!raw)
3475
- continue;
3476
- const previewBuf = await downscaleForPreview(raw);
3477
- toolImages.push({
3478
- src: `data:${mediaType};base64,${previewBuf.toString("base64")}`,
3479
- path: extraPath,
3480
- });
3481
- }
3482
- if (toolImages.length > 0) {
3483
- history.push({
3484
- role: "assistant",
3485
- text: "",
3486
- toolImages,
3487
- });
3488
- }
3489
- }
3490
- return;
3491
- }
3492
- // User or assistant message — text/hook/command/compacted extraction,
3493
- // plus sub-agent group detection for assistant tool_calls.
3494
- if (msg.role === "user") {
3495
- // Rebuild the live bubble: strip the steering wrapper, drop
3496
- // attachment/file notes the model saw but the bubble never showed.
3497
- const restored = restoreUserRow(msg.content, msg.provenance);
3498
- const text = restored.text;
3499
- const hook = msg.provenance ? null : detectHookKind(text);
3500
- const compacted = visibility === "summary" ||
3501
- (!msg.provenance && !hook && text.startsWith("[Previous conversation summary]"));
3502
- const hint = userHintByCount.get(nonSystemCount);
3503
- // The typed invocation persisted alongside the prompt is
3504
- // authoritative. Reversing the expanded body only works while the
3505
- // template is byte-identical, and templates drift (edited
3506
- // `.gg/commands/*.md`, reworded built-ins, app-vs-CLI phrasing) —
3507
- // after which the resumed session dumped the raw multi-KB body
3508
- // instead of the `/name` chip. Older sessions have no hint, so the
3509
- // body match stays as the fallback.
3510
- const command = !hook && !compacted
3511
- ? resolveRestoredCommand(typeof hint?.command === "string" ? hint.command : null, text, commandCandidates)
3512
- : null;
3513
- // Autopilot injected this turn — live showed only the Ken-tinted
3514
- // marker for it, never a user bubble. Emitting one here would print
3515
- // the injected instruction a second time, unstyled.
3516
- //
3517
- // Pushed background-status updates are skipped for the same reason:
3518
- // the live run rendered no bubble for them, so showing them here
3519
- // would fill a reopened session with machine-facing status lines
3520
- // the user never saw while working.
3521
- if ((!msg.provenance || (!restored.autopilotInjected && !restored.notification)) &&
3522
- (text.trim() || restored.images.length > 0)) {
3523
- history.push({
3524
- role: "user",
3525
- text: command ?? text,
3526
- images: restored.images,
3527
- hook,
3528
- command: command !== null,
3529
- compacted,
3530
- // Markers accumulate across continuation files (each rewrite
3531
- // re-persists prior ones) but only the LATEST summary row
3532
- // survives compaction — so consume from the newest end.
3533
- ...(compacted && compactionCounts.length > 0
3534
- ? { compactionCounts: compactionCounts.pop() }
3535
- : {}),
3536
- ...(hint?.kenSent === true ? { kenSent: true } : {}),
3537
- ...(Array.isArray(hint?.enhancements) ? { enhancements: hint.enhancements } : {}),
3538
- });
3539
- // Live showed the video-capability warning right after the bubble.
3540
- if (restored.videoWarning) {
3541
- history.push({ role: "assistant", text: "", infoKind: "video_warning" });
3542
- }
3543
- }
3544
- }
3545
- else if (!hiddenIdealDrafts.has(msg)) {
3546
- // Assistant: one wire row per persisted text block — live streaming
3547
- // splits bubbles at server_tool_call boundaries, and the persisted
3548
- // content keeps those blocks separate. Ideal-review candidate drafts
3549
- // are intentionally omitted to match the live pre-final hook flow.
3550
- for (const blockText of restoreAssistantTexts(msg.content)) {
3551
- history.push({
3552
- role: "assistant",
3553
- text: blockText,
3554
- images: [],
3555
- hook: null,
3556
- command: false,
3557
- compacted: false,
3558
- });
3559
- }
3560
- }
3561
- // Assistant tool_call blocks: detect sub-agent delegations.
3562
- if (msg.role === "assistant" && typeof msg.content !== "string") {
3563
- const subagentCalls = msg.content.filter((c) => c.type === "tool_call" && (c.name === "subagent" || c.name === "spawn_agent"));
3564
- if (subagentCalls.length > 0) {
3565
- const agents = subagentCalls.flatMap((c) => {
3566
- const result = toolResultMap.get(c.id);
3567
- // Async workers are intentionally non-resumable; restored rows are historical.
3568
- const status = result?.isError ? "error" : "done";
3569
- if (c.name === "spawn_agent") {
3570
- // One row per child: a batch call starts several.
3571
- return spawnedTasks(c.args).map((spawn) => ({
3572
- agentName: spawn.task_name ?? spawn.agent,
3573
- status,
3574
- toolUseCount: 0,
3575
- }));
3576
- }
3577
- return [
3578
- {
3579
- agentName: typeof c.args?.agent === "string" ? c.args.agent : undefined,
3580
- status,
3581
- toolUseCount: 0,
3582
- },
3583
- ];
3584
- });
3585
- history.push({
3586
- role: "assistant",
3587
- text: "",
3588
- subagentGroup: agents,
3589
- });
3590
- }
3591
- }
3592
- }, (count) => {
3593
- // Markers flush after every physical message, including tools and
3594
- // provenance-hidden runtime context.
3595
- flushKen(count);
3596
- flushAutopilot(count);
3597
- flushAppMarkers(count);
3598
- });
3599
- // Flush remaining Ken turns whose anchor is at/after the message count so
3600
- // none are dropped. Autopilot/app markers beyond the restored message
3601
- // count were already filtered above; any remaining marker here is valid.
3602
- for (const count of [...kenByCount.keys()].sort((a, b) => a - b))
3603
- flushKen(count);
3604
- for (const count of [...autopilotByCount.keys()].sort((a, b) => a - b))
3605
- flushAutopilot(count);
3606
- for (const count of [...appMarkersByCount.keys()].sort((a, b) => a - b))
3607
- flushAppMarkers(count);
3608
- json(res, 200, { history });
3609
- })();
2020
+ if (handleMcpRoutes(routeCtx, req, res, url, method))
3610
2021
  return;
3611
- }
3612
- if (method === "GET" && url === "/commands") {
3613
- if (mode !== "code") {
3614
- json(res, 200, { commands: [] });
3615
- return;
3616
- }
3617
- // Workflow commands with agent functionality: built-in prompt templates +
3618
- // the user's own `.gg/commands/*.md`.
3619
- void (async () => {
3620
- const builtins = PROMPT_COMMANDS.map((c) => ({
3621
- name: c.name,
3622
- aliases: c.aliases,
3623
- description: c.description,
3624
- source: "built-in",
3625
- }));
3626
- // Desktop-safe registry actions belong in the same picker as workflows.
3627
- // Most registry commands have dedicated app controls or TUI-only flows;
3628
- // multi-root management has no other affordance, so expose only these.
3629
- const workspaceActions = [
3630
- {
3631
- name: "add-dir",
3632
- aliases: ["adddir"],
3633
- description: "Add another project folder to this workspace",
3634
- source: "built-in",
3635
- },
3636
- {
3637
- name: "remove-dir",
3638
- aliases: ["removedir"],
3639
- description: "Remove an added project folder from this workspace",
3640
- source: "built-in",
3641
- },
3642
- ];
3643
- const custom = (await loadCustomCommands(cwd))
3644
- // A custom command can't shadow a built-in name or app action.
3645
- .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name) &&
3646
- !workspaceActions.some((action) => action.name === c.name))
3647
- .map((c) => ({
3648
- name: c.name,
3649
- aliases: [],
3650
- description: c.description,
3651
- source: "custom",
3652
- }));
3653
- json(res, 200, { commands: [...workspaceActions, ...builtins, ...custom] });
3654
- })();
3655
- return;
3656
- }
3657
- if (method === "POST" && url === "/prompt") {
3658
- // Per-request: only the prompt that actually claimed the start may release
3659
- // it. A bare release would let an early-returning request (bad JSON, or a
3660
- // prompt that queued) clear a claim another request is still holding.
3661
- let claimedStart = false;
3662
- void readBody(req, res)
3663
- .then(async (raw) => {
3664
- if (raw === null)
3665
- return;
3666
- let text;
3667
- let attachments;
3668
- let meta;
3669
- try {
3670
- const body = JSON.parse(raw);
3671
- text = body.text ?? "";
3672
- attachments = Array.isArray(body.attachments) ? body.attachments : [];
3673
- meta = typeof body.meta === "object" && body.meta !== null ? body.meta : undefined;
3674
- }
3675
- catch {
3676
- json(res, 400, { error: "invalid JSON body" });
3677
- return;
3678
- }
3679
- if (!text.trim() && attachments.length === 0) {
3680
- json(res, 400, { error: "empty prompt" });
3681
- return;
3682
- }
3683
- // A typed prompt supersedes any question parked on the user: they
3684
- // answered with a message of their own. Release the blocked tool call
3685
- // NOW — otherwise it waits out its ten-minute timeout while this very
3686
- // message sits behind it as steering that only drains once the tool
3687
- // returns, so the turn looks frozen. The webview closes the band on
3688
- // send; a racing /ask POST just 409s.
3689
- asks.cancelAll({ action: "cancel", superseded: true });
3690
- if (runLifecycle.running &&
3691
- runLifecycle.isCancellationRequested(runLifecycle.generation)) {
3692
- json(res, 409, {
3693
- error: runLifecycle.state === "cancelling" ? "run_cancelling" : "cancel_failed",
3694
- runState: runLifecycle.state,
3695
- });
3696
- return;
3697
- }
3698
- // `runClaim` covers the gap before `runAgent` flips `running`: a
3699
- // prompt arriving in that window must queue, not start a second run.
3700
- if (running || runClaim.active || autopilotActive) {
3701
- // Queue prompts as mid-run steering (mirrors the CLI). Also queue while
3702
- // an autopilot cycle is active but between injected runs (build idle,
3703
- // Ken reviewing) so the message never starts a run that collides with
3704
- // an injected one on the same session. Attachments are persisted to
3705
- // .gg/uploads first so the queued media rides the same native-block
3706
- // path as a non-queued attachment prompt when it drains.
3707
- const prepared = attachments.length > 0 ? await prepareAttachments(cwd, attachments) : [];
3708
- const count = session.queueMessage(text, prepared);
3709
- broadcast("queued", { count, messages: session.listQueuedMessages() });
3710
- json(res, 202, { queued: true, count });
3711
- return;
3712
- }
3713
- // Claim the run NOW, synchronously. Everything below this line may
3714
- // yield, and `running` does not flip until runAgent begins.
3715
- claimedStart = runClaim.claim();
3716
- // A scheduled prompt has nobody watching: short ask deadline.
3717
- scheduledRunActive = meta?.scheduled === true;
3718
- json(res, 202, { accepted: true });
3719
- // Gate inputs captured around the run: whether this turn is a workflow
3720
- // slash command (attachment prompts skip slash expansion entirely), and
3721
- // how many assistant messages the run actually adds. Computed even when
3722
- // autopilot is currently off — the toggle can flip ON mid-run, and the
3723
- // gate reads the post-run value.
3724
- const workflowCommand = attachments.length === 0 &&
3725
- isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
3726
- // Does this input actually expand into a persisted user message? Asked
3727
- // of the session itself, because only it knows whether the command
3728
- // resolves here (name/alias casing, custom `.gg/commands`, non-coder
3729
- // agents that don't expand at all). A looser guess would anchor the
3730
- // hint at +1 with no message to land on — decorating an unrelated
3731
- // later bubble with the wrong `/name`.
3732
- const expandsToTemplate = attachments.length === 0 && (await session.willExpandPromptTemplate(text));
3733
- // Webview display hint for this prompt's user bubble (kenSent shimmer
3734
- // label / enhancer highlight segments / the `/name` a command was typed
3735
- // as). Anchored +1 so it attaches to the user message the prompt below
3736
- // is about to push. Queued prompts skip this (their position in the run
3737
- // is unpredictable).
3738
- //
3739
- // Recording the invocation matters because the agent persists the
3740
- // EXPANDED template as the user message. Resume used to recover
3741
- // `/name` by matching that body against the current templates, which
3742
- // silently fails the moment a template is edited or reworded — the
3743
- // reopened session then rendered the raw multi-KB prompt instead of
3744
- // the command chip.
3745
- if (expandsToTemplate ||
3746
- (meta && (meta.kenSent === true || Array.isArray(meta.enhancements)))) {
3747
- void session
3748
- .persistAppMarker("user_hint", {
3749
- ...(expandsToTemplate ? { command: text.trim() } : {}),
3750
- ...(meta?.kenSent === true ? { kenSent: true } : {}),
3751
- ...(Array.isArray(meta?.enhancements) ? { enhancements: meta.enhancements } : {}),
3752
- }, 1)
3753
- .catch(() => { });
3754
- }
3755
- // Fresh user turn: clear any cancel flag left from a prior cycle so this
3756
- // turn's autopilot review can run.
3757
- autopilotCancelled = false;
3758
- // A typed message while a plan modal/review is pending (reject,
3759
- // feedback, anything) supersedes the pending plan — the bump also
3760
- // invalidates any in-flight Ken plan review.
3761
- clearPendingPlan();
3762
- const assistantsBefore = countAssistantMessages(session.getMessages());
3763
- const messagesBefore = session.getMessages().length;
3764
- await runAgent(text, async () => {
3765
- if (attachments.length > 0) {
3766
- // Persist each attachment under .gg/uploads so files are inspectable
3767
- // by the agent's tools, then prompt with the media as native blocks.
3768
- const prepared = await prepareAttachments(cwd, attachments);
3769
- await session.promptWithAttachments(text, prepared);
3770
- }
3771
- else {
3772
- // Pass the raw text straight through. AgentSession.prompt() is the
3773
- // single source of truth for slash-command expansion (built-in +
3774
- // `.gg/commands/*.md` custom), so the agent gets the right body
3775
- // while the webview keeps showing the short `/name`.
3776
- await session.prompt(text);
3777
- }
3778
- }, () => autopilot);
3779
- // After the user's run settles, kick off Ken's auto-review loop — but
3780
- // only when the turn is actually reviewable (shouldStartAutopilotCycle):
3781
- // workflow commands (/compare, /expand, …) end with reports or
3782
- // A/B/C choices reserved for the USER; registry commands (/help) and
3783
- // failed runs add no assistant work to judge; a turn that ended in plan
3784
- // mode has a pending Accept/Reject modal Ken must not preempt. This is
3785
- // the ONLY entry point into the cycle besides the stranded-queue drain —
3786
- // it drives any follow-up GG Coder runs itself, so the shared runAgent
3787
- // finally never recurses.
3788
- const decision = shouldStartAutopilotCycle({
3789
- enabled: autopilot,
3790
- cancelled: autopilotCancelled,
3791
- planMode: session.getPlanMode(),
3792
- // A submitted plan (exit_plan fired) routes into the PLAN review
3793
- // branch — the cycle reviews the plan itself instead of skipping.
3794
- planPending: pendingPlanPath !== null,
3795
- workflowCommand,
3796
- assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
3797
- // Skip the review API call outright for turns that only started a
3798
- // background process (dev server/watcher), ran a read-only lookup, or
3799
- // committed/pushed — Ken's autopilot contract already IGNOREs these,
3800
- // so there's no reason to pay for that verdict.
3801
- mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
3802
- });
3803
- if (decision.start) {
3804
- log("INFO", "app-sidecar", "autopilot cycle starting", { kind: decision.kind });
3805
- await runAutopilotCycle(text);
3806
- }
3807
- else {
3808
- broadcast("autopilot_ignored", { reason: decision.reason });
3809
- log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
3810
- }
3811
- // A prompt sent while Ken was reviewing (build idle) queued but had no
3812
- // run to steer into — run it now as a fresh turn so it never strands.
3813
- await runStrandedQueue();
3814
- })
3815
- .finally(() => {
3816
- if (claimedStart) {
3817
- scheduledRunActive = false;
3818
- runClaim.release();
3819
- }
3820
- });
3821
- return;
3822
- }
3823
- // Ken Kai (mentor): an independent read-only advisory run on the kenSession.
3824
- // Runs concurrently with a build run — its events are ken_-prefixed so the
3825
- // webview keeps the bubbles separate. The context digest is assembled fresh
3826
- // from the BUILD session's transcript each turn (one-way mirror).
3827
- if (method === "POST" && url === "/ken/prompt") {
3828
- if (mode !== "code") {
3829
- json(res, 404, {
3830
- error: `Ken is not available in GG ${mode === "chat" ? "Chat" : "Motion"}.`,
3831
- });
3832
- return;
3833
- }
3834
- void readBody(req, res).then(async (raw) => {
3835
- if (raw === null)
3836
- return;
3837
- let text;
3838
- try {
3839
- text = JSON.parse(raw).text ?? "";
3840
- }
3841
- catch {
3842
- json(res, 400, { error: "invalid JSON body" });
3843
- return;
3844
- }
3845
- if (!text.trim()) {
3846
- json(res, 400, { error: "empty prompt" });
3847
- return;
3848
- }
3849
- if (kenRunning) {
3850
- json(res, 409, { error: "Ken is already thinking — wait for his reply." });
3851
- return;
3852
- }
3853
- json(res, 202, { accepted: true });
3854
- kenRunning = true;
3855
- const releaseKenAwake = keepAwake.acquire("ken");
3856
- broadcast("ken_run_start", { text });
3857
- try {
3858
- const ken = await ensureKenSession();
3859
- const digest = await buildKenContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
3860
- await ken.prompt(digest);
3861
- // Record the turn against the BUILD session so it persists + survives
3862
- // resume (advisory custom entry, never an LLM message). Reply is Ken's
3863
- // last assistant message; skip persistence if he produced nothing.
3864
- const reply = lastAssistantText(ken.getMessages());
3865
- if (reply.trim())
3866
- await session.persistKenTurn(text, reply);
3867
- }
3868
- catch (err) {
3869
- broadcastError("ken_error", "ken run failed", err);
3870
- }
3871
- finally {
3872
- releaseKenAwake();
3873
- kenRunning = false;
3874
- broadcast("ken_run_end", {});
3875
- const pending = pendingKenModel;
3876
- pendingKenModel = null;
3877
- if (pending)
3878
- await syncKenModel(pending.provider, pending.model);
3879
- }
3880
- });
3881
- return;
3882
- }
3883
- if (method === "POST" && url === "/ken/cancel") {
3884
- kenAbort.abort();
3885
- kenAbort = new AbortController();
3886
- kenSession?.setSignal(kenAbort.signal);
3887
- kenRunning = false;
3888
- broadcast("ken_run_end", { cancelled: true });
3889
- json(res, 200, { cancelled: true });
3890
- return;
3891
- }
3892
- if (method === "POST" && url === "/autopilot") {
3893
- if (mode !== "code") {
3894
- json(res, 404, {
3895
- error: `Autopilot is not available in GG ${mode === "chat" ? "Chat" : "Motion"}.`,
3896
- });
3897
- return;
3898
- }
3899
- void readBody(req, res).then(async (raw) => {
3900
- if (raw === null)
3901
- return;
3902
- let enabled;
3903
- try {
3904
- enabled = Boolean(JSON.parse(raw).enabled);
3905
- }
3906
- catch {
3907
- json(res, 400, { error: "invalid JSON body" });
3908
- return;
3909
- }
3910
- autopilot = enabled;
3911
- await saveAutopilot(cwd, enabled);
3912
- log("INFO", "app-sidecar", "autopilot toggled", { enabled: String(enabled) });
3913
- broadcast("autopilot", { autopilot: enabled });
3914
- json(res, 200, { autopilot: enabled });
3915
- });
3916
- return;
3917
- }
3918
- if (method === "POST" && url === "/enhance") {
3919
- void readBody(req, res).then(async (raw) => {
3920
- if (raw === null)
3921
- return;
3922
- let text;
3923
- try {
3924
- text = JSON.parse(raw).text ?? "";
3925
- }
3926
- catch {
3927
- json(res, 400, { error: "invalid JSON body" });
3928
- return;
3929
- }
3930
- if (!text.trim()) {
3931
- json(res, 400, { error: "empty prompt" });
3932
- return;
3933
- }
3934
- // An independent read-only LLM call — touches no session state, so it's
3935
- // allowed even while a run is in flight.
3936
- try {
3937
- const result = await session.enhancePrompt(text);
3938
- json(res, 200, result);
3939
- }
3940
- catch (err) {
3941
- const message = err instanceof Error ? err.message : String(err);
3942
- log("ERROR", "app-sidecar", "enhance failed", { message });
3943
- json(res, 500, { error: message });
3944
- }
3945
- });
3946
- return;
3947
- }
3948
- if (method === "GET" && url === "/tasks") {
3949
- json(res, 200, { tasks: pruneDoneTasksSync(cwd) });
3950
- return;
3951
- }
3952
- if (method === "GET" && url === "/checklist") {
3953
- const controller = new AbortController();
3954
- res.once("close", () => controller.abort());
3955
- void (async () => {
3956
- try {
3957
- const result = await readChecklistSnapshot(cwd, new Date(), controller.signal);
3958
- if (res.destroyed)
3959
- return;
3960
- if (!result.ok) {
3961
- log("WARN", "app-sidecar", "checklist read failed", { error: result.error });
3962
- json(res, 500, { error: result.error });
3963
- return;
3964
- }
3965
- json(res, 200, result.value);
3966
- }
3967
- catch (error) {
3968
- log("WARN", "app-sidecar", "checklist snapshot failed", { error: String(error) });
3969
- if (!res.destroyed)
3970
- json(res, 500, { error: "Could not read the project checklist" });
3971
- }
3972
- })();
3973
- return;
3974
- }
3975
- // ── Radio (app-wide) ──────────────────────────────────────
3976
- // Radio is now APP-WIDE: all windows share one daemon process, and the
3977
- // player lives in `core/radio.ts` module-level singletons (one stream for
3978
- // the whole app). Any window's /radio reads/controls that single stream —
3979
- // starting a station in one window replaces whatever was playing, and every
3980
- // window's footer reflects the same `current`. This intentionally prevents
3981
- // duplicate audio across windows (the original per-window goal), now for
3982
- // free. (To restore per-window radio, key playback by sessionId.)
3983
- if (method === "GET" && url === "/radio") {
3984
- json(res, 200, {
3985
- stations: RADIO_STATIONS,
3986
- current: getCurrentStation(),
3987
- volume: getRadioVolume(),
3988
- });
3989
- return;
3990
- }
3991
- if (method === "POST" && url === "/radio/volume") {
3992
- void readBody(req, res).then((raw) => {
3993
- if (raw === null)
3994
- return;
3995
- let volume;
3996
- try {
3997
- volume = Number(JSON.parse(raw).volume);
3998
- }
3999
- catch {
4000
- json(res, 400, { error: "invalid JSON body" });
4001
- return;
4002
- }
4003
- if (!Number.isFinite(volume)) {
4004
- json(res, 400, { error: "volume must be a number" });
4005
- return;
4006
- }
4007
- const result = setRadioVolume(volume);
4008
- if (!result.ok) {
4009
- json(res, 400, { error: result.error ?? "Radio volume failed to update." });
4010
- return;
4011
- }
4012
- json(res, 200, { current: getCurrentStation(), volume: getRadioVolume() });
4013
- });
4014
- return;
4015
- }
4016
- if (method === "POST" && url === "/radio") {
4017
- void readBody(req, res).then((raw) => {
4018
- if (raw === null)
4019
- return;
4020
- let station;
4021
- try {
4022
- station = JSON.parse(raw).station ?? "";
4023
- }
4024
- catch {
4025
- json(res, 400, { error: "invalid JSON body" });
4026
- return;
4027
- }
4028
- if (!station || station === "off") {
4029
- stopRadio();
4030
- json(res, 200, { current: null });
4031
- return;
4032
- }
4033
- const result = playRadio(station);
4034
- if (!result.ok) {
4035
- json(res, 400, { error: result.error ?? "Radio failed to start." });
4036
- return;
4037
- }
4038
- json(res, 200, { current: getCurrentStation() });
4039
- });
4040
- return;
4041
- }
4042
- if (method === "POST" && url === "/tasks/run") {
4043
- void readBody(req, res).then((raw) => {
4044
- if (raw === null)
4045
- return;
4046
- let id;
4047
- let all;
4048
- try {
4049
- const body = JSON.parse(raw);
4050
- id = body.id ?? null;
4051
- all = Boolean(body.all);
4052
- }
4053
- catch {
4054
- json(res, 400, { error: "invalid JSON body" });
4055
- return;
4056
- }
4057
- if (running) {
4058
- json(res, 409, { error: "cannot run a task while the agent is running" });
4059
- return;
4060
- }
4061
- json(res, 202, { accepted: true });
4062
- void runTasks(id, all);
4063
- });
4064
- return;
4065
- }
4066
- if (method === "POST" && url === "/tasks/delete") {
4067
- void readBody(req, res).then((raw) => {
4068
- if (raw === null)
4069
- return;
4070
- let id;
4071
- try {
4072
- id = JSON.parse(raw).id ?? "";
4073
- }
4074
- catch {
4075
- json(res, 400, { error: "invalid JSON body" });
4076
- return;
4077
- }
4078
- if (!id.trim()) {
4079
- json(res, 400, { error: "missing task id" });
4080
- return;
4081
- }
4082
- const remaining = loadTasksSync(cwd).filter((t) => t.id !== id && !t.id.startsWith(id));
4083
- saveTasksSync(cwd, remaining);
4084
- json(res, 200, { tasks: remaining });
4085
- });
4086
- return;
4087
- }
4088
- if (method === "GET" && url === "/models") {
4089
- void (async () => {
4090
- const loggedIn = [];
4091
- for (const p of ALL_PROVIDERS) {
4092
- if (await auth.hasProviderAuth(p))
4093
- loggedIn.push(p);
4094
- }
4095
- // Just the names, grouped by provider in registry order — the UI shows
4096
- // a clean multi-column list of model ids. Local models come from the
4097
- // runtime registry (populated by the background scan) and are always
4098
- // listed: their "login" is the endpoint answering a probe.
4099
- const models = getAllModels()
4100
- .filter((m) => m.provider === "local" || loggedIn.includes(m.provider))
4101
- .map((m) => {
4102
- if (m.provider !== "local") {
4103
- return { id: m.id, name: m.name, provider: m.provider };
4104
- }
4105
- const probed = findProbedModel(localProbes, m.id);
4106
- return {
4107
- id: m.id,
4108
- name: m.name,
4109
- provider: m.provider,
4110
- local: true,
4111
- endpoint: probed?.endpoint.label ?? parseLocalModelId(m.id)?.endpointId,
4112
- // A local model that can't call tools can't run the agent — the UI
4113
- // renders it disabled rather than hiding it, so the user learns why.
4114
- supportsTools: probed?.model.supportsTools ?? true,
4115
- contextWindow: m.contextWindow,
4116
- contextWindowKnown: probed?.model.contextWindowKnown ?? false,
4117
- supportsThinking: m.supportsThinking,
4118
- };
4119
- });
4120
- json(res, 200, { models });
4121
- })();
4122
- return;
4123
- }
4124
- if (method === "POST" && url === "/model") {
4125
- void readBody(req, res).then(async (raw) => {
4126
- if (raw === null)
4127
- return;
4128
- let modelId;
4129
- try {
4130
- modelId = JSON.parse(raw).model ?? "";
4131
- }
4132
- catch {
4133
- json(res, 400, { error: "invalid JSON body" });
4134
- return;
4135
- }
4136
- const target = getModel(modelId);
4137
- if (!target) {
4138
- json(res, 404, { error: `unknown model: ${modelId}` });
4139
- return;
4140
- }
4141
- if (running) {
4142
- json(res, 409, { error: "cannot switch model while running" });
4143
- return;
4144
- }
4145
- if (target.provider === "local") {
4146
- const problem = await localModelBlocker(target.id);
4147
- if (problem) {
4148
- json(res, 409, { error: problem });
4149
- return;
4150
- }
4151
- }
4152
- await session.switchModel(target.provider, target.id);
4153
- // Ken follows GG Coder's model only while un-pinned; a user-set Ken
4154
- // override survives GG model switches untouched.
4155
- if (!kenModelOverride) {
4156
- await syncKenModel(target.provider, target.id);
4157
- await syncKenAutoModel(target.provider, target.id);
4158
- }
4159
- // Clamp the reasoning level to what the new model supports (mirrors the
4160
- // CLI): keep thinking on at the first supported tier if it was on but
4161
- // the prior level is unsupported here; leave it off if it was off.
4162
- const prevLevel = session.getThinkingLevel();
4163
- if (prevLevel && !isThinkingLevelSupported(target.provider, target.id, prevLevel)) {
4164
- session.setThinkingLevel(getNextThinkingLevel(target.provider, target.id, undefined));
4165
- }
4166
- // Persist per-project so THIS window/project restores its own model on
4167
- // restart (not the single global slot every window shares). Keep the
4168
- // global write too as a "last used" fallback for never-opened projects
4169
- // and so the CLI stays in sync.
4170
- await saveProjectModelPrefs(cwd, {
4171
- provider: target.provider,
4172
- model: target.id,
4173
- thinkingEnabled: !!session.getThinkingLevel(),
4174
- thinkingLevel: session.getThinkingLevel() ?? undefined,
4175
- });
4176
- await persistModelSelection(paths.settingsFile, target.provider, target.id);
4177
- await persistThinkingLevel(paths.settingsFile, session.getThinkingLevel());
4178
- const payload = {
4179
- thinkingLevel: session.getThinkingLevel() ?? null,
4180
- supportedThinkingLevels: getSupportedThinkingLevels(target.provider, target.id),
4181
- };
4182
- // model_change is emitted by switchModel; follow with thinking_change so
4183
- // the footer toggle reflects the new model's supported levels.
4184
- broadcast("thinking_change", payload);
4185
- // Un-pinned Ken just followed the switch — update his footer chip too.
4186
- // When Ken is pinned, his effective model did not change, so skip the
4187
- // no-op event (keeps footer/event tests from treating a GG switch as a
4188
- // Ken switch).
4189
- if (!kenModelOverride)
4190
- broadcast("ken_model_change", kenStatePayload());
4191
- // The new model usually has a different context window — push extras so
4192
- // the footer's context meter rescales immediately.
4193
- broadcast("extras", footerExtras());
4194
- json(res, 200, { provider: target.provider, model: target.id, ...payload });
4195
- });
4196
- return;
4197
- }
4198
- // Set or clear Ken's model pin. Body: { model: "<id>" } to pin, or
4199
- // { model: null } / "" to clear (Ken resumes following GG Coder). Applies
4200
- // to BOTH Ken sessions (chat + autopilot reviewer); a switch landing while
4201
- // either is mid-run defers via the pending-model mechanics.
4202
- if (method === "POST" && url === "/ken/model") {
4203
- void readBody(req, res).then(async (raw) => {
4204
- if (raw === null)
4205
- return;
4206
- let modelId;
4207
- try {
4208
- const parsed = JSON.parse(raw).model;
4209
- modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
4210
- }
4211
- catch {
4212
- json(res, 400, { error: "invalid JSON body" });
4213
- return;
4214
- }
4215
- // Same lock as POST /model, for the same reason: this retargets Ken's
4216
- // chat session AND the autopilot reviewer, and `switchModel` on a
4217
- // session mid-turn races the stream it is already consuming. The
4218
- // footer picker is disabled to match; this is the enforcement.
4219
- if (running || kenRunning || autopilotReviewing) {
4220
- json(res, 409, { error: "cannot switch Ken's model while running" });
4221
- return;
4222
- }
4223
- if (modelId === null) {
4224
- // Clear the pin → follow GG Coder again, syncing both sessions back.
4225
- kenModelOverride = null;
4226
- await saveKenModelPref(cwd, null);
4227
- const st = session.getState();
4228
- await syncKenModel(st.provider, st.model);
4229
- await syncKenAutoModel(st.provider, st.model);
4230
- log("INFO", "app-sidecar", "ken model pin cleared — following GG", {
4231
- provider: st.provider,
4232
- model: st.model,
4233
- });
4234
- }
4235
- else {
4236
- const target = getModel(modelId);
4237
- if (!target) {
4238
- json(res, 404, { error: `unknown model: ${modelId}` });
4239
- return;
4240
- }
4241
- kenModelOverride = { provider: target.provider, model: target.id };
4242
- await saveKenModelPref(cwd, kenModelOverride);
4243
- await syncKenModel(target.provider, target.id);
4244
- await syncKenAutoModel(target.provider, target.id);
4245
- log("INFO", "app-sidecar", "ken model pinned", {
4246
- provider: target.provider,
4247
- model: target.id,
4248
- });
4249
- }
4250
- const payload = kenStatePayload();
4251
- broadcast("ken_model_change", payload);
4252
- json(res, 200, payload);
4253
- });
4254
- return;
4255
- }
4256
- // Pending queued steering, for the composer's cancel affordance.
4257
- if (method === "GET" && url === "/queued") {
4258
- json(res, 200, { queued: session.listQueuedMessages() });
4259
- return;
4260
- }
4261
- // Cancel one pending queued message by id.
4262
- if (method === "POST" && url === "/queued/cancel") {
4263
- void readBody(req, res).then((raw) => {
4264
- if (raw === null)
4265
- return;
4266
- let id;
4267
- try {
4268
- id = JSON.parse(raw).id ?? "";
4269
- }
4270
- catch {
4271
- json(res, 400, { error: "invalid JSON body" });
4272
- return;
4273
- }
4274
- if (!id.trim()) {
4275
- json(res, 400, { error: "missing queued message id" });
4276
- return;
4277
- }
4278
- // `false` means it already drained into the run between render and
4279
- // click. That is a race, not an error, so report it as a normal result
4280
- // and let the client reconcile through the ordered event stream.
4281
- const cancelled = session.cancelQueuedMessage(id);
4282
- const queued = session.listQueuedMessages();
4283
- broadcast("queued", {
4284
- count: queued.length,
4285
- messages: queued,
4286
- ...(cancelled ? { cancelledId: id } : {}),
4287
- });
4288
- json(res, 200, { cancelled, queued });
4289
- });
4290
- return;
4291
- }
4292
- if (method === "POST" && url === "/kill") {
4293
- void readBody(req, res).then(async (raw) => {
4294
- if (raw === null)
4295
- return;
4296
- let id;
4297
- try {
4298
- id = JSON.parse(raw).id ?? "";
4299
- }
4300
- catch {
4301
- json(res, 400, { error: "invalid JSON body" });
4302
- return;
4303
- }
4304
- if (!id.trim()) {
4305
- json(res, 400, { error: "missing task id" });
4306
- return;
4307
- }
4308
- const message = await session.killBackgroundProcess(id);
4309
- // Push the updated task list right away rather than waiting for the poll.
4310
- broadcast("tasks", { tasks: session.listBackgroundProcesses() });
4311
- json(res, 200, { message });
4312
- });
4313
- return;
4314
- }
4315
- // Import a Claude Code / Codex / Cursor transcript into a resumable GG
4316
- // Coder session. The importer never throws — it returns a typed failure so
4317
- // the app can show the reason verbatim.
4318
- if (method === "POST" && url === "/import-transcript") {
4319
- void readBody(req, res).then(async (raw) => {
4320
- if (raw === null)
4321
- return;
4322
- let body;
4323
- try {
4324
- body = JSON.parse(raw);
4325
- }
4326
- catch {
4327
- json(res, 400, { error: "invalid JSON body" });
4328
- return;
4329
- }
4330
- const filePath = body.path?.trim();
4331
- if (!filePath) {
4332
- json(res, 400, { error: "missing transcript path" });
4333
- return;
4334
- }
4335
- const result = await session.importForeignTranscript(filePath, {
4336
- ...(body.cwd ? { cwd: body.cwd } : {}),
4337
- });
4338
- json(res, result.ok ? 200 : 400, result);
4339
- });
4340
- return;
4341
- }
4342
- if (method === "POST" && url === "/thinking") {
4343
- // The in-flight request already carries its reasoning config, so a
4344
- // mid-run cycle cannot affect the turn the user is watching — it just
4345
- // persists a level the footer then reports for a run that never used it.
4346
- // Locked like POST /model; the footer button is disabled to match.
4347
- if (running) {
4348
- json(res, 409, { error: "cannot change reasoning level while running" });
4349
- return;
4350
- }
4351
- const st = session.getState();
4352
- const next = getNextThinkingLevel(st.provider, st.model, session.getThinkingLevel());
4353
- session.setThinkingLevel(next);
4354
- // Persist per-project so THIS window restores its thinking state on
4355
- // restart; keep the global write as a fallback (mirrors the CLI).
4356
- void saveProjectModelPrefs(cwd, {
4357
- provider: st.provider,
4358
- model: st.model,
4359
- thinkingEnabled: !!next,
4360
- thinkingLevel: next ?? undefined,
4361
- }).then(() => persistThinkingLevel(paths.settingsFile, next));
4362
- const payload = {
4363
- thinkingLevel: next ?? null,
4364
- supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
4365
- };
4366
- broadcast("thinking_change", payload);
4367
- json(res, 200, payload);
4368
- return;
4369
- }
4370
- if (method === "POST" && url === "/prewarm") {
4371
- // Best-effort Anthropic cache prewarm on the composer's first keystroke.
4372
- // Fire-and-forget: AgentSession.prewarm() applies its own gating (provider,
4373
- // setting, history size, TTL, in-flight) and swallows request errors.
4374
- if (!running) {
4375
- void session.prewarm().catch((err) => {
4376
- log("WARN", "app-sidecar", "prewarm failed", { err: String(err) });
4377
- });
4378
- }
4379
- json(res, 202, { accepted: !running });
4380
- return;
4381
- }
4382
- if (method === "POST" && url === "/cancel") {
4383
- void (async () => {
4384
- // Even between task runs, cancellation stops the sweep. Active provider
4385
- // ownership invokes the full abort hook exactly once through lifecycle.
4386
- taskRunAll = false;
4387
- autopilotCancelled = true;
4388
- if (!runLifecycle.running) {
4389
- kenAutoAbort.abort();
4390
- kenAutoAbort = new AbortController();
4391
- kenAutoSession?.setSignal(kenAutoAbort.signal);
4392
- }
4393
- const generation = runLifecycle.generation;
4394
- if (!pendingCancelDrain || pendingCancelDrain.generation !== generation) {
4395
- pendingCancelDrain = { generation, text: session.drainQueue() };
4396
- broadcast("queued", { count: 0, messages: [] });
4397
- }
4398
- const result = await runLifecycle.cancel(CANCEL_TIMEOUT_MS);
4399
- const drained = pendingCancelDrain.text;
4400
- if (result.status === "failed") {
4401
- broadcast("cancel_failed", {
4402
- error: "cancel_failed",
4403
- reason: result.reason,
4404
- runState: runLifecycle.state,
4405
- });
4406
- json(res, 504, {
4407
- error: "cancel_failed",
4408
- reason: result.reason,
4409
- runState: runLifecycle.state,
4410
- drained,
4411
- });
4412
- return;
4413
- }
4414
- json(res, 200, {
4415
- cancelled: result.status === "cancelled",
4416
- runState: runLifecycle.state,
4417
- drained,
4418
- });
4419
- })().catch((error) => {
4420
- broadcast("cancel_failed", { error: "cancel_failed", runState: runLifecycle.state });
4421
- json(res, 500, {
4422
- error: "cancel_failed",
4423
- message: error instanceof Error ? error.message : String(error),
4424
- runState: runLifecycle.state,
4425
- });
4426
- });
4427
- return;
4428
- }
4429
- if (method === "POST" && url === "/new-session") {
4430
- if (running) {
4431
- json(res, 409, { error: "cannot start a new session while running" });
4432
- return;
4433
- }
4434
- void session
4435
- .newSession()
4436
- .then(async () => {
4437
- if (mode === "chat") {
4438
- await session.persistAppMarker("agent_handoff", { chatAgent });
4439
- }
4440
- deactivateApprovedPlan();
4441
- injectedAutopilotPrompts = [];
4442
- clearPendingPlan();
4443
- broadcast("session_reset", {});
4444
- json(res, 200, { ok: true });
4445
- })
4446
- .catch((err) => {
4447
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
4448
- });
4449
- return;
4450
- }
4451
- // Accept an approved plan and begin implementation in a FRESH session
4452
- // (mirrors the CLI's handleApprovePlan). The plan-mode conversation — all the
4453
- // research, file reads, and exploration done while drafting — must NOT bleed
4454
- // into the build, or it bloats the context and distracts the model. So:
4455
- // 1. newSession() wipes history + starts a new session file.
4456
- // 2. setApprovedPlan() bakes the plan into the fresh system prompt so the
4457
- // model emits `[DONE:n]` markers the plan-progress widget reads.
4458
- // 3. session_reset tells the webview to clear its transcript; it then runs
4459
- // the "implement it now" prompt in the clean session.
4460
- if (method === "POST" && url === "/plan/accept") {
4461
- void readBody(req, res).then(async (raw) => {
4462
- if (raw === null)
4463
- return;
4464
- let planPath;
4465
- try {
4466
- planPath = JSON.parse(raw).planPath || undefined;
4467
- }
4468
- catch {
4469
- json(res, 400, { error: "invalid JSON body" });
4470
- return;
4471
- }
4472
- if (running) {
4473
- json(res, 409, { error: "cannot accept a plan while the agent is running" });
4474
- return;
4475
- }
4476
- // Manual accept, possibly racing Ken's autopilot plan review: the user
4477
- // always wins. Bump the plan generation (invalidates any in-flight
4478
- // review's verdict), stop the cycle, abort a mid-prompt review on the
4479
- // kenAuto session, and clear the spinner — autopilot_ignored renders
4480
- // nothing, so no stale "approve or reject" bubble ever lands. The
4481
- // webview's follow-up "implement" /prompt arrives as a fresh turn
4482
- // (resetting autopilotCancelled), so the implementation still gets its
4483
- // normal post-run review; if it lands while the cycle is winding down
4484
- // it queues and runStrandedQueue drains it as a fresh turn.
4485
- clearPendingPlan();
4486
- autopilotCancelled = true;
4487
- kenAutoAbort.abort();
4488
- kenAutoAbort = new AbortController();
4489
- kenAutoSession?.setSignal(kenAutoAbort.signal);
4490
- if (autopilotReviewing) {
4491
- autopilotReviewing = false;
4492
- broadcast("autopilot_ignored", {});
4493
- }
4494
- try {
4495
- await session.newSession(true);
4496
- injectedAutopilotPrompts = [];
4497
- const planTotal = await activateApprovedPlan(planPath);
4498
- broadcast("session_reset", { planTotal });
4499
- broadcast("plan_progress", planProgressPayload());
4500
- json(res, 200, { ok: true, planTotal });
4501
- }
4502
- catch (err) {
4503
- json(res, 500, { error: err instanceof Error ? err.message : String(err) });
4504
- }
4505
- });
4506
- return;
4507
- }
4508
- // ── Provider auth (login) ───────────────────────────────
4509
- if (method === "GET" && url === "/auth/status") {
4510
- void authStatusPayload().then((payload) => json(res, 200, payload));
4511
- return;
4512
- }
4513
- if (method === "POST" && url === "/auth/apikey") {
4514
- void readBody(req, res).then(async (raw) => {
4515
- if (raw === null)
4516
- return;
4517
- let provider = "";
4518
- let key;
4519
- let variant;
4520
- try {
4521
- const body = JSON.parse(raw);
4522
- provider = body.provider ?? "";
4523
- key = (body.key ?? "").trim();
4524
- variant = body.variant;
4525
- }
4526
- catch {
4527
- json(res, 400, { error: "invalid JSON body" });
4528
- return;
4529
- }
4530
- const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
4531
- if (!meta || !meta.methods.includes("apikey")) {
4532
- json(res, 400, { error: "provider does not support API key auth" });
4533
- return;
4534
- }
4535
- if (!key) {
4536
- json(res, 400, { error: "API key is required" });
4537
- return;
4538
- }
4539
- // Providers with multiple API-key variants (currently only Xiaomi: Token
4540
- // Plan vs. API Credits) store under the chosen variant's key/baseUrl,
4541
- // defaulting to the first variant. Single-variant providers fall back to
4542
- // the legacy provider-id storage key + flat apiKeyBaseUrl.
4543
- const chosenVariant = meta.apiKeyVariants?.find((v) => v.key === variant) ?? meta.apiKeyVariants?.[0];
4544
- const storageKey = chosenVariant?.key ?? provider;
4545
- const baseUrl = chosenVariant?.baseUrl ?? meta.apiKeyBaseUrl;
4546
- const creds = {
4547
- accessToken: key,
4548
- refreshToken: "",
4549
- expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
4550
- ...(baseUrl ? { baseUrl } : {}),
4551
- };
4552
- await auth.setCredentials(storageKey, creds);
4553
- // auth.json is shared by every window, so this is a global change:
4554
- // close their login modals and refresh their provider lists too.
4555
- broadcastAll("auth_done", { provider });
4556
- // `auth_done` means "a login succeeded" (modals close on it).
4557
- // `auth_change` means "auth.json changed" — which a DISCONNECT also is,
4558
- // so connection state has one signal that covers both directions.
4559
- broadcastAll("auth_change", { provider });
4560
- // A newly connected provider unlocks its models. `/models` filters on
4561
- // who is logged in, so every window's picker is now stale — without
4562
- // this the new models don't appear until the session is reopened.
4563
- broadcastAll("models_change", {});
4564
- json(res, 200, { ok: true });
4565
- });
4566
- return;
4567
- }
4568
- if (method === "POST" && url === "/auth/oauth/start") {
4569
- void readBody(req, res).then((raw) => {
4570
- if (raw === null)
4571
- return;
4572
- let provider = "";
4573
- try {
4574
- provider = JSON.parse(raw).provider ?? "";
4575
- }
4576
- catch {
4577
- json(res, 400, { error: "invalid JSON body" });
4578
- return;
4579
- }
4580
- const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
4581
- if (!meta || !meta.methods.includes("oauth")) {
4582
- json(res, 400, { error: "provider does not support OAuth" });
4583
- return;
4584
- }
4585
- if (oauthInFlight) {
4586
- json(res, 409, { error: "a login is already in progress" });
4587
- return;
4588
- }
4589
- // A login writes the shared ~/.gg/auth.json, so two windows racing the
4590
- // same provider means two browser tabs and two token exchanges whose
4591
- // writes clobber each other. The per-session flag above cannot see
4592
- // that — guard the provider daemon-wide as well.
4593
- if (oauthInFlightProviders.has(provider)) {
4594
- json(res, 409, {
4595
- error: `a ${meta.label} login is already in progress in another window`,
4596
- });
4597
- return;
4598
- }
4599
- oauthInFlight = true;
4600
- oauthInFlightProviders.add(provider);
4601
- json(res, 202, { accepted: true });
4602
- void (async () => {
4603
- const cb = authCallbacks();
4604
- try {
4605
- let creds;
4606
- let storageKey = provider;
4607
- if (provider === "anthropic")
4608
- creds = await loginAnthropic(cb);
4609
- else if (provider === "openai")
4610
- creds = await loginOpenAI(cb);
4611
- else if (provider === "gemini")
4612
- creds = await loginGemini(cb);
4613
- else if (provider === "moonshot" || provider === "xai") {
4614
- // Subscription OAuth (Kimi plan / SuperGrok-X Premium) stores under
4615
- // a distinct key so it can coexist with the provider's API key.
4616
- creds = provider === "moonshot" ? await loginKimi(cb) : await loginXai(cb);
4617
- storageKey = dualAuthProvider(provider).oauthKey;
4618
- }
4619
- else {
4620
- throw new Error(`OAuth not implemented for ${provider}`);
4621
- }
4622
- await auth.setCredentials(storageKey, creds);
4623
- // Terminal outcome of a GLOBAL change: every window's login modal
4624
- // should close and its provider list refresh, not just the one
4625
- // that started the flow.
4626
- broadcastAll("auth_done", { provider });
4627
- broadcastAll("auth_change", { provider });
4628
- // The OAuth provider's models just became selectable everywhere.
4629
- broadcastAll("models_change", {});
4630
- }
4631
- catch (err) {
4632
- // Deliberately session-scoped: this is the outcome of ONE window's
4633
- // attempt. Another window that never pressed Connect has nothing to
4634
- // show an error about, and its modal correctly still offers login.
4635
- broadcast("auth_error", {
4636
- provider,
4637
- message: err instanceof Error ? err.message : String(err),
4638
- });
4639
- }
4640
- finally {
4641
- oauthInFlight = false;
4642
- oauthInFlightProviders.delete(provider);
4643
- pendingCode = null;
4644
- }
4645
- })();
4646
- });
4647
- return;
4648
- }
4649
- if (method === "POST" && url === "/auth/oauth/code") {
4650
- void readBody(req, res).then((raw) => {
4651
- if (raw === null)
4652
- return;
4653
- let code;
4654
- try {
4655
- code = JSON.parse(raw).code ?? "";
4656
- }
4657
- catch {
4658
- json(res, 400, { error: "invalid JSON body" });
4659
- return;
4660
- }
4661
- if (!pendingCode) {
4662
- json(res, 409, { error: "no login is awaiting a code" });
4663
- return;
4664
- }
4665
- pendingCode(code.trim());
4666
- pendingCode = null;
4667
- json(res, 200, { ok: true });
4668
- });
4669
- return;
4670
- }
4671
- if (method === "POST" && url.startsWith("/mcp/elicit/")) {
4672
- const id = decodeURIComponent(url.slice("/mcp/elicit/".length));
4673
- void readBody(req, res).then((raw) => {
4674
- if (raw === null)
4675
- return;
4676
- let result;
4677
- try {
4678
- const parsed = JSON.parse(raw);
4679
- if (parsed.action !== "accept" &&
4680
- parsed.action !== "decline" &&
4681
- parsed.action !== "cancel") {
4682
- json(res, 400, { error: "action must be accept, decline, or cancel" });
4683
- return;
4684
- }
4685
- result =
4686
- parsed.action === "accept"
4687
- ? { action: "accept", content: parsed.content ?? {} }
4688
- : { action: parsed.action };
4689
- }
4690
- catch {
4691
- json(res, 400, { error: "invalid JSON body" });
4692
- return;
4693
- }
4694
- // Unknown id means it already timed out or was cancelled by an abort —
4695
- // the tool call has moved on, so the answer has nowhere to go.
4696
- if (!elicitations.settle(id, result)) {
4697
- json(res, 409, { error: "no elicitation is awaiting a response" });
4698
- return;
4699
- }
4700
- json(res, 200, { ok: true });
4701
- });
4702
- return;
4703
- }
4704
- // Answer (or dismiss) an `ask_user` question band. The turn is blocked on
4705
- // this, so both paths must land: "answer" carries the picked values,
4706
- // "cancel" releases the tool call with no answer.
4707
- if (method === "POST" && url.startsWith("/ask/")) {
4708
- const id = decodeURIComponent(url.slice("/ask/".length));
4709
- void readBody(req, res).then((raw) => {
4710
- if (raw === null)
4711
- return;
4712
- let result;
4713
- try {
4714
- const parsed = JSON.parse(raw);
4715
- if (parsed.action !== "answer" && parsed.action !== "cancel") {
4716
- json(res, 400, { error: "action must be answer or cancel" });
4717
- return;
4718
- }
4719
- if (parsed.action === "cancel") {
4720
- result = { action: "cancel" };
4721
- }
4722
- else {
4723
- // Only strings and string arrays are answers; anything else is a
4724
- // malformed client, not a value to hand the model.
4725
- const answers = {};
4726
- for (const [key, value] of Object.entries(parsed.answers ?? {})) {
4727
- if (typeof value === "string")
4728
- answers[key] = value;
4729
- else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
4730
- answers[key] = value;
4731
- }
4732
- }
4733
- result = { action: "answer", answers };
4734
- }
4735
- }
4736
- catch {
4737
- json(res, 400, { error: "invalid JSON body" });
4738
- return;
4739
- }
4740
- if (!asks.settle(id, result)) {
4741
- json(res, 409, { error: "no question is awaiting an answer" });
4742
- return;
4743
- }
4744
- json(res, 200, { ok: true });
4745
- });
4746
- return;
4747
- }
4748
- if (method === "POST" && url === "/auth/logout") {
4749
- void readBody(req, res).then(async (raw) => {
4750
- if (raw === null)
4751
- return;
4752
- let provider;
4753
- let logoutMethod;
4754
- try {
4755
- const body = JSON.parse(raw);
4756
- provider = body.provider ?? "";
4757
- logoutMethod =
4758
- body.method === "oauth" || body.method === "apikey" ? body.method : undefined;
4759
- }
4760
- catch {
4761
- json(res, 400, { error: "invalid JSON body" });
4762
- return;
4763
- }
4764
- const dual = dualAuthProvider(provider);
4765
- // A dual-auth provider can be disconnected one method at a time: dropping
4766
- // a spent API key should not sign the user out of their subscription, and
4767
- // vice versa. Omitting `method` still clears everything (the plain
4768
- // "Disconnect" action).
4769
- if (logoutMethod !== "apikey") {
4770
- await auth.clearCredentials(dual ? dual.oauthKey : provider);
4771
- }
4772
- if (logoutMethod !== "oauth") {
4773
- // Non-dual providers store their only credential under the provider id,
4774
- // so this covers both them and a dual provider's API key.
4775
- await auth.clearCredentials(provider);
4776
- // Xiaomi's API Credits credential lives under a distinct key — clear it
4777
- // too so "disconnect" fully removes both the Token Plan and Credits keys.
4778
- if (provider === "xiaomi")
4779
- await auth.clearCredentials(XIAOMI_CREDITS_KEY);
4780
- }
4781
- broadcast("auth_done", { provider });
4782
- json(res, 200, { ok: true });
4783
- });
4784
- return;
4785
- }
4786
- // ── Telegram config (mirrors `ggcoder telegram`) ─────────
4787
- // ── Local models ──────────────────────────────────────
4788
- // GET returns the last scan (cheap, no probing) so opening the modal is
4789
- // instant; POST /local/scan is the explicit refresh.
4790
- if (method === "GET" && url === "/local") {
4791
- json(res, 200, localStatePayload());
4792
- return;
4793
- }
4794
- if (method === "POST" && url === "/local/scan") {
4795
- void scanLocalModels(true)
4796
- .then(() => {
4797
- broadcast("models_change", { local: localStatePayload() });
4798
- json(res, 200, localStatePayload());
4799
- })
4800
- .catch((err) => {
4801
- broadcastError("error", "local model scan failed", err);
4802
- json(res, 500, { error: "Local model scan failed — see the sidecar log." });
4803
- });
4804
- return;
4805
- }
4806
- if (method === "POST" && url === "/local/endpoints") {
4807
- void readBody(req, res).then(async (raw) => {
4808
- if (raw === null)
4809
- return;
4810
- let body;
4811
- try {
4812
- body = JSON.parse(raw);
4813
- }
4814
- catch {
4815
- json(res, 400, { error: "invalid JSON body" });
4816
- return;
4817
- }
4818
- try {
4819
- const endpoint = await addCustomEndpoint({
4820
- baseUrl: body.baseUrl ?? "",
4821
- ...(body.label ? { label: body.label } : {}),
4822
- ...(body.apiKey ? { apiKey: body.apiKey } : {}),
4823
- });
4824
- await scanLocalModels(true);
4825
- broadcast("models_change", { local: localStatePayload() });
4826
- json(res, 200, {
4827
- endpoint: { id: endpoint.id, label: endpoint.label },
4828
- ...localStatePayload(),
4829
- });
4830
- }
4831
- catch (err) {
4832
- // Validation errors are the user's typo, not a system fault — 400 with
4833
- // the exact reason, and nothing in the transcript.
4834
- if (err instanceof LocalEndpointError) {
4835
- json(res, 400, { error: err.message });
4836
- return;
4837
- }
4838
- broadcastError("error", "add local endpoint failed", err);
4839
- json(res, 500, { error: "Could not save the endpoint — see the sidecar log." });
4840
- }
4841
- });
4842
- return;
4843
- }
4844
- if (method === "DELETE" && url.startsWith("/local/endpoints/")) {
4845
- const id = decodeURIComponent(url.slice("/local/endpoints/".length));
4846
- void (async () => {
4847
- try {
4848
- await removeCustomEndpoint(id);
4849
- clearRuntimeModels((m) => m.provider === "local" && parseLocalModelId(m.id)?.endpointId === id);
4850
- await scanLocalModels(true);
4851
- broadcast("models_change", { local: localStatePayload() });
4852
- json(res, 200, localStatePayload());
4853
- }
4854
- catch (err) {
4855
- if (err instanceof LocalEndpointError) {
4856
- json(res, 400, { error: err.message });
4857
- return;
4858
- }
4859
- broadcastError("error", "remove local endpoint failed", err);
4860
- json(res, 500, { error: "Could not remove the endpoint — see the sidecar log." });
4861
- }
4862
- })();
4863
- return;
4864
- }
4865
- // ── Hugging Face search & pull (the "Add from Hugging Face" modal) ──
4866
- if (method === "GET" && url === "/hf/pull") {
4867
- json(res, 200, hfPull ? { active: hfPullPayload(hfPull) } : { active: null });
4868
- return;
4869
- }
4870
- if (method === "POST" && url === "/hf/search") {
4871
- void readBody(req, res).then(async (raw) => {
4872
- if (raw === null)
4873
- return;
4874
- let body;
4875
- try {
4876
- body = JSON.parse(raw);
4877
- }
4878
- catch {
4879
- json(res, 400, { error: "invalid JSON body" });
4880
- return;
4881
- }
4882
- // Strip control characters and cap length — this goes into a URL query.
4883
- const query = String(body.query ?? "")
4884
- // eslint-disable-next-line no-control-regex -- stripping control characters is the point
4885
- .replace(/[\u0000-\u001f]/g, "")
4886
- .trim()
4887
- .slice(0, 100);
4888
- if (!query) {
4889
- json(res, 400, { error: "Type something to search for." });
4890
- return;
4891
- }
4892
- try {
4893
- json(res, 200, { models: await hfSearch(query) });
4894
- }
4895
- catch (err) {
4896
- broadcastError("error", "hugging face search failed", err);
4897
- json(res, 502, {
4898
- error: `Hugging Face search failed — ${err instanceof Error ? err.message : "network error"}.`,
4899
- });
4900
- }
4901
- });
4902
- return;
4903
- }
4904
- if (method === "POST" && url === "/hf/pull") {
4905
- void readBody(req, res).then(async (raw) => {
4906
- if (raw === null)
4907
- return;
4908
- let body;
4909
- try {
4910
- body = JSON.parse(raw);
4911
- }
4912
- catch {
4913
- json(res, 400, { error: "invalid JSON body" });
4914
- return;
4915
- }
4916
- const repo = String(body.repo ?? "").trim();
4917
- if (!isValidHfRepoId(repo)) {
4918
- json(res, 400, { error: 'Expected a Hugging Face repo like "org/model".' });
4919
- return;
4920
- }
4921
- try {
4922
- json(res, 200, await startHfPull(repo));
4923
- }
4924
- catch (err) {
4925
- const status = err instanceof Error && "status" in err && typeof err.status === "number"
4926
- ? err.status
4927
- : 502;
4928
- if (status >= 500)
4929
- broadcastError("error", "hugging face pull failed", err);
4930
- json(res, status, { error: err instanceof Error ? err.message : "Pull failed." });
4931
- }
4932
- });
4933
- return;
4934
- }
4935
- if (method === "POST" && url === "/hf/pull/cancel") {
4936
- json(res, 200, { ok: cancelHfPull() });
4937
- return;
4938
- }
4939
- if (method === "GET" && url === "/telegram") {
4940
- void loadTelegramConfig().then((cfg) => {
4941
- if (!cfg) {
4942
- json(res, 200, { configured: false });
4943
- return;
4944
- }
4945
- // Never return the raw token to the webview — a short masked preview is
4946
- // enough to show "already set".
4947
- const t = cfg.botToken;
4948
- const tokenPreview = t.length > 14 ? `${t.slice(0, 10)}\u2026${t.slice(-4)}` : "set";
4949
- json(res, 200, { configured: true, userId: cfg.userId, tokenPreview });
4950
- });
4951
- return;
4952
- }
4953
- if (method === "POST" && url === "/telegram") {
4954
- void readBody(req, res).then(async (raw) => {
4955
- if (raw === null)
4956
- return;
4957
- let botTokenInput;
4958
- let userIdInput;
4959
- try {
4960
- const body = JSON.parse(raw);
4961
- botTokenInput = (body.botToken ?? "").trim();
4962
- userIdInput = String(body.userId ?? "").trim();
4963
- }
4964
- catch {
4965
- json(res, 400, { error: "invalid JSON body" });
4966
- return;
4967
- }
4968
- // Keep the existing token when the field is left blank (the webview shows
4969
- // a masked preview, not the real token).
4970
- const existing = await loadTelegramConfig();
4971
- const botToken = botTokenInput || existing?.botToken || "";
4972
- if (!botToken) {
4973
- json(res, 400, { error: "Bot token is required." });
4974
- return;
4975
- }
4976
- const userId = userIdInput ? parseInt(userIdInput, 10) : existing?.userId;
4977
- if (!userId || Number.isNaN(userId)) {
4978
- json(res, 400, { error: "A numeric Telegram user ID is required." });
4979
- return;
4980
- }
4981
- const verified = await verifyBotToken(botToken);
4982
- if (!verified.ok) {
4983
- json(res, 400, { error: "Invalid bot token — Telegram rejected it." });
4984
- return;
4985
- }
4986
- await saveTelegramConfig({ botToken, userId });
4987
- json(res, 200, { ok: true, userId, username: verified.username ?? null });
4988
- });
4989
- return;
4990
- }
4991
- // ── Serve lifecycle (mirrors `ggcoder serve`) ───────────
4992
- if (method === "GET" && url === "/serve") {
4993
- void loadTelegramConfig().then((cfg) => json(res, 200, { running: serveController !== null, configured: cfg !== null }));
4994
- return;
4995
- }
4996
- if (method === "POST" && url === "/serve/start") {
4997
- void (async () => {
4998
- if (serveController) {
4999
- json(res, 200, { running: true });
5000
- return;
5001
- }
5002
- const cfg = await loadTelegramConfig();
5003
- if (!cfg) {
5004
- json(res, 400, { error: "Telegram isn't set up yet. Open Serve settings first." });
5005
- return;
5006
- }
5007
- const st = session.getState();
5008
- try {
5009
- serveController = await startServeMode({
5010
- provider: st.provider,
5011
- model: st.model,
5012
- cwd,
5013
- version: "app",
5014
- thinkingLevel: session.getThinkingLevel() ?? undefined,
5015
- telegram: { botToken: cfg.botToken, userId: cfg.userId },
5016
- embedded: true,
5017
- });
5018
- broadcast("serve_change", { running: true });
5019
- log("INFO", "app-sidecar", "serve started", { userId: cfg.userId });
5020
- json(res, 200, { running: true });
5021
- }
5022
- catch (err) {
5023
- serveController = null;
5024
- json(res, 400, { error: err instanceof Error ? err.message : String(err) });
5025
- }
5026
- })();
5027
- return;
5028
- }
5029
- if (method === "POST" && url === "/serve/stop") {
5030
- void (async () => {
5031
- if (serveController) {
5032
- await serveController.stop().catch(() => { });
5033
- serveController = null;
5034
- broadcast("serve_change", { running: false });
5035
- log("INFO", "app-sidecar", "serve stopped");
5036
- }
5037
- json(res, 200, { running: false });
5038
- })();
5039
- return;
5040
- }
5041
- // ── Agent Steroids (local code corpus) ───────────────────────────────
5042
- if (method === "GET" && url === "/steroids") {
5043
- void probeSteroids().then((status) => json(res, 200, status));
5044
- return;
5045
- }
5046
- if (method === "POST" && url === "/steroids/install") {
5047
- void installSteroids()
5048
- .then((status) => {
5049
- broadcast("steroids_change", status);
5050
- log("INFO", "app-sidecar", "steroids installed", { version: status.version });
5051
- json(res, 200, status);
5052
- })
5053
- .catch((err) => {
5054
- json(res, 400, { error: err instanceof Error ? err.message : String(err) });
5055
- });
5056
- return;
5057
- }
5058
- // ── MCP server management (mirrors `ggcoder mcp`) ──────────────────
5059
- // `targetCwd` (project scope) overrides the window cwd so a server can be
5060
- // added/removed for ANY discovered project, not just this window's. Global
5061
- // scope ignores it (always ~/.gg/mcp.json).
5062
- if (method === "GET" && (url === "/mcp" || url.startsWith("/mcp?"))) {
5063
- const targetCwd = new URL(url, `http://${host}`).searchParams.get("cwd") ?? cwd;
5064
- void buildMcpRows(targetCwd, paths.settingsFile)
5065
- .then((servers) => json(res, 200, { servers }))
5066
- .catch((err) => {
5067
- log("ERROR", "app-sidecar", "buildMcpRows failed", {
5068
- message: err instanceof Error ? err.message : String(err),
5069
- });
5070
- json(res, 200, { servers: [] });
5071
- });
5072
- return;
5073
- }
5074
- if (method === "POST" && url === "/mcp/add") {
5075
- void readBody(req, res).then(async (raw) => {
5076
- if (raw === null)
5077
- return;
5078
- let line;
5079
- let scopeValue;
5080
- let bodyCwd;
5081
- try {
5082
- const body = JSON.parse(raw);
5083
- line = body.line ?? "";
5084
- scopeValue = body.scope ?? "global";
5085
- bodyCwd = body.cwd;
5086
- }
5087
- catch {
5088
- json(res, 400, { error: "invalid JSON body" });
5089
- return;
5090
- }
5091
- const scope = scopeValue === "project" ? "project" : "global";
5092
- if (scope === "project" && !bodyCwd) {
5093
- json(res, 400, { error: "project scope requires a project (cwd)." });
5094
- return;
5095
- }
5096
- const targetCwd = bodyCwd ?? cwd;
5097
- const parsed = parseMcpAddCommand(line);
5098
- if (!parsed.ok) {
5099
- json(res, 400, { error: parsed.error });
5100
- return;
5101
- }
5102
- const config = parsed.value.config;
5103
- try {
5104
- // Best-effort probe — never blocks the save. A failed connect is
5105
- // surfaced to the UI but the config is still persisted (mirrors the
5106
- // CLI). probeMcp swallows connect errors; the try/catch guards the
5107
- // persist step so a write failure returns a 500 instead of becoming
5108
- // an unhandled rejection that would crash the sidecar.
5109
- const probe = await probeMcp(config);
5110
- const saved = await addServer(config, scope, targetCwd, true);
5111
- if (!saved.ok) {
5112
- json(res, 400, { error: saved.error });
5113
- return;
5114
- }
5115
- // Adding a project-scope server is an explicit trust signal — the
5116
- // user chose to put a server in this repo's .gg/mcp.json. Auto-trust
5117
- // the project so all project-scope servers connect on next load.
5118
- if (scope === "project") {
5119
- await session.trustProject(targetCwd);
5120
- }
5121
- json(res, 200, {
5122
- ok: true,
5123
- name: config.name,
5124
- connected: probe.ok,
5125
- toolCount: probe.toolCount,
5126
- error: probe.error,
5127
- requiresAuth: probe.requiresAuth,
5128
- });
5129
- }
5130
- catch (err) {
5131
- json(res, 500, {
5132
- error: err instanceof Error ? err.message : String(err),
5133
- });
5134
- }
5135
- });
5136
- return;
5137
- }
5138
- if (method === "POST" && url === "/mcp/remove") {
5139
- void readBody(req, res).then(async (raw) => {
5140
- if (raw === null)
5141
- return;
5142
- let name;
5143
- let scopeValue;
5144
- let bodyCwd;
5145
- try {
5146
- const body = JSON.parse(raw);
5147
- name = body.name ?? "";
5148
- scopeValue = body.scope ?? "global";
5149
- bodyCwd = body.cwd;
5150
- }
5151
- catch {
5152
- json(res, 400, { error: "invalid JSON body" });
5153
- return;
5154
- }
5155
- if (!name.trim()) {
5156
- json(res, 400, { error: "missing server name" });
5157
- return;
5158
- }
5159
- const scope = scopeValue === "project" ? "project" : "global";
5160
- if (scope === "project" && !bodyCwd) {
5161
- json(res, 400, { error: "project scope requires a project (cwd)." });
5162
- return;
5163
- }
5164
- const targetCwd = bodyCwd ?? cwd;
5165
- const removed = await removeServer(name, scope, targetCwd);
5166
- // Drop any saved OAuth tokens for this server so a re-add starts clean.
5167
- await new McpOAuthStore().clear(name).catch(() => { });
5168
- json(res, 200, { removed });
5169
- });
5170
- return;
5171
- }
5172
- // Interactive OAuth login for a remote (HTTP) MCP server. The browser is
5173
- // opened by the webview in response to the broadcast `mcp_auth_url` event;
5174
- // progress + outcome stream via `mcp_auth_status` / `mcp_auth_done` /
5175
- // `mcp_auth_error`. Responds 202 immediately and runs the flow in the
5176
- // background (the browser round-trip can take a while).
5177
- if (method === "POST" && url === "/mcp/login") {
5178
- void readBody(req, res).then(async (raw) => {
5179
- if (raw === null)
5180
- return;
5181
- let name;
5182
- let scopeValue;
5183
- let bodyCwd;
5184
- try {
5185
- const body = JSON.parse(raw);
5186
- name = body.name ?? "";
5187
- scopeValue = body.scope ?? "global";
5188
- bodyCwd = body.cwd;
5189
- }
5190
- catch {
5191
- json(res, 400, { error: "invalid JSON body" });
5192
- return;
5193
- }
5194
- if (!name.trim()) {
5195
- json(res, 400, { error: "missing server name" });
5196
- return;
5197
- }
5198
- const scope = scopeValue === "project" ? "project" : "global";
5199
- const targetCwd = bodyCwd ?? cwd;
5200
- const scoped = await getServer(name, targetCwd);
5201
- if (!scoped || scoped.scope !== scope) {
5202
- json(res, 404, { error: `No "${name}" server found.` });
5203
- return;
5204
- }
5205
- if (!scoped.config.url) {
5206
- json(res, 400, { error: "Login is only supported for HTTP MCP servers." });
5207
- return;
5208
- }
5209
- json(res, 202, { accepted: true });
5210
- broadcast("mcp_auth_status", { name, message: "Starting login\u2026" });
5211
- const manager = new MCPClientManager();
5212
- try {
5213
- const result = await manager.login(scoped.config, (authUrl) => {
5214
- broadcast("mcp_auth_url", { name, url: authUrl });
5215
- });
5216
- if (result.ok) {
5217
- broadcast("mcp_auth_done", { name, toolCount: result.toolCount });
5218
- }
5219
- else {
5220
- broadcast("mcp_auth_error", { name, message: result.error ?? "Login failed." });
5221
- }
5222
- }
5223
- catch (err) {
5224
- broadcast("mcp_auth_error", {
5225
- name,
5226
- message: err instanceof Error ? err.message : String(err),
5227
- });
5228
- }
5229
- finally {
5230
- await manager.dispose().catch(() => { });
5231
- }
5232
- });
5233
- return;
5234
- }
5235
2022
  json(res, 404, { error: "not found" });
5236
2023
  }
5237
2024
  async function dispose() {