@kenkaiiii/ggcoder 4.10.2 → 4.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,13 +24,19 @@ import { loginGemini } from "./core/oauth/gemini.js";
24
24
  import { loginKimi } from "./core/oauth/kimi.js";
25
25
  import { AUTH_PROVIDERS } from "./core/auth-providers.js";
26
26
  import { ensureAppDirs, loadSavedSettings } from "./config.js";
27
+ import { SettingsManager } from "./core/settings-manager.js";
27
28
  import { getDefaultModel, getModel, getMaxThinkingLevel, getContextWindow, MODELS, } from "./core/model-registry.js";
28
- import { getGitBranch } from "./utils/git.js";
29
+ import { getGitBranch, isGitRepo } from "./utils/git.js";
29
30
  import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSupported, } from "./core/thinking-level.js";
30
31
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
31
32
  import { loadCustomCommands } from "./core/custom-commands.js";
32
33
  import { discoverProjects, listRecentSessions } from "./core/project-discovery.js";
34
+ import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/tasks-store.js";
33
35
  import { initLogger, log } from "./core/logger.js";
36
+ import { RADIO_STATIONS, getCurrentStation, playRadio, stopRadio } from "./core/radio.js";
37
+ import { enrichProcessPath } from "./core/shell-path.js";
38
+ import { startServeMode } from "./modes/serve-mode.js";
39
+ import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
34
40
  const ALL_PROVIDERS = [
35
41
  "anthropic",
36
42
  "xiaomi",
@@ -65,6 +71,37 @@ async function saveAppSettings(settings) {
65
71
  await fs.mkdir(path.dirname(appSettingsFile()), { recursive: true });
66
72
  await fs.writeFile(appSettingsFile(), JSON.stringify(settings, null, 2), "utf-8");
67
73
  }
74
+ /**
75
+ * Persist the active model selection to ~/.gg/settings.json so it survives app
76
+ * restarts. Mirrors the CLI's handleModelSelect persistence (App.tsx).
77
+ */
78
+ async function persistModelSelection(settingsFile, provider, model) {
79
+ try {
80
+ const sm = new SettingsManager(settingsFile);
81
+ await sm.load();
82
+ await sm.set("defaultProvider", provider);
83
+ await sm.set("defaultModel", model);
84
+ }
85
+ catch (err) {
86
+ log("WARN", "app-sidecar", "failed to persist model selection", { err: String(err) });
87
+ }
88
+ }
89
+ /**
90
+ * Persist the thinking level to ~/.gg/settings.json so it survives app restarts.
91
+ * Mirrors the CLI's handleToggleThinking persistence (App.tsx).
92
+ */
93
+ async function persistThinkingLevel(settingsFile, level) {
94
+ try {
95
+ const sm = new SettingsManager(settingsFile);
96
+ await sm.load();
97
+ await sm.set("thinkingEnabled", !!level);
98
+ if (level)
99
+ await sm.set("thinkingLevel", level);
100
+ }
101
+ catch (err) {
102
+ log("WARN", "app-sidecar", "failed to persist thinking level", { err: String(err) });
103
+ }
104
+ }
68
105
  /** Validate a project folder name: lowercase letters, digits, dashes only. */
69
106
  function isValidProjectName(name) {
70
107
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
@@ -103,6 +140,30 @@ function detectHookKind(text) {
103
140
  return "regrounding";
104
141
  return null;
105
142
  }
143
+ // Separator AgentSession.prompt() inserts between a command's prompt body and
144
+ // the user's trailing args. Must stay in sync with the expansion there.
145
+ const COMMAND_ARGS_SEP = "\n\n## User Instructions\n\n";
146
+ /**
147
+ * Reverse a prompt-template command's expansion. When a `/name` command runs,
148
+ * the agent persists the FULL expanded prompt body as the user message — so on
149
+ * resume the raw body would render instead of the short `/name` chip the user
150
+ * saw live. Given the candidate commands (built-in + custom) and a restored
151
+ * message body, recover the original `/name [args]` invocation. Returns null
152
+ * when the text isn't a known command body (an ordinary user message).
153
+ */
154
+ function detectPromptCommand(text, candidates) {
155
+ for (const c of candidates) {
156
+ if (!c.prompt)
157
+ continue;
158
+ if (text === c.prompt)
159
+ return `/${c.name}`;
160
+ if (text.startsWith(c.prompt + COMMAND_ARGS_SEP)) {
161
+ const args = text.slice(c.prompt.length + COMMAND_ARGS_SEP.length).trim();
162
+ return args ? `/${c.name} ${args}` : `/${c.name}`;
163
+ }
164
+ }
165
+ return null;
166
+ }
106
167
  /**
107
168
  * Pick a provider/model the user is actually logged into, preferring the saved
108
169
  * defaults. Mirrors the CLI's resolveActiveProvider without exporting internals.
@@ -138,6 +199,12 @@ async function main() {
138
199
  // ~/.gg/debug.log (initLogger truncates on each start).
139
200
  const sidecarLog = path.join(paths.agentDir, "gg-app-sidecar.log");
140
201
  initLogger(sidecarLog);
202
+ // The packaged desktop app launches from Finder/Dock with a minimal PATH that
203
+ // omits Homebrew/Cargo/version-manager dirs, so the agent can't find node,
204
+ // git, python, rg, etc. Enrich process.env.PATH from the login shell once,
205
+ // before anything spawns (bash tool, background tasks, LSP, git helpers all
206
+ // inherit it). Best-effort — never blocks startup beyond its internal cap.
207
+ await enrichProcessPath();
141
208
  const auth = new AuthStorage(paths.authFile);
142
209
  await auth.load();
143
210
  const saved = loadSavedSettings(paths.settingsFile);
@@ -193,6 +260,7 @@ async function main() {
193
260
  // branch is resolved once at startup and refreshed lazily; the context
194
261
  // window follows the active model.
195
262
  let gitBranch = await getGitBranch(cwd).catch(() => null);
263
+ let gitIsRepo = await isGitRepo(cwd).catch(() => false);
196
264
  function currentContextWindow() {
197
265
  const st = session.getState();
198
266
  return getContextWindow(st.model, { provider: st.provider });
@@ -203,6 +271,7 @@ async function main() {
203
271
  return {
204
272
  contextWindow: currentContextWindow(),
205
273
  gitBranch,
274
+ isGitRepo: gitIsRepo,
206
275
  tasks: session.listBackgroundProcesses(),
207
276
  };
208
277
  }
@@ -221,6 +290,10 @@ async function main() {
221
290
  session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
222
291
  let running = false;
223
292
  let titleGenerated = false;
293
+ // ── Telegram serve (remote control via Telegram) ───────────
294
+ // A single embedded serve session lives in this sidecar process. Only the main
295
+ // window's home screen exposes the controls, so there's one bot per app.
296
+ let serveController = null;
224
297
  // Resumed session: if it already has a conversation, generate its title now so
225
298
  // the title bar shows it immediately on load (not just after the next prompt).
226
299
  {
@@ -235,6 +308,84 @@ async function main() {
235
308
  });
236
309
  }
237
310
  }
311
+ // Core run lifecycle shared by /prompt and the task runner: flips `running`,
312
+ // brackets the run with run_start/run_end, refreshes the footer extras, and
313
+ // generates the session title once. `label` is the text shown live with the
314
+ // run_start frame.
315
+ async function runAgent(label, run) {
316
+ running = true;
317
+ broadcast("run_start", { text: label });
318
+ try {
319
+ await run();
320
+ }
321
+ catch (err) {
322
+ const message = err instanceof Error ? err.message : String(err);
323
+ broadcast("error", { message });
324
+ log("ERROR", "app-sidecar", "run failed", { message });
325
+ }
326
+ finally {
327
+ running = false;
328
+ // A run may have switched branches (git checkout) or spawned/finished
329
+ // background tasks — refresh the footer extras once it settles.
330
+ gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
331
+ gitIsRepo = await isGitRepo(cwd).catch(() => gitIsRepo);
332
+ broadcast("run_end", {});
333
+ // Queue drains into the run as steering, so it's empty by run_end —
334
+ // sync the webview indicator.
335
+ broadcast("queued", { count: session.getQueuedCount() });
336
+ broadcast("extras", footerExtras());
337
+ // Generate a session title once, after the first run, for the title bar
338
+ // (best-effort, async — don't block the response).
339
+ if (!titleGenerated) {
340
+ titleGenerated = true;
341
+ void session.generateTitle().then((title) => {
342
+ if (title)
343
+ broadcast("session_title", { title });
344
+ });
345
+ }
346
+ }
347
+ }
348
+ // ── Task runner (project task list → sessions) ──────────────
349
+ // Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
350
+ // completion hint instructing the agent to mark the task done via the tasks
351
+ // tool. Run-all advances to the next pending task after each run finishes.
352
+ let taskRunAll = false;
353
+ async function runTaskById(taskId) {
354
+ const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
355
+ if (!task)
356
+ return false;
357
+ // Fresh session per task so one task's context never bleeds into the next.
358
+ await session.newSession();
359
+ titleGenerated = false;
360
+ broadcast("session_reset", {});
361
+ markTaskInProgress(cwd, task.id);
362
+ broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
363
+ broadcast("task_start", { id: task.id, title: task.title });
364
+ const shortId = task.id.slice(0, 8);
365
+ const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
366
+ `tasks({ action: "done", id: "${shortId}" })`;
367
+ await runAgent(task.title, () => session.prompt(task.prompt + completionHint));
368
+ // The agent typically marks the task done via the tasks tool during the run;
369
+ // push the refreshed list so the webview's task modal reflects it.
370
+ broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
371
+ return true;
372
+ }
373
+ async function runTasks(startId, all) {
374
+ taskRunAll = all;
375
+ let currentId = startId ?? getNextPendingTask(cwd)?.id ?? null;
376
+ while (currentId) {
377
+ const ran = await runTaskById(currentId);
378
+ if (!ran || !taskRunAll)
379
+ break;
380
+ const next = getNextPendingTask(cwd);
381
+ currentId = next ? next.id : null;
382
+ // Brief pause between tasks (mirrors the CLI cadence).
383
+ if (currentId)
384
+ await new Promise((resolve) => setTimeout(resolve, 500));
385
+ }
386
+ taskRunAll = false;
387
+ broadcast("tasks_run_done", {});
388
+ }
238
389
  // ── Provider auth (login) bridge ───────────────────────────
239
390
  // OAuth login functions are interactive (open a URL, sometimes prompt for a
240
391
  // pasted code). We run one at a time and surface every step over SSE so the
@@ -261,17 +412,29 @@ async function main() {
261
412
  }
262
413
  // Background tasks have no event source (the bash tool just spawns them), so
263
414
  // poll the process manager and broadcast only when the snapshot changes. This
264
- // keeps the webview footer live without a busy render loop.
415
+ // keeps the webview footer live without a busy render loop. Adaptive cadence:
416
+ // tasks can only change while a run is active (the bash tool spawns them), so
417
+ // poll fast (1500ms) while running or while tasks exist, and back off to
418
+ // 5000ms when fully idle — fewer wakeups per idle window.
265
419
  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?.();
420
+ let tasksPoll;
421
+ let tasksPollStopped = false;
422
+ const scheduleTasksPoll = (delay) => {
423
+ if (tasksPollStopped)
424
+ return;
425
+ tasksPoll = setTimeout(() => {
426
+ const tasks = session.listBackgroundProcesses();
427
+ const next = JSON.stringify(tasks);
428
+ if (next !== lastTasksJson) {
429
+ lastTasksJson = next;
430
+ broadcast("tasks", { tasks });
431
+ }
432
+ const active = running || tasks.length > 0;
433
+ scheduleTasksPoll(active ? 1500 : 5000);
434
+ }, delay);
435
+ tasksPoll.unref?.();
436
+ };
437
+ scheduleTasksPoll(1500);
275
438
  function readBody(req) {
276
439
  return new Promise((resolve, reject) => {
277
440
  const chunks = [];
@@ -446,24 +609,49 @@ async function main() {
446
609
  // hook prompts (injected as user messages) are tagged with their `hook`
447
610
  // kind so the webview renders the short "Hook engaged" line, not the raw
448
611
  // 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 });
612
+ //
613
+ // Prompt-template commands persist their FULL expanded body as the user
614
+ // message, so on resume we reverse the expansion (built-in + custom
615
+ // candidates) back to the short `/name [args]` chip the user saw live.
616
+ void (async () => {
617
+ const commandCandidates = [...PROMPT_COMMANDS, ...(await loadCustomCommands(cwd))];
618
+ const history = session
619
+ .getMessages()
620
+ .filter((m) => m.role === "user" || m.role === "assistant")
621
+ .map((m) => {
622
+ // `m.content` is a union of differently-typed arrays (user vs
623
+ // assistant parts), so a type-predicate filter won't narrow cleanly.
624
+ // A structural `"text" in c` check extracts text from any text-bearing
625
+ // part regardless of the surrounding union.
626
+ const text = typeof m.content === "string"
627
+ ? m.content
628
+ : m.content
629
+ .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
630
+ .join("");
631
+ // Reconstruct image attachments as data URLs so they re-render on
632
+ // resume — the webview only ever saw the live SSE stream, and the
633
+ // persisted message holds the base64 bytes. Without this, attached
634
+ // images vanish when returning to a session.
635
+ const images = typeof m.content === "string"
636
+ ? []
637
+ : m.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
638
+ const hook = m.role === "user" ? detectHookKind(text) : null;
639
+ // Recover a `/name [args]` command invocation from its expanded body
640
+ // (skip messages already claimed as hooks).
641
+ const command = m.role === "user" && !hook ? detectPromptCommand(text, commandCandidates) : null;
642
+ return {
643
+ role: m.role,
644
+ text: command ?? text,
645
+ images,
646
+ hook,
647
+ command: command !== null,
648
+ };
649
+ })
650
+ // Keep messages with text OR images — an image-only user turn has empty
651
+ // text but must still appear.
652
+ .filter((m) => m.text.trim().length > 0 || m.images.length > 0);
653
+ json(res, 200, { history });
654
+ })();
467
655
  return;
468
656
  }
469
657
  if (method === "GET" && url === "/commands") {
@@ -520,9 +708,7 @@ async function main() {
520
708
  return;
521
709
  }
522
710
  json(res, 202, { accepted: true });
523
- running = true;
524
- broadcast("run_start", { text });
525
- try {
711
+ await runAgent(text, async () => {
526
712
  if (attachments.length > 0) {
527
713
  // Persist each attachment under .gg/uploads so files are inspectable
528
714
  // by the agent's tools, then prompt with the media as native blocks.
@@ -536,32 +722,84 @@ async function main() {
536
722
  // while the webview keeps showing the short `/name`.
537
723
  await session.prompt(text);
538
724
  }
725
+ });
726
+ });
727
+ return;
728
+ }
729
+ if (method === "GET" && url === "/tasks") {
730
+ json(res, 200, { tasks: loadTasksSync(cwd) });
731
+ return;
732
+ }
733
+ // ── Radio ─────────────────────────────────────────────────
734
+ // Playback runs in THIS sidecar process, which is unique per window, so a
735
+ // station only plays in the window that started it.
736
+ if (method === "GET" && url === "/radio") {
737
+ json(res, 200, { stations: RADIO_STATIONS, current: getCurrentStation() });
738
+ return;
739
+ }
740
+ if (method === "POST" && url === "/radio") {
741
+ void readBody(req).then((raw) => {
742
+ let station;
743
+ try {
744
+ station = JSON.parse(raw).station ?? "";
539
745
  }
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
- }
746
+ catch {
747
+ json(res, 400, { error: "invalid JSON body" });
748
+ return;
749
+ }
750
+ if (!station || station === "off") {
751
+ stopRadio();
752
+ json(res, 200, { current: null });
753
+ return;
754
+ }
755
+ const result = playRadio(station);
756
+ if (!result.ok) {
757
+ json(res, 400, { error: result.error ?? "Radio failed to start." });
758
+ return;
759
+ }
760
+ json(res, 200, { current: getCurrentStation() });
761
+ });
762
+ return;
763
+ }
764
+ if (method === "POST" && url === "/tasks/run") {
765
+ void readBody(req).then((raw) => {
766
+ let id;
767
+ let all;
768
+ try {
769
+ const body = JSON.parse(raw);
770
+ id = body.id ?? null;
771
+ all = Boolean(body.all);
772
+ }
773
+ catch {
774
+ json(res, 400, { error: "invalid JSON body" });
775
+ return;
564
776
  }
777
+ if (running) {
778
+ json(res, 409, { error: "cannot run a task while the agent is running" });
779
+ return;
780
+ }
781
+ json(res, 202, { accepted: true });
782
+ void runTasks(id, all);
783
+ });
784
+ return;
785
+ }
786
+ if (method === "POST" && url === "/tasks/delete") {
787
+ void readBody(req).then((raw) => {
788
+ let id;
789
+ try {
790
+ id = JSON.parse(raw).id ?? "";
791
+ }
792
+ catch {
793
+ json(res, 400, { error: "invalid JSON body" });
794
+ return;
795
+ }
796
+ if (!id.trim()) {
797
+ json(res, 400, { error: "missing task id" });
798
+ return;
799
+ }
800
+ const remaining = loadTasksSync(cwd).filter((t) => t.id !== id && !t.id.startsWith(id));
801
+ saveTasksSync(cwd, remaining);
802
+ json(res, 200, { tasks: remaining });
565
803
  });
566
804
  return;
567
805
  }
@@ -610,6 +848,9 @@ async function main() {
610
848
  if (prevLevel && !isThinkingLevelSupported(target.provider, target.id, prevLevel)) {
611
849
  session.setThinkingLevel(getNextThinkingLevel(target.provider, target.id, undefined));
612
850
  }
851
+ // Persist so the selection (and clamped thinking level) survives restarts.
852
+ await persistModelSelection(paths.settingsFile, target.provider, target.id);
853
+ await persistThinkingLevel(paths.settingsFile, session.getThinkingLevel());
613
854
  const payload = {
614
855
  thinkingLevel: session.getThinkingLevel() ?? null,
615
856
  supportedThinkingLevels: getSupportedThinkingLevels(target.provider, target.id),
@@ -649,6 +890,8 @@ async function main() {
649
890
  const st = session.getState();
650
891
  const next = getNextThinkingLevel(st.provider, st.model, session.getThinkingLevel());
651
892
  session.setThinkingLevel(next);
893
+ // Persist so the thinking level survives app restarts (mirrors the CLI).
894
+ void persistThinkingLevel(paths.settingsFile, next);
652
895
  const payload = {
653
896
  thinkingLevel: next ?? null,
654
897
  supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
@@ -662,6 +905,8 @@ async function main() {
662
905
  abort = new AbortController();
663
906
  session.setSignal(abort.signal);
664
907
  running = false;
908
+ // Stop a run-all sweep so the next pending task isn't auto-started.
909
+ taskRunAll = false;
665
910
  // Drop any queued steering and return it so the webview can restore it to
666
911
  // the composer.
667
912
  const drained = session.drainQueue();
@@ -821,6 +1066,107 @@ async function main() {
821
1066
  });
822
1067
  return;
823
1068
  }
1069
+ // ── Telegram config (mirrors `ggcoder telegram`) ─────────
1070
+ if (method === "GET" && url === "/telegram") {
1071
+ void loadTelegramConfig().then((cfg) => {
1072
+ if (!cfg) {
1073
+ json(res, 200, { configured: false });
1074
+ return;
1075
+ }
1076
+ // Never return the raw token to the webview — a short masked preview is
1077
+ // enough to show "already set".
1078
+ const t = cfg.botToken;
1079
+ const tokenPreview = t.length > 14 ? `${t.slice(0, 10)}\u2026${t.slice(-4)}` : "set";
1080
+ json(res, 200, { configured: true, userId: cfg.userId, tokenPreview });
1081
+ });
1082
+ return;
1083
+ }
1084
+ if (method === "POST" && url === "/telegram") {
1085
+ void readBody(req).then(async (raw) => {
1086
+ let botTokenInput;
1087
+ let userIdInput;
1088
+ try {
1089
+ const body = JSON.parse(raw);
1090
+ botTokenInput = (body.botToken ?? "").trim();
1091
+ userIdInput = String(body.userId ?? "").trim();
1092
+ }
1093
+ catch {
1094
+ json(res, 400, { error: "invalid JSON body" });
1095
+ return;
1096
+ }
1097
+ // Keep the existing token when the field is left blank (the webview shows
1098
+ // a masked preview, not the real token).
1099
+ const existing = await loadTelegramConfig();
1100
+ const botToken = botTokenInput || existing?.botToken || "";
1101
+ if (!botToken) {
1102
+ json(res, 400, { error: "Bot token is required." });
1103
+ return;
1104
+ }
1105
+ const userId = userIdInput ? parseInt(userIdInput, 10) : existing?.userId;
1106
+ if (!userId || Number.isNaN(userId)) {
1107
+ json(res, 400, { error: "A numeric Telegram user ID is required." });
1108
+ return;
1109
+ }
1110
+ const verified = await verifyBotToken(botToken);
1111
+ if (!verified.ok) {
1112
+ json(res, 400, { error: "Invalid bot token — Telegram rejected it." });
1113
+ return;
1114
+ }
1115
+ await saveTelegramConfig({ botToken, userId });
1116
+ json(res, 200, { ok: true, userId, username: verified.username ?? null });
1117
+ });
1118
+ return;
1119
+ }
1120
+ // ── Serve lifecycle (mirrors `ggcoder serve`) ───────────
1121
+ if (method === "GET" && url === "/serve") {
1122
+ void loadTelegramConfig().then((cfg) => json(res, 200, { running: serveController !== null, configured: cfg !== null }));
1123
+ return;
1124
+ }
1125
+ if (method === "POST" && url === "/serve/start") {
1126
+ void (async () => {
1127
+ if (serveController) {
1128
+ json(res, 200, { running: true });
1129
+ return;
1130
+ }
1131
+ const cfg = await loadTelegramConfig();
1132
+ if (!cfg) {
1133
+ json(res, 400, { error: "Telegram isn't set up yet. Open Serve settings first." });
1134
+ return;
1135
+ }
1136
+ const st = session.getState();
1137
+ try {
1138
+ serveController = await startServeMode({
1139
+ provider: st.provider,
1140
+ model: st.model,
1141
+ cwd,
1142
+ version: "app",
1143
+ thinkingLevel: session.getThinkingLevel() ?? undefined,
1144
+ telegram: { botToken: cfg.botToken, userId: cfg.userId },
1145
+ embedded: true,
1146
+ });
1147
+ broadcast("serve_change", { running: true });
1148
+ log("INFO", "app-sidecar", "serve started", { userId: cfg.userId });
1149
+ json(res, 200, { running: true });
1150
+ }
1151
+ catch (err) {
1152
+ serveController = null;
1153
+ json(res, 400, { error: err instanceof Error ? err.message : String(err) });
1154
+ }
1155
+ })();
1156
+ return;
1157
+ }
1158
+ if (method === "POST" && url === "/serve/stop") {
1159
+ void (async () => {
1160
+ if (serveController) {
1161
+ await serveController.stop().catch(() => { });
1162
+ serveController = null;
1163
+ broadcast("serve_change", { running: false });
1164
+ log("INFO", "app-sidecar", "serve stopped");
1165
+ }
1166
+ json(res, 200, { running: false });
1167
+ })();
1168
+ return;
1169
+ }
824
1170
  json(res, 404, { error: "not found" });
825
1171
  });
826
1172
  server.listen(port, host, () => {
@@ -830,7 +1176,14 @@ async function main() {
830
1176
  log("INFO", "app-sidecar", "listening", { port: String(addr.port), host });
831
1177
  });
832
1178
  const shutdown = async () => {
833
- clearInterval(tasksPoll);
1179
+ tasksPollStopped = true;
1180
+ if (tasksPoll)
1181
+ clearTimeout(tasksPoll);
1182
+ // Kill any playing radio so the stream dies with its window.
1183
+ stopRadio();
1184
+ // Stop the Telegram serve loop + dispose its per-chat sessions.
1185
+ if (serveController)
1186
+ await serveController.stop().catch(() => { });
834
1187
  for (const c of clients)
835
1188
  c.res.end();
836
1189
  server.close();