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