@kenkaiiii/ggcoder 4.10.1 → 4.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/app-sidecar.d.ts +2 -0
  2. package/dist/app-sidecar.d.ts.map +1 -0
  3. package/dist/app-sidecar.js +848 -0
  4. package/dist/app-sidecar.js.map +1 -0
  5. package/dist/core/agent-session.d.ts +94 -0
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +313 -1
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/auth-providers.d.ts +18 -0
  10. package/dist/core/auth-providers.d.ts.map +1 -0
  11. package/dist/core/auth-providers.js +71 -0
  12. package/dist/core/auth-providers.js.map +1 -0
  13. package/dist/core/event-bus.d.ts +5 -0
  14. package/dist/core/event-bus.d.ts.map +1 -1
  15. package/dist/core/event-bus.js +1 -0
  16. package/dist/core/event-bus.js.map +1 -1
  17. package/dist/core/project-discovery.d.ts +41 -0
  18. package/dist/core/project-discovery.d.ts.map +1 -0
  19. package/dist/core/project-discovery.js +439 -0
  20. package/dist/core/project-discovery.js.map +1 -0
  21. package/dist/core/settings-manager.d.ts +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/tools/tasks.d.ts +1 -1
  27. package/dist/ui/hooks/useTranscriptHistory.d.ts +2 -0
  28. package/dist/ui/hooks/useTranscriptHistory.d.ts.map +1 -1
  29. package/dist/ui/hooks/useTranscriptHistory.js +3 -2
  30. package/dist/ui/hooks/useTranscriptHistory.js.map +1 -1
  31. package/dist/ui/render.d.ts.map +1 -1
  32. package/dist/ui/render.js +60 -8
  33. package/dist/ui/render.js.map +1 -1
  34. package/dist/ui/terminal-history-repaint.test.d.ts +2 -0
  35. package/dist/ui/terminal-history-repaint.test.d.ts.map +1 -0
  36. package/dist/ui/terminal-history-repaint.test.js +73 -0
  37. package/dist/ui/terminal-history-repaint.test.js.map +1 -0
  38. package/dist/ui/terminal-history.d.ts +1 -0
  39. package/dist/ui/terminal-history.d.ts.map +1 -1
  40. package/dist/ui/terminal-history.js +31 -2
  41. package/dist/ui/terminal-history.js.map +1 -1
  42. package/package.json +4 -4
