@kenkaiiii/ggcoder 4.10.2 → 4.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-sidecar.js +509 -57
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -22
- package/dist/cli.js.map +1 -1
- package/dist/core/project-discovery.d.ts.map +1 -1
- package/dist/core/project-discovery.js +3 -1
- package/dist/core/project-discovery.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/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/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/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/subagent.d.ts.map +1 -1
- package/dist/tools/subagent.js +20 -3
- package/dist/tools/subagent.js.map +1 -1
- package/dist/ui/hooks/useTranscriptHistory.d.ts.map +1 -1
- package/dist/ui/hooks/useTranscriptHistory.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
package/dist/app-sidecar.js
CHANGED
|
@@ -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);
|
|
@@ -88,6 +125,86 @@ async function prepareAttachments(cwd, attachments) {
|
|
|
88
125
|
}
|
|
89
126
|
return out;
|
|
90
127
|
}
|
|
128
|
+
const FILE_SEARCH_LIMIT = 20;
|
|
129
|
+
/** Score a candidate path against a lowercased query. Higher is better; a
|
|
130
|
+
* negative score means "no match". Basename hits beat path hits; prefix beats
|
|
131
|
+
* substring; shorter paths break ties. */
|
|
132
|
+
function scoreFile(relPath, name, query) {
|
|
133
|
+
const lcPath = relPath.toLowerCase();
|
|
134
|
+
const lcName = name.toLowerCase();
|
|
135
|
+
let score = -1;
|
|
136
|
+
if (lcName === query)
|
|
137
|
+
score = 1000;
|
|
138
|
+
else if (lcName.startsWith(query))
|
|
139
|
+
score = 800;
|
|
140
|
+
else if (lcName.includes(query))
|
|
141
|
+
score = 600;
|
|
142
|
+
else if (lcPath.startsWith(query))
|
|
143
|
+
score = 400;
|
|
144
|
+
else if (lcPath.includes(query))
|
|
145
|
+
score = 200;
|
|
146
|
+
else if (subsequenceMatch(lcPath, query))
|
|
147
|
+
score = 100;
|
|
148
|
+
if (score < 0)
|
|
149
|
+
return -1;
|
|
150
|
+
// Prefer shorter paths (closer to root, less nesting) on equal match class.
|
|
151
|
+
return score - relPath.length * 0.1;
|
|
152
|
+
}
|
|
153
|
+
/** True when every char of `needle` appears in `haystack` in order (fuzzy). */
|
|
154
|
+
function subsequenceMatch(haystack, needle) {
|
|
155
|
+
let i = 0;
|
|
156
|
+
for (const ch of haystack) {
|
|
157
|
+
if (ch === needle[i])
|
|
158
|
+
i++;
|
|
159
|
+
if (i === needle.length)
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return needle.length === 0;
|
|
163
|
+
}
|
|
164
|
+
async function searchProjectFiles(cwd, rawQuery) {
|
|
165
|
+
const fg = await import("fast-glob");
|
|
166
|
+
const ignore = await import("ignore");
|
|
167
|
+
const query = rawQuery.trim().toLowerCase();
|
|
168
|
+
let gitignore = [];
|
|
169
|
+
try {
|
|
170
|
+
const content = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8");
|
|
171
|
+
gitignore = content
|
|
172
|
+
.split("\n")
|
|
173
|
+
.map((l) => l.trim())
|
|
174
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// No .gitignore — nothing extra to ignore.
|
|
178
|
+
}
|
|
179
|
+
const ig = ignore.default().add(gitignore);
|
|
180
|
+
// `stats: true` gives mtime without a second stat pass, so the empty-query
|
|
181
|
+
// "recent files" path is a single walk.
|
|
182
|
+
const entries = await fg.default("**/*", {
|
|
183
|
+
cwd,
|
|
184
|
+
dot: false,
|
|
185
|
+
onlyFiles: true,
|
|
186
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.gg/**"],
|
|
187
|
+
suppressErrors: true,
|
|
188
|
+
followSymbolicLinks: false,
|
|
189
|
+
stats: true,
|
|
190
|
+
});
|
|
191
|
+
const files = entries.filter((e) => !ig.ignores(e.path));
|
|
192
|
+
if (!query) {
|
|
193
|
+
return files
|
|
194
|
+
.sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0))
|
|
195
|
+
.slice(0, FILE_SEARCH_LIMIT)
|
|
196
|
+
.map((e) => ({ path: e.path, name: path.posix.basename(e.path) }));
|
|
197
|
+
}
|
|
198
|
+
const scored = [];
|
|
199
|
+
for (const e of files) {
|
|
200
|
+
const name = path.posix.basename(e.path);
|
|
201
|
+
const score = scoreFile(e.path, name, query);
|
|
202
|
+
if (score >= 0)
|
|
203
|
+
scored.push({ hit: { path: e.path, name }, score });
|
|
204
|
+
}
|
|
205
|
+
scored.sort((a, b) => b.score - a.score);
|
|
206
|
+
return scored.slice(0, FILE_SEARCH_LIMIT).map((s) => s.hit);
|
|
207
|
+
}
|
|
91
208
|
/**
|
|
92
209
|
* Detect whether a restored user message is actually an injected self-correction
|
|
93
210
|
* hook prompt, by its distinctive opening phrase. Returns the hook kind so the
|
|
@@ -103,6 +220,30 @@ function detectHookKind(text) {
|
|
|
103
220
|
return "regrounding";
|
|
104
221
|
return null;
|
|
105
222
|
}
|
|
223
|
+
// Separator AgentSession.prompt() inserts between a command's prompt body and
|
|
224
|
+
// the user's trailing args. Must stay in sync with the expansion there.
|
|
225
|
+
const COMMAND_ARGS_SEP = "\n\n## User Instructions\n\n";
|
|
226
|
+
/**
|
|
227
|
+
* Reverse a prompt-template command's expansion. When a `/name` command runs,
|
|
228
|
+
* the agent persists the FULL expanded prompt body as the user message — so on
|
|
229
|
+
* resume the raw body would render instead of the short `/name` chip the user
|
|
230
|
+
* saw live. Given the candidate commands (built-in + custom) and a restored
|
|
231
|
+
* message body, recover the original `/name [args]` invocation. Returns null
|
|
232
|
+
* when the text isn't a known command body (an ordinary user message).
|
|
233
|
+
*/
|
|
234
|
+
function detectPromptCommand(text, candidates) {
|
|
235
|
+
for (const c of candidates) {
|
|
236
|
+
if (!c.prompt)
|
|
237
|
+
continue;
|
|
238
|
+
if (text === c.prompt)
|
|
239
|
+
return `/${c.name}`;
|
|
240
|
+
if (text.startsWith(c.prompt + COMMAND_ARGS_SEP)) {
|
|
241
|
+
const args = text.slice(c.prompt.length + COMMAND_ARGS_SEP.length).trim();
|
|
242
|
+
return args ? `/${c.name} ${args}` : `/${c.name}`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
106
247
|
/**
|
|
107
248
|
* Pick a provider/model the user is actually logged into, preferring the saved
|
|
108
249
|
* defaults. Mirrors the CLI's resolveActiveProvider without exporting internals.
|
|
@@ -138,6 +279,12 @@ async function main() {
|
|
|
138
279
|
// ~/.gg/debug.log (initLogger truncates on each start).
|
|
139
280
|
const sidecarLog = path.join(paths.agentDir, "gg-app-sidecar.log");
|
|
140
281
|
initLogger(sidecarLog);
|
|
282
|
+
// The packaged desktop app launches from Finder/Dock with a minimal PATH that
|
|
283
|
+
// omits Homebrew/Cargo/version-manager dirs, so the agent can't find node,
|
|
284
|
+
// git, python, rg, etc. Enrich process.env.PATH from the login shell once,
|
|
285
|
+
// before anything spawns (bash tool, background tasks, LSP, git helpers all
|
|
286
|
+
// inherit it). Best-effort — never blocks startup beyond its internal cap.
|
|
287
|
+
await enrichProcessPath();
|
|
141
288
|
const auth = new AuthStorage(paths.authFile);
|
|
142
289
|
await auth.load();
|
|
143
290
|
const saved = loadSavedSettings(paths.settingsFile);
|
|
@@ -193,6 +340,7 @@ async function main() {
|
|
|
193
340
|
// branch is resolved once at startup and refreshed lazily; the context
|
|
194
341
|
// window follows the active model.
|
|
195
342
|
let gitBranch = await getGitBranch(cwd).catch(() => null);
|
|
343
|
+
let gitIsRepo = await isGitRepo(cwd).catch(() => false);
|
|
196
344
|
function currentContextWindow() {
|
|
197
345
|
const st = session.getState();
|
|
198
346
|
return getContextWindow(st.model, { provider: st.provider });
|
|
@@ -203,6 +351,7 @@ async function main() {
|
|
|
203
351
|
return {
|
|
204
352
|
contextWindow: currentContextWindow(),
|
|
205
353
|
gitBranch,
|
|
354
|
+
isGitRepo: gitIsRepo,
|
|
206
355
|
tasks: session.listBackgroundProcesses(),
|
|
207
356
|
};
|
|
208
357
|
}
|
|
@@ -221,6 +370,10 @@ async function main() {
|
|
|
221
370
|
session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
|
|
222
371
|
let running = false;
|
|
223
372
|
let titleGenerated = false;
|
|
373
|
+
// ── Telegram serve (remote control via Telegram) ───────────
|
|
374
|
+
// A single embedded serve session lives in this sidecar process. Only the main
|
|
375
|
+
// window's home screen exposes the controls, so there's one bot per app.
|
|
376
|
+
let serveController = null;
|
|
224
377
|
// Resumed session: if it already has a conversation, generate its title now so
|
|
225
378
|
// the title bar shows it immediately on load (not just after the next prompt).
|
|
226
379
|
{
|
|
@@ -235,6 +388,84 @@ async function main() {
|
|
|
235
388
|
});
|
|
236
389
|
}
|
|
237
390
|
}
|
|
391
|
+
// Core run lifecycle shared by /prompt and the task runner: flips `running`,
|
|
392
|
+
// brackets the run with run_start/run_end, refreshes the footer extras, and
|
|
393
|
+
// generates the session title once. `label` is the text shown live with the
|
|
394
|
+
// run_start frame.
|
|
395
|
+
async function runAgent(label, run) {
|
|
396
|
+
running = true;
|
|
397
|
+
broadcast("run_start", { text: label });
|
|
398
|
+
try {
|
|
399
|
+
await run();
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
403
|
+
broadcast("error", { message });
|
|
404
|
+
log("ERROR", "app-sidecar", "run failed", { message });
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
running = false;
|
|
408
|
+
// A run may have switched branches (git checkout) or spawned/finished
|
|
409
|
+
// background tasks — refresh the footer extras once it settles.
|
|
410
|
+
gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
|
|
411
|
+
gitIsRepo = await isGitRepo(cwd).catch(() => gitIsRepo);
|
|
412
|
+
broadcast("run_end", {});
|
|
413
|
+
// Queue drains into the run as steering, so it's empty by run_end —
|
|
414
|
+
// sync the webview indicator.
|
|
415
|
+
broadcast("queued", { count: session.getQueuedCount() });
|
|
416
|
+
broadcast("extras", footerExtras());
|
|
417
|
+
// Generate a session title once, after the first run, for the title bar
|
|
418
|
+
// (best-effort, async — don't block the response).
|
|
419
|
+
if (!titleGenerated) {
|
|
420
|
+
titleGenerated = true;
|
|
421
|
+
void session.generateTitle().then((title) => {
|
|
422
|
+
if (title)
|
|
423
|
+
broadcast("session_title", { title });
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ── Task runner (project task list → sessions) ──────────────
|
|
429
|
+
// Mirrors the CLI's task flow: each task runs in its OWN fresh session, with a
|
|
430
|
+
// completion hint instructing the agent to mark the task done via the tasks
|
|
431
|
+
// tool. Run-all advances to the next pending task after each run finishes.
|
|
432
|
+
let taskRunAll = false;
|
|
433
|
+
async function runTaskById(taskId) {
|
|
434
|
+
const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
|
|
435
|
+
if (!task)
|
|
436
|
+
return false;
|
|
437
|
+
// Fresh session per task so one task's context never bleeds into the next.
|
|
438
|
+
await session.newSession();
|
|
439
|
+
titleGenerated = false;
|
|
440
|
+
broadcast("session_reset", {});
|
|
441
|
+
markTaskInProgress(cwd, task.id);
|
|
442
|
+
broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
|
|
443
|
+
broadcast("task_start", { id: task.id, title: task.title });
|
|
444
|
+
const shortId = task.id.slice(0, 8);
|
|
445
|
+
const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
|
|
446
|
+
`tasks({ action: "done", id: "${shortId}" })`;
|
|
447
|
+
await runAgent(task.title, () => session.prompt(task.prompt + completionHint));
|
|
448
|
+
// The agent typically marks the task done via the tasks tool during the run;
|
|
449
|
+
// push the refreshed list so the webview's task modal reflects it.
|
|
450
|
+
broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
async function runTasks(startId, all) {
|
|
454
|
+
taskRunAll = all;
|
|
455
|
+
let currentId = startId ?? getNextPendingTask(cwd)?.id ?? null;
|
|
456
|
+
while (currentId) {
|
|
457
|
+
const ran = await runTaskById(currentId);
|
|
458
|
+
if (!ran || !taskRunAll)
|
|
459
|
+
break;
|
|
460
|
+
const next = getNextPendingTask(cwd);
|
|
461
|
+
currentId = next ? next.id : null;
|
|
462
|
+
// Brief pause between tasks (mirrors the CLI cadence).
|
|
463
|
+
if (currentId)
|
|
464
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
465
|
+
}
|
|
466
|
+
taskRunAll = false;
|
|
467
|
+
broadcast("tasks_run_done", {});
|
|
468
|
+
}
|
|
238
469
|
// ── Provider auth (login) bridge ───────────────────────────
|
|
239
470
|
// OAuth login functions are interactive (open a URL, sometimes prompt for a
|
|
240
471
|
// pasted code). We run one at a time and surface every step over SSE so the
|
|
@@ -261,17 +492,29 @@ async function main() {
|
|
|
261
492
|
}
|
|
262
493
|
// Background tasks have no event source (the bash tool just spawns them), so
|
|
263
494
|
// poll the process manager and broadcast only when the snapshot changes. This
|
|
264
|
-
// keeps the webview footer live without a busy render loop.
|
|
495
|
+
// keeps the webview footer live without a busy render loop. Adaptive cadence:
|
|
496
|
+
// tasks can only change while a run is active (the bash tool spawns them), so
|
|
497
|
+
// poll fast (1500ms) while running or while tasks exist, and back off to
|
|
498
|
+
// 5000ms when fully idle — fewer wakeups per idle window.
|
|
265
499
|
let lastTasksJson = "[]";
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
500
|
+
let tasksPoll;
|
|
501
|
+
let tasksPollStopped = false;
|
|
502
|
+
const scheduleTasksPoll = (delay) => {
|
|
503
|
+
if (tasksPollStopped)
|
|
504
|
+
return;
|
|
505
|
+
tasksPoll = setTimeout(() => {
|
|
506
|
+
const tasks = session.listBackgroundProcesses();
|
|
507
|
+
const next = JSON.stringify(tasks);
|
|
508
|
+
if (next !== lastTasksJson) {
|
|
509
|
+
lastTasksJson = next;
|
|
510
|
+
broadcast("tasks", { tasks });
|
|
511
|
+
}
|
|
512
|
+
const active = running || tasks.length > 0;
|
|
513
|
+
scheduleTasksPoll(active ? 1500 : 5000);
|
|
514
|
+
}, delay);
|
|
515
|
+
tasksPoll.unref?.();
|
|
516
|
+
};
|
|
517
|
+
scheduleTasksPoll(1500);
|
|
275
518
|
function readBody(req) {
|
|
276
519
|
return new Promise((resolve, reject) => {
|
|
277
520
|
const chunks = [];
|
|
@@ -439,6 +682,18 @@ async function main() {
|
|
|
439
682
|
.catch(() => json(res, 200, { sessions: [] }));
|
|
440
683
|
return;
|
|
441
684
|
}
|
|
685
|
+
if (method === "GET" && url.startsWith("/files")) {
|
|
686
|
+
const q = new URL(url, `http://${host}`).searchParams.get("q") ?? "";
|
|
687
|
+
void searchProjectFiles(cwd, q)
|
|
688
|
+
.then((files) => json(res, 200, { files }))
|
|
689
|
+
.catch((err) => {
|
|
690
|
+
log("ERROR", "app-sidecar", "searchProjectFiles failed", {
|
|
691
|
+
message: err instanceof Error ? err.message : String(err),
|
|
692
|
+
});
|
|
693
|
+
json(res, 200, { files: [] });
|
|
694
|
+
});
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
442
697
|
if (method === "GET" && url === "/history") {
|
|
443
698
|
// Flatten the resumed conversation into the webview's transcript shape:
|
|
444
699
|
// user + assistant TEXT only (tools live in the live panel, never the
|
|
@@ -446,24 +701,56 @@ async function main() {
|
|
|
446
701
|
// hook prompts (injected as user messages) are tagged with their `hook`
|
|
447
702
|
// kind so the webview renders the short "Hook engaged" line, not the raw
|
|
448
703
|
// prompt body — matching how they appear live.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
704
|
+
//
|
|
705
|
+
// Prompt-template commands persist their FULL expanded body as the user
|
|
706
|
+
// message, so on resume we reverse the expansion (built-in + custom
|
|
707
|
+
// candidates) back to the short `/name [args]` chip the user saw live.
|
|
708
|
+
void (async () => {
|
|
709
|
+
const commandCandidates = [...PROMPT_COMMANDS, ...(await loadCustomCommands(cwd))];
|
|
710
|
+
const history = session
|
|
711
|
+
.getMessages()
|
|
712
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
713
|
+
.map((m) => {
|
|
714
|
+
// `m.content` is a union of differently-typed arrays (user vs
|
|
715
|
+
// assistant parts), so a type-predicate filter won't narrow cleanly.
|
|
716
|
+
// A structural `"text" in c` check extracts text from any text-bearing
|
|
717
|
+
// part regardless of the surrounding union.
|
|
718
|
+
const text = typeof m.content === "string"
|
|
719
|
+
? m.content
|
|
720
|
+
: m.content
|
|
721
|
+
.map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
|
|
722
|
+
.join("");
|
|
723
|
+
// Reconstruct image attachments as data URLs so they re-render on
|
|
724
|
+
// resume — the webview only ever saw the live SSE stream, and the
|
|
725
|
+
// persisted message holds the base64 bytes. Without this, attached
|
|
726
|
+
// images vanish when returning to a session.
|
|
727
|
+
const images = typeof m.content === "string"
|
|
728
|
+
? []
|
|
729
|
+
: m.content.flatMap((c) => c.type === "image" ? [`data:${c.mediaType};base64,${c.data}`] : []);
|
|
730
|
+
const hook = m.role === "user" ? detectHookKind(text) : null;
|
|
731
|
+
// A compacted session persists its summary as a user message prefixed
|
|
732
|
+
// with this marker. Tag it so the webview renders the quiet "Compacted
|
|
733
|
+
// context" notice instead of dumping the full summary body.
|
|
734
|
+
const compacted = m.role === "user" && !hook && text.startsWith("[Previous conversation summary]");
|
|
735
|
+
// Recover a `/name [args]` command invocation from its expanded body
|
|
736
|
+
// (skip messages already claimed as hooks or compaction summaries).
|
|
737
|
+
const command = m.role === "user" && !hook && !compacted
|
|
738
|
+
? detectPromptCommand(text, commandCandidates)
|
|
739
|
+
: null;
|
|
740
|
+
return {
|
|
741
|
+
role: m.role,
|
|
742
|
+
text: command ?? text,
|
|
743
|
+
images,
|
|
744
|
+
hook,
|
|
745
|
+
command: command !== null,
|
|
746
|
+
compacted,
|
|
747
|
+
};
|
|
748
|
+
})
|
|
749
|
+
// Keep messages with text OR images — an image-only user turn has empty
|
|
750
|
+
// text but must still appear.
|
|
751
|
+
.filter((m) => m.text.trim().length > 0 || m.images.length > 0);
|
|
752
|
+
json(res, 200, { history });
|
|
753
|
+
})();
|
|
467
754
|
return;
|
|
468
755
|
}
|
|
469
756
|
if (method === "GET" && url === "/commands") {
|
|
@@ -520,9 +807,7 @@ async function main() {
|
|
|
520
807
|
return;
|
|
521
808
|
}
|
|
522
809
|
json(res, 202, { accepted: true });
|
|
523
|
-
|
|
524
|
-
broadcast("run_start", { text });
|
|
525
|
-
try {
|
|
810
|
+
await runAgent(text, async () => {
|
|
526
811
|
if (attachments.length > 0) {
|
|
527
812
|
// Persist each attachment under .gg/uploads so files are inspectable
|
|
528
813
|
// by the agent's tools, then prompt with the media as native blocks.
|
|
@@ -536,32 +821,84 @@ async function main() {
|
|
|
536
821
|
// while the webview keeps showing the short `/name`.
|
|
537
822
|
await session.prompt(text);
|
|
538
823
|
}
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (method === "GET" && url === "/tasks") {
|
|
829
|
+
json(res, 200, { tasks: loadTasksSync(cwd) });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
// ── Radio ─────────────────────────────────────────────────
|
|
833
|
+
// Playback runs in THIS sidecar process, which is unique per window, so a
|
|
834
|
+
// station only plays in the window that started it.
|
|
835
|
+
if (method === "GET" && url === "/radio") {
|
|
836
|
+
json(res, 200, { stations: RADIO_STATIONS, current: getCurrentStation() });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (method === "POST" && url === "/radio") {
|
|
840
|
+
void readBody(req).then((raw) => {
|
|
841
|
+
let station;
|
|
842
|
+
try {
|
|
843
|
+
station = JSON.parse(raw).station ?? "";
|
|
539
844
|
}
|
|
540
|
-
catch
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
}
|
|
845
|
+
catch {
|
|
846
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (!station || station === "off") {
|
|
850
|
+
stopRadio();
|
|
851
|
+
json(res, 200, { current: null });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const result = playRadio(station);
|
|
855
|
+
if (!result.ok) {
|
|
856
|
+
json(res, 400, { error: result.error ?? "Radio failed to start." });
|
|
857
|
+
return;
|
|
564
858
|
}
|
|
859
|
+
json(res, 200, { current: getCurrentStation() });
|
|
860
|
+
});
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (method === "POST" && url === "/tasks/run") {
|
|
864
|
+
void readBody(req).then((raw) => {
|
|
865
|
+
let id;
|
|
866
|
+
let all;
|
|
867
|
+
try {
|
|
868
|
+
const body = JSON.parse(raw);
|
|
869
|
+
id = body.id ?? null;
|
|
870
|
+
all = Boolean(body.all);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (running) {
|
|
877
|
+
json(res, 409, { error: "cannot run a task while the agent is running" });
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
json(res, 202, { accepted: true });
|
|
881
|
+
void runTasks(id, all);
|
|
882
|
+
});
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (method === "POST" && url === "/tasks/delete") {
|
|
886
|
+
void readBody(req).then((raw) => {
|
|
887
|
+
let id;
|
|
888
|
+
try {
|
|
889
|
+
id = JSON.parse(raw).id ?? "";
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (!id.trim()) {
|
|
896
|
+
json(res, 400, { error: "missing task id" });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const remaining = loadTasksSync(cwd).filter((t) => t.id !== id && !t.id.startsWith(id));
|
|
900
|
+
saveTasksSync(cwd, remaining);
|
|
901
|
+
json(res, 200, { tasks: remaining });
|
|
565
902
|
});
|
|
566
903
|
return;
|
|
567
904
|
}
|
|
@@ -610,6 +947,9 @@ async function main() {
|
|
|
610
947
|
if (prevLevel && !isThinkingLevelSupported(target.provider, target.id, prevLevel)) {
|
|
611
948
|
session.setThinkingLevel(getNextThinkingLevel(target.provider, target.id, undefined));
|
|
612
949
|
}
|
|
950
|
+
// Persist so the selection (and clamped thinking level) survives restarts.
|
|
951
|
+
await persistModelSelection(paths.settingsFile, target.provider, target.id);
|
|
952
|
+
await persistThinkingLevel(paths.settingsFile, session.getThinkingLevel());
|
|
613
953
|
const payload = {
|
|
614
954
|
thinkingLevel: session.getThinkingLevel() ?? null,
|
|
615
955
|
supportedThinkingLevels: getSupportedThinkingLevels(target.provider, target.id),
|
|
@@ -649,6 +989,8 @@ async function main() {
|
|
|
649
989
|
const st = session.getState();
|
|
650
990
|
const next = getNextThinkingLevel(st.provider, st.model, session.getThinkingLevel());
|
|
651
991
|
session.setThinkingLevel(next);
|
|
992
|
+
// Persist so the thinking level survives app restarts (mirrors the CLI).
|
|
993
|
+
void persistThinkingLevel(paths.settingsFile, next);
|
|
652
994
|
const payload = {
|
|
653
995
|
thinkingLevel: next ?? null,
|
|
654
996
|
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
@@ -662,6 +1004,8 @@ async function main() {
|
|
|
662
1004
|
abort = new AbortController();
|
|
663
1005
|
session.setSignal(abort.signal);
|
|
664
1006
|
running = false;
|
|
1007
|
+
// Stop a run-all sweep so the next pending task isn't auto-started.
|
|
1008
|
+
taskRunAll = false;
|
|
665
1009
|
// Drop any queued steering and return it so the webview can restore it to
|
|
666
1010
|
// the composer.
|
|
667
1011
|
const drained = session.drainQueue();
|
|
@@ -821,6 +1165,107 @@ async function main() {
|
|
|
821
1165
|
});
|
|
822
1166
|
return;
|
|
823
1167
|
}
|
|
1168
|
+
// ── Telegram config (mirrors `ggcoder telegram`) ─────────
|
|
1169
|
+
if (method === "GET" && url === "/telegram") {
|
|
1170
|
+
void loadTelegramConfig().then((cfg) => {
|
|
1171
|
+
if (!cfg) {
|
|
1172
|
+
json(res, 200, { configured: false });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
// Never return the raw token to the webview — a short masked preview is
|
|
1176
|
+
// enough to show "already set".
|
|
1177
|
+
const t = cfg.botToken;
|
|
1178
|
+
const tokenPreview = t.length > 14 ? `${t.slice(0, 10)}\u2026${t.slice(-4)}` : "set";
|
|
1179
|
+
json(res, 200, { configured: true, userId: cfg.userId, tokenPreview });
|
|
1180
|
+
});
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (method === "POST" && url === "/telegram") {
|
|
1184
|
+
void readBody(req).then(async (raw) => {
|
|
1185
|
+
let botTokenInput;
|
|
1186
|
+
let userIdInput;
|
|
1187
|
+
try {
|
|
1188
|
+
const body = JSON.parse(raw);
|
|
1189
|
+
botTokenInput = (body.botToken ?? "").trim();
|
|
1190
|
+
userIdInput = String(body.userId ?? "").trim();
|
|
1191
|
+
}
|
|
1192
|
+
catch {
|
|
1193
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
// Keep the existing token when the field is left blank (the webview shows
|
|
1197
|
+
// a masked preview, not the real token).
|
|
1198
|
+
const existing = await loadTelegramConfig();
|
|
1199
|
+
const botToken = botTokenInput || existing?.botToken || "";
|
|
1200
|
+
if (!botToken) {
|
|
1201
|
+
json(res, 400, { error: "Bot token is required." });
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const userId = userIdInput ? parseInt(userIdInput, 10) : existing?.userId;
|
|
1205
|
+
if (!userId || Number.isNaN(userId)) {
|
|
1206
|
+
json(res, 400, { error: "A numeric Telegram user ID is required." });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const verified = await verifyBotToken(botToken);
|
|
1210
|
+
if (!verified.ok) {
|
|
1211
|
+
json(res, 400, { error: "Invalid bot token — Telegram rejected it." });
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
await saveTelegramConfig({ botToken, userId });
|
|
1215
|
+
json(res, 200, { ok: true, userId, username: verified.username ?? null });
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
// ── Serve lifecycle (mirrors `ggcoder serve`) ───────────
|
|
1220
|
+
if (method === "GET" && url === "/serve") {
|
|
1221
|
+
void loadTelegramConfig().then((cfg) => json(res, 200, { running: serveController !== null, configured: cfg !== null }));
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (method === "POST" && url === "/serve/start") {
|
|
1225
|
+
void (async () => {
|
|
1226
|
+
if (serveController) {
|
|
1227
|
+
json(res, 200, { running: true });
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const cfg = await loadTelegramConfig();
|
|
1231
|
+
if (!cfg) {
|
|
1232
|
+
json(res, 400, { error: "Telegram isn't set up yet. Open Serve settings first." });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const st = session.getState();
|
|
1236
|
+
try {
|
|
1237
|
+
serveController = await startServeMode({
|
|
1238
|
+
provider: st.provider,
|
|
1239
|
+
model: st.model,
|
|
1240
|
+
cwd,
|
|
1241
|
+
version: "app",
|
|
1242
|
+
thinkingLevel: session.getThinkingLevel() ?? undefined,
|
|
1243
|
+
telegram: { botToken: cfg.botToken, userId: cfg.userId },
|
|
1244
|
+
embedded: true,
|
|
1245
|
+
});
|
|
1246
|
+
broadcast("serve_change", { running: true });
|
|
1247
|
+
log("INFO", "app-sidecar", "serve started", { userId: cfg.userId });
|
|
1248
|
+
json(res, 200, { running: true });
|
|
1249
|
+
}
|
|
1250
|
+
catch (err) {
|
|
1251
|
+
serveController = null;
|
|
1252
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
1253
|
+
}
|
|
1254
|
+
})();
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (method === "POST" && url === "/serve/stop") {
|
|
1258
|
+
void (async () => {
|
|
1259
|
+
if (serveController) {
|
|
1260
|
+
await serveController.stop().catch(() => { });
|
|
1261
|
+
serveController = null;
|
|
1262
|
+
broadcast("serve_change", { running: false });
|
|
1263
|
+
log("INFO", "app-sidecar", "serve stopped");
|
|
1264
|
+
}
|
|
1265
|
+
json(res, 200, { running: false });
|
|
1266
|
+
})();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
824
1269
|
json(res, 404, { error: "not found" });
|
|
825
1270
|
});
|
|
826
1271
|
server.listen(port, host, () => {
|
|
@@ -830,7 +1275,14 @@ async function main() {
|
|
|
830
1275
|
log("INFO", "app-sidecar", "listening", { port: String(addr.port), host });
|
|
831
1276
|
});
|
|
832
1277
|
const shutdown = async () => {
|
|
833
|
-
|
|
1278
|
+
tasksPollStopped = true;
|
|
1279
|
+
if (tasksPoll)
|
|
1280
|
+
clearTimeout(tasksPoll);
|
|
1281
|
+
// Kill any playing radio so the stream dies with its window.
|
|
1282
|
+
stopRadio();
|
|
1283
|
+
// Stop the Telegram serve loop + dispose its per-chat sessions.
|
|
1284
|
+
if (serveController)
|
|
1285
|
+
await serveController.stop().catch(() => { });
|
|
834
1286
|
for (const c of clients)
|
|
835
1287
|
c.res.end();
|
|
836
1288
|
server.close();
|