@tiagojct/subsub 0.5.0 → 0.6.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.
package/src/web.ts ADDED
@@ -0,0 +1,710 @@
1
+ /**
2
+ * `subsub web`: Sub-Sub in the browser.
3
+ *
4
+ * - pi runs as a child process in RPC mode, with the Sub-Sub extension, exactly
5
+ * as `subsub` would run it. Approvals are the extension's own confirm
6
+ * dialogs, forwarded to the page; the page only answers them.
7
+ * - The server listens on 127.0.0.1 only. The first request must carry a
8
+ * random key (?t=...), which becomes an HttpOnly, SameSite=Strict cookie.
9
+ * Every other request needs that cookie, the right Host header and, for
10
+ * POST, a same-origin JSON request. Other websites and other computers
11
+ * cannot read the conversation or answer a dialog.
12
+ * - One server per user: a second `subsub web` opens the running one.
13
+ * - Without an open page for a while (and with nothing waiting), the server
14
+ * stops by itself, so a shortcut can start it without a terminal.
15
+ */
16
+
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { randomBytes, timingSafeEqual } from "node:crypto";
19
+ import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
21
+ import { homedir } from "node:os";
22
+ import { extname, join, normalize, resolve, sep } from "node:path";
23
+ import { StringDecoder } from "node:string_decoder";
24
+ import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
+ import { expand, loadConfig, saveConfig, type SubsubConfig } from "./config.ts";
26
+ import { TESTED_MODELS } from "./init.ts";
27
+ import { within } from "./paths.ts";
28
+
29
+ // ---------------------------------------------------------------- options
30
+
31
+ export interface WebOptions {
32
+ port: number;
33
+ open: boolean;
34
+ librarian: boolean;
35
+ here: boolean;
36
+ /** Minutes without an open page before the server stops; 0 = never. */
37
+ idleMinutes: number;
38
+ /** Extra arguments for pi (for example --provider and --model in tests). */
39
+ piArgs: string[];
40
+ }
41
+
42
+ export function parseWebArgs(args: string[]): WebOptions {
43
+ const o: WebOptions = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
44
+ for (let i = 0; i < args.length; i++) {
45
+ const a = args[i];
46
+ if (a === "--port") o.port = Number(args[++i] ?? 0) || 0;
47
+ else if (a === "--no-open") o.open = false;
48
+ else if (a === "--librarian") o.librarian = true;
49
+ else if (a === "--here") o.here = true;
50
+ else if (a === "--stay") o.idleMinutes = 0;
51
+ else if (a === "--idle") o.idleMinutes = Math.max(0, Number(args[++i] ?? 10));
52
+ else o.piArgs.push(a);
53
+ }
54
+ return o;
55
+ }
56
+
57
+ /** "pt" when the language setting is Portuguese, else "en". */
58
+ export function uiLanguage(language: string | undefined): "pt" | "en" {
59
+ return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
60
+ }
61
+
62
+ // ---------------------------------------------------------------- providers (API keys)
63
+
64
+ export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
65
+ { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
66
+ { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
67
+ { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
68
+ { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
69
+ { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
70
+ { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
71
+ { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
72
+ ];
73
+
74
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
75
+ export function saveApiKey(agentDir: string, provider: string, key: string): void {
76
+ if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
77
+ const clean = key.trim();
78
+ if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
79
+ mkdirSync(agentDir, { recursive: true });
80
+ const file = join(agentDir, "auth.json");
81
+ let data: Record<string, unknown> = {};
82
+ if (existsSync(file)) {
83
+ try {
84
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
85
+ } catch {
86
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
87
+ }
88
+ }
89
+ data[provider] = { type: "api_key", key: clean };
90
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
91
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
92
+ }
93
+
94
+ // ---------------------------------------------------------------- pi in RPC mode
95
+
96
+ type Listener = (ev: any) => void;
97
+
98
+ /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
99
+ export class Agent {
100
+ proc?: ChildProcess;
101
+ private decoder = new StringDecoder("utf8");
102
+ private buf = "";
103
+ private n = 0;
104
+ private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
105
+ private listeners = new Set<Listener>();
106
+ stderr = "";
107
+ onExit?: (code: number | null) => void;
108
+
109
+ private readonly command: string;
110
+ private readonly args: string[];
111
+ private readonly cwd: string;
112
+ private readonly env: NodeJS.ProcessEnv;
113
+
114
+ constructor(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv) {
115
+ this.command = command;
116
+ this.args = args;
117
+ this.cwd = cwd;
118
+ this.env = env;
119
+ }
120
+
121
+ start(extra: string[] = []): void {
122
+ this.buf = "";
123
+ this.decoder = new StringDecoder("utf8");
124
+ const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
125
+ this.proc = proc;
126
+ proc.stdout!.on("data", (chunk: Buffer) => this.feed(this.decoder.write(chunk)));
127
+ proc.stderr!.on("data", (d: Buffer) => {
128
+ this.stderr = (this.stderr + d.toString()).slice(-20_000);
129
+ });
130
+ proc.once("exit", (code) => {
131
+ if (this.proc !== proc) return;
132
+ this.proc = undefined;
133
+ for (const p of this.pending.values()) {
134
+ clearTimeout(p.timer);
135
+ p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
136
+ }
137
+ this.pending.clear();
138
+ this.onExit?.(code);
139
+ });
140
+ }
141
+
142
+ private feed(text: string): void {
143
+ this.buf += text;
144
+ let i: number;
145
+ while ((i = this.buf.indexOf("\n")) >= 0) {
146
+ const line = this.buf.slice(0, i).replace(/\r$/, "");
147
+ this.buf = this.buf.slice(i + 1);
148
+ if (!line.trim()) continue;
149
+ let rec: any;
150
+ try {
151
+ rec = JSON.parse(line);
152
+ } catch {
153
+ continue;
154
+ }
155
+ if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
156
+ const p = this.pending.get(rec.id)!;
157
+ this.pending.delete(rec.id);
158
+ clearTimeout(p.timer);
159
+ if (rec.success === false) p.reject(new Error(rec.error ?? `${rec.command} failed`));
160
+ else p.resolve(rec.data);
161
+ continue;
162
+ }
163
+ for (const l of this.listeners) l(rec);
164
+ }
165
+ }
166
+
167
+ on(l: Listener): () => void {
168
+ this.listeners.add(l);
169
+ return () => this.listeners.delete(l);
170
+ }
171
+
172
+ write(rec: unknown): void {
173
+ const stdin = this.proc?.stdin;
174
+ if (!stdin || !stdin.writable) throw new Error("pi is not running");
175
+ stdin.write(`${JSON.stringify(rec)}\n`);
176
+ }
177
+
178
+ request<T = any>(cmd: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
179
+ const id = `w${++this.n}`;
180
+ return new Promise<T>((resolvePromise, reject) => {
181
+ const timer = setTimeout(() => {
182
+ this.pending.delete(id);
183
+ reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
184
+ }, timeoutMs);
185
+ this.pending.set(id, { resolve: resolvePromise, reject, timer });
186
+ try {
187
+ this.write({ ...cmd, id });
188
+ } catch (err) {
189
+ clearTimeout(timer);
190
+ this.pending.delete(id);
191
+ reject(err as Error);
192
+ }
193
+ });
194
+ }
195
+
196
+ async stop(): Promise<void> {
197
+ const proc = this.proc;
198
+ if (!proc) return;
199
+ this.proc = undefined;
200
+ for (const p of this.pending.values()) {
201
+ clearTimeout(p.timer);
202
+ p.reject(new Error("pi was stopped"));
203
+ }
204
+ this.pending.clear();
205
+ await new Promise<void>((done) => {
206
+ const t = setTimeout(() => {
207
+ proc.kill("SIGKILL");
208
+ done();
209
+ }, 3000);
210
+ proc.once("exit", () => {
211
+ clearTimeout(t);
212
+ done();
213
+ });
214
+ proc.kill();
215
+ });
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------- helpers
220
+
221
+ const TYPES: Record<string, string> = {
222
+ ".html": "text/html; charset=utf-8",
223
+ ".js": "text/javascript; charset=utf-8",
224
+ ".css": "text/css; charset=utf-8",
225
+ ".svg": "image/svg+xml",
226
+ ".woff2": "font/woff2",
227
+ ".png": "image/png",
228
+ ".ico": "image/x-icon",
229
+ };
230
+
231
+ /** Files that the Open button may hand to the system (never programs or scripts). */
232
+ export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
233
+
234
+ const CSP = [
235
+ "default-src 'none'",
236
+ "script-src 'self'",
237
+ "style-src 'self'",
238
+ "font-src 'self'",
239
+ "img-src 'self' data:",
240
+ "connect-src 'self'",
241
+ "base-uri 'none'",
242
+ "form-action 'none'",
243
+ "frame-ancestors 'none'",
244
+ ].join("; ");
245
+
246
+ function same(a: string, b: string): boolean {
247
+ const x = Buffer.from(a);
248
+ const y = Buffer.from(b);
249
+ return x.length === y.length && timingSafeEqual(x, y);
250
+ }
251
+
252
+ function cookies(req: IncomingMessage): Record<string, string> {
253
+ const out: Record<string, string> = {};
254
+ for (const part of (req.headers.cookie ?? "").split(";")) {
255
+ const i = part.indexOf("=");
256
+ if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
257
+ }
258
+ return out;
259
+ }
260
+
261
+ function send(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
262
+ const text = typeof body === "string" ? body : JSON.stringify(body);
263
+ res.writeHead(status, {
264
+ "Content-Type": typeof body === "string" ? "text/plain; charset=utf-8" : "application/json; charset=utf-8",
265
+ "Cache-Control": "no-store",
266
+ "X-Content-Type-Options": "nosniff",
267
+ ...headers,
268
+ });
269
+ res.end(text);
270
+ }
271
+
272
+ async function readJson(req: IncomingMessage, limit = 1_000_000): Promise<any> {
273
+ let size = 0;
274
+ const chunks: Buffer[] = [];
275
+ for await (const c of req) {
276
+ size += (c as Buffer).length;
277
+ if (size > limit) throw new Error("request too large");
278
+ chunks.push(c as Buffer);
279
+ }
280
+ const text = Buffer.concat(chunks).toString("utf8");
281
+ return text ? JSON.parse(text) : {};
282
+ }
283
+
284
+ function alive(pid: number): boolean {
285
+ try {
286
+ process.kill(pid, 0);
287
+ return true;
288
+ } catch (err) {
289
+ return (err as NodeJS.ErrnoException).code === "EPERM";
290
+ }
291
+ }
292
+
293
+ /** Open a URL or a file with the system's default program. */
294
+ export function openExternal(target: string): void {
295
+ const opts = { detached: true, stdio: "ignore" as const, windowsHide: true };
296
+ let child: ChildProcess;
297
+ if (process.platform === "darwin") child = spawn("open", [target], opts);
298
+ // rundll32 hands the target to the default program without cmd.exe parsing it.
299
+ else if (process.platform === "win32") child = spawn("rundll32", ["url.dll,FileProtocolHandler", target], opts);
300
+ else child = spawn("xdg-open", [target], opts);
301
+ child.on("error", () => {});
302
+ child.unref();
303
+ }
304
+
305
+ // ---------------------------------------------------------------- server
306
+
307
+ interface RunningFile {
308
+ pid: number;
309
+ port: number;
310
+ token: string;
311
+ }
312
+
313
+ export interface WebServer {
314
+ url: string;
315
+ port: number;
316
+ token: string;
317
+ server: Server;
318
+ agent: Agent;
319
+ close(): Promise<void>;
320
+ }
321
+
322
+ /**
323
+ * Start the web server and pi. Resolves when both are listening; the caller
324
+ * decides whether to open a browser. Used by `subsub web` and by the tests.
325
+ */
326
+ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = process.env, log: (s: string) => void = () => {}): Promise<WebServer> {
327
+ const home = homedir();
328
+ const cfg: SubsubConfig = loadConfig(env);
329
+ const agentDir = agentDirFor(env, home);
330
+ const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
331
+ const lang = uiLanguage(cfg.language);
332
+ const token = randomBytes(24).toString("base64url");
333
+ const webDir = join(PACKAGE_DIR, "web");
334
+ const bin = join(PACKAGE_DIR, "bin", "subsub.js");
335
+ env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
336
+
337
+ const childEnv: NodeJS.ProcessEnv = { ...env, SUBSUB_WEB: "1" };
338
+ const baseArgs = [bin, "--mode", "rpc", "--here", ...(opts.librarian ? ["--librarian"] : []), ...opts.piArgs];
339
+ const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
340
+
341
+ // ---- state shared with the pages
342
+ const clients = new Set<ServerResponse>();
343
+ const dialogs = new Map<string, any>();
344
+ let subsubState: unknown = null;
345
+ let busy = false;
346
+ let lastSeen = Date.now();
347
+ let restarting = false;
348
+ let refreshAfterRun = false;
349
+
350
+ function broadcast(ev: unknown): void {
351
+ const data = `data: ${JSON.stringify(ev)}\n\n`;
352
+ for (const c of clients) c.write(data);
353
+ }
354
+
355
+ agent.on((ev) => {
356
+ if (ev.type === "extension_ui_request") {
357
+ if (["select", "confirm", "input", "editor"].includes(ev.method)) {
358
+ dialogs.set(ev.id, ev);
359
+ broadcast(ev);
360
+ } else if (ev.method === "setStatus" && ev.statusKey === "subsub.web") {
361
+ try {
362
+ subsubState = ev.statusText ? JSON.parse(ev.statusText) : null;
363
+ } catch {
364
+ subsubState = null;
365
+ }
366
+ broadcast({ type: "subsub_state", state: subsubState });
367
+ } else if (ev.method === "notify") {
368
+ broadcast(ev);
369
+ }
370
+ return;
371
+ }
372
+ if (ev.type === "agent_start") busy = true;
373
+ if (ev.type === "agent_settled") {
374
+ busy = false;
375
+ if (refreshAfterRun) {
376
+ refreshAfterRun = false;
377
+ agent.request({ type: "prompt", message: "/subsub-refresh" }).catch(() => {});
378
+ }
379
+ }
380
+ if (ev.type === "tool_execution_end" && /^zotero_/.test(String(ev.toolName)) && !ev.isError) refreshAfterRun = true;
381
+ broadcast(ev);
382
+ });
383
+ agent.onExit = (code) => {
384
+ if (restarting) return;
385
+ busy = false;
386
+ dialogs.clear();
387
+ broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
388
+ log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
389
+ };
390
+
391
+ agent.start();
392
+
393
+ async function snapshot(): Promise<Record<string, unknown>> {
394
+ const out: Record<string, unknown> = {
395
+ type: "hello",
396
+ lang,
397
+ version: packageVersion(),
398
+ state: subsubState,
399
+ busy,
400
+ dialogs: [...dialogs.values()],
401
+ providers: KEY_PROVIDERS,
402
+ };
403
+ try {
404
+ out.session = await agent.request({ type: "get_state" });
405
+ out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
406
+ out.commands = ((await agent.request({ type: "get_commands" })) as { commands: unknown[] }).commands;
407
+ } catch (err) {
408
+ out.error = (err as Error).message;
409
+ }
410
+ return out;
411
+ }
412
+
413
+ async function sessions(): Promise<unknown[]> {
414
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
415
+ const list = await SessionManager.list(cwd);
416
+ return list
417
+ .filter((s) => s.messageCount > 0)
418
+ .sort((a, b) => +new Date(b.modified) - +new Date(a.modified))
419
+ .slice(0, 40)
420
+ .map((s) => ({ path: s.path, name: s.name ?? null, first: s.firstMessage.slice(0, 140), modified: s.modified, messages: s.messageCount }));
421
+ }
422
+
423
+ async function restartAgent(): Promise<void> {
424
+ let sessionFile: string | undefined;
425
+ try {
426
+ sessionFile = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
427
+ } catch {
428
+ /* start fresh */
429
+ }
430
+ restarting = true;
431
+ for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
432
+ dialogs.clear();
433
+ await agent.stop();
434
+ restarting = false;
435
+ busy = false;
436
+ agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
437
+ broadcast(await snapshot());
438
+ }
439
+
440
+ // ---- idle stop
441
+ let closing = false;
442
+ const idleTimer = setInterval(() => {
443
+ if (clients.size > 0) lastSeen = Date.now();
444
+ if (!opts.idleMinutes || closing) return;
445
+ if (clients.size === 0 && !busy && dialogs.size === 0 && Date.now() - lastSeen > opts.idleMinutes * 60_000) {
446
+ log("No open page; stopping.");
447
+ void close().then(() => process.exit(0));
448
+ }
449
+ }, 15_000);
450
+ const keepAlive = setInterval(() => {
451
+ for (const c of clients) c.write(": keep-alive\n\n");
452
+ }, 20_000);
453
+
454
+ // ---- HTTP
455
+ let port = 0;
456
+ const hosts = () => [`127.0.0.1:${port}`, `localhost:${port}`];
457
+ const origins = () => hosts().map((h) => `http://${h}`);
458
+ const cookieName = () => `subsub_${port}`;
459
+
460
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
461
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
462
+ const common = { "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer" };
463
+ if (!hosts().includes(req.headers.host ?? "")) return send(res, 403, "Wrong host.", common);
464
+
465
+ // First visit: the key in the URL becomes a cookie, and the URL loses the key.
466
+ const t = url.searchParams.get("t");
467
+ if (req.method === "GET" && url.pathname === "/" && t) {
468
+ if (!same(t, token)) return send(res, 403, "This link is not valid. Open Sub-Sub again.", common);
469
+ res.writeHead(303, { ...common, Location: "/", "Set-Cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/`, "Cache-Control": "no-store" });
470
+ return void res.end();
471
+ }
472
+ const authed = same(cookies(req)[cookieName()] ?? "", token);
473
+ if (!authed) return send(res, 403, "Open Sub-Sub with its shortcut or with: subsub web", common);
474
+
475
+ if (req.method === "POST") {
476
+ const origin = req.headers.origin;
477
+ if (origin && !origins().includes(origin)) return send(res, 403, "Wrong origin.", common);
478
+ const site = req.headers["sec-fetch-site"];
479
+ if (site && site !== "same-origin") return send(res, 403, "Wrong origin.", common);
480
+ if (!String(req.headers["content-type"] ?? "").startsWith("application/json")) return send(res, 415, "JSON only.", common);
481
+ }
482
+
483
+ // ---- static files
484
+ if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
485
+ const rel = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
486
+ const file = normalize(join(webDir, rel));
487
+ if (!file.startsWith(webDir + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, "Not found.", common);
488
+ res.writeHead(200, {
489
+ ...common,
490
+ "Content-Type": TYPES[extname(file)] ?? "application/octet-stream",
491
+ "Content-Security-Policy": CSP,
492
+ "X-Content-Type-Options": "nosniff",
493
+ "Cache-Control": "no-cache",
494
+ });
495
+ return void createReadStream(file).pipe(res);
496
+ }
497
+
498
+ // ---- API
499
+ const route = `${req.method} ${url.pathname}`;
500
+ try {
501
+ switch (route) {
502
+ case "GET /api/ping":
503
+ return send(res, 200, { ok: true, version: packageVersion() });
504
+ case "GET /api/state":
505
+ return send(res, 200, await agent.request({ type: "get_state" }));
506
+ case "GET /api/events": {
507
+ res.writeHead(200, { ...common, "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
508
+ res.write(": hello\n\n");
509
+ clients.add(res);
510
+ lastSeen = Date.now();
511
+ req.on("close", () => {
512
+ clients.delete(res);
513
+ lastSeen = Date.now();
514
+ });
515
+ res.write(`data: ${JSON.stringify(await snapshot())}\n\n`);
516
+ agent.request({ type: "prompt", message: "/subsub-refresh" }).catch(() => {});
517
+ return;
518
+ }
519
+ case "POST /api/prompt": {
520
+ const body = await readJson(req);
521
+ const message = String(body.message ?? "").trim();
522
+ if (!message) return send(res, 400, { error: "empty message" });
523
+ await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
524
+ return send(res, 200, { ok: true });
525
+ }
526
+ case "POST /api/abort":
527
+ await agent.request({ type: "abort" });
528
+ return send(res, 200, { ok: true });
529
+ case "POST /api/dialog": {
530
+ const body = await readJson(req);
531
+ const id = String(body.id ?? "");
532
+ const d = dialogs.get(id);
533
+ if (!d) return send(res, 404, { error: "no such dialog" });
534
+ const reply: Record<string, unknown> = { type: "extension_ui_response", id };
535
+ if (body.cancelled) reply.cancelled = true;
536
+ else if (d.method === "confirm") reply.confirmed = body.confirmed === true;
537
+ else if (d.method === "select") {
538
+ if (!d.options?.includes(body.value)) return send(res, 400, { error: "not an option" });
539
+ reply.value = body.value;
540
+ } else reply.value = String(body.value ?? "");
541
+ agent.write(reply);
542
+ dialogs.delete(id);
543
+ broadcast({ type: "dialog_closed", id });
544
+ return send(res, 200, { ok: true });
545
+ }
546
+ case "POST /api/new":
547
+ if (busy) return send(res, 409, { error: "busy" });
548
+ await agent.request({ type: "new_session" });
549
+ broadcast(await snapshot());
550
+ return send(res, 200, { ok: true });
551
+ case "GET /api/sessions":
552
+ return send(res, 200, { sessions: await sessions() });
553
+ case "POST /api/session": {
554
+ if (busy) return send(res, 409, { error: "busy" });
555
+ const body = await readJson(req);
556
+ const known = (await sessions()) as Array<{ path: string }>;
557
+ if (!known.some((s) => s.path === body.path)) return send(res, 404, { error: "no such session" });
558
+ await agent.request({ type: "switch_session", sessionPath: body.path });
559
+ broadcast(await snapshot());
560
+ return send(res, 200, { ok: true });
561
+ }
562
+ case "GET /api/models": {
563
+ const data = (await agent.request({ type: "get_available_models" })) as { models?: unknown[] } | unknown[];
564
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
565
+ }
566
+ case "POST /api/model": {
567
+ if (busy) return send(res, 409, { error: "busy" });
568
+ const body = await readJson(req);
569
+ const spec = `${String(body.provider)}/${String(body.id)}`;
570
+ // Sub-Sub keeps one model per mode: remember it for the current mode, then restart pi so the
571
+ // extension reads the new setting.
572
+ const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
573
+ const models = { ...(loadConfig(env).models ?? {}), [mode]: spec };
574
+ if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
575
+ saveConfig({ models }, env);
576
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
577
+ await restartAgent();
578
+ return send(res, 200, { ok: true });
579
+ }
580
+ case "POST /api/restart":
581
+ if (agent.proc) return send(res, 200, { ok: true });
582
+ agent.start();
583
+ broadcast(await snapshot());
584
+ return send(res, 200, { ok: true });
585
+ case "POST /api/login": {
586
+ if (busy) return send(res, 409, { error: "busy" });
587
+ const body = await readJson(req);
588
+ saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
589
+ const current = loadConfig(env);
590
+ if (body.provider === "opencode-go" && Object.keys(current.models ?? {}).length === 0) saveConfig({ models: TESTED_MODELS }, env);
591
+ await restartAgent();
592
+ return send(res, 200, { ok: true });
593
+ }
594
+ case "POST /api/open": {
595
+ const body = await readJson(req);
596
+ const target = resolve(cwd, expand(String(body.path ?? "")));
597
+ const vault = cfg.vault;
598
+ if (!(within(target, cwd) || (vault && within(target, vault))) || !existsSync(target)) return send(res, 404, { error: "not found in the notes folder" });
599
+ // Documents only: a file that the model wrote must never be run.
600
+ if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
601
+ if (vault && within(target, vault) && existsSync(join(vault, ".obsidian"))) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
602
+ else openExternal(target);
603
+ return send(res, 200, { ok: true });
604
+ }
605
+ case "POST /api/quit":
606
+ send(res, 200, { ok: true });
607
+ setTimeout(() => void close().then(() => process.exit(0)), 100);
608
+ return;
609
+ default:
610
+ return send(res, 404, { error: "not found" });
611
+ }
612
+ } catch (err) {
613
+ return send(res, 500, { error: (err as Error).message });
614
+ }
615
+ }
616
+
617
+ const server = createServer((req, res) => {
618
+ handle(req, res).catch((err) => send(res, 500, { error: (err as Error).message }));
619
+ });
620
+ await new Promise<void>((ok, fail) => {
621
+ server.once("error", fail);
622
+ server.listen(opts.port, "127.0.0.1", () => ok());
623
+ });
624
+ port = (server.address() as { port: number }).port;
625
+
626
+ async function close(): Promise<void> {
627
+ if (closing) return;
628
+ closing = true;
629
+ clearInterval(idleTimer);
630
+ clearInterval(keepAlive);
631
+ for (const c of clients) c.end();
632
+ clients.clear();
633
+ await agent.stop();
634
+ await new Promise<void>((ok) => server.close(() => ok()));
635
+ }
636
+
637
+ return { url: `http://127.0.0.1:${port}/?t=${token}`, port, token, server, agent, close };
638
+ }
639
+
640
+ // ---------------------------------------------------------------- command
641
+
642
+ function runningFile(env: NodeJS.ProcessEnv): string {
643
+ return join(agentDirFor(env, homedir()), "..", "web.json");
644
+ }
645
+
646
+ async function findRunning(env: NodeJS.ProcessEnv): Promise<RunningFile | undefined> {
647
+ const file = runningFile(env);
648
+ if (!existsSync(file)) return undefined;
649
+ try {
650
+ const r = JSON.parse(readFileSync(file, "utf8")) as RunningFile;
651
+ if (!alive(r.pid)) return undefined;
652
+ const res = await fetch(`http://127.0.0.1:${r.port}/api/ping`, {
653
+ headers: { Cookie: `subsub_${r.port}=${r.token}` },
654
+ signal: AbortSignal.timeout(1500),
655
+ });
656
+ return res.ok ? r : undefined;
657
+ } catch {
658
+ return undefined;
659
+ }
660
+ }
661
+
662
+ export async function webMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
663
+ const opts = parseWebArgs(args);
664
+ const logFile = join(agentDirFor(env, homedir()), "..", "web.log");
665
+ mkdirSync(join(logFile, ".."), { recursive: true });
666
+ try {
667
+ if (existsSync(logFile) && statSync(logFile).size > 1_000_000) renameSync(logFile, `${logFile}.old`);
668
+ } catch {
669
+ /* not important */
670
+ }
671
+ const log = (s: string) => {
672
+ try {
673
+ appendFileSync(logFile, `${new Date().toISOString()} ${s}\n`);
674
+ } catch {
675
+ /* not important */
676
+ }
677
+ if (process.stderr.isTTY) process.stderr.write(`${s}\n`);
678
+ };
679
+
680
+ const running = opts.port === 0 ? await findRunning(env) : undefined;
681
+ if (running) {
682
+ const url = `http://127.0.0.1:${running.port}/?t=${running.token}`;
683
+ console.log(`Sub-Sub is already open: ${url}`);
684
+ if (opts.open) openExternal(url);
685
+ return 0;
686
+ }
687
+
688
+ let web: WebServer;
689
+ try {
690
+ web = await startWeb(opts, env, log);
691
+ } catch (err) {
692
+ log(`Sub-Sub could not start the web view: ${(err as Error).message}`);
693
+ console.error(`Sub-Sub could not start the web view: ${(err as Error).message}`);
694
+ return 1;
695
+ }
696
+ const file = runningFile(env);
697
+ writeFileSync(file, JSON.stringify({ pid: process.pid, port: web.port, token: web.token }), { mode: 0o600 });
698
+ log(`Sub-Sub web view on port ${web.port}.`);
699
+ const minutes = `${opts.idleMinutes} minute${opts.idleMinutes === 1 ? "" : "s"}`;
700
+ const stopHint = `To stop Sub-Sub, press Ctrl+C${opts.idleMinutes ? `; it also stops ${minutes} after you close the page` : ""}.`;
701
+ console.log(opts.open ? `Sub-Sub is open in your browser. If it did not open, go to:\n${web.url}\n${stopHint}` : `Sub-Sub is running. Open:\n${web.url}\n${stopHint}`);
702
+ if (opts.open) openExternal(web.url);
703
+
704
+ const stop = () => {
705
+ void web.close().then(() => process.exit(0));
706
+ };
707
+ process.on("SIGINT", stop);
708
+ process.on("SIGTERM", stop);
709
+ return await new Promise<number>(() => {});
710
+ }