smolcoder 0.4.3 → 0.5.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.
@@ -0,0 +1,786 @@
1
+ "use strict";
2
+ // The web hub: what `smol --web` serves. One local HTTP server hosting many
3
+ // sessions across many workspaces — the browser page shows them in a sidebar
4
+ // and can start, resume, switch between, close and delete them. All sessions
5
+ // share one SSE stream (events are tagged with a session id) and one random
6
+ // URL token; the server binds to loopback only.
7
+ //
8
+ // Sessions are saved under ~/.smolcoder/sessions/ as they run, so the sidebar
9
+ // survives a restart and any past session can be resumed. A second
10
+ // `smol --web` started elsewhere finds the running hub through
11
+ // ~/.smolcoder/web.json and adds its folder there instead of starting a
12
+ // second server.
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.WebHub = void 0;
48
+ exports.readHubRecord = readHubRecord;
49
+ exports.pingHub = pingHub;
50
+ exports.askHubToOpen = askHubToOpen;
51
+ exports.browseDir = browseDir;
52
+ const crypto = __importStar(require("crypto"));
53
+ const fs = __importStar(require("fs"));
54
+ const http = __importStar(require("http"));
55
+ const os = __importStar(require("os"));
56
+ const path = __importStar(require("path"));
57
+ const config_1 = require("../config");
58
+ const session_1 = require("../session");
59
+ const util_1 = require("../util");
60
+ const channel_1 = require("./channel");
61
+ const page_1 = require("./page");
62
+ const store_1 = require("./store");
63
+ const terminal_1 = require("./terminal");
64
+ const HUB_FILE = "web.json";
65
+ function readHubRecord(dataDir = config_1.DATA_DIR) {
66
+ try {
67
+ const r = JSON.parse(fs.readFileSync(path.join(dataDir, HUB_FILE), "utf8"));
68
+ if (r && typeof r.port === "number" && typeof r.token === "string")
69
+ return r;
70
+ }
71
+ catch {
72
+ /* none */
73
+ }
74
+ return null;
75
+ }
76
+ function writeHubRecord(rec, dataDir) {
77
+ try {
78
+ fs.mkdirSync(dataDir, { recursive: true });
79
+ fs.writeFileSync(path.join(dataDir, HUB_FILE), JSON.stringify(rec));
80
+ }
81
+ catch {
82
+ /* non-fatal: a second smol --web will just start its own server */
83
+ }
84
+ }
85
+ function clearHubRecord(pid, dataDir) {
86
+ const rec = readHubRecord(dataDir);
87
+ if (rec && rec.pid === pid) {
88
+ try {
89
+ fs.unlinkSync(path.join(dataDir, HUB_FILE));
90
+ }
91
+ catch {
92
+ /* gone */
93
+ }
94
+ }
95
+ }
96
+ /** Is the hub a previous invocation recorded still answering? */
97
+ async function pingHub(rec) {
98
+ const r = await (0, util_1.tryFetchJson)(`http://127.0.0.1:${rec.port}/ping?k=${rec.token}`, undefined, 1500);
99
+ return !!r?.ok;
100
+ }
101
+ /** Ask a running hub to add a workspace (and optionally start a session). */
102
+ function askHubToOpen(rec, workspace, start) {
103
+ return (0, util_1.tryFetchJson)(`http://127.0.0.1:${rec.port}/workspaces/add?k=${rec.token}`, {
104
+ method: "POST",
105
+ headers: { "content-type": "application/json" },
106
+ body: JSON.stringify({ path: workspace, start }),
107
+ }, 5000);
108
+ }
109
+ // ---- helpers ----------------------------------------------------------------
110
+ function tilde(p) {
111
+ const home = os.homedir();
112
+ const same = process.platform === "win32" ? p.slice(0, home.length).toLowerCase() === home.toLowerCase() : p.startsWith(home);
113
+ return same ? "~" + p.slice(home.length).replace(/\\/g, "/") : p;
114
+ }
115
+ function expandHome(s) {
116
+ if (s === "~")
117
+ return os.homedir();
118
+ if (s.startsWith("~/") || s.startsWith("~\\"))
119
+ return path.join(os.homedir(), s.slice(2));
120
+ return s;
121
+ }
122
+ const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "AGENTS.md", "pom.xml", "Gemfile"];
123
+ function isProject(dir) {
124
+ return PROJECT_MARKERS.some((m) => fs.existsSync(path.join(dir, m)));
125
+ }
126
+ let rootsCache = null;
127
+ function listRoots() {
128
+ if (rootsCache && Date.now() - rootsCache.at < 60_000)
129
+ return rootsCache.roots;
130
+ let roots;
131
+ if (process.platform === "win32") {
132
+ roots = [];
133
+ for (let i = 67; i <= 90; i++) {
134
+ // C: through Z:; A:/B: are floppy letters and slow to probe
135
+ const r = `${String.fromCharCode(i)}:\\`;
136
+ try {
137
+ if (fs.existsSync(r))
138
+ roots.push(r);
139
+ }
140
+ catch {
141
+ /* skip */
142
+ }
143
+ }
144
+ }
145
+ else {
146
+ roots = ["/"];
147
+ }
148
+ rootsCache = { at: Date.now(), roots };
149
+ return roots;
150
+ }
151
+ const SKIP_DIRS = /^(node_modules|\$recycle\.bin|system volume information|windows|program files( \(x86\))?|programdata|appdata)$/i;
152
+ /** Folder listing for the "open folder" picker. Exported for tests. */
153
+ function browseDir(input) {
154
+ const home = os.homedir();
155
+ const target = input && input.trim() ? path.resolve(expandHome(input.trim())) : home;
156
+ const roots = listRoots();
157
+ const base = { path: target, display: tilde(target), home, roots };
158
+ let st;
159
+ try {
160
+ st = fs.statSync(target);
161
+ }
162
+ catch {
163
+ return { ...base, error: `not a folder: ${target}`, parent: path.dirname(target), dirs: [] };
164
+ }
165
+ if (!st.isDirectory())
166
+ return { ...base, error: `not a folder: ${target}`, parent: path.dirname(target), dirs: [] };
167
+ let entries;
168
+ try {
169
+ entries = fs.readdirSync(target, { withFileTypes: true });
170
+ }
171
+ catch (err) {
172
+ return { ...base, error: `cannot read ${target}: ${err?.message ?? err}`, parent: path.dirname(target), dirs: [] };
173
+ }
174
+ const dirs = entries
175
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.test(e.name))
176
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }))
177
+ .slice(0, 500)
178
+ .map((e) => {
179
+ const full = path.join(target, e.name);
180
+ let project = false;
181
+ try {
182
+ project = isProject(full);
183
+ }
184
+ catch {
185
+ /* unreadable */
186
+ }
187
+ return { name: e.name, path: full, project };
188
+ });
189
+ const parent = path.dirname(target);
190
+ return { ...base, parent: parent === target ? null : parent, dirs, project: isProject(target) };
191
+ }
192
+ function defaultFactory(help) {
193
+ return async (ui, workspace, prefs) => {
194
+ const cfg = (0, config_1.loadConfig)();
195
+ const chosen = await (0, session_1.prepareModel)(prefs, cfg, (label) => ui.startSpinner(label));
196
+ ui.stopSpinner();
197
+ if (!chosen)
198
+ throw new Error((0, session_1.noBackendsMessage)());
199
+ return new session_1.Session(ui, { workspace, chosen, prefs, cfg, help });
200
+ };
201
+ }
202
+ // ---- the hub ----------------------------------------------------------------
203
+ class WebHub {
204
+ opts;
205
+ authToken = crypto.randomBytes(9).toString("base64url");
206
+ dataDir;
207
+ port;
208
+ server = null;
209
+ clients = new Set();
210
+ live = new Map();
211
+ metas = new Map();
212
+ store;
213
+ workspaces;
214
+ factory;
215
+ hubTimer = null;
216
+ termCounter = 0;
217
+ stopped = false;
218
+ constructor(opts) {
219
+ this.opts = opts;
220
+ this.port = opts.port;
221
+ this.dataDir = opts.dataDir ?? config_1.DATA_DIR;
222
+ this.store = new store_1.SessionStore(this.dataDir);
223
+ this.workspaces = new store_1.WorkspaceStore(this.dataDir);
224
+ for (const m of this.store.listMetas())
225
+ this.metas.set(m.id, m);
226
+ this.factory = opts.factory ?? defaultFactory(opts.help);
227
+ }
228
+ url() {
229
+ return `http://127.0.0.1:${this.port}/?k=${this.authToken}`;
230
+ }
231
+ start() {
232
+ return new Promise((resolve, reject) => {
233
+ const server = http.createServer((req, res) => this.route(req, res));
234
+ this.server = server;
235
+ server.once("error", reject);
236
+ server.listen(this.opts.port, "127.0.0.1", () => {
237
+ const addr = server.address();
238
+ if (addr && typeof addr === "object")
239
+ this.port = addr.port;
240
+ writeHubRecord({ port: this.port, token: this.authToken, pid: process.pid, startedAt: Date.now() }, this.dataDir);
241
+ resolve();
242
+ });
243
+ });
244
+ }
245
+ /** Synchronous teardown for exit paths: save what has something to save,
246
+ * kill background tasks and terminals, drop the running-hub record. */
247
+ shutdownSync() {
248
+ if (this.stopped)
249
+ return;
250
+ this.stopped = true;
251
+ for (const live of this.live.values()) {
252
+ if (live.saveTimer)
253
+ clearTimeout(live.saveTimer);
254
+ try {
255
+ if (!live.discard && live.session && live.channel.title)
256
+ this.saveSync(live);
257
+ }
258
+ catch {
259
+ /* best effort */
260
+ }
261
+ live.session?.taskManager.killAll();
262
+ for (const t of live.terminals.values())
263
+ t.close();
264
+ }
265
+ clearHubRecord(process.pid, this.dataDir);
266
+ for (const c of this.clients)
267
+ c.end();
268
+ this.server?.close();
269
+ }
270
+ close() {
271
+ this.shutdownSync();
272
+ }
273
+ log(s) {
274
+ if (!this.opts.quiet)
275
+ console.log(s);
276
+ }
277
+ // ---- sessions ---------------------------------------------------------------
278
+ addWorkspace(p) {
279
+ this.workspaces.add(p);
280
+ this.changed();
281
+ }
282
+ /** Start a new session in a workspace; returns its id right away while the
283
+ * model is detected and loaded in the background. */
284
+ openSession(workspace) {
285
+ const id = crypto.randomBytes(4).toString("hex");
286
+ this.workspaces.add(workspace);
287
+ const now = Date.now();
288
+ const live = this.makeLive(id, { id, workspace, title: "", createdAt: now, updatedAt: now });
289
+ void this.spawn(live, null);
290
+ this.changed();
291
+ return id;
292
+ }
293
+ /** Bring a saved session back to life (or retry one that failed to start). */
294
+ resumeSession(id) {
295
+ const existing = this.live.get(id);
296
+ if (existing) {
297
+ if (existing.error)
298
+ void this.spawn(existing, existing.pendingRestore);
299
+ return true;
300
+ }
301
+ const meta = this.metas.get(id);
302
+ if (!meta)
303
+ return false;
304
+ const body = this.store.loadBody(id);
305
+ const live = this.makeLive(id, meta);
306
+ live.channel.title = meta.title;
307
+ if (body) {
308
+ live.channel.replay = body.events.slice(-2000);
309
+ for (const ev of live.channel.replay)
310
+ this.send(ev);
311
+ }
312
+ this.workspaces.add(meta.workspace);
313
+ void this.spawn(live, body?.snapshot ?? null);
314
+ this.changed();
315
+ return true;
316
+ }
317
+ closeSession(id) {
318
+ const live = this.live.get(id);
319
+ if (!live)
320
+ return false;
321
+ if (!live.session) {
322
+ // Still starting (or failed to start): there is no loop to unwind.
323
+ live.channel.close();
324
+ this.dropLive(live);
325
+ return true;
326
+ }
327
+ live.channel.cancel();
328
+ live.channel.requestExit();
329
+ return true;
330
+ }
331
+ deleteSession(id) {
332
+ const live = this.live.get(id);
333
+ if (live) {
334
+ live.discard = true;
335
+ this.closeSession(id);
336
+ }
337
+ this.store.delete(id);
338
+ this.metas.delete(id);
339
+ this.changed();
340
+ }
341
+ renameSession(id, title) {
342
+ title = title.replace(/\s+/g, " ").trim().slice(0, 80);
343
+ if (!title)
344
+ return;
345
+ const live = this.live.get(id);
346
+ if (live) {
347
+ live.channel.title = title;
348
+ live.meta.title = title;
349
+ this.scheduleSave(live);
350
+ }
351
+ const meta = this.metas.get(id);
352
+ if (meta) {
353
+ meta.title = title;
354
+ try {
355
+ this.store.saveMeta(meta);
356
+ }
357
+ catch {
358
+ /* non-fatal */
359
+ }
360
+ }
361
+ this.changed();
362
+ }
363
+ /** Forget a workspace and delete its saved sessions. Returns an error
364
+ * message when it still has open sessions. */
365
+ removeWorkspace(p) {
366
+ const key = (0, store_1.workspaceKey)(p);
367
+ for (const live of this.live.values()) {
368
+ if ((0, store_1.workspaceKey)(live.workspace) === key)
369
+ return "close its open sessions first";
370
+ }
371
+ for (const m of [...this.metas.values()]) {
372
+ if ((0, store_1.workspaceKey)(m.workspace) === key) {
373
+ this.store.delete(m.id);
374
+ this.metas.delete(m.id);
375
+ }
376
+ }
377
+ this.workspaces.remove(p);
378
+ this.changed();
379
+ return null;
380
+ }
381
+ makeLive(id, meta) {
382
+ const channel = new channel_1.SessionChannel(id, {
383
+ send: (ev) => this.send(ev),
384
+ changed: () => this.changed(),
385
+ touched: (sid) => this.touched(sid),
386
+ });
387
+ const live = {
388
+ id,
389
+ workspace: meta.workspace,
390
+ channel,
391
+ session: null,
392
+ meta,
393
+ error: null,
394
+ pendingRestore: null,
395
+ terminals: new Map(),
396
+ saveTimer: null,
397
+ saving: false,
398
+ dirty: false,
399
+ discard: false,
400
+ };
401
+ this.live.set(id, live);
402
+ return live;
403
+ }
404
+ async spawn(live, restore) {
405
+ live.error = null;
406
+ live.pendingRestore = restore;
407
+ live.channel.phase = "starting";
408
+ this.changed();
409
+ let prefs = this.opts.prefs;
410
+ if (restore) {
411
+ // A saved bypass mode is not inherited silently, same as the config:
412
+ // the user re-enables it per session.
413
+ const mode = restore.mode === "bypass" ? "edit" : restore.mode ?? prefs.mode;
414
+ prefs = { ...prefs, mode, model: restore.model ?? prefs.model, effort: restore.effort !== undefined ? restore.effort : prefs.effort };
415
+ }
416
+ try {
417
+ const session = await this.factory(live.channel, live.workspace, prefs);
418
+ if (live.channel.closed || this.live.get(live.id) !== live) {
419
+ session.taskManager.killAll();
420
+ return;
421
+ }
422
+ live.session = session;
423
+ session.onExit = () => this.sessionEnded(live.id);
424
+ live.channel.getState = () => session.state();
425
+ if (restore)
426
+ session.restore(restore);
427
+ live.meta.model = session.chosen.id;
428
+ live.meta.backend = session.chosen.backend;
429
+ session.announce();
430
+ if (restore) {
431
+ live.channel.status(`· session resumed${restore.mode === "bypass" ? " in edit mode (bypass is not restored)" : ""} — earlier command approvals are not remembered`);
432
+ }
433
+ this.log(` ● ${live.id} · ${tilde(live.workspace)} · ${session.chosen.id}`);
434
+ this.changed();
435
+ session.run().catch((err) => live.channel.error(String(err?.message ?? err)));
436
+ }
437
+ catch (err) {
438
+ live.error = String(err?.message ?? err);
439
+ live.channel.phase = "error";
440
+ live.channel.error(live.error);
441
+ live.channel.warn("Start a backend, then click this session in the sidebar to retry.");
442
+ this.changed();
443
+ }
444
+ }
445
+ sessionEnded(id) {
446
+ const live = this.live.get(id);
447
+ if (!live)
448
+ return;
449
+ if (!live.discard && live.session && live.channel.title) {
450
+ try {
451
+ this.saveSync(live);
452
+ }
453
+ catch {
454
+ /* best effort */
455
+ }
456
+ }
457
+ this.dropLive(live);
458
+ this.log(` ○ ${id} closed`);
459
+ }
460
+ dropLive(live) {
461
+ if (live.saveTimer) {
462
+ clearTimeout(live.saveTimer);
463
+ live.saveTimer = null;
464
+ }
465
+ for (const t of live.terminals.values())
466
+ t.close();
467
+ live.terminals.clear();
468
+ if (this.live.get(live.id) === live)
469
+ this.live.delete(live.id);
470
+ this.send({ t: "closed", sid: live.id });
471
+ this.changed();
472
+ }
473
+ // ---- saving -----------------------------------------------------------------
474
+ touched(id) {
475
+ const live = this.live.get(id);
476
+ if (!live)
477
+ return;
478
+ live.meta.updatedAt = Date.now();
479
+ this.scheduleSave(live);
480
+ }
481
+ scheduleSave(live) {
482
+ live.dirty = true;
483
+ if (live.saveTimer)
484
+ return;
485
+ live.saveTimer = setTimeout(() => {
486
+ live.saveTimer = null;
487
+ void this.save(live);
488
+ }, 1500);
489
+ }
490
+ async save(live) {
491
+ // Untitled sessions (nothing sent yet) are not worth a file.
492
+ if (!live.session || live.discard || !live.channel.title)
493
+ return;
494
+ if (live.saving) {
495
+ live.dirty = true;
496
+ return;
497
+ }
498
+ live.saving = true;
499
+ live.dirty = false;
500
+ try {
501
+ live.meta.title = live.channel.title;
502
+ this.metas.set(live.id, live.meta);
503
+ this.store.saveMeta(live.meta);
504
+ await this.store.saveBody(live.id, { snapshot: live.session.snapshot(), events: live.channel.replay });
505
+ }
506
+ catch {
507
+ /* disk trouble is not worth interrupting the session for */
508
+ }
509
+ live.saving = false;
510
+ if (live.dirty)
511
+ this.scheduleSave(live);
512
+ }
513
+ saveSync(live) {
514
+ if (!live.session)
515
+ return;
516
+ live.meta.title = live.channel.title;
517
+ this.metas.set(live.id, live.meta);
518
+ this.store.saveMeta(live.meta);
519
+ this.store.saveBodySync(live.id, { snapshot: live.session.snapshot(), events: live.channel.replay });
520
+ }
521
+ // ---- terminals --------------------------------------------------------------
522
+ openTerminal(sid) {
523
+ const live = this.live.get(sid);
524
+ if (!live)
525
+ return null;
526
+ const tid = `t${++this.termCounter}`;
527
+ this.send({ t: "termopen", sid, tid, cwd: live.workspace });
528
+ const term = new terminal_1.Terminal(tid, live.workspace, {
529
+ output: (text) => this.send({ t: "term", sid, tid, s: text }),
530
+ done: (code, cwd) => this.send({ t: "termdone", sid, tid, code, cwd }),
531
+ });
532
+ live.terminals.set(tid, term);
533
+ this.changed();
534
+ return { tid, cwd: term.cwd };
535
+ }
536
+ closeTerminal(sid, tid) {
537
+ const live = this.live.get(sid);
538
+ const t = live?.terminals.get(tid);
539
+ if (!live || !t)
540
+ return;
541
+ t.close();
542
+ live.terminals.delete(tid);
543
+ this.send({ t: "termclosed", sid, tid });
544
+ this.changed();
545
+ }
546
+ // ---- broadcasting -----------------------------------------------------------
547
+ send(ev) {
548
+ const line = `data: ${JSON.stringify(ev)}\n\n`;
549
+ for (const c of this.clients)
550
+ c.write(line);
551
+ }
552
+ /** Coalesced: many status flips in one tick produce one snapshot. */
553
+ changed() {
554
+ if (this.hubTimer)
555
+ return;
556
+ this.hubTimer = setTimeout(() => {
557
+ this.hubTimer = null;
558
+ this.send(this.snapshot());
559
+ }, 25);
560
+ }
561
+ /** What the sidebar shows. Exported through the SSE stream and /ping. */
562
+ snapshot() {
563
+ const groups = new Map();
564
+ const group = (p) => {
565
+ const key = (0, store_1.workspaceKey)(p);
566
+ let g = groups.get(key);
567
+ if (!g) {
568
+ g = { path: p, sessions: [], last: 0 };
569
+ groups.set(key, g);
570
+ }
571
+ return g;
572
+ };
573
+ for (const w of this.workspaces.list()) {
574
+ const g = group(w.path);
575
+ g.last = Math.max(g.last, w.lastOpened);
576
+ }
577
+ for (const m of this.metas.values()) {
578
+ if (this.live.has(m.id))
579
+ continue;
580
+ group(m.workspace).sessions.push({
581
+ id: m.id,
582
+ title: m.title,
583
+ status: "stored",
584
+ live: false,
585
+ createdAt: m.createdAt,
586
+ updatedAt: m.updatedAt,
587
+ model: m.model,
588
+ terminals: [],
589
+ });
590
+ }
591
+ for (const l of this.live.values()) {
592
+ group(l.workspace).sessions.push({
593
+ id: l.id,
594
+ title: l.channel.title || l.meta.title,
595
+ status: l.channel.phase,
596
+ live: true,
597
+ createdAt: l.meta.createdAt,
598
+ updatedAt: l.meta.updatedAt,
599
+ model: l.meta.model,
600
+ terminals: [...l.terminals.values()].map((t) => ({ tid: t.id, cwd: t.cwd })),
601
+ });
602
+ }
603
+ const workspaces = [...groups.values()]
604
+ .map((g) => {
605
+ g.sessions.sort((a, b) => b.updatedAt - a.updatedAt);
606
+ const last = Math.max(g.last, ...g.sessions.map((s) => s.updatedAt));
607
+ return { path: g.path, name: path.basename(g.path) || g.path, display: tilde(g.path), last, sessions: g.sessions };
608
+ })
609
+ .sort((a, b) => b.last - a.last);
610
+ return { t: "hub", home: os.homedir(), sep: path.sep, version: this.opts.version, workspaces };
611
+ }
612
+ // ---- http ---------------------------------------------------------------------
613
+ route(req, res) {
614
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
615
+ const origin = req.headers.origin;
616
+ const sameOrigin = !origin || origin === `http://127.0.0.1:${this.port}` || origin === `http://localhost:${this.port}`;
617
+ if (url.searchParams.get("k") !== this.authToken || !sameOrigin) {
618
+ res.writeHead(403, { "content-type": "text/plain" });
619
+ res.end("forbidden — open smolcoder's printed URL (it includes the session key)");
620
+ return;
621
+ }
622
+ const json = (code, body) => {
623
+ res.writeHead(code, { "content-type": "application/json" });
624
+ res.end(JSON.stringify(body));
625
+ };
626
+ if (req.method === "GET") {
627
+ switch (url.pathname) {
628
+ case "/":
629
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
630
+ res.end(page_1.PAGE_HTML);
631
+ return;
632
+ case "/ping":
633
+ json(200, { ok: true, pid: process.pid, version: this.opts.version });
634
+ return;
635
+ case "/events":
636
+ this.sse(req, res);
637
+ return;
638
+ case "/fs":
639
+ json(200, browseDir(url.searchParams.get("path")));
640
+ return;
641
+ default:
642
+ res.writeHead(404);
643
+ res.end();
644
+ return;
645
+ }
646
+ }
647
+ if (req.method === "POST") {
648
+ let body = "";
649
+ req.on("data", (d) => {
650
+ body += d;
651
+ if (body.length > 1_000_000)
652
+ req.destroy();
653
+ });
654
+ req.on("end", () => {
655
+ let data = {};
656
+ try {
657
+ data = body ? JSON.parse(body) : {};
658
+ }
659
+ catch {
660
+ /* ignore */
661
+ }
662
+ try {
663
+ json(200, this.handlePost(url.pathname, data) ?? {});
664
+ }
665
+ catch (err) {
666
+ json(400, { error: String(err?.message ?? err) });
667
+ }
668
+ });
669
+ return;
670
+ }
671
+ res.writeHead(404);
672
+ res.end();
673
+ }
674
+ sse(req, res) {
675
+ res.writeHead(200, {
676
+ "content-type": "text/event-stream",
677
+ "cache-control": "no-cache",
678
+ connection: "keep-alive",
679
+ });
680
+ const write = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
681
+ write(this.snapshot());
682
+ for (const l of this.live.values()) {
683
+ for (const ev of l.channel.replay)
684
+ write(ev);
685
+ write(l.channel.stateEvent());
686
+ for (const t of l.terminals.values()) {
687
+ write({ t: "termopen", sid: l.id, tid: t.id, cwd: t.cwd });
688
+ if (t.buffer)
689
+ write({ t: "term", sid: l.id, tid: t.id, s: t.buffer });
690
+ }
691
+ }
692
+ this.clients.add(res);
693
+ req.on("close", () => this.clients.delete(res));
694
+ }
695
+ checkDir(p) {
696
+ const s = String(p ?? "").trim();
697
+ if (!s)
698
+ throw new Error("path is required");
699
+ const abs = path.resolve(expandHome(s));
700
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
701
+ throw new Error(`not a folder: ${abs}`);
702
+ return abs;
703
+ }
704
+ handlePost(p, d) {
705
+ const sid = String(d.sid ?? "");
706
+ const live = () => {
707
+ const l = this.live.get(sid);
708
+ if (!l)
709
+ throw new Error("no such session");
710
+ return l;
711
+ };
712
+ switch (p) {
713
+ case "/msg":
714
+ live().channel.handleMessage(String(d.text ?? ""));
715
+ return {};
716
+ case "/confirm":
717
+ live().channel.handleAnswer(Number(d.id), d.answer);
718
+ return {};
719
+ case "/select":
720
+ live().channel.handleAnswer(Number(d.id), d.index);
721
+ return {};
722
+ case "/cycle": {
723
+ const l = live();
724
+ l.channel.onModeCycle?.();
725
+ l.channel.refresh();
726
+ return {};
727
+ }
728
+ case "/cancel":
729
+ live().channel.cancel();
730
+ return {};
731
+ case "/sessions/new":
732
+ return { id: this.openSession(this.checkDir(d.workspace)) };
733
+ case "/sessions/resume":
734
+ if (!this.resumeSession(String(d.id ?? "")))
735
+ throw new Error("unknown session");
736
+ return { id: d.id };
737
+ case "/sessions/close":
738
+ this.closeSession(String(d.id ?? ""));
739
+ return {};
740
+ case "/sessions/delete":
741
+ this.deleteSession(String(d.id ?? ""));
742
+ return {};
743
+ case "/sessions/rename":
744
+ this.renameSession(String(d.id ?? ""), String(d.title ?? ""));
745
+ return {};
746
+ case "/workspaces/add": {
747
+ const ws = this.checkDir(d.path);
748
+ this.workspaces.add(ws);
749
+ this.changed();
750
+ return d.start ? { path: ws, id: this.openSession(ws) } : { path: ws };
751
+ }
752
+ case "/workspaces/remove": {
753
+ const err = this.removeWorkspace(String(d.path ?? ""));
754
+ if (err)
755
+ throw new Error(err);
756
+ return {};
757
+ }
758
+ case "/term/open": {
759
+ const r = this.openTerminal(sid);
760
+ if (!r)
761
+ throw new Error("no such session");
762
+ return r;
763
+ }
764
+ case "/term/input": {
765
+ const t = live().terminals.get(String(d.tid ?? ""));
766
+ if (!t)
767
+ throw new Error("no such terminal");
768
+ t.write(String(d.text ?? ""));
769
+ return {};
770
+ }
771
+ case "/term/interrupt": {
772
+ const t = live().terminals.get(String(d.tid ?? ""));
773
+ if (!t)
774
+ throw new Error("no such terminal");
775
+ t.interrupt();
776
+ return {};
777
+ }
778
+ case "/term/close":
779
+ this.closeTerminal(sid, String(d.tid ?? ""));
780
+ return {};
781
+ default:
782
+ throw new Error(`unknown endpoint ${p}`);
783
+ }
784
+ }
785
+ }
786
+ exports.WebHub = WebHub;