@prestyj/cli 4.10.2 → 4.11.4
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 +4 -4
- package/dist/app-sidecar.d.ts +2 -0
- package/dist/app-sidecar.d.ts.map +1 -0
- package/dist/app-sidecar.js +1407 -0
- package/dist/app-sidecar.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -22
- package/dist/cli.js.map +1 -1
- package/dist/core/agent-session.d.ts +120 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +412 -6
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/auth-providers.d.ts +18 -0
- package/dist/core/auth-providers.d.ts.map +1 -0
- package/dist/core/auth-providers.js +71 -0
- package/dist/core/auth-providers.js.map +1 -0
- package/dist/core/event-bus.d.ts +6 -0
- package/dist/core/event-bus.d.ts.map +1 -1
- package/dist/core/event-bus.js +1 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/project-discovery.d.ts +41 -0
- package/dist/core/project-discovery.d.ts.map +1 -0
- package/dist/core/project-discovery.js +441 -0
- package/dist/core/project-discovery.js.map +1 -0
- package/dist/core/prompt-commands.d.ts.map +1 -1
- package/dist/core/prompt-commands.js +91 -0
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/prompt-commands.test.js +17 -0
- package/dist/core/prompt-commands.test.js.map +1 -1
- package/dist/core/radio.d.ts +44 -0
- package/dist/core/radio.d.ts.map +1 -0
- package/dist/core/radio.js +250 -0
- package/dist/core/radio.js.map +1 -0
- package/dist/core/resolve-start.d.ts +29 -0
- package/dist/core/resolve-start.d.ts.map +1 -0
- package/dist/core/resolve-start.js +35 -0
- package/dist/core/resolve-start.js.map +1 -0
- package/dist/core/resolve-start.test.d.ts +2 -0
- package/dist/core/resolve-start.test.d.ts.map +1 -0
- package/dist/core/resolve-start.test.js +66 -0
- package/dist/core/resolve-start.test.js.map +1 -0
- package/dist/core/settings-manager.d.ts +1 -1
- package/dist/core/shell-path.d.ts +12 -0
- package/dist/core/shell-path.d.ts.map +1 -0
- package/dist/core/shell-path.js +166 -0
- package/dist/core/shell-path.js.map +1 -0
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +10 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/telegram-config.d.ts +21 -0
- package/dist/core/telegram-config.d.ts.map +1 -0
- package/dist/core/telegram-config.js +52 -0
- package/dist/core/telegram-config.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modes/serve-mode.d.ts +24 -0
- package/dist/modes/serve-mode.d.ts.map +1 -1
- package/dist/modes/serve-mode.js +54 -19
- package/dist/modes/serve-mode.js.map +1 -1
- package/dist/tools/goal-mode.test.js +1 -1
- package/dist/tools/goal-mode.test.js.map +1 -1
- package/dist/tools/goals.d.ts +7 -7
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/subagent.d.ts +2 -4
- package/dist/tools/subagent.d.ts.map +1 -1
- package/dist/tools/subagent.js +24 -23
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/tasks.d.ts +1 -1
- package/dist/ui/hooks/useTranscriptHistory.d.ts +2 -0
- package/dist/ui/hooks/useTranscriptHistory.d.ts.map +1 -1
- package/dist/ui/hooks/useTranscriptHistory.js +3 -2
- package/dist/ui/hooks/useTranscriptHistory.js.map +1 -1
- package/dist/ui/long-prompt-regression-harness.test.js +8 -8
- package/dist/ui/render.d.ts.map +1 -1
- package/dist/ui/render.js +60 -8
- package/dist/ui/render.js.map +1 -1
- package/dist/ui/startup-banner-order.test.js +1 -1
- package/dist/ui/startup-banner-order.test.js.map +1 -1
- package/dist/ui/terminal-history-repaint.test.d.ts +2 -0
- package/dist/ui/terminal-history-repaint.test.d.ts.map +1 -0
- package/dist/ui/terminal-history-repaint.test.js +73 -0
- package/dist/ui/terminal-history-repaint.test.js.map +1 -0
- package/dist/ui/terminal-history.d.ts +1 -0
- package/dist/ui/terminal-history.d.ts.map +1 -1
- package/dist/ui/terminal-history.js +31 -2
- package/dist/ui/terminal-history.js.map +1 -1
- package/dist/utils/git.d.ts +6 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +12 -0
- package/dist/utils/git.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ezcoder-app sidecar — bridges the full ezcoder AgentSession to the Tauri webview
|
|
3
|
+
* over plain HTTP + Server-Sent Events (zero browser-side dependencies).
|
|
4
|
+
*
|
|
5
|
+
* Transport:
|
|
6
|
+
* GET /state → { provider, model, cwd, ready }
|
|
7
|
+
* GET /events → text/event-stream of forwarded agent + session events
|
|
8
|
+
* POST /prompt → { text } ; runs AgentSession.prompt(text)
|
|
9
|
+
* POST /cancel → aborts the in-flight run
|
|
10
|
+
*
|
|
11
|
+
* The agent spine (gg-ai → gg-agent → gg-core) and every tool are reused
|
|
12
|
+
* unchanged via AgentSession — this file is only a network seam.
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
import { runJsonMode } from "./modes/json-mode.js";
|
|
20
|
+
import { AgentSession } from "./core/agent-session.js";
|
|
21
|
+
import { AuthStorage } from "./core/auth-storage.js";
|
|
22
|
+
import { MOONSHOT_OAUTH_KEY } from "@prestyj/core";
|
|
23
|
+
import { loginAnthropic } from "./core/oauth/anthropic.js";
|
|
24
|
+
import { loginOpenAI } from "./core/oauth/openai.js";
|
|
25
|
+
import { loginGemini } from "./core/oauth/gemini.js";
|
|
26
|
+
import { loginKimi } from "./core/oauth/kimi.js";
|
|
27
|
+
import { AUTH_PROVIDERS } from "./core/auth-providers.js";
|
|
28
|
+
import { ensureAppDirs, loadSavedSettings } from "./config.js";
|
|
29
|
+
import { SettingsManager } from "./core/settings-manager.js";
|
|
30
|
+
import { getModel, getMaxThinkingLevel, getContextWindow, MODELS } from "./core/model-registry.js";
|
|
31
|
+
import { resolveStartOrFallback } from "./core/resolve-start.js";
|
|
32
|
+
import { getGitBranch, isGitRepo } from "./utils/git.js";
|
|
33
|
+
import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSupported, } from "./core/thinking-level.js";
|
|
34
|
+
import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
|
|
35
|
+
import { loadCustomCommands } from "./core/custom-commands.js";
|
|
36
|
+
import { discoverProjects, listRecentSessions } from "./core/project-discovery.js";
|
|
37
|
+
import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/tasks-store.js";
|
|
38
|
+
import { initLogger, log } from "./core/logger.js";
|
|
39
|
+
import { RADIO_STATIONS, getCurrentStation, playRadio, stopRadio } from "./core/radio.js";
|
|
40
|
+
import { enrichProcessPath } from "./core/shell-path.js";
|
|
41
|
+
import { startServeMode } from "./modes/serve-mode.js";
|
|
42
|
+
import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
|
|
43
|
+
const ALL_PROVIDERS = [
|
|
44
|
+
"anthropic",
|
|
45
|
+
"xiaomi",
|
|
46
|
+
"openai",
|
|
47
|
+
"gemini",
|
|
48
|
+
"glm",
|
|
49
|
+
"moonshot",
|
|
50
|
+
"minimax",
|
|
51
|
+
"deepseek",
|
|
52
|
+
"openrouter",
|
|
53
|
+
];
|
|
54
|
+
function appSettingsFile() {
|
|
55
|
+
return path.join(os.homedir(), ".ezcoder", "ezcoder-app.json");
|
|
56
|
+
}
|
|
57
|
+
function defaultProjectsRoot() {
|
|
58
|
+
return path.join(os.homedir(), "ez-projects");
|
|
59
|
+
}
|
|
60
|
+
/** Normalize a project cwd to a stable settings key so trailing slashes /
|
|
61
|
+
* relative segments collapse — the same project always maps to one entry. */
|
|
62
|
+
function projectModelKey(cwd) {
|
|
63
|
+
return path.resolve(cwd);
|
|
64
|
+
}
|
|
65
|
+
async function loadAppSettings() {
|
|
66
|
+
try {
|
|
67
|
+
const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
|
|
68
|
+
return {
|
|
69
|
+
projectsRoot: typeof raw.projectsRoot === "string" && raw.projectsRoot.trim()
|
|
70
|
+
? raw.projectsRoot
|
|
71
|
+
: defaultProjectsRoot(),
|
|
72
|
+
// Preserve the per-project map verbatim (validated + written by the
|
|
73
|
+
// model/thinking handlers below).
|
|
74
|
+
projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { projectsRoot: defaultProjectsRoot() };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function saveAppSettings(settings) {
|
|
82
|
+
await fs.mkdir(path.dirname(appSettingsFile()), { recursive: true });
|
|
83
|
+
await fs.writeFile(appSettingsFile(), JSON.stringify(settings, null, 2), "utf-8");
|
|
84
|
+
}
|
|
85
|
+
/** Read this project's persisted model/thinking prefs, if any. */
|
|
86
|
+
async function loadProjectModelPrefs(cwd) {
|
|
87
|
+
const s = await loadAppSettings();
|
|
88
|
+
return s.projectModels?.[projectModelKey(cwd)];
|
|
89
|
+
}
|
|
90
|
+
/** Persist this project's model/thinking prefs via read-modify-write so the rest
|
|
91
|
+
* of the settings file (projectsRoot, other projects' entries) is preserved. */
|
|
92
|
+
async function saveProjectModelPrefs(cwd, prefs) {
|
|
93
|
+
const s = await loadAppSettings();
|
|
94
|
+
const key = projectModelKey(cwd);
|
|
95
|
+
s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
|
|
96
|
+
await saveAppSettings(s);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Persist the active model selection to ~/.ezcoder/settings.json so it survives app
|
|
100
|
+
* restarts. Mirrors the CLI's handleModelSelect persistence (App.tsx).
|
|
101
|
+
*/
|
|
102
|
+
async function persistModelSelection(settingsFile, provider, model) {
|
|
103
|
+
try {
|
|
104
|
+
const sm = new SettingsManager(settingsFile);
|
|
105
|
+
await sm.load();
|
|
106
|
+
await sm.set("defaultProvider", provider);
|
|
107
|
+
await sm.set("defaultModel", model);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
log("WARN", "app-sidecar", "failed to persist model selection", { err: String(err) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Persist the thinking level to ~/.ezcoder/settings.json so it survives app restarts.
|
|
115
|
+
* Mirrors the CLI's handleToggleThinking persistence (App.tsx).
|
|
116
|
+
*/
|
|
117
|
+
async function persistThinkingLevel(settingsFile, level) {
|
|
118
|
+
try {
|
|
119
|
+
const sm = new SettingsManager(settingsFile);
|
|
120
|
+
await sm.load();
|
|
121
|
+
await sm.set("thinkingEnabled", !!level);
|
|
122
|
+
if (level)
|
|
123
|
+
await sm.set("thinkingLevel", level);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
log("WARN", "app-sidecar", "failed to persist thinking level", { err: String(err) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Validate a project folder name: lowercase letters, digits, dashes only. */
|
|
130
|
+
function isValidProjectName(name) {
|
|
131
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
|
|
132
|
+
}
|
|
133
|
+
async function prepareAttachments(cwd, attachments) {
|
|
134
|
+
const dir = path.join(cwd, ".ezcoder", "uploads");
|
|
135
|
+
await fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const a of attachments) {
|
|
138
|
+
// Sanitize the filename and prefix with a short timestamp to avoid clobber.
|
|
139
|
+
const safe = a.name.replace(/[^\w.-]+/g, "_").slice(-80) || "file";
|
|
140
|
+
const fileName = `${Date.now().toString(36)}-${safe}`;
|
|
141
|
+
const filePath = path.join(dir, fileName);
|
|
142
|
+
try {
|
|
143
|
+
await fs.writeFile(filePath, Buffer.from(a.data, "base64"));
|
|
144
|
+
out.push({ ...a, path: filePath });
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
out.push({ ...a });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
const FILE_SEARCH_LIMIT = 20;
|
|
153
|
+
/** Score a candidate path against a lowercased query. Higher is better; a
|
|
154
|
+
* negative score means "no match". Basename hits beat path hits; prefix beats
|
|
155
|
+
* substring; shorter paths break ties. */
|
|
156
|
+
function scoreFile(relPath, name, query) {
|
|
157
|
+
const lcPath = relPath.toLowerCase();
|
|
158
|
+
const lcName = name.toLowerCase();
|
|
159
|
+
let score = -1;
|
|
160
|
+
if (lcName === query)
|
|
161
|
+
score = 1000;
|
|
162
|
+
else if (lcName.startsWith(query))
|
|
163
|
+
score = 800;
|
|
164
|
+
else if (lcName.includes(query))
|
|
165
|
+
score = 600;
|
|
166
|
+
else if (lcPath.startsWith(query))
|
|
167
|
+
score = 400;
|
|
168
|
+
else if (lcPath.includes(query))
|
|
169
|
+
score = 200;
|
|
170
|
+
else if (subsequenceMatch(lcPath, query))
|
|
171
|
+
score = 100;
|
|
172
|
+
if (score < 0)
|
|
173
|
+
return -1;
|
|
174
|
+
// Prefer shorter paths (closer to root, less nesting) on equal match class.
|
|
175
|
+
return score - relPath.length * 0.1;
|
|
176
|
+
}
|
|
177
|
+
/** True when every char of `needle` appears in `haystack` in order (fuzzy). */
|
|
178
|
+
function subsequenceMatch(haystack, needle) {
|
|
179
|
+
let i = 0;
|
|
180
|
+
for (const ch of haystack) {
|
|
181
|
+
if (ch === needle[i])
|
|
182
|
+
i++;
|
|
183
|
+
if (i === needle.length)
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
return needle.length === 0;
|
|
187
|
+
}
|
|
188
|
+
async function searchProjectFiles(cwd, rawQuery) {
|
|
189
|
+
const fg = await import("fast-glob");
|
|
190
|
+
const ignore = await import("ignore");
|
|
191
|
+
const query = rawQuery.trim().toLowerCase();
|
|
192
|
+
let gitignore = [];
|
|
193
|
+
try {
|
|
194
|
+
const content = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8");
|
|
195
|
+
gitignore = content
|
|
196
|
+
.split("\n")
|
|
197
|
+
.map((l) => l.trim())
|
|
198
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// No .gitignore — nothing extra to ignore.
|
|
202
|
+
}
|
|
203
|
+
const ig = ignore.default().add(gitignore);
|
|
204
|
+
// `stats: true` gives mtime without a second stat pass, so the empty-query
|
|
205
|
+
// "recent files" path is a single walk.
|
|
206
|
+
const entries = await fg.default("**/*", {
|
|
207
|
+
cwd,
|
|
208
|
+
dot: false,
|
|
209
|
+
onlyFiles: true,
|
|
210
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.ezcoder/**"],
|
|
211
|
+
suppressErrors: true,
|
|
212
|
+
followSymbolicLinks: false,
|
|
213
|
+
stats: true,
|
|
214
|
+
});
|
|
215
|
+
const files = entries.filter((e) => !ig.ignores(e.path));
|
|
216
|
+
if (!query) {
|
|
217
|
+
return files
|
|
218
|
+
.sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0))
|
|
219
|
+
.slice(0, FILE_SEARCH_LIMIT)
|
|
220
|
+
.map((e) => ({ path: e.path, name: path.posix.basename(e.path) }));
|
|
221
|
+
}
|
|
222
|
+
const scored = [];
|
|
223
|
+
for (const e of files) {
|
|
224
|
+
const name = path.posix.basename(e.path);
|
|
225
|
+
const score = scoreFile(e.path, name, query);
|
|
226
|
+
if (score >= 0)
|
|
227
|
+
scored.push({ hit: { path: e.path, name }, score });
|
|
228
|
+
}
|
|
229
|
+
scored.sort((a, b) => b.score - a.score);
|
|
230
|
+
return scored.slice(0, FILE_SEARCH_LIMIT).map((s) => s.hit);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Detect whether a restored user message is actually an injected self-correction
|
|
234
|
+
* hook prompt, by its distinctive opening phrase. Returns the hook kind so the
|
|
235
|
+
* webview can render the short notice line instead of the full prompt body.
|
|
236
|
+
*/
|
|
237
|
+
function detectHookKind(text) {
|
|
238
|
+
const t = text.trimStart();
|
|
239
|
+
if (t.startsWith("Ideal? Review the actual work"))
|
|
240
|
+
return "ideal";
|
|
241
|
+
if (t.startsWith("Stuck? You've repeated essentially"))
|
|
242
|
+
return "loop_break";
|
|
243
|
+
if (t.startsWith("Re-ground. The conversation was just compacted"))
|
|
244
|
+
return "regrounding";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
// Separator AgentSession.prompt() inserts between a command's prompt body and
|
|
248
|
+
// the user's trailing args. Must stay in sync with the expansion there.
|
|
249
|
+
const COMMAND_ARGS_SEP = "\n\n## User Instructions\n\n";
|
|
250
|
+
/**
|
|
251
|
+
* Reverse a prompt-template command's expansion. When a `/name` command runs,
|
|
252
|
+
* the agent persists the FULL expanded prompt body as the user message — so on
|
|
253
|
+
* resume the raw body would render instead of the short `/name` chip the user
|
|
254
|
+
* saw live. Given the candidate commands (built-in + custom) and a restored
|
|
255
|
+
* message body, recover the original `/name [args]` invocation. Returns null
|
|
256
|
+
* when the text isn't a known command body (an ordinary user message).
|
|
257
|
+
*/
|
|
258
|
+
function detectPromptCommand(text, candidates) {
|
|
259
|
+
for (const c of candidates) {
|
|
260
|
+
if (!c.prompt)
|
|
261
|
+
continue;
|
|
262
|
+
if (text === c.prompt)
|
|
263
|
+
return `/${c.name}`;
|
|
264
|
+
if (text.startsWith(c.prompt + COMMAND_ARGS_SEP)) {
|
|
265
|
+
const args = text.slice(c.prompt.length + COMMAND_ARGS_SEP.length).trim();
|
|
266
|
+
return args ? `/${c.name} ${args}` : `/${c.name}`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Sub-agents spawn the ezcoder CLI in JSON mode to run a delegated task. In the
|
|
273
|
+
* packaged desktop app the only runnable entry is THIS bundle (there's no
|
|
274
|
+
* sibling `cli.js`), so the subagent tool ends up spawning the sidecar itself.
|
|
275
|
+
* Without this guard that would boot a second HTTP server, emit no NDJSON, and
|
|
276
|
+
* hang until the 10-minute hard timeout. So when invoked with `--json`, behave
|
|
277
|
+
* exactly like `ezcoder --json …`: stream the sub-agent run as NDJSON and exit,
|
|
278
|
+
* never starting the HTTP/SSE server. Mirrors the `values.json` branch in cli.ts.
|
|
279
|
+
*/
|
|
280
|
+
async function runJsonModeIfRequested() {
|
|
281
|
+
if (!process.argv.includes("--json"))
|
|
282
|
+
return false;
|
|
283
|
+
const { values, positionals } = parseArgs({
|
|
284
|
+
args: process.argv.slice(2),
|
|
285
|
+
options: {
|
|
286
|
+
json: { type: "boolean" },
|
|
287
|
+
provider: { type: "string" },
|
|
288
|
+
model: { type: "string" },
|
|
289
|
+
"max-turns": { type: "string" },
|
|
290
|
+
"system-prompt": { type: "string" },
|
|
291
|
+
"prompt-cache-key": { type: "string" },
|
|
292
|
+
},
|
|
293
|
+
allowPositionals: true,
|
|
294
|
+
strict: true,
|
|
295
|
+
});
|
|
296
|
+
const maxTurnsRaw = values["max-turns"];
|
|
297
|
+
await runJsonMode({
|
|
298
|
+
message: positionals[0] ?? "",
|
|
299
|
+
provider: (values.provider ?? "anthropic"),
|
|
300
|
+
model: values.model ?? "claude-opus-4-8",
|
|
301
|
+
cwd: process.cwd(),
|
|
302
|
+
systemPrompt: values["system-prompt"],
|
|
303
|
+
maxTurns: maxTurnsRaw ? parseInt(maxTurnsRaw, 10) : undefined,
|
|
304
|
+
promptCacheKey: values["prompt-cache-key"],
|
|
305
|
+
}).catch((err) => {
|
|
306
|
+
process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
|
|
307
|
+
process.exit(1);
|
|
308
|
+
});
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
async function main() {
|
|
312
|
+
// Sub-agent JSON-mode dispatch must win before any sidecar/server setup.
|
|
313
|
+
if (await runJsonModeIfRequested())
|
|
314
|
+
return;
|
|
315
|
+
const cwd = process.env.GG_APP_CWD ?? process.cwd();
|
|
316
|
+
// Default to an ephemeral port (0) so concurrent/orphaned instances never
|
|
317
|
+
// collide on a fixed port. The actual port is reported via the
|
|
318
|
+
// GG_APP_LISTENING handshake and consumed by the shell.
|
|
319
|
+
const port = Number(process.env.GG_APP_PORT ?? 0);
|
|
320
|
+
const host = "127.0.0.1";
|
|
321
|
+
const paths = await ensureAppDirs();
|
|
322
|
+
// Own log file so the app sidecar never clobbers the interactive CLI's
|
|
323
|
+
// ~/.ezcoder/debug.log (initLogger truncates on each start).
|
|
324
|
+
const sidecarLog = path.join(paths.agentDir, "ezcoder-app-sidecar.log");
|
|
325
|
+
initLogger(sidecarLog);
|
|
326
|
+
// The packaged desktop app launches from Finder/Dock with a minimal PATH that
|
|
327
|
+
// omits Homebrew/Cargo/version-manager dirs, so the agent can't find node,
|
|
328
|
+
// git, python, rg, etc. Enrich process.env.PATH from the login shell once,
|
|
329
|
+
// before anything spawns (bash tool, background tasks, LSP, git helpers all
|
|
330
|
+
// inherit it). Best-effort — never blocks startup beyond its internal cap.
|
|
331
|
+
await enrichProcessPath();
|
|
332
|
+
const auth = new AuthStorage(paths.authFile);
|
|
333
|
+
await auth.load();
|
|
334
|
+
const saved = loadSavedSettings(paths.settingsFile);
|
|
335
|
+
// Per-project model/thinking prefs win over the shared global settings.json:
|
|
336
|
+
// each window (one project cwd) restores its own selection instead of every
|
|
337
|
+
// window reading the same single global slot that the last writer clobbered
|
|
338
|
+
// (the old bug — switching models in one window reset every other window).
|
|
339
|
+
const projectPrefs = await loadProjectModelPrefs(cwd);
|
|
340
|
+
const preferred = projectPrefs?.provider ?? saved.provider ?? "anthropic";
|
|
341
|
+
const savedModel = projectPrefs?.model ?? saved.model;
|
|
342
|
+
// Boot-tolerant: when no provider is configured this returns a logged-out
|
|
343
|
+
// fallback instead of throwing, so the sidecar still listens and the login
|
|
344
|
+
// endpoints are reachable for a fresh user (throwing here used to kill the
|
|
345
|
+
// sidecar before server.listen, making first-time login impossible).
|
|
346
|
+
const { provider, model, loggedIn } = await resolveStartOrFallback(auth, ALL_PROVIDERS, preferred, savedModel);
|
|
347
|
+
if (!loggedIn) {
|
|
348
|
+
log("WARN", "app-sidecar", "no provider configured — booting logged-out for login", {
|
|
349
|
+
fallbackProvider: provider,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
// Per-project thinking prefs win over the global settings.json fallback.
|
|
353
|
+
const thinkEnabled = projectPrefs?.thinkingEnabled ?? saved.thinkingEnabled;
|
|
354
|
+
const thinkingLevel = thinkEnabled
|
|
355
|
+
? (projectPrefs?.thinkingLevel ?? saved.thinkingLevel ?? getMaxThinkingLevel(model))
|
|
356
|
+
: undefined;
|
|
357
|
+
// ── SSE fan-out (declared before the session so plan callbacks can use it) ─
|
|
358
|
+
const clients = new Set();
|
|
359
|
+
let clientSeq = 0;
|
|
360
|
+
function broadcast(type, data) {
|
|
361
|
+
const frame = `data: ${JSON.stringify({ type, data })}\n\n`;
|
|
362
|
+
for (const c of clients)
|
|
363
|
+
c.res.write(frame);
|
|
364
|
+
}
|
|
365
|
+
// When the shell respawns this sidecar for a chosen project, it passes the
|
|
366
|
+
// session file path to resume; empty/unset starts a fresh session.
|
|
367
|
+
const resumeSessionPath = process.env.GG_APP_SESSION_ID || undefined;
|
|
368
|
+
let abort = new AbortController();
|
|
369
|
+
const session = new AgentSession({
|
|
370
|
+
provider,
|
|
371
|
+
model,
|
|
372
|
+
cwd,
|
|
373
|
+
thinkingLevel,
|
|
374
|
+
sessionId: resumeSessionPath,
|
|
375
|
+
signal: abort.signal,
|
|
376
|
+
// Plan mode: the agent's enter_plan/exit_plan tools drive these. We flip
|
|
377
|
+
// session plan state (rebuilds the system prompt + enforces read-only
|
|
378
|
+
// tools) and surface the transition to the webview.
|
|
379
|
+
onEnterPlan: async (reason) => {
|
|
380
|
+
await session.setPlanMode(true);
|
|
381
|
+
broadcast("plan_enter", { reason: reason ?? "" });
|
|
382
|
+
},
|
|
383
|
+
onExitPlan: async (planPath) => {
|
|
384
|
+
await session.setPlanMode(false);
|
|
385
|
+
// Surface the plan's path + markdown so the webview can show the review
|
|
386
|
+
// modal (Accept / Feedback / Reject). Best-effort content read.
|
|
387
|
+
let content;
|
|
388
|
+
try {
|
|
389
|
+
content = await fs.readFile(planPath, "utf-8");
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
content = "";
|
|
393
|
+
}
|
|
394
|
+
broadcast("plan_exit", { planPath, content });
|
|
395
|
+
return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
await session.initialize();
|
|
399
|
+
log("INFO", "app-sidecar", "session ready", { provider, model, cwd });
|
|
400
|
+
// Footer extras (context window, git branch, background tasks). The git
|
|
401
|
+
// branch is resolved once at startup and refreshed lazily; the context
|
|
402
|
+
// window follows the active model.
|
|
403
|
+
let gitBranch = await getGitBranch(cwd).catch(() => null);
|
|
404
|
+
let gitIsRepo = await isGitRepo(cwd).catch(() => false);
|
|
405
|
+
function currentContextWindow() {
|
|
406
|
+
const st = session.getState();
|
|
407
|
+
return getContextWindow(st.model, { provider: st.provider });
|
|
408
|
+
}
|
|
409
|
+
// Shared shape merged into /state + the SSE `ready` frame so the footer can
|
|
410
|
+
// render context %, branch, and tasks immediately on connect.
|
|
411
|
+
function footerExtras() {
|
|
412
|
+
return {
|
|
413
|
+
contextWindow: currentContextWindow(),
|
|
414
|
+
gitBranch,
|
|
415
|
+
isGitRepo: gitIsRepo,
|
|
416
|
+
tasks: session.listBackgroundProcesses(),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// Forward every relevant bus event to the webview.
|
|
420
|
+
session.eventBus.on("text_delta", (d) => broadcast("text_delta", d));
|
|
421
|
+
session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
|
|
422
|
+
session.eventBus.on("tool_call_start", (d) => broadcast("tool_call_start", d));
|
|
423
|
+
session.eventBus.on("tool_call_update", (d) => broadcast("tool_call_update", d));
|
|
424
|
+
session.eventBus.on("tool_call_end", (d) => broadcast("tool_call_end", d));
|
|
425
|
+
session.eventBus.on("turn_end", (d) => broadcast("turn_end", d));
|
|
426
|
+
session.eventBus.on("agent_done", (d) => broadcast("agent_done", d));
|
|
427
|
+
session.eventBus.on("error", (d) => broadcast("error", { message: d.error instanceof Error ? d.error.message : String(d.error) }));
|
|
428
|
+
session.eventBus.on("model_change", (d) => broadcast("model_change", d));
|
|
429
|
+
session.eventBus.on("hook", (d) => broadcast("hook", d));
|
|
430
|
+
session.eventBus.on("compaction_start", (d) => broadcast("compaction_start", d));
|
|
431
|
+
session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
|
|
432
|
+
let running = false;
|
|
433
|
+
let titleGenerated = false;
|
|
434
|
+
// ── Telegram serve (remote control via Telegram) ───────────
|
|
435
|
+
// A single embedded serve session lives in this sidecar process. Only the main
|
|
436
|
+
// window's home screen exposes the controls, so there's one bot per app.
|
|
437
|
+
let serveController = null;
|
|
438
|
+
// Resumed session: if it already has a conversation, generate its title now so
|
|
439
|
+
// the title bar shows it immediately on load (not just after the next prompt).
|
|
440
|
+
{
|
|
441
|
+
const hasHistory = session
|
|
442
|
+
.getMessages()
|
|
443
|
+
.some((m) => m.role === "user" || m.role === "assistant");
|
|
444
|
+
if (hasHistory) {
|
|
445
|
+
titleGenerated = true;
|
|
446
|
+
void session.generateTitle().then((title) => {
|
|
447
|
+
if (title)
|
|
448
|
+
broadcast("session_title", { title });
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Core run lifecycle shared by /prompt and the task runner: flips `running`,
|
|
453
|
+
// brackets the run with run_start/run_end, refreshes the footer extras, and
|
|
454
|
+
// generates the session title once. `label` is the text shown live with the
|
|
455
|
+
// run_start frame.
|
|
456
|
+
async function runAgent(label, run) {
|
|
457
|
+
running = true;
|
|
458
|
+
broadcast("run_start", { text: label });
|
|
459
|
+
try {
|
|
460
|
+
await run();
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
464
|
+
broadcast("error", { message });
|
|
465
|
+
log("ERROR", "app-sidecar", "run failed", { message });
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
running = false;
|
|
469
|
+
// A run may have switched branches (git checkout) or spawned/finished
|
|
470
|
+
// background tasks — refresh the footer extras once it settles.
|
|
471
|
+
gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
|
|
472
|
+
gitIsRepo = await isGitRepo(cwd).catch(() => gitIsRepo);
|
|
473
|
+
broadcast("run_end", {});
|
|
474
|
+
// Queue drains into the run as steering, so it's empty by run_end —
|
|
475
|
+
// sync the webview indicator.
|
|
476
|
+
broadcast("queued", { count: session.getQueuedCount() });
|
|
477
|
+
broadcast("extras", footerExtras());
|
|
478
|
+
// Generate a session title once, after the first run, for the title bar
|
|
479
|
+
// (best-effort, async — don't block the response).
|
|
480
|
+
if (!titleGenerated) {
|
|
481
|
+
titleGenerated = true;
|
|
482
|
+
void session.generateTitle().then((title) => {
|
|
483
|
+
if (title)
|
|
484
|
+
broadcast("session_title", { title });
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// ── Task runner (project task list → sessions) ──────────────
|
|
490
|
+
// Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
|
|
491
|
+
// completion hint instructing the agent to mark the task done via the tasks
|
|
492
|
+
// tool. Run-all advances to the next pending task after each run finishes.
|
|
493
|
+
let taskRunAll = false;
|
|
494
|
+
async function runTaskById(taskId) {
|
|
495
|
+
const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
|
|
496
|
+
if (!task)
|
|
497
|
+
return false;
|
|
498
|
+
// Fresh session per task so one task's context never bleeds into the next.
|
|
499
|
+
await session.newSession();
|
|
500
|
+
titleGenerated = false;
|
|
501
|
+
broadcast("session_reset", {});
|
|
502
|
+
markTaskInProgress(cwd, task.id);
|
|
503
|
+
broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
|
|
504
|
+
broadcast("task_start", { id: task.id, title: task.title });
|
|
505
|
+
const shortId = task.id.slice(0, 8);
|
|
506
|
+
const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
|
|
507
|
+
`tasks({ action: "done", id: "${shortId}" })`;
|
|
508
|
+
await runAgent(task.title, () => session.prompt(task.prompt + completionHint));
|
|
509
|
+
// The agent typically marks the task done via the tasks tool during the run;
|
|
510
|
+
// push the refreshed list so the webview's task modal reflects it.
|
|
511
|
+
broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
async function runTasks(startId, all) {
|
|
515
|
+
taskRunAll = all;
|
|
516
|
+
let currentId = startId ?? getNextPendingTask(cwd)?.id ?? null;
|
|
517
|
+
while (currentId) {
|
|
518
|
+
const ran = await runTaskById(currentId);
|
|
519
|
+
if (!ran || !taskRunAll)
|
|
520
|
+
break;
|
|
521
|
+
const next = getNextPendingTask(cwd);
|
|
522
|
+
currentId = next ? next.id : null;
|
|
523
|
+
// Brief pause between tasks (mirrors the CLI cadence).
|
|
524
|
+
if (currentId)
|
|
525
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
526
|
+
}
|
|
527
|
+
taskRunAll = false;
|
|
528
|
+
broadcast("tasks_run_done", {});
|
|
529
|
+
}
|
|
530
|
+
// ── Provider auth (login) bridge ───────────────────────────
|
|
531
|
+
// OAuth login functions are interactive (open a URL, sometimes prompt for a
|
|
532
|
+
// pasted code). We run one at a time and surface every step over SSE so the
|
|
533
|
+
// webview can open the URL and collect a code via a modal. `pendingCode`
|
|
534
|
+
// resolves when the webview POSTs /auth/oauth/code.
|
|
535
|
+
let oauthInFlight = false;
|
|
536
|
+
let pendingCode = null;
|
|
537
|
+
function authCallbacks() {
|
|
538
|
+
return {
|
|
539
|
+
onOpenUrl: (url) => broadcast("auth_url", { url }),
|
|
540
|
+
onStatus: (message) => broadcast("auth_status", { message }),
|
|
541
|
+
onPromptCode: (message) => new Promise((resolve) => {
|
|
542
|
+
pendingCode = resolve;
|
|
543
|
+
broadcast("auth_need_code", { message });
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async function authStatusPayload() {
|
|
548
|
+
const providers = await Promise.all(AUTH_PROVIDERS.map(async (p) => ({
|
|
549
|
+
...p,
|
|
550
|
+
connected: await auth.hasProviderAuth(p.value),
|
|
551
|
+
})));
|
|
552
|
+
return { providers };
|
|
553
|
+
}
|
|
554
|
+
// Background tasks have no event source (the bash tool just spawns them), so
|
|
555
|
+
// poll the process manager and broadcast only when the snapshot changes. This
|
|
556
|
+
// keeps the webview footer live without a busy render loop. Adaptive cadence:
|
|
557
|
+
// tasks can only change while a run is active (the bash tool spawns them), so
|
|
558
|
+
// poll fast (1500ms) while running or while tasks exist, and back off to
|
|
559
|
+
// 5000ms when fully idle — fewer wakeups per idle window.
|
|
560
|
+
let lastTasksJson = "[]";
|
|
561
|
+
let tasksPoll;
|
|
562
|
+
let tasksPollStopped = false;
|
|
563
|
+
const scheduleTasksPoll = (delay) => {
|
|
564
|
+
if (tasksPollStopped)
|
|
565
|
+
return;
|
|
566
|
+
tasksPoll = setTimeout(() => {
|
|
567
|
+
const tasks = session.listBackgroundProcesses();
|
|
568
|
+
const next = JSON.stringify(tasks);
|
|
569
|
+
if (next !== lastTasksJson) {
|
|
570
|
+
lastTasksJson = next;
|
|
571
|
+
broadcast("tasks", { tasks });
|
|
572
|
+
}
|
|
573
|
+
const active = running || tasks.length > 0;
|
|
574
|
+
scheduleTasksPoll(active ? 1500 : 5000);
|
|
575
|
+
}, delay);
|
|
576
|
+
tasksPoll.unref?.();
|
|
577
|
+
};
|
|
578
|
+
scheduleTasksPoll(1500);
|
|
579
|
+
function readBody(req) {
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const chunks = [];
|
|
582
|
+
req.on("data", (c) => chunks.push(c));
|
|
583
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
584
|
+
req.on("error", reject);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
function json(res, status, body) {
|
|
588
|
+
const payload = JSON.stringify(body);
|
|
589
|
+
res.writeHead(status, {
|
|
590
|
+
"content-type": "application/json",
|
|
591
|
+
"access-control-allow-origin": "*",
|
|
592
|
+
});
|
|
593
|
+
res.end(payload);
|
|
594
|
+
}
|
|
595
|
+
const server = http.createServer((req, res) => {
|
|
596
|
+
const url = req.url ?? "/";
|
|
597
|
+
const method = req.method ?? "GET";
|
|
598
|
+
// CORS preflight — the webview origin differs from 127.0.0.1.
|
|
599
|
+
if (method === "OPTIONS") {
|
|
600
|
+
res.writeHead(204, {
|
|
601
|
+
"access-control-allow-origin": "*",
|
|
602
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
603
|
+
"access-control-allow-headers": "content-type",
|
|
604
|
+
});
|
|
605
|
+
res.end();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (method === "GET" && url === "/state") {
|
|
609
|
+
const st = session.getState();
|
|
610
|
+
json(res, 200, {
|
|
611
|
+
...st,
|
|
612
|
+
running,
|
|
613
|
+
ready: true,
|
|
614
|
+
thinkingLevel: session.getThinkingLevel() ?? null,
|
|
615
|
+
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
616
|
+
supportsVideo: getModel(st.model)?.supportsVideo ?? false,
|
|
617
|
+
...footerExtras(),
|
|
618
|
+
});
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (method === "GET" && url === "/events") {
|
|
622
|
+
res.writeHead(200, {
|
|
623
|
+
"content-type": "text/event-stream",
|
|
624
|
+
"cache-control": "no-cache",
|
|
625
|
+
connection: "keep-alive",
|
|
626
|
+
"access-control-allow-origin": "*",
|
|
627
|
+
});
|
|
628
|
+
res.write(`retry: 1000\n\n`);
|
|
629
|
+
const client = { id: ++clientSeq, res };
|
|
630
|
+
clients.add(client);
|
|
631
|
+
const st = session.getState();
|
|
632
|
+
res.write(`data: ${JSON.stringify({
|
|
633
|
+
type: "ready",
|
|
634
|
+
data: {
|
|
635
|
+
...st,
|
|
636
|
+
running,
|
|
637
|
+
thinkingLevel: session.getThinkingLevel() ?? null,
|
|
638
|
+
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
639
|
+
supportsVideo: getModel(st.model)?.supportsVideo ?? false,
|
|
640
|
+
...footerExtras(),
|
|
641
|
+
},
|
|
642
|
+
})}\n\n`);
|
|
643
|
+
const keepAlive = setInterval(() => res.write(`: ping\n\n`), 15000);
|
|
644
|
+
req.on("close", () => {
|
|
645
|
+
clearInterval(keepAlive);
|
|
646
|
+
clients.delete(client);
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (method === "GET" && url === "/settings") {
|
|
651
|
+
// `configured` is true only when the user explicitly saved a projects root
|
|
652
|
+
// (the ezcoder-app.json file exists with a value) — not when we fall back to the
|
|
653
|
+
// default. The home screen gates "Your Projects" on this.
|
|
654
|
+
void (async () => {
|
|
655
|
+
const s = await loadAppSettings();
|
|
656
|
+
let configured;
|
|
657
|
+
try {
|
|
658
|
+
const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
|
|
659
|
+
configured = typeof raw.projectsRoot === "string" && raw.projectsRoot.trim().length > 0;
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
configured = false;
|
|
663
|
+
}
|
|
664
|
+
// Only projectsRoot + configured flag are webview-facing; the
|
|
665
|
+
// per-project model map is internal persistence, never shipped out.
|
|
666
|
+
json(res, 200, { projectsRoot: s.projectsRoot, configured });
|
|
667
|
+
})();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (method === "POST" && url === "/settings") {
|
|
671
|
+
void readBody(req).then(async (raw) => {
|
|
672
|
+
let projectsRoot;
|
|
673
|
+
try {
|
|
674
|
+
projectsRoot = JSON.parse(raw).projectsRoot ?? "";
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (!projectsRoot.trim()) {
|
|
681
|
+
json(res, 400, { error: "projectsRoot is required" });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
// Read-modify-write so the per-project model map survives a projectsRoot
|
|
685
|
+
// change (a naive overwrite would drop every window's saved model).
|
|
686
|
+
const s = await loadAppSettings();
|
|
687
|
+
s.projectsRoot = projectsRoot;
|
|
688
|
+
await saveAppSettings(s);
|
|
689
|
+
json(res, 200, { projectsRoot });
|
|
690
|
+
});
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (method === "POST" && url === "/create-project") {
|
|
694
|
+
void readBody(req).then(async (raw) => {
|
|
695
|
+
let name;
|
|
696
|
+
try {
|
|
697
|
+
name = JSON.parse(raw).name ?? "";
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
name = name.trim();
|
|
704
|
+
if (!isValidProjectName(name)) {
|
|
705
|
+
json(res, 400, {
|
|
706
|
+
error: "Project name must be lowercase letters, digits, and dashes (e.g. my-project).",
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const { projectsRoot } = await loadAppSettings();
|
|
711
|
+
const dir = path.join(projectsRoot, name);
|
|
712
|
+
try {
|
|
713
|
+
// Refuse to clobber an existing directory.
|
|
714
|
+
const exists = await fs
|
|
715
|
+
.stat(dir)
|
|
716
|
+
.then(() => true)
|
|
717
|
+
.catch(() => false);
|
|
718
|
+
if (exists) {
|
|
719
|
+
json(res, 409, { error: `A folder named "${name}" already exists.` });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
await fs.mkdir(dir, { recursive: true });
|
|
723
|
+
json(res, 200, { path: dir });
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (method === "GET" && url === "/projects") {
|
|
732
|
+
// Scan ezcoder + Claude Code + Codex session stores for known projects.
|
|
733
|
+
void discoverProjects()
|
|
734
|
+
.then((projects) => json(res, 200, { projects }))
|
|
735
|
+
.catch((err) => {
|
|
736
|
+
log("ERROR", "app-sidecar", "discoverProjects failed", {
|
|
737
|
+
message: err instanceof Error ? err.message : String(err),
|
|
738
|
+
});
|
|
739
|
+
json(res, 200, { projects: [] });
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (method === "GET" && url.startsWith("/sessions")) {
|
|
744
|
+
const target = new URL(url, `http://${host}`).searchParams.get("cwd");
|
|
745
|
+
if (!target) {
|
|
746
|
+
json(res, 400, { error: "missing cwd query param" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
void listRecentSessions(target, 5)
|
|
750
|
+
.then((sessions) => json(res, 200, { sessions }))
|
|
751
|
+
.catch(() => json(res, 200, { sessions: [] }));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (method === "GET" && url.startsWith("/files")) {
|
|
755
|
+
const q = new URL(url, `http://${host}`).searchParams.get("q") ?? "";
|
|
756
|
+
void searchProjectFiles(cwd, q)
|
|
757
|
+
.then((files) => json(res, 200, { files }))
|
|
758
|
+
.catch((err) => {
|
|
759
|
+
log("ERROR", "app-sidecar", "searchProjectFiles failed", {
|
|
760
|
+
message: err instanceof Error ? err.message : String(err),
|
|
761
|
+
});
|
|
762
|
+
json(res, 200, { files: [] });
|
|
763
|
+
});
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (method === "GET" && url === "/history") {
|
|
767
|
+
// Flatten the resumed conversation into the webview's transcript shape:
|
|
768
|
+
// user + assistant TEXT only (tools live in the live panel, never the
|
|
769
|
+
// transcript; system + tool-result messages are omitted). Self-correction
|
|
770
|
+
// hook prompts (injected as user messages) are tagged with their `hook`
|
|
771
|
+
// kind so the webview renders the short "Hook engaged" line, not the raw
|
|
772
|
+
// prompt body — matching how they appear live.
|
|
773
|
+
//
|
|
774
|
+
// Prompt-template commands persist their FULL expanded body as the user
|
|
775
|
+
// message, so on resume we reverse the expansion (built-in + custom
|
|
776
|
+
// candidates) back to the short `/name [args]` chip the user saw live.
|
|
777
|
+
void (async () => {
|
|
778
|
+
const commandCandidates = [...PROMPT_COMMANDS, ...(await loadCustomCommands(cwd))];
|
|
779
|
+
const history = session
|
|
780
|
+
.getMessages()
|
|
781
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
782
|
+
.map((m) => {
|
|
783
|
+
// `m.content` is a union of differently-typed arrays (user vs
|
|
784
|
+
// assistant parts), so a type-predicate filter won't narrow cleanly.
|
|
785
|
+
// A structural `"text" in c` check extracts text from any text-bearing
|
|
786
|
+
// part regardless of the surrounding union.
|
|
787
|
+
const text = typeof m.content === "string"
|
|
788
|
+
? m.content
|
|
789
|
+
: m.content
|
|
790
|
+
.map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
|
|
791
|
+
.join("");
|
|
792
|
+
// Reconstruct image attachments as data URLs so they re-render on
|
|
793
|
+
// resume — the webview only ever saw the live SSE stream, and the
|
|
794
|
+
// persisted message holds the base64 bytes. Without this, attached
|
|
795
|
+
// images vanish when returning to a session.
|
|
796
|
+
const images = typeof m.content === "string"
|
|
797
|
+
? []
|
|
798
|
+
: m.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
|
|
799
|
+
const hook = m.role === "user" ? detectHookKind(text) : null;
|
|
800
|
+
// A compacted session persists its summary as a user message prefixed
|
|
801
|
+
// with this marker. Tag it so the webview renders the quiet "Compacted
|
|
802
|
+
// context" notice instead of dumping the full summary body.
|
|
803
|
+
const compacted = m.role === "user" && !hook && text.startsWith("[Previous conversation summary]");
|
|
804
|
+
// Recover a `/name [args]` command invocation from its expanded body
|
|
805
|
+
// (skip messages already claimed as hooks or compaction summaries).
|
|
806
|
+
const command = m.role === "user" && !hook && !compacted
|
|
807
|
+
? detectPromptCommand(text, commandCandidates)
|
|
808
|
+
: null;
|
|
809
|
+
return {
|
|
810
|
+
role: m.role,
|
|
811
|
+
text: command ?? text,
|
|
812
|
+
images,
|
|
813
|
+
hook,
|
|
814
|
+
command: command !== null,
|
|
815
|
+
compacted,
|
|
816
|
+
};
|
|
817
|
+
})
|
|
818
|
+
// Keep messages with text OR images — an image-only user turn has empty
|
|
819
|
+
// text but must still appear.
|
|
820
|
+
.filter((m) => m.text.trim().length > 0 || m.images.length > 0);
|
|
821
|
+
json(res, 200, { history });
|
|
822
|
+
})();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (method === "GET" && url === "/commands") {
|
|
826
|
+
// Workflow commands with agent functionality: built-in prompt templates +
|
|
827
|
+
// the user's own `.ezcoder/commands/*.md`. UI commands (model/quit/etc.) are
|
|
828
|
+
// handled webview-side and intentionally excluded.
|
|
829
|
+
void (async () => {
|
|
830
|
+
const builtins = PROMPT_COMMANDS.map((c) => ({
|
|
831
|
+
name: c.name,
|
|
832
|
+
aliases: c.aliases,
|
|
833
|
+
description: c.description,
|
|
834
|
+
source: "built-in",
|
|
835
|
+
}));
|
|
836
|
+
const custom = (await loadCustomCommands(cwd))
|
|
837
|
+
// A custom command can't shadow a built-in name.
|
|
838
|
+
.filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name))
|
|
839
|
+
.map((c) => ({
|
|
840
|
+
name: c.name,
|
|
841
|
+
aliases: [],
|
|
842
|
+
description: c.description,
|
|
843
|
+
source: "custom",
|
|
844
|
+
}));
|
|
845
|
+
json(res, 200, { commands: [...builtins, ...custom] });
|
|
846
|
+
})();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (method === "POST" && url === "/prompt") {
|
|
850
|
+
void readBody(req).then(async (raw) => {
|
|
851
|
+
let text;
|
|
852
|
+
let attachments;
|
|
853
|
+
try {
|
|
854
|
+
const body = JSON.parse(raw);
|
|
855
|
+
text = body.text ?? "";
|
|
856
|
+
attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (!text.trim() && attachments.length === 0) {
|
|
863
|
+
json(res, 400, { error: "empty prompt" });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (running) {
|
|
867
|
+
// Queue text prompts as mid-run steering (mirrors the CLI). Attachments
|
|
868
|
+
// aren't supported mid-run — reject those so the user resends after.
|
|
869
|
+
if (attachments.length > 0) {
|
|
870
|
+
json(res, 409, { error: "cannot attach files while the agent is running" });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const count = session.queueMessage(text);
|
|
874
|
+
broadcast("queued", { count });
|
|
875
|
+
json(res, 202, { queued: true, count });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
json(res, 202, { accepted: true });
|
|
879
|
+
await runAgent(text, async () => {
|
|
880
|
+
if (attachments.length > 0) {
|
|
881
|
+
// Persist each attachment under .ezcoder/uploads so files are inspectable
|
|
882
|
+
// by the agent's tools, then prompt with the media as native blocks.
|
|
883
|
+
const prepared = await prepareAttachments(cwd, attachments);
|
|
884
|
+
await session.promptWithAttachments(text, prepared);
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
// Pass the raw text straight through. AgentSession.prompt() is the
|
|
888
|
+
// single source of truth for slash-command expansion (built-in +
|
|
889
|
+
// `.ezcoder/commands/*.md` custom), so the agent gets the right body
|
|
890
|
+
// while the webview keeps showing the short `/name`.
|
|
891
|
+
await session.prompt(text);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (method === "GET" && url === "/tasks") {
|
|
898
|
+
json(res, 200, { tasks: loadTasksSync(cwd) });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
// ── Radio ─────────────────────────────────────────────────
|
|
902
|
+
// Playback runs in THIS sidecar process, which is unique per window, so a
|
|
903
|
+
// station only plays in the window that started it.
|
|
904
|
+
if (method === "GET" && url === "/radio") {
|
|
905
|
+
json(res, 200, { stations: RADIO_STATIONS, current: getCurrentStation() });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (method === "POST" && url === "/radio") {
|
|
909
|
+
void readBody(req).then((raw) => {
|
|
910
|
+
let station;
|
|
911
|
+
try {
|
|
912
|
+
station = JSON.parse(raw).station ?? "";
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (!station || station === "off") {
|
|
919
|
+
stopRadio();
|
|
920
|
+
json(res, 200, { current: null });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const result = playRadio(station);
|
|
924
|
+
if (!result.ok) {
|
|
925
|
+
json(res, 400, { error: result.error ?? "Radio failed to start." });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
json(res, 200, { current: getCurrentStation() });
|
|
929
|
+
});
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (method === "POST" && url === "/tasks/run") {
|
|
933
|
+
void readBody(req).then((raw) => {
|
|
934
|
+
let id;
|
|
935
|
+
let all;
|
|
936
|
+
try {
|
|
937
|
+
const body = JSON.parse(raw);
|
|
938
|
+
id = body.id ?? null;
|
|
939
|
+
all = Boolean(body.all);
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (running) {
|
|
946
|
+
json(res, 409, { error: "cannot run a task while the agent is running" });
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
json(res, 202, { accepted: true });
|
|
950
|
+
void runTasks(id, all);
|
|
951
|
+
});
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (method === "POST" && url === "/tasks/delete") {
|
|
955
|
+
void readBody(req).then((raw) => {
|
|
956
|
+
let id;
|
|
957
|
+
try {
|
|
958
|
+
id = JSON.parse(raw).id ?? "";
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (!id.trim()) {
|
|
965
|
+
json(res, 400, { error: "missing task id" });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const remaining = loadTasksSync(cwd).filter((t) => t.id !== id && !t.id.startsWith(id));
|
|
969
|
+
saveTasksSync(cwd, remaining);
|
|
970
|
+
json(res, 200, { tasks: remaining });
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (method === "GET" && url === "/models") {
|
|
975
|
+
void (async () => {
|
|
976
|
+
const loggedIn = [];
|
|
977
|
+
for (const p of ALL_PROVIDERS) {
|
|
978
|
+
if (await auth.hasProviderAuth(p))
|
|
979
|
+
loggedIn.push(p);
|
|
980
|
+
}
|
|
981
|
+
// Just the names, grouped by provider in registry order — the UI shows
|
|
982
|
+
// a clean multi-column list of model ids.
|
|
983
|
+
const models = MODELS.filter((m) => loggedIn.includes(m.provider)).map((m) => ({
|
|
984
|
+
id: m.id,
|
|
985
|
+
name: m.name,
|
|
986
|
+
provider: m.provider,
|
|
987
|
+
}));
|
|
988
|
+
json(res, 200, { models });
|
|
989
|
+
})();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (method === "POST" && url === "/model") {
|
|
993
|
+
void readBody(req).then(async (raw) => {
|
|
994
|
+
let modelId;
|
|
995
|
+
try {
|
|
996
|
+
modelId = JSON.parse(raw).model ?? "";
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const target = getModel(modelId);
|
|
1003
|
+
if (!target) {
|
|
1004
|
+
json(res, 404, { error: `unknown model: ${modelId}` });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (running) {
|
|
1008
|
+
json(res, 409, { error: "cannot switch model while running" });
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
await session.switchModel(target.provider, target.id);
|
|
1012
|
+
// Clamp the reasoning level to what the new model supports (mirrors the
|
|
1013
|
+
// CLI): keep thinking on at the first supported tier if it was on but
|
|
1014
|
+
// the prior level is unsupported here; leave it off if it was off.
|
|
1015
|
+
const prevLevel = session.getThinkingLevel();
|
|
1016
|
+
if (prevLevel && !isThinkingLevelSupported(target.provider, target.id, prevLevel)) {
|
|
1017
|
+
session.setThinkingLevel(getNextThinkingLevel(target.provider, target.id, undefined));
|
|
1018
|
+
}
|
|
1019
|
+
// Persist per-project so THIS window/project restores its own model on
|
|
1020
|
+
// restart (not the single global slot every window shares). Keep the
|
|
1021
|
+
// global write too as a "last used" fallback for never-opened projects
|
|
1022
|
+
// and so the CLI stays in sync.
|
|
1023
|
+
await saveProjectModelPrefs(cwd, {
|
|
1024
|
+
provider: target.provider,
|
|
1025
|
+
model: target.id,
|
|
1026
|
+
thinkingEnabled: !!session.getThinkingLevel(),
|
|
1027
|
+
thinkingLevel: session.getThinkingLevel() ?? undefined,
|
|
1028
|
+
});
|
|
1029
|
+
await persistModelSelection(paths.settingsFile, target.provider, target.id);
|
|
1030
|
+
await persistThinkingLevel(paths.settingsFile, session.getThinkingLevel());
|
|
1031
|
+
const payload = {
|
|
1032
|
+
thinkingLevel: session.getThinkingLevel() ?? null,
|
|
1033
|
+
supportedThinkingLevels: getSupportedThinkingLevels(target.provider, target.id),
|
|
1034
|
+
};
|
|
1035
|
+
// model_change is emitted by switchModel; follow with thinking_change so
|
|
1036
|
+
// the footer toggle reflects the new model's supported levels.
|
|
1037
|
+
broadcast("thinking_change", payload);
|
|
1038
|
+
// The new model usually has a different context window — push extras so
|
|
1039
|
+
// the footer's context meter rescales immediately.
|
|
1040
|
+
broadcast("extras", footerExtras());
|
|
1041
|
+
json(res, 200, { provider: target.provider, model: target.id, ...payload });
|
|
1042
|
+
});
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (method === "POST" && url === "/kill") {
|
|
1046
|
+
void readBody(req).then(async (raw) => {
|
|
1047
|
+
let id;
|
|
1048
|
+
try {
|
|
1049
|
+
id = JSON.parse(raw).id ?? "";
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (!id.trim()) {
|
|
1056
|
+
json(res, 400, { error: "missing task id" });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const message = await session.killBackgroundProcess(id);
|
|
1060
|
+
// Push the updated task list right away rather than waiting for the poll.
|
|
1061
|
+
broadcast("tasks", { tasks: session.listBackgroundProcesses() });
|
|
1062
|
+
json(res, 200, { message });
|
|
1063
|
+
});
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (method === "POST" && url === "/thinking") {
|
|
1067
|
+
const st = session.getState();
|
|
1068
|
+
const next = getNextThinkingLevel(st.provider, st.model, session.getThinkingLevel());
|
|
1069
|
+
session.setThinkingLevel(next);
|
|
1070
|
+
// Persist per-project so THIS window restores its thinking state on
|
|
1071
|
+
// restart; keep the global write as a fallback (mirrors the CLI).
|
|
1072
|
+
void saveProjectModelPrefs(cwd, {
|
|
1073
|
+
provider: st.provider,
|
|
1074
|
+
model: st.model,
|
|
1075
|
+
thinkingEnabled: !!next,
|
|
1076
|
+
thinkingLevel: next ?? undefined,
|
|
1077
|
+
}).then(() => persistThinkingLevel(paths.settingsFile, next));
|
|
1078
|
+
const payload = {
|
|
1079
|
+
thinkingLevel: next ?? null,
|
|
1080
|
+
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
1081
|
+
};
|
|
1082
|
+
broadcast("thinking_change", payload);
|
|
1083
|
+
json(res, 200, payload);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (method === "POST" && url === "/cancel") {
|
|
1087
|
+
abort.abort();
|
|
1088
|
+
abort = new AbortController();
|
|
1089
|
+
session.setSignal(abort.signal);
|
|
1090
|
+
running = false;
|
|
1091
|
+
// Stop a run-all sweep so the next pending task isn't auto-started.
|
|
1092
|
+
taskRunAll = false;
|
|
1093
|
+
// Drop any queued steering and return it so the webview can restore it to
|
|
1094
|
+
// the composer.
|
|
1095
|
+
const drained = session.drainQueue();
|
|
1096
|
+
broadcast("run_end", { cancelled: true });
|
|
1097
|
+
broadcast("queued", { count: 0 });
|
|
1098
|
+
json(res, 200, { cancelled: true, drained });
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (method === "POST" && url === "/new-session") {
|
|
1102
|
+
if (running) {
|
|
1103
|
+
json(res, 409, { error: "cannot start a new session while running" });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
void session
|
|
1107
|
+
.newSession()
|
|
1108
|
+
.then(() => {
|
|
1109
|
+
broadcast("session_reset", {});
|
|
1110
|
+
json(res, 200, { ok: true });
|
|
1111
|
+
})
|
|
1112
|
+
.catch((err) => {
|
|
1113
|
+
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1114
|
+
});
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
// Bake an approved plan into the system prompt before the implementation
|
|
1118
|
+
// prompt runs, so the model is told to emit `[DONE:n]` markers and the
|
|
1119
|
+
// webview's plan-progress widget advances (mirrors the CLI accept flow).
|
|
1120
|
+
if (method === "POST" && url === "/plan/accept") {
|
|
1121
|
+
void readBody(req).then(async (raw) => {
|
|
1122
|
+
let planPath;
|
|
1123
|
+
try {
|
|
1124
|
+
planPath = JSON.parse(raw).planPath || undefined;
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
await session.setApprovedPlan(planPath);
|
|
1132
|
+
json(res, 200, { ok: true });
|
|
1133
|
+
}
|
|
1134
|
+
catch (err) {
|
|
1135
|
+
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
// ── Provider auth (login) ───────────────────────────────
|
|
1141
|
+
if (method === "GET" && url === "/auth/status") {
|
|
1142
|
+
void authStatusPayload().then((payload) => json(res, 200, payload));
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (method === "POST" && url === "/auth/apikey") {
|
|
1146
|
+
void readBody(req).then(async (raw) => {
|
|
1147
|
+
let provider = "";
|
|
1148
|
+
let key;
|
|
1149
|
+
try {
|
|
1150
|
+
const body = JSON.parse(raw);
|
|
1151
|
+
provider = body.provider ?? "";
|
|
1152
|
+
key = (body.key ?? "").trim();
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
|
|
1159
|
+
if (!meta || !meta.methods.includes("apikey")) {
|
|
1160
|
+
json(res, 400, { error: "provider does not support API key auth" });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (!key) {
|
|
1164
|
+
json(res, 400, { error: "API key is required" });
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const creds = {
|
|
1168
|
+
accessToken: key,
|
|
1169
|
+
refreshToken: "",
|
|
1170
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
|
|
1171
|
+
...(meta.apiKeyBaseUrl ? { baseUrl: meta.apiKeyBaseUrl } : {}),
|
|
1172
|
+
};
|
|
1173
|
+
await auth.setCredentials(provider, creds);
|
|
1174
|
+
broadcast("auth_done", { provider });
|
|
1175
|
+
json(res, 200, { ok: true });
|
|
1176
|
+
});
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (method === "POST" && url === "/auth/oauth/start") {
|
|
1180
|
+
void readBody(req).then((raw) => {
|
|
1181
|
+
let provider = "";
|
|
1182
|
+
try {
|
|
1183
|
+
provider = JSON.parse(raw).provider ?? "";
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
|
|
1190
|
+
if (!meta || !meta.methods.includes("oauth")) {
|
|
1191
|
+
json(res, 400, { error: "provider does not support OAuth" });
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (oauthInFlight) {
|
|
1195
|
+
json(res, 409, { error: "a login is already in progress" });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
oauthInFlight = true;
|
|
1199
|
+
json(res, 202, { accepted: true });
|
|
1200
|
+
void (async () => {
|
|
1201
|
+
const cb = authCallbacks();
|
|
1202
|
+
try {
|
|
1203
|
+
let creds;
|
|
1204
|
+
let storageKey = provider;
|
|
1205
|
+
if (provider === "anthropic")
|
|
1206
|
+
creds = await loginAnthropic(cb);
|
|
1207
|
+
else if (provider === "openai")
|
|
1208
|
+
creds = await loginOpenAI(cb);
|
|
1209
|
+
else if (provider === "gemini")
|
|
1210
|
+
creds = await loginGemini(cb);
|
|
1211
|
+
else if (provider === "moonshot") {
|
|
1212
|
+
creds = await loginKimi(cb);
|
|
1213
|
+
storageKey = MOONSHOT_OAUTH_KEY;
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
throw new Error(`OAuth not implemented for ${provider}`);
|
|
1217
|
+
}
|
|
1218
|
+
await auth.setCredentials(storageKey, creds);
|
|
1219
|
+
broadcast("auth_done", { provider });
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
broadcast("auth_error", {
|
|
1223
|
+
provider,
|
|
1224
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
finally {
|
|
1228
|
+
oauthInFlight = false;
|
|
1229
|
+
pendingCode = null;
|
|
1230
|
+
}
|
|
1231
|
+
})();
|
|
1232
|
+
});
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (method === "POST" && url === "/auth/oauth/code") {
|
|
1236
|
+
void readBody(req).then((raw) => {
|
|
1237
|
+
let code;
|
|
1238
|
+
try {
|
|
1239
|
+
code = JSON.parse(raw).code ?? "";
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (!pendingCode) {
|
|
1246
|
+
json(res, 409, { error: "no login is awaiting a code" });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
pendingCode(code.trim());
|
|
1250
|
+
pendingCode = null;
|
|
1251
|
+
json(res, 200, { ok: true });
|
|
1252
|
+
});
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (method === "POST" && url === "/auth/logout") {
|
|
1256
|
+
void readBody(req).then(async (raw) => {
|
|
1257
|
+
let provider;
|
|
1258
|
+
try {
|
|
1259
|
+
provider = JSON.parse(raw).provider ?? "";
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
await auth.clearCredentials(provider);
|
|
1266
|
+
// Moonshot's OAuth credential lives under a distinct key — clear both so
|
|
1267
|
+
// "disconnect" fully removes Kimi OAuth and the API key.
|
|
1268
|
+
if (provider === "moonshot")
|
|
1269
|
+
await auth.clearCredentials(MOONSHOT_OAUTH_KEY);
|
|
1270
|
+
broadcast("auth_done", { provider });
|
|
1271
|
+
json(res, 200, { ok: true });
|
|
1272
|
+
});
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
// ── Telegram config (mirrors `ezcoder telegram`) ─────────
|
|
1276
|
+
if (method === "GET" && url === "/telegram") {
|
|
1277
|
+
void loadTelegramConfig().then((cfg) => {
|
|
1278
|
+
if (!cfg) {
|
|
1279
|
+
json(res, 200, { configured: false });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// Never return the raw token to the webview — a short masked preview is
|
|
1283
|
+
// enough to show "already set".
|
|
1284
|
+
const t = cfg.botToken;
|
|
1285
|
+
const tokenPreview = t.length > 14 ? `${t.slice(0, 10)}\u2026${t.slice(-4)}` : "set";
|
|
1286
|
+
json(res, 200, { configured: true, userId: cfg.userId, tokenPreview });
|
|
1287
|
+
});
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (method === "POST" && url === "/telegram") {
|
|
1291
|
+
void readBody(req).then(async (raw) => {
|
|
1292
|
+
let botTokenInput;
|
|
1293
|
+
let userIdInput;
|
|
1294
|
+
try {
|
|
1295
|
+
const body = JSON.parse(raw);
|
|
1296
|
+
botTokenInput = (body.botToken ?? "").trim();
|
|
1297
|
+
userIdInput = String(body.userId ?? "").trim();
|
|
1298
|
+
}
|
|
1299
|
+
catch {
|
|
1300
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
// Keep the existing token when the field is left blank (the webview shows
|
|
1304
|
+
// a masked preview, not the real token).
|
|
1305
|
+
const existing = await loadTelegramConfig();
|
|
1306
|
+
const botToken = botTokenInput || existing?.botToken || "";
|
|
1307
|
+
if (!botToken) {
|
|
1308
|
+
json(res, 400, { error: "Bot token is required." });
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const userId = userIdInput ? parseInt(userIdInput, 10) : existing?.userId;
|
|
1312
|
+
if (!userId || Number.isNaN(userId)) {
|
|
1313
|
+
json(res, 400, { error: "A numeric Telegram user ID is required." });
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const verified = await verifyBotToken(botToken);
|
|
1317
|
+
if (!verified.ok) {
|
|
1318
|
+
json(res, 400, { error: "Invalid bot token — Telegram rejected it." });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
await saveTelegramConfig({ botToken, userId });
|
|
1322
|
+
json(res, 200, { ok: true, userId, username: verified.username ?? null });
|
|
1323
|
+
});
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
// ── Serve lifecycle (mirrors `ezcoder serve`) ───────────
|
|
1327
|
+
if (method === "GET" && url === "/serve") {
|
|
1328
|
+
void loadTelegramConfig().then((cfg) => json(res, 200, { running: serveController !== null, configured: cfg !== null }));
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (method === "POST" && url === "/serve/start") {
|
|
1332
|
+
void (async () => {
|
|
1333
|
+
if (serveController) {
|
|
1334
|
+
json(res, 200, { running: true });
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const cfg = await loadTelegramConfig();
|
|
1338
|
+
if (!cfg) {
|
|
1339
|
+
json(res, 400, { error: "Telegram isn't set up yet. Open Serve settings first." });
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
const st = session.getState();
|
|
1343
|
+
try {
|
|
1344
|
+
serveController = await startServeMode({
|
|
1345
|
+
provider: st.provider,
|
|
1346
|
+
model: st.model,
|
|
1347
|
+
cwd,
|
|
1348
|
+
version: "app",
|
|
1349
|
+
thinkingLevel: session.getThinkingLevel() ?? undefined,
|
|
1350
|
+
telegram: { botToken: cfg.botToken, userId: cfg.userId },
|
|
1351
|
+
embedded: true,
|
|
1352
|
+
});
|
|
1353
|
+
broadcast("serve_change", { running: true });
|
|
1354
|
+
log("INFO", "app-sidecar", "serve started", { userId: cfg.userId });
|
|
1355
|
+
json(res, 200, { running: true });
|
|
1356
|
+
}
|
|
1357
|
+
catch (err) {
|
|
1358
|
+
serveController = null;
|
|
1359
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
1360
|
+
}
|
|
1361
|
+
})();
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
if (method === "POST" && url === "/serve/stop") {
|
|
1365
|
+
void (async () => {
|
|
1366
|
+
if (serveController) {
|
|
1367
|
+
await serveController.stop().catch(() => { });
|
|
1368
|
+
serveController = null;
|
|
1369
|
+
broadcast("serve_change", { running: false });
|
|
1370
|
+
log("INFO", "app-sidecar", "serve stopped");
|
|
1371
|
+
}
|
|
1372
|
+
json(res, 200, { running: false });
|
|
1373
|
+
})();
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
json(res, 404, { error: "not found" });
|
|
1377
|
+
});
|
|
1378
|
+
server.listen(port, host, () => {
|
|
1379
|
+
const addr = server.address();
|
|
1380
|
+
// The Rust shell reads this line to learn the port.
|
|
1381
|
+
process.stdout.write(`GG_APP_LISTENING ${addr.port}\n`);
|
|
1382
|
+
log("INFO", "app-sidecar", "listening", { port: String(addr.port), host });
|
|
1383
|
+
});
|
|
1384
|
+
const shutdown = async () => {
|
|
1385
|
+
tasksPollStopped = true;
|
|
1386
|
+
if (tasksPoll)
|
|
1387
|
+
clearTimeout(tasksPoll);
|
|
1388
|
+
// Kill any playing radio so the stream dies with its window.
|
|
1389
|
+
stopRadio();
|
|
1390
|
+
// Stop the Telegram serve loop + dispose its per-chat sessions.
|
|
1391
|
+
if (serveController)
|
|
1392
|
+
await serveController.stop().catch(() => { });
|
|
1393
|
+
for (const c of clients)
|
|
1394
|
+
c.res.end();
|
|
1395
|
+
server.close();
|
|
1396
|
+
await session.dispose().catch(() => { });
|
|
1397
|
+
process.exit(0);
|
|
1398
|
+
};
|
|
1399
|
+
process.on("SIGINT", () => void shutdown());
|
|
1400
|
+
process.on("SIGTERM", () => void shutdown());
|
|
1401
|
+
}
|
|
1402
|
+
main().catch((err) => {
|
|
1403
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1404
|
+
process.stderr.write(`GG_APP_FATAL ${message}\n`);
|
|
1405
|
+
process.exit(1);
|
|
1406
|
+
});
|
|
1407
|
+
//# sourceMappingURL=app-sidecar.js.map
|