@@ -0,0 +1,848 @@
1
+ /**
2
+ * gg-app sidecar — bridges the full ggcoder 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 { AgentSession } from "./core/agent-session.js";
19
+ import { AuthStorage } from "./core/auth-storage.js";
20
+ import { MOONSHOT_OAUTH_KEY } from "@kenkaiiii/gg-core";
21
+ import { loginAnthropic } from "./core/oauth/anthropic.js";
22
+ import { loginOpenAI } from "./core/oauth/openai.js";
23
+ import { loginGemini } from "./core/oauth/gemini.js";
24
+ import { loginKimi } from "./core/oauth/kimi.js";
25
+ import { AUTH_PROVIDERS } from "./core/auth-providers.js";
26
+ import { ensureAppDirs, loadSavedSettings } from "./config.js";
27
+ import { getDefaultModel, getModel, getMaxThinkingLevel, getContextWindow, MODELS, } from "./core/model-registry.js";
28
+ import { getGitBranch } from "./utils/git.js";
29
+ import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSupported, } from "./core/thinking-level.js";
30
+ import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
31
+ import { loadCustomCommands } from "./core/custom-commands.js";
32
+ import { discoverProjects, listRecentSessions } from "./core/project-discovery.js";
33
+ import { initLogger, log } from "./core/logger.js";
34
+ const ALL_PROVIDERS = [
35
+ "anthropic",
36
+ "xiaomi",
37
+ "openai",
38
+ "gemini",
39
+ "glm",
40
+ "moonshot",
41
+ "minimax",
42
+ "deepseek",
43
+ "openrouter",
44
+ ];
45
+ function appSettingsFile() {
46
+ return path.join(os.homedir(), ".gg", "gg-app.json");
47
+ }
48
+ function defaultProjectsRoot() {
49
+ return path.join(os.homedir(), "gg-projects");
50
+ }
51
+ async function loadAppSettings() {
52
+ try {
53
+ const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
54
+ return {
55
+ projectsRoot: typeof raw.projectsRoot === "string" && raw.projectsRoot.trim()
56
+ ? raw.projectsRoot
57
+ : defaultProjectsRoot(),
58
+ };
59
+ }
60
+ catch {
61
+ return { projectsRoot: defaultProjectsRoot() };
62
+ }
63
+ }
64
+ async function saveAppSettings(settings) {
65
+ await fs.mkdir(path.dirname(appSettingsFile()), { recursive: true });
66
+ await fs.writeFile(appSettingsFile(), JSON.stringify(settings, null, 2), "utf-8");
67
+ }
68
+ /** Validate a project folder name: lowercase letters, digits, dashes only. */
69
+ function isValidProjectName(name) {
70
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
71
+ }
72
+ async function prepareAttachments(cwd, attachments) {
73
+ const dir = path.join(cwd, ".gg", "uploads");
74
+ await fs.mkdir(dir, { recursive: true }).catch(() => { });
75
+ const out = [];
76
+ for (const a of attachments) {
77
+ // Sanitize the filename and prefix with a short timestamp to avoid clobber.
78
+ const safe = a.name.replace(/[^\w.-]+/g, "_").slice(-80) || "file";
79
+ const fileName = `${Date.now().toString(36)}-${safe}`;
80
+ const filePath = path.join(dir, fileName);
81
+ try {
82
+ await fs.writeFile(filePath, Buffer.from(a.data, "base64"));
83
+ out.push({ ...a, path: filePath });
84
+ }
85
+ catch {
86
+ out.push({ ...a });
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Detect whether a restored user message is actually an injected self-correction
93
+ * hook prompt, by its distinctive opening phrase. Returns the hook kind so the
94
+ * webview can render the short notice line instead of the full prompt body.
95
+ */
96
+ function detectHookKind(text) {
97
+ const t = text.trimStart();
98
+ if (t.startsWith("Ideal? Review the actual work"))
99
+ return "ideal";
100
+ if (t.startsWith("Stuck? You've repeated essentially"))
101
+ return "loop_break";
102
+ if (t.startsWith("Re-ground. The conversation was just compacted"))
103
+ return "regrounding";
104
+ return null;
105
+ }
106
+ /**
107
+ * Pick a provider/model the user is actually logged into, preferring the saved
108
+ * defaults. Mirrors the CLI's resolveActiveProvider without exporting internals.
109
+ */
110
+ async function resolveStart(auth, preferred, savedModel) {
111
+ const loggedIn = [];
112
+ for (const p of ALL_PROVIDERS) {
113
+ if (await auth.hasProviderAuth(p))
114
+ loggedIn.push(p);
115
+ }
116
+ if (loggedIn.length === 0) {
117
+ throw new Error('Not logged in to any provider. Run "ggcoder login" to authenticate.');
118
+ }
119
+ if (loggedIn.includes(preferred)) {
120
+ const saved = savedModel ? getModel(savedModel) : undefined;
121
+ return {
122
+ provider: preferred,
123
+ model: saved?.provider === preferred ? saved.id : getDefaultModel(preferred).id,
124
+ };
125
+ }
126
+ const provider = loggedIn[0];
127
+ return { provider, model: getDefaultModel(provider).id };
128
+ }
129
+ async function main() {
130
+ const cwd = process.env.GG_APP_CWD ?? process.cwd();
131
+ // Default to an ephemeral port (0) so concurrent/orphaned instances never
132
+ // collide on a fixed port. The actual port is reported via the
133
+ // GG_APP_LISTENING handshake and consumed by the shell.
134
+ const port = Number(process.env.GG_APP_PORT ?? 0);
135
+ const host = "127.0.0.1";
136
+ const paths = await ensureAppDirs();
137
+ // Own log file so the app sidecar never clobbers the interactive CLI's
138
+ // ~/.gg/debug.log (initLogger truncates on each start).
139
+ const sidecarLog = path.join(paths.agentDir, "gg-app-sidecar.log");
140
+ initLogger(sidecarLog);
141
+ const auth = new AuthStorage(paths.authFile);
142
+ await auth.load();
143
+ const saved = loadSavedSettings(paths.settingsFile);
144
+ const preferred = saved.provider ?? "anthropic";
145
+ const { provider, model } = await resolveStart(auth, preferred, saved.model);
146
+ const thinkingLevel = saved.thinkingEnabled
147
+ ? (saved.thinkingLevel ?? getMaxThinkingLevel(model))
148
+ : undefined;
149
+ // ── SSE fan-out (declared before the session so plan callbacks can use it) ─
150
+ const clients = new Set();
151
+ let clientSeq = 0;
152
+ function broadcast(type, data) {
153
+ const frame = `data: ${JSON.stringify({ type, data })}\n\n`;
154
+ for (const c of clients)
155
+ c.res.write(frame);
156
+ }
157
+ // When the shell respawns this sidecar for a chosen project, it passes the
158
+ // session file path to resume; empty/unset starts a fresh session.
159
+ const resumeSessionPath = process.env.GG_APP_SESSION_ID || undefined;
160
+ let abort = new AbortController();
161
+ const session = new AgentSession({
162
+ provider,
163
+ model,
164
+ cwd,
165
+ thinkingLevel,
166
+ sessionId: resumeSessionPath,
167
+ signal: abort.signal,
168
+ // Plan mode: the agent's enter_plan/exit_plan tools drive these. We flip
169
+ // session plan state (rebuilds the system prompt + enforces read-only
170
+ // tools) and surface the transition to the webview.
171
+ onEnterPlan: async (reason) => {
172
+ await session.setPlanMode(true);
173
+ broadcast("plan_enter", { reason: reason ?? "" });
174
+ },
175
+ onExitPlan: async (planPath) => {
176
+ await session.setPlanMode(false);
177
+ // Surface the plan's path + markdown so the webview can show the review
178
+ // modal (Accept / Feedback / Reject). Best-effort content read.
179
+ let content;
180
+ try {
181
+ content = await fs.readFile(planPath, "utf-8");
182
+ }
183
+ catch {
184
+ content = "";
185
+ }
186
+ broadcast("plan_exit", { planPath, content });
187
+ return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
188
+ },
189
+ });
190
+ await session.initialize();
191
+ log("INFO", "app-sidecar", "session ready", { provider, model, cwd });
192
+ // Footer extras (context window, git branch, background tasks). The git
193
+ // branch is resolved once at startup and refreshed lazily; the context
194
+ // window follows the active model.
195
+ let gitBranch = await getGitBranch(cwd).catch(() => null);
196
+ function currentContextWindow() {
197
+ const st = session.getState();
198
+ return getContextWindow(st.model, { provider: st.provider });
199
+ }
200
+ // Shared shape merged into /state + the SSE `ready` frame so the footer can
201
+ // render context %, branch, and tasks immediately on connect.
202
+ function footerExtras() {
203
+ return {
204
+ contextWindow: currentContextWindow(),
205
+ gitBranch,
206
+ tasks: session.listBackgroundProcesses(),
207
+ };
208
+ }
209
+ // Forward every relevant bus event to the webview.
210
+ session.eventBus.on("text_delta", (d) => broadcast("text_delta", d));
211
+ session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
212
+ session.eventBus.on("tool_call_start", (d) => broadcast("tool_call_start", d));
213
+ session.eventBus.on("tool_call_update", (d) => broadcast("tool_call_update", d));
214
+ session.eventBus.on("tool_call_end", (d) => broadcast("tool_call_end", d));
215
+ session.eventBus.on("turn_end", (d) => broadcast("turn_end", d));
216
+ session.eventBus.on("agent_done", (d) => broadcast("agent_done", d));
217
+ session.eventBus.on("error", (d) => broadcast("error", { message: d.error instanceof Error ? d.error.message : String(d.error) }));
218
+ session.eventBus.on("model_change", (d) => broadcast("model_change", d));
219
+ session.eventBus.on("hook", (d) => broadcast("hook", d));
220
+ session.eventBus.on("compaction_start", (d) => broadcast("compaction_start", d));
221
+ session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
222
+ let running = false;
223
+ let titleGenerated = false;
224
+ // Resumed session: if it already has a conversation, generate its title now so
225
+ // the title bar shows it immediately on load (not just after the next prompt).
226
+ {
227
+ const hasHistory = session
228
+ .getMessages()
229
+ .some((m) => m.role === "user" || m.role === "assistant");
230
+ if (hasHistory) {
231
+ titleGenerated = true;
232
+ void session.generateTitle().then((title) => {
233
+ if (title)
234
+ broadcast("session_title", { title });
235
+ });
236
+ }
237
+ }
238
+ // ── Provider auth (login) bridge ───────────────────────────
239
+ // OAuth login functions are interactive (open a URL, sometimes prompt for a
240
+ // pasted code). We run one at a time and surface every step over SSE so the
241
+ // webview can open the URL and collect a code via a modal. `pendingCode`
242
+ // resolves when the webview POSTs /auth/oauth/code.
243
+ let oauthInFlight = false;
244
+ let pendingCode = null;
245
+ function authCallbacks() {
246
+ return {
247
+ onOpenUrl: (url) => broadcast("auth_url", { url }),
248
+ onStatus: (message) => broadcast("auth_status", { message }),
249
+ onPromptCode: (message) => new Promise((resolve) => {
250
+ pendingCode = resolve;
251
+ broadcast("auth_need_code", { message });
252
+ }),
253
+ };
254
+ }
255
+ async function authStatusPayload() {
256
+ const providers = await Promise.all(AUTH_PROVIDERS.map(async (p) => ({
257
+ ...p,
258
+ connected: await auth.hasProviderAuth(p.value),
259
+ })));
260
+ return { providers };
261
+ }
262
+ // Background tasks have no event source (the bash tool just spawns them), so
263
+ // poll the process manager and broadcast only when the snapshot changes. This
264
+ // keeps the webview footer live without a busy render loop.
265
+ let lastTasksJson = "[]";
266
+ const tasksPoll = setInterval(() => {
267
+ const tasks = session.listBackgroundProcesses();
268
+ const next = JSON.stringify(tasks);
269
+ if (next !== lastTasksJson) {
270
+ lastTasksJson = next;
271
+ broadcast("tasks", { tasks });
272
+ }
273
+ }, 1500);
274
+ tasksPoll.unref?.();
275
+ function readBody(req) {
276
+ return new Promise((resolve, reject) => {
277
+ const chunks = [];
278
+ req.on("data", (c) => chunks.push(c));
279
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
280
+ req.on("error", reject);
281
+ });
282
+ }
283
+ function json(res, status, body) {
284
+ const payload = JSON.stringify(body);
285
+ res.writeHead(status, {
286
+ "content-type": "application/json",
287
+ "access-control-allow-origin": "*",
288
+ });
289
+ res.end(payload);
290
+ }
291
+ const server = http.createServer((req, res) => {
292
+ const url = req.url ?? "/";
293
+ const method = req.method ?? "GET";
294
+ // CORS preflight — the webview origin differs from 127.0.0.1.
295
+ if (method === "OPTIONS") {
296
+ res.writeHead(204, {
297
+ "access-control-allow-origin": "*",
298
+ "access-control-allow-methods": "GET, POST, OPTIONS",
299
+ "access-control-allow-headers": "content-type",
300
+ });
301
+ res.end();
302
+ return;
303
+ }
304
+ if (method === "GET" && url === "/state") {
305
+ const st = session.getState();
306
+ json(res, 200, {
307
+ ...st,
308
+ running,
309
+ ready: true,
310
+ thinkingLevel: session.getThinkingLevel() ?? null,
311
+ supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
312
+ ...footerExtras(),
313
+ });
314
+ return;
315
+ }
316
+ if (method === "GET" && url === "/events") {
317
+ res.writeHead(200, {
318
+ "content-type": "text/event-stream",
319
+ "cache-control": "no-cache",
320
+ connection: "keep-alive",
321
+ "access-control-allow-origin": "*",
322
+ });
323
+ res.write(`retry: 1000\n\n`);
324
+ const client = { id: ++clientSeq, res };
325
+ clients.add(client);
326
+ const st = session.getState();
327
+ res.write(`data: ${JSON.stringify({
328
+ type: "ready",
329
+ data: {
330
+ ...st,
331
+ running,
332
+ thinkingLevel: session.getThinkingLevel() ?? null,
333
+ supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
334
+ ...footerExtras(),
335
+ },
336
+ })}\n\n`);
337
+ const keepAlive = setInterval(() => res.write(`: ping\n\n`), 15000);
338
+ req.on("close", () => {
339
+ clearInterval(keepAlive);
340
+ clients.delete(client);
341
+ });
342
+ return;
343
+ }
344
+ if (method === "GET" && url === "/settings") {
345
+ // `configured` is true only when the user explicitly saved a projects root
346
+ // (the gg-app.json file exists with a value) — not when we fall back to the
347
+ // default. The home screen gates "Your Projects" on this.
348
+ void (async () => {
349
+ const s = await loadAppSettings();
350
+ let configured;
351
+ try {
352
+ const raw = JSON.parse(await fs.readFile(appSettingsFile(), "utf-8"));
353
+ configured = typeof raw.projectsRoot === "string" && raw.projectsRoot.trim().length > 0;
354
+ }
355
+ catch {
356
+ configured = false;
357
+ }
358
+ json(res, 200, { ...s, configured });
359
+ })();
360
+ return;
361
+ }
362
+ if (method === "POST" && url === "/settings") {
363
+ void readBody(req).then(async (raw) => {
364
+ let projectsRoot;
365
+ try {
366
+ projectsRoot = JSON.parse(raw).projectsRoot ?? "";
367
+ }
368
+ catch {
369
+ json(res, 400, { error: "invalid JSON body" });
370
+ return;
371
+ }
372
+ if (!projectsRoot.trim()) {
373
+ json(res, 400, { error: "projectsRoot is required" });
374
+ return;
375
+ }
376
+ await saveAppSettings({ projectsRoot });
377
+ json(res, 200, { projectsRoot });
378
+ });
379
+ return;
380
+ }
381
+ if (method === "POST" && url === "/create-project") {
382
+ void readBody(req).then(async (raw) => {
383
+ let name;
384
+ try {
385
+ name = JSON.parse(raw).name ?? "";
386
+ }
387
+ catch {
388
+ json(res, 400, { error: "invalid JSON body" });
389
+ return;
390
+ }
391
+ name = name.trim();
392
+ if (!isValidProjectName(name)) {
393
+ json(res, 400, {
394
+ error: "Project name must be lowercase letters, digits, and dashes (e.g. my-project).",
395
+ });
396
+ return;
397
+ }
398
+ const { projectsRoot } = await loadAppSettings();
399
+ const dir = path.join(projectsRoot, name);
400
+ try {
401
+ // Refuse to clobber an existing directory.
402
+ const exists = await fs
403
+ .stat(dir)
404
+ .then(() => true)
405
+ .catch(() => false);
406
+ if (exists) {
407
+ json(res, 409, { error: `A folder named "${name}" already exists.` });
408
+ return;
409
+ }
410
+ await fs.mkdir(dir, { recursive: true });
411
+ json(res, 200, { path: dir });
412
+ }
413
+ catch (err) {
414
+ json(res, 500, { error: err instanceof Error ? err.message : String(err) });
415
+ }
416
+ });
417
+ return;
418
+ }
419
+ if (method === "GET" && url === "/projects") {
420
+ // Scan ggcoder + Claude Code + Codex session stores for known projects.
421
+ void discoverProjects()
422
+ .then((projects) => json(res, 200, { projects }))
423
+ .catch((err) => {
424
+ log("ERROR", "app-sidecar", "discoverProjects failed", {
425
+ message: err instanceof Error ? err.message : String(err),
426
+ });
427
+ json(res, 200, { projects: [] });
428
+ });
429
+ return;
430
+ }
431
+ if (method === "GET" && url.startsWith("/sessions")) {
432
+ const target = new URL(url, `http://${host}`).searchParams.get("cwd");
433
+ if (!target) {
434
+ json(res, 400, { error: "missing cwd query param" });
435
+ return;
436
+ }
437
+ void listRecentSessions(target, 5)
438
+ .then((sessions) => json(res, 200, { sessions }))
439
+ .catch(() => json(res, 200, { sessions: [] }));
440
+ return;
441
+ }
442
+ if (method === "GET" && url === "/history") {
443
+ // Flatten the resumed conversation into the webview's transcript shape:
444
+ // user + assistant TEXT only (tools live in the live panel, never the
445
+ // transcript; system + tool-result messages are omitted). Self-correction
446
+ // hook prompts (injected as user messages) are tagged with their `hook`
447
+ // kind so the webview renders the short "Hook engaged" line, not the raw
448
+ // prompt body — matching how they appear live.
449
+ const history = session
450
+ .getMessages()
451
+ .filter((m) => m.role === "user" || m.role === "assistant")
452
+ .map((m) => {
453
+ // `m.content` is a union of differently-typed arrays (user vs
454
+ // assistant parts), so a type-predicate filter won't narrow cleanly.
455
+ // A structural `"text" in c` check extracts text from any text-bearing
456
+ // part regardless of the surrounding union.
457
+ const text = typeof m.content === "string"
458
+ ? m.content
459
+ : m.content
460
+ .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
461
+ .join("");
462
+ const hook = m.role === "user" ? detectHookKind(text) : null;
463
+ return { role: m.role, text, hook };
464
+ })
465
+ .filter((m) => m.text.trim().length > 0);
466
+ json(res, 200, { history });
467
+ return;
468
+ }
469
+ if (method === "GET" && url === "/commands") {
470
+ // Workflow commands with agent functionality: built-in prompt templates +
471
+ // the user's own `.gg/commands/*.md`. UI commands (model/quit/etc.) are
472
+ // handled webview-side and intentionally excluded.
473
+ void (async () => {
474
+ const builtins = PROMPT_COMMANDS.map((c) => ({
475
+ name: c.name,
476
+ aliases: c.aliases,
477
+ description: c.description,
478
+ source: "built-in",
479
+ }));
480
+ const custom = (await loadCustomCommands(cwd))
481
+ // A custom command can't shadow a built-in name.
482
+ .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name))
483
+ .map((c) => ({
484
+ name: c.name,
485
+ aliases: [],
486
+ description: c.description,
487
+ source: "custom",
488
+ }));
489
+ json(res, 200, { commands: [...builtins, ...custom] });
490
+ })();
491
+ return;
492
+ }
493
+ if (method === "POST" && url === "/prompt") {
494
+ void readBody(req).then(async (raw) => {
495
+ let text;
496
+ let attachments;
497
+ try {
498
+ const body = JSON.parse(raw);
499
+ text = body.text ?? "";
500
+ attachments = Array.isArray(body.attachments) ? body.attachments : [];
501
+ }
502
+ catch {
503
+ json(res, 400, { error: "invalid JSON body" });
504
+ return;
505
+ }
506
+ if (!text.trim() && attachments.length === 0) {
507
+ json(res, 400, { error: "empty prompt" });
508
+ return;
509
+ }
510
+ if (running) {
511
+ // Queue text prompts as mid-run steering (mirrors the CLI). Attachments
512
+ // aren't supported mid-run — reject those so the user resends after.
513
+ if (attachments.length > 0) {
514
+ json(res, 409, { error: "cannot attach files while the agent is running" });
515
+ return;
516
+ }
517
+ const count = session.queueMessage(text);
518
+ broadcast("queued", { count });
519
+ json(res, 202, { queued: true, count });
520
+ return;
521
+ }
522
+ json(res, 202, { accepted: true });
523
+ running = true;
524
+ broadcast("run_start", { text });
525
+ try {
526
+ if (attachments.length > 0) {
527
+ // Persist each attachment under .gg/uploads so files are inspectable
528
+ // by the agent's tools, then prompt with the media as native blocks.
529
+ const prepared = await prepareAttachments(cwd, attachments);
530
+ await session.promptWithAttachments(text, prepared);
531
+ }
532
+ else {
533
+ // Pass the raw text straight through. AgentSession.prompt() is the
534
+ // single source of truth for slash-command expansion (built-in +
535
+ // `.gg/commands/*.md` custom), so the agent gets the right body
536
+ // while the webview keeps showing the short `/name`.
537
+ await session.prompt(text);
538
+ }
539
+ }
540
+ catch (err) {
541
+ const message = err instanceof Error ? err.message : String(err);
542
+ broadcast("error", { message });
543
+ log("ERROR", "app-sidecar", "prompt failed", { message });
544
+ }
545
+ finally {
546
+ running = false;
547
+ // A run may have switched branches (git checkout) or spawned/finished
548
+ // background tasks — refresh the footer extras once it settles.
549
+ gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
550
+ broadcast("run_end", {});
551
+ // Queue drains into the run as steering, so it's empty by run_end —
552
+ // sync the webview indicator.
553
+ broadcast("queued", { count: session.getQueuedCount() });
554
+ broadcast("extras", footerExtras());
555
+ // Generate a session title once, after the first run, for the title
556
+ // bar (best-effort, async — don't block the response).
557
+ if (!titleGenerated) {
558
+ titleGenerated = true;
559
+ void session.generateTitle().then((title) => {
560
+ if (title)
561
+ broadcast("session_title", { title });
562
+ });
563
+ }
564
+ }
565
+ });
566
+ return;
567
+ }
568
+ if (method === "GET" && url === "/models") {
569
+ void (async () => {
570
+ const loggedIn = [];
571
+ for (const p of ALL_PROVIDERS) {
572
+ if (await auth.hasProviderAuth(p))
573
+ loggedIn.push(p);
574
+ }
575
+ // Just the names, grouped by provider in registry order — the UI shows
576
+ // a clean multi-column list of model ids.
577
+ const models = MODELS.filter((m) => loggedIn.includes(m.provider)).map((m) => ({
578
+ id: m.id,
579
+ name: m.name,
580
+ provider: m.provider,
581
+ }));
582
+ json(res, 200, { models });
583
+ })();
584
+ return;
585
+ }
586
+ if (method === "POST" && url === "/model") {
587
+ void readBody(req).then(async (raw) => {
588
+ let modelId;
589
+ try {
590
+ modelId = JSON.parse(raw).model ?? "";
591
+ }
592
+ catch {
593
+ json(res, 400, { error: "invalid JSON body" });
594
+ return;
595
+ }
596
+ const target = getModel(modelId);
597
+ if (!target) {
598
+ json(res, 404, { error: `unknown model: ${modelId}` });
599
+ return;
600
+ }
601
+ if (running) {
602
+ json(res, 409, { error: "cannot switch model while running" });
603
+ return;
604
+ }
605
+ await session.switchModel(target.provider, target.id);
606
+ // Clamp the reasoning level to what the new model supports (mirrors the
607
+ // CLI): keep thinking on at the first supported tier if it was on but
608
+ // the prior level is unsupported here; leave it off if it was off.
609
+ const prevLevel = session.getThinkingLevel();
610
+ if (prevLevel && !isThinkingLevelSupported(target.provider, target.id, prevLevel)) {
611
+ session.setThinkingLevel(getNextThinkingLevel(target.provider, target.id, undefined));
612
+ }
613
+ const payload = {
614
+ thinkingLevel: session.getThinkingLevel() ?? null,
615
+ supportedThinkingLevels: getSupportedThinkingLevels(target.provider, target.id),
616
+ };
617
+ // model_change is emitted by switchModel; follow with thinking_change so
618
+ // the footer toggle reflects the new model's supported levels.
619
+ broadcast("thinking_change", payload);
620
+ // The new model usually has a different context window — push extras so
621
+ // the footer's context meter rescales immediately.
622
+ broadcast("extras", footerExtras());
623
+ json(res, 200, { provider: target.provider, model: target.id, ...payload });
624
+ });
625
+ return;
626
+ }
627
+ if (method === "POST" && url === "/kill") {
628
+ void readBody(req).then(async (raw) => {
629
+ let id;
630
+ try {
631
+ id = JSON.parse(raw).id ?? "";
632
+ }
633
+ catch {
634
+ json(res, 400, { error: "invalid JSON body" });
635
+ return;
636
+ }
637
+ if (!id.trim()) {
638
+ json(res, 400, { error: "missing task id" });
639
+ return;
640
+ }
641
+ const message = await session.killBackgroundProcess(id);
642
+ // Push the updated task list right away rather than waiting for the poll.
643
+ broadcast("tasks", { tasks: session.listBackgroundProcesses() });
644
+ json(res, 200, { message });
645
+ });
646
+ return;
647
+ }
648
+ if (method === "POST" && url === "/thinking") {
649
+ const st = session.getState();
650
+ const next = getNextThinkingLevel(st.provider, st.model, session.getThinkingLevel());
651
+ session.setThinkingLevel(next);
652
+ const payload = {
653
+ thinkingLevel: next ?? null,
654
+ supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
655
+ };
656
+ broadcast("thinking_change", payload);
657
+ json(res, 200, payload);
658
+ return;
659
+ }
660
+ if (method === "POST" && url === "/cancel") {
661
+ abort.abort();
662
+ abort = new AbortController();
663
+ session.setSignal(abort.signal);
664
+ running = false;
665
+ // Drop any queued steering and return it so the webview can restore it to
666
+ // the composer.
667
+ const drained = session.drainQueue();
668
+ broadcast("run_end", { cancelled: true });
669
+ broadcast("queued", { count: 0 });
670
+ json(res, 200, { cancelled: true, drained });
671
+ return;
672
+ }
673
+ if (method === "POST" && url === "/new-session") {
674
+ if (running) {
675
+ json(res, 409, { error: "cannot start a new session while running" });
676
+ return;
677
+ }
678
+ void session
679
+ .newSession()
680
+ .then(() => {
681
+ broadcast("session_reset", {});
682
+ json(res, 200, { ok: true });
683
+ })
684
+ .catch((err) => {
685
+ json(res, 500, { error: err instanceof Error ? err.message : String(err) });
686
+ });
687
+ return;
688
+ }
689
+ // ── Provider auth (login) ───────────────────────────────
690
+ if (method === "GET" && url === "/auth/status") {
691
+ void authStatusPayload().then((payload) => json(res, 200, payload));
692
+ return;
693
+ }
694
+ if (method === "POST" && url === "/auth/apikey") {
695
+ void readBody(req).then(async (raw) => {
696
+ let provider = "";
697
+ let key;
698
+ try {
699
+ const body = JSON.parse(raw);
700
+ provider = body.provider ?? "";
701
+ key = (body.key ?? "").trim();
702
+ }
703
+ catch {
704
+ json(res, 400, { error: "invalid JSON body" });
705
+ return;
706
+ }
707
+ const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
708
+ if (!meta || !meta.methods.includes("apikey")) {
709
+ json(res, 400, { error: "provider does not support API key auth" });
710
+ return;
711
+ }
712
+ if (!key) {
713
+ json(res, 400, { error: "API key is required" });
714
+ return;
715
+ }
716
+ const creds = {
717
+ accessToken: key,
718
+ refreshToken: "",
719
+ expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000 * 100, // ~100y
720
+ ...(meta.apiKeyBaseUrl ? { baseUrl: meta.apiKeyBaseUrl } : {}),
721
+ };
722
+ await auth.setCredentials(provider, creds);
723
+ broadcast("auth_done", { provider });
724
+ json(res, 200, { ok: true });
725
+ });
726
+ return;
727
+ }
728
+ if (method === "POST" && url === "/auth/oauth/start") {
729
+ void readBody(req).then((raw) => {
730
+ let provider = "";
731
+ try {
732
+ provider = JSON.parse(raw).provider ?? "";
733
+ }
734
+ catch {
735
+ json(res, 400, { error: "invalid JSON body" });
736
+ return;
737
+ }
738
+ const meta = AUTH_PROVIDERS.find((p) => p.value === provider);
739
+ if (!meta || !meta.methods.includes("oauth")) {
740
+ json(res, 400, { error: "provider does not support OAuth" });
741
+ return;
742
+ }
743
+ if (oauthInFlight) {
744
+ json(res, 409, { error: "a login is already in progress" });
745
+ return;
746
+ }
747
+ oauthInFlight = true;
748
+ json(res, 202, { accepted: true });
749
+ void (async () => {
750
+ const cb = authCallbacks();
751
+ try {
752
+ let creds;
753
+ let storageKey = provider;
754
+ if (provider === "anthropic")
755
+ creds = await loginAnthropic(cb);
756
+ else if (provider === "openai")
757
+ creds = await loginOpenAI(cb);
758
+ else if (provider === "gemini")
759
+ creds = await loginGemini(cb);
760
+ else if (provider === "moonshot") {
761
+ creds = await loginKimi(cb);
762
+ storageKey = MOONSHOT_OAUTH_KEY;
763
+ }
764
+ else {
765
+ throw new Error(`OAuth not implemented for ${provider}`);
766
+ }
767
+ await auth.setCredentials(storageKey, creds);
768
+ broadcast("auth_done", { provider });
769
+ }
770
+ catch (err) {
771
+ broadcast("auth_error", {
772
+ provider,
773
+ message: err instanceof Error ? err.message : String(err),
774
+ });
775
+ }
776
+ finally {
777
+ oauthInFlight = false;
778
+ pendingCode = null;
779
+ }
780
+ })();
781
+ });
782
+ return;
783
+ }
784
+ if (method === "POST" && url === "/auth/oauth/code") {
785
+ void readBody(req).then((raw) => {
786
+ let code;
787
+ try {
788
+ code = JSON.parse(raw).code ?? "";
789
+ }
790
+ catch {
791
+ json(res, 400, { error: "invalid JSON body" });
792
+ return;
793
+ }
794
+ if (!pendingCode) {
795
+ json(res, 409, { error: "no login is awaiting a code" });
796
+ return;
797
+ }
798
+ pendingCode(code.trim());
799
+ pendingCode = null;
800
+ json(res, 200, { ok: true });
801
+ });
802
+ return;
803
+ }
804
+ if (method === "POST" && url === "/auth/logout") {
805
+ void readBody(req).then(async (raw) => {
806
+ let provider;
807
+ try {
808
+ provider = JSON.parse(raw).provider ?? "";
809
+ }
810
+ catch {
811
+ json(res, 400, { error: "invalid JSON body" });
812
+ return;
813
+ }
814
+ await auth.clearCredentials(provider);
815
+ // Moonshot's OAuth credential lives under a distinct key — clear both so
816
+ // "disconnect" fully removes Kimi OAuth and the API key.
817
+ if (provider === "moonshot")
818
+ await auth.clearCredentials(MOONSHOT_OAUTH_KEY);
819
+ broadcast("auth_done", { provider });
820
+ json(res, 200, { ok: true });
821
+ });
822
+ return;
823
+ }
824
+ json(res, 404, { error: "not found" });
825
+ });
826
+ server.listen(port, host, () => {
827
+ const addr = server.address();
828
+ // The Rust shell reads this line to learn the port.
829
+ process.stdout.write(`GG_APP_LISTENING ${addr.port}\n`);
830
+ log("INFO", "app-sidecar", "listening", { port: String(addr.port), host });
831
+ });
832
+ const shutdown = async () => {
833
+ clearInterval(tasksPoll);
834
+ for (const c of clients)
835
+ c.res.end();
836
+ server.close();
837
+ await session.dispose().catch(() => { });
838
+ process.exit(0);
839
+ };
840
+ process.on("SIGINT", () => void shutdown());
841
+ process.on("SIGTERM", () => void shutdown());
842
+ }
843
+ main().catch((err) => {
844
+ const message = err instanceof Error ? err.message : String(err);
845
+ process.stderr.write(`GG_APP_FATAL ${message}\n`);
846
+ process.exit(1);
847
+ });
848
+ //# sourceMappingURL=app-sidecar.js.map