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