smolcoder-plus 1.0.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/dist/agent.js +748 -0
  4. package/dist/attachments.js +158 -0
  5. package/dist/config.js +87 -0
  6. package/dist/context.js +498 -0
  7. package/dist/detect.js +474 -0
  8. package/dist/events.js +24 -0
  9. package/dist/history.js +9 -0
  10. package/dist/hosts.js +107 -0
  11. package/dist/index.js +391 -0
  12. package/dist/logo.js +48 -0
  13. package/dist/netscan.js +159 -0
  14. package/dist/network.js +193 -0
  15. package/dist/plan.js +102 -0
  16. package/dist/prompt.js +84 -0
  17. package/dist/providers/lmstudio.js +347 -0
  18. package/dist/providers/ollama.js +269 -0
  19. package/dist/providers/scheduler.js +57 -0
  20. package/dist/providers/transport.js +86 -0
  21. package/dist/providers/types.js +62 -0
  22. package/dist/sandbox.js +207 -0
  23. package/dist/session.js +639 -0
  24. package/dist/tools/check.js +193 -0
  25. package/dist/tools/fs-tools.js +431 -0
  26. package/dist/tools/index.js +260 -0
  27. package/dist/tools/search-worker.js +34 -0
  28. package/dist/tools/shell.js +186 -0
  29. package/dist/tools/tasks.js +147 -0
  30. package/dist/tools/web-search.js +155 -0
  31. package/dist/tui/editor.js +134 -0
  32. package/dist/tui/keys.js +145 -0
  33. package/dist/tui/tui.js +723 -0
  34. package/dist/ui.js +226 -0
  35. package/dist/util.js +91 -0
  36. package/dist/verification.js +71 -0
  37. package/dist/web/channel.js +260 -0
  38. package/dist/web/client.js +1010 -0
  39. package/dist/web/hub.js +952 -0
  40. package/dist/web/page.js +87 -0
  41. package/dist/web/store.js +199 -0
  42. package/dist/web/styles.js +333 -0
  43. package/dist/web/terminal.js +190 -0
  44. package/package.json +49 -0
@@ -0,0 +1,952 @@
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 attachments_1 = require("../attachments");
61
+ const channel_1 = require("./channel");
62
+ const page_1 = require("./page");
63
+ const store_1 = require("./store");
64
+ const terminal_1 = require("./terminal");
65
+ /** Session ids are short hex and upload ids are 16 hex chars. Both end up in
66
+ * file paths under the data folder, so nothing else is accepted. */
67
+ const SESSION_ID_RE = /^[a-z0-9_-]{1,64}$/i;
68
+ const UPLOAD_ID_RE = /^[a-f0-9]{16}$/;
69
+ const HUB_FILE = "web.json";
70
+ function readHubRecord(dataDir = config_1.DATA_DIR) {
71
+ try {
72
+ const r = JSON.parse(fs.readFileSync(path.join(dataDir, HUB_FILE), "utf8"));
73
+ if (r && typeof r.port === "number" && typeof r.token === "string")
74
+ return r;
75
+ }
76
+ catch {
77
+ /* none */
78
+ }
79
+ return null;
80
+ }
81
+ function writeHubRecord(rec, dataDir) {
82
+ try {
83
+ fs.mkdirSync(dataDir, { recursive: true });
84
+ fs.writeFileSync(path.join(dataDir, HUB_FILE), JSON.stringify(rec));
85
+ }
86
+ catch {
87
+ /* non-fatal: a second smol --web will just start its own server */
88
+ }
89
+ }
90
+ function clearHubRecord(pid, dataDir) {
91
+ const rec = readHubRecord(dataDir);
92
+ if (rec && rec.pid === pid) {
93
+ try {
94
+ fs.unlinkSync(path.join(dataDir, HUB_FILE));
95
+ }
96
+ catch {
97
+ /* gone */
98
+ }
99
+ }
100
+ }
101
+ /** Is the hub a previous invocation recorded still answering? */
102
+ async function pingHub(rec) {
103
+ const r = await (0, util_1.tryFetchJson)(`http://127.0.0.1:${rec.port}/ping?k=${rec.token}`, undefined, 1500);
104
+ return !!r?.ok;
105
+ }
106
+ /** Ask a running hub to add a workspace (and optionally start a session). */
107
+ function askHubToOpen(rec, workspace, start) {
108
+ return (0, util_1.tryFetchJson)(`http://127.0.0.1:${rec.port}/workspaces/add?k=${rec.token}`, {
109
+ method: "POST",
110
+ headers: { "content-type": "application/json" },
111
+ body: JSON.stringify({ path: workspace, start }),
112
+ }, 5000);
113
+ }
114
+ // ---- helpers ----------------------------------------------------------------
115
+ function tilde(p) {
116
+ const home = os.homedir();
117
+ const same = process.platform === "win32" ? p.slice(0, home.length).toLowerCase() === home.toLowerCase() : p.startsWith(home);
118
+ return same ? "~" + p.slice(home.length).replace(/\\/g, "/") : p;
119
+ }
120
+ function expandHome(s) {
121
+ if (s === "~")
122
+ return os.homedir();
123
+ if (s.startsWith("~/") || s.startsWith("~\\"))
124
+ return path.join(os.homedir(), s.slice(2));
125
+ return s;
126
+ }
127
+ const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "AGENTS.md", "pom.xml", "Gemfile"];
128
+ function isProject(dir) {
129
+ return PROJECT_MARKERS.some((m) => fs.existsSync(path.join(dir, m)));
130
+ }
131
+ let rootsCache = null;
132
+ function listRoots() {
133
+ if (rootsCache && Date.now() - rootsCache.at < 60_000)
134
+ return rootsCache.roots;
135
+ let roots;
136
+ if (process.platform === "win32") {
137
+ roots = [];
138
+ for (let i = 67; i <= 90; i++) {
139
+ // C: through Z:; A:/B: are floppy letters and slow to probe
140
+ const r = `${String.fromCharCode(i)}:\\`;
141
+ try {
142
+ if (fs.existsSync(r))
143
+ roots.push(r);
144
+ }
145
+ catch {
146
+ /* skip */
147
+ }
148
+ }
149
+ }
150
+ else {
151
+ roots = ["/"];
152
+ }
153
+ rootsCache = { at: Date.now(), roots };
154
+ return roots;
155
+ }
156
+ const SKIP_DIRS = /^(node_modules|\$recycle\.bin|system volume information|windows|program files( \(x86\))?|programdata|appdata)$/i;
157
+ /** Folder listing for the "open folder" picker. Exported for tests. */
158
+ function browseDir(input) {
159
+ const home = os.homedir();
160
+ const target = input && input.trim() ? path.resolve(expandHome(input.trim())) : home;
161
+ const roots = listRoots();
162
+ const base = { path: target, display: tilde(target), home, roots };
163
+ let st;
164
+ try {
165
+ st = fs.statSync(target);
166
+ }
167
+ catch {
168
+ return { ...base, error: `not a folder: ${target}`, parent: path.dirname(target), dirs: [] };
169
+ }
170
+ if (!st.isDirectory())
171
+ return { ...base, error: `not a folder: ${target}`, parent: path.dirname(target), dirs: [] };
172
+ let entries;
173
+ try {
174
+ entries = fs.readdirSync(target, { withFileTypes: true });
175
+ }
176
+ catch (err) {
177
+ return { ...base, error: `cannot read ${target}: ${err?.message ?? err}`, parent: path.dirname(target), dirs: [] };
178
+ }
179
+ const dirs = entries
180
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.test(e.name))
181
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }))
182
+ .slice(0, 500)
183
+ .map((e) => {
184
+ const full = path.join(target, e.name);
185
+ let project = false;
186
+ try {
187
+ project = isProject(full);
188
+ }
189
+ catch {
190
+ /* unreadable */
191
+ }
192
+ return { name: e.name, path: full, project };
193
+ });
194
+ const parent = path.dirname(target);
195
+ return { ...base, parent: parent === target ? null : parent, dirs, project: isProject(target) };
196
+ }
197
+ function defaultFactory(help) {
198
+ return async (ui, workspace, prefs) => {
199
+ const cfg = (0, config_1.loadConfig)();
200
+ let chosen = await (0, session_1.prepareModel)(prefs, cfg, (label) => ui.startSpinner(label));
201
+ ui.stopSpinner();
202
+ // Nothing on this computer: the page offers to look on the network.
203
+ if (!chosen)
204
+ chosen = await (0, session_1.setupWithoutLocalModels)(ui, prefs);
205
+ if (!chosen)
206
+ throw new Error((0, session_1.noBackendsMessage)());
207
+ return new session_1.Session(ui, { workspace, chosen, prefs, cfg, help });
208
+ };
209
+ }
210
+ // ---- the hub ----------------------------------------------------------------
211
+ class WebHub {
212
+ opts;
213
+ authToken = crypto.randomBytes(9).toString("base64url");
214
+ dataDir;
215
+ port;
216
+ server = null;
217
+ clients = new Set();
218
+ live = new Map();
219
+ metas = new Map();
220
+ store;
221
+ workspaces;
222
+ factory;
223
+ hubTimer = null;
224
+ termCounter = 0;
225
+ stopped = false;
226
+ constructor(opts) {
227
+ this.opts = opts;
228
+ this.port = opts.port;
229
+ this.dataDir = opts.dataDir ?? config_1.DATA_DIR;
230
+ this.store = new store_1.SessionStore(this.dataDir);
231
+ this.workspaces = new store_1.WorkspaceStore(this.dataDir);
232
+ for (const m of this.store.listMetas())
233
+ this.metas.set(m.id, m);
234
+ this.factory = opts.factory ?? defaultFactory(opts.help);
235
+ }
236
+ url() {
237
+ return `http://127.0.0.1:${this.port}/?k=${this.authToken}`;
238
+ }
239
+ start() {
240
+ return new Promise((resolve, reject) => {
241
+ const server = http.createServer((req, res) => this.route(req, res));
242
+ this.server = server;
243
+ server.once("error", reject);
244
+ server.listen(this.opts.port, "127.0.0.1", () => {
245
+ const addr = server.address();
246
+ if (addr && typeof addr === "object")
247
+ this.port = addr.port;
248
+ writeHubRecord({ port: this.port, token: this.authToken, pid: process.pid, startedAt: Date.now() }, this.dataDir);
249
+ resolve();
250
+ });
251
+ });
252
+ }
253
+ /** Synchronous teardown for exit paths: save what has something to save,
254
+ * kill background tasks and terminals, drop the running-hub record. */
255
+ shutdownSync() {
256
+ if (this.stopped)
257
+ return;
258
+ this.stopped = true;
259
+ for (const live of this.live.values()) {
260
+ if (live.saveTimer)
261
+ clearTimeout(live.saveTimer);
262
+ try {
263
+ if (!live.discard && live.session && live.channel.title)
264
+ this.saveSync(live);
265
+ }
266
+ catch {
267
+ /* best effort */
268
+ }
269
+ live.session?.taskManager.killAll();
270
+ for (const t of live.terminals.values())
271
+ t.close();
272
+ }
273
+ clearHubRecord(process.pid, this.dataDir);
274
+ for (const c of this.clients)
275
+ c.end();
276
+ this.server?.close();
277
+ }
278
+ close() {
279
+ this.shutdownSync();
280
+ }
281
+ log(s) {
282
+ if (!this.opts.quiet)
283
+ console.log(s);
284
+ }
285
+ // ---- sessions ---------------------------------------------------------------
286
+ addWorkspace(p) {
287
+ this.workspaces.add(p);
288
+ this.changed();
289
+ }
290
+ /** Start a new session in a workspace; returns its id right away while the
291
+ * model is detected and loaded in the background. */
292
+ openSession(workspace) {
293
+ const id = crypto.randomBytes(4).toString("hex");
294
+ this.workspaces.add(workspace);
295
+ const now = Date.now();
296
+ const live = this.makeLive(id, { id, workspace, title: "", createdAt: now, updatedAt: now });
297
+ void this.spawn(live, null);
298
+ this.changed();
299
+ return id;
300
+ }
301
+ /** Bring a saved session back to life (or retry one that failed to start). */
302
+ resumeSession(id) {
303
+ const existing = this.live.get(id);
304
+ if (existing) {
305
+ if (existing.error)
306
+ void this.spawn(existing, existing.pendingRestore);
307
+ return true;
308
+ }
309
+ const meta = this.metas.get(id);
310
+ if (!meta)
311
+ return false;
312
+ const body = this.store.loadBody(id);
313
+ const live = this.makeLive(id, meta);
314
+ live.channel.title = meta.title;
315
+ if (body) {
316
+ live.channel.restoreReplay(body.events);
317
+ for (const ev of live.channel.replay)
318
+ this.send(ev);
319
+ }
320
+ this.workspaces.add(meta.workspace);
321
+ void this.spawn(live, body?.snapshot ?? null);
322
+ this.changed();
323
+ return true;
324
+ }
325
+ closeSession(id) {
326
+ const live = this.live.get(id);
327
+ if (!live)
328
+ return false;
329
+ if (!live.session) {
330
+ // Still starting (or failed to start): there is no loop to unwind.
331
+ live.channel.close();
332
+ this.dropLive(live);
333
+ return true;
334
+ }
335
+ live.channel.cancel();
336
+ live.channel.requestExit();
337
+ return true;
338
+ }
339
+ deleteSession(id) {
340
+ const live = this.live.get(id);
341
+ if (live) {
342
+ live.discard = true;
343
+ this.closeSession(id);
344
+ }
345
+ this.store.delete(id);
346
+ this.metas.delete(id);
347
+ try {
348
+ fs.rmSync(this.uploadsDir(id), { recursive: true, force: true });
349
+ }
350
+ catch {
351
+ /* best effort */
352
+ }
353
+ this.changed();
354
+ }
355
+ renameSession(id, title) {
356
+ title = title.replace(/\s+/g, " ").trim().slice(0, 80);
357
+ if (!title)
358
+ return;
359
+ const live = this.live.get(id);
360
+ if (live) {
361
+ live.channel.title = title;
362
+ live.meta.title = title;
363
+ live.titleByUser = true;
364
+ live.channel.pushState();
365
+ this.scheduleSave(live);
366
+ }
367
+ const meta = this.metas.get(id);
368
+ if (meta) {
369
+ meta.title = title;
370
+ try {
371
+ this.store.saveMeta(meta);
372
+ }
373
+ catch {
374
+ /* non-fatal */
375
+ }
376
+ }
377
+ this.changed();
378
+ }
379
+ /** Forget a workspace and delete its saved sessions. Returns an error
380
+ * message when it still has open sessions. */
381
+ removeWorkspace(p) {
382
+ const key = (0, store_1.workspaceKey)(p);
383
+ for (const live of this.live.values()) {
384
+ if ((0, store_1.workspaceKey)(live.workspace) === key)
385
+ return "close its open sessions first";
386
+ }
387
+ for (const m of [...this.metas.values()]) {
388
+ if ((0, store_1.workspaceKey)(m.workspace) === key) {
389
+ this.store.delete(m.id);
390
+ this.metas.delete(m.id);
391
+ }
392
+ }
393
+ this.workspaces.remove(p);
394
+ this.changed();
395
+ return null;
396
+ }
397
+ makeLive(id, meta) {
398
+ const channel = new channel_1.SessionChannel(id, {
399
+ send: (ev) => this.send(ev),
400
+ changed: () => this.changed(),
401
+ touched: (sid) => this.touched(sid),
402
+ });
403
+ const live = {
404
+ id,
405
+ workspace: meta.workspace,
406
+ channel,
407
+ session: null,
408
+ meta,
409
+ error: null,
410
+ pendingRestore: null,
411
+ terminals: new Map(),
412
+ uploads: new Map(),
413
+ saveTimer: null,
414
+ saving: false,
415
+ dirty: false,
416
+ discard: false,
417
+ // A saved session already has a name; only fresh ones get one written.
418
+ titleTries: meta.title ? 99 : 0,
419
+ titleByUser: false,
420
+ };
421
+ this.live.set(id, live);
422
+ return live;
423
+ }
424
+ /** After the first turn: replace the verbatim first-message title with a
425
+ * short model-written one. Bounded retries, never over a manual rename. */
426
+ async autoTitle(live) {
427
+ if (!live.session || live.titleByUser || live.titleTries >= 2)
428
+ return;
429
+ live.titleTries++;
430
+ const title = await live.session.suggestTitle();
431
+ if (!title || live.titleByUser || live.channel.closed || this.live.get(live.id) !== live)
432
+ return;
433
+ live.channel.title = title;
434
+ live.meta.title = title;
435
+ live.channel.pushState();
436
+ this.changed();
437
+ this.scheduleSave(live);
438
+ }
439
+ async spawn(live, restore) {
440
+ live.error = null;
441
+ live.pendingRestore = restore;
442
+ live.channel.phase = "starting";
443
+ this.changed();
444
+ let prefs = this.opts.prefs;
445
+ if (restore) {
446
+ // A saved bypass mode is not inherited silently, same as the config:
447
+ // the user re-enables it per session.
448
+ const mode = restore.mode === "bypass" ? "edit" : restore.mode ?? prefs.mode;
449
+ prefs = { ...prefs, mode, backend: restore.backend, model: restore.model ?? prefs.model, baseUrl: restore.baseUrl, effort: restore.effort !== undefined ? restore.effort : prefs.effort };
450
+ }
451
+ try {
452
+ const session = await this.factory(live.channel, live.workspace, prefs);
453
+ if (live.channel.closed || this.live.get(live.id) !== live) {
454
+ session.taskManager.killAll();
455
+ return;
456
+ }
457
+ live.session = session;
458
+ session.onExit = () => this.sessionEnded(live.id);
459
+ session.onTurnDone = () => void this.autoTitle(live);
460
+ live.channel.getState = () => session.state();
461
+ if (restore)
462
+ session.restore(restore);
463
+ live.meta.model = session.chosen.id;
464
+ live.meta.backend = session.chosen.backend;
465
+ session.announce();
466
+ if (restore) {
467
+ live.channel.status(`· session resumed${restore.mode === "bypass" ? " in edit mode (bypass is not restored)" : ""} — earlier command approvals are not remembered`);
468
+ }
469
+ this.log(` ● ${live.id} · ${tilde(live.workspace)} · ${session.chosen.id}`);
470
+ this.changed();
471
+ session.run().catch((err) => live.channel.error(String(err?.message ?? err)));
472
+ }
473
+ catch (err) {
474
+ live.error = String(err?.message ?? err);
475
+ live.channel.phase = "error";
476
+ live.channel.error(live.error);
477
+ live.channel.warn("Start a backend, then click this session in the sidebar to retry.");
478
+ this.changed();
479
+ }
480
+ }
481
+ sessionEnded(id) {
482
+ const live = this.live.get(id);
483
+ if (!live)
484
+ return;
485
+ if (!live.discard && live.session && live.channel.title) {
486
+ try {
487
+ this.saveSync(live);
488
+ }
489
+ catch {
490
+ /* best effort */
491
+ }
492
+ }
493
+ this.dropLive(live);
494
+ this.log(` ○ ${id} closed`);
495
+ }
496
+ dropLive(live) {
497
+ if (live.saveTimer) {
498
+ clearTimeout(live.saveTimer);
499
+ live.saveTimer = null;
500
+ }
501
+ for (const t of live.terminals.values())
502
+ t.close();
503
+ live.terminals.clear();
504
+ if (this.live.get(live.id) === live)
505
+ this.live.delete(live.id);
506
+ this.send({ t: "closed", sid: live.id });
507
+ this.changed();
508
+ }
509
+ // ---- saving -----------------------------------------------------------------
510
+ touched(id) {
511
+ const live = this.live.get(id);
512
+ if (!live)
513
+ return;
514
+ live.meta.updatedAt = Date.now();
515
+ this.scheduleSave(live);
516
+ }
517
+ scheduleSave(live) {
518
+ live.dirty = true;
519
+ if (live.saveTimer)
520
+ return;
521
+ live.saveTimer = setTimeout(() => {
522
+ live.saveTimer = null;
523
+ void this.save(live);
524
+ }, 1500);
525
+ }
526
+ async save(live) {
527
+ // Untitled sessions (nothing sent yet) are not worth a file.
528
+ if (!live.session || live.discard || !live.channel.title)
529
+ return;
530
+ if (live.saving) {
531
+ live.dirty = true;
532
+ return;
533
+ }
534
+ live.saving = true;
535
+ live.dirty = false;
536
+ try {
537
+ live.meta.title = live.channel.title;
538
+ this.metas.set(live.id, live.meta);
539
+ this.store.saveMeta(live.meta);
540
+ await this.store.saveBody(live.id, { snapshot: live.session.snapshot(), events: live.channel.replay });
541
+ live.saveFailed = false;
542
+ }
543
+ catch (err) {
544
+ if (!live.saveFailed) {
545
+ live.saveFailed = true;
546
+ live.channel.warn(`Session could not be saved: ${err?.message ?? err}. Keep this session open and check free disk space and permissions.`);
547
+ }
548
+ }
549
+ live.saving = false;
550
+ if (live.dirty)
551
+ this.scheduleSave(live);
552
+ }
553
+ saveSync(live) {
554
+ if (!live.session)
555
+ return;
556
+ live.meta.title = live.channel.title;
557
+ this.metas.set(live.id, live.meta);
558
+ this.store.saveMeta(live.meta);
559
+ this.store.saveBodySync(live.id, { snapshot: live.session.snapshot(), events: live.channel.replay });
560
+ }
561
+ // ---- terminals --------------------------------------------------------------
562
+ openTerminal(sid) {
563
+ const live = this.live.get(sid);
564
+ if (!live)
565
+ return null;
566
+ const tid = `t${++this.termCounter}`;
567
+ this.send({ t: "termopen", sid, tid, cwd: live.workspace });
568
+ const term = new terminal_1.Terminal(tid, live.workspace, {
569
+ output: (text) => this.send({ t: "term", sid, tid, s: text }),
570
+ done: (code, cwd) => this.send({ t: "termdone", sid, tid, code, cwd }),
571
+ });
572
+ live.terminals.set(tid, term);
573
+ this.changed();
574
+ return { tid, cwd: term.cwd };
575
+ }
576
+ closeTerminal(sid, tid) {
577
+ const live = this.live.get(sid);
578
+ const t = live?.terminals.get(tid);
579
+ if (!live || !t)
580
+ return;
581
+ t.close();
582
+ live.terminals.delete(tid);
583
+ this.send({ t: "termclosed", sid, tid });
584
+ this.changed();
585
+ }
586
+ // ---- broadcasting -----------------------------------------------------------
587
+ send(ev) {
588
+ const line = `data: ${JSON.stringify(ev)}\n\n`;
589
+ for (const c of this.clients)
590
+ c.write(line);
591
+ }
592
+ /** Coalesced: many status flips in one tick produce one snapshot. */
593
+ changed() {
594
+ if (this.hubTimer)
595
+ return;
596
+ this.hubTimer = setTimeout(() => {
597
+ this.hubTimer = null;
598
+ this.send(this.snapshot());
599
+ }, 25);
600
+ }
601
+ /** What the sidebar shows. Exported through the SSE stream and /ping. */
602
+ snapshot() {
603
+ const groups = new Map();
604
+ const group = (p) => {
605
+ const key = (0, store_1.workspaceKey)(p);
606
+ let g = groups.get(key);
607
+ if (!g) {
608
+ g = { path: p, sessions: [], last: 0 };
609
+ groups.set(key, g);
610
+ }
611
+ return g;
612
+ };
613
+ for (const w of this.workspaces.list()) {
614
+ const g = group(w.path);
615
+ g.last = Math.max(g.last, w.lastOpened);
616
+ }
617
+ for (const m of this.metas.values()) {
618
+ if (this.live.has(m.id))
619
+ continue;
620
+ group(m.workspace).sessions.push({
621
+ id: m.id,
622
+ title: m.title,
623
+ status: "stored",
624
+ live: false,
625
+ createdAt: m.createdAt,
626
+ updatedAt: m.updatedAt,
627
+ model: m.model,
628
+ terminals: [],
629
+ });
630
+ }
631
+ for (const l of this.live.values()) {
632
+ group(l.workspace).sessions.push({
633
+ id: l.id,
634
+ title: l.channel.title || l.meta.title,
635
+ status: l.channel.phase,
636
+ live: true,
637
+ createdAt: l.meta.createdAt,
638
+ updatedAt: l.meta.updatedAt,
639
+ model: l.meta.model,
640
+ terminals: [...l.terminals.values()].map((t) => ({ tid: t.id, cwd: t.cwd })),
641
+ });
642
+ }
643
+ const workspaces = [...groups.values()]
644
+ .map((g) => {
645
+ g.sessions.sort((a, b) => b.updatedAt - a.updatedAt);
646
+ const last = Math.max(g.last, ...g.sessions.map((s) => s.updatedAt));
647
+ return { path: g.path, name: path.basename(g.path) || g.path, display: tilde(g.path), last, sessions: g.sessions };
648
+ })
649
+ .sort((a, b) => b.last - a.last);
650
+ return { t: "hub", home: os.homedir(), sep: path.sep, version: this.opts.version, workspaces };
651
+ }
652
+ // ---- http ---------------------------------------------------------------------
653
+ route(req, res) {
654
+ res.setHeader("Referrer-Policy", "no-referrer");
655
+ res.setHeader("X-Content-Type-Options", "nosniff");
656
+ res.setHeader("X-Frame-Options", "DENY");
657
+ res.setHeader("Cache-Control", "no-store");
658
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
659
+ const origin = req.headers.origin;
660
+ const sameOrigin = !origin || origin === `http://127.0.0.1:${this.port}` || origin === `http://localhost:${this.port}`;
661
+ if (url.searchParams.get("k") !== this.authToken || !sameOrigin) {
662
+ res.writeHead(403, { "content-type": "text/plain" });
663
+ res.end("forbidden — open smolcoder's printed URL (it includes the session key)");
664
+ return;
665
+ }
666
+ const json = (code, body) => {
667
+ res.writeHead(code, { "content-type": "application/json" });
668
+ res.end(JSON.stringify(body));
669
+ };
670
+ if (req.method === "GET") {
671
+ switch (url.pathname) {
672
+ case "/":
673
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
674
+ res.end(page_1.PAGE_HTML);
675
+ return;
676
+ case "/ping":
677
+ json(200, { ok: true, pid: process.pid, version: this.opts.version });
678
+ return;
679
+ case "/events":
680
+ this.sse(req, res);
681
+ return;
682
+ case "/fs":
683
+ json(200, browseDir(url.searchParams.get("path")));
684
+ return;
685
+ case "/upload":
686
+ this.serveUpload(res, url);
687
+ return;
688
+ default:
689
+ res.writeHead(404);
690
+ res.end();
691
+ return;
692
+ }
693
+ }
694
+ if (req.method === "POST") {
695
+ if (url.pathname === "/upload") {
696
+ this.receiveUpload(req, res, url);
697
+ return;
698
+ }
699
+ let body = "";
700
+ req.on("data", (d) => {
701
+ body += d;
702
+ if (body.length > 1_000_000)
703
+ req.destroy();
704
+ });
705
+ req.on("end", () => {
706
+ let data = {};
707
+ try {
708
+ data = body ? JSON.parse(body) : {};
709
+ }
710
+ catch {
711
+ /* ignore */
712
+ }
713
+ try {
714
+ json(200, this.handlePost(url.pathname, data) ?? {});
715
+ }
716
+ catch (err) {
717
+ json(400, { error: String(err?.message ?? err) });
718
+ }
719
+ });
720
+ return;
721
+ }
722
+ res.writeHead(404);
723
+ res.end();
724
+ }
725
+ sse(req, res) {
726
+ res.writeHead(200, {
727
+ "content-type": "text/event-stream",
728
+ "cache-control": "no-cache",
729
+ connection: "keep-alive",
730
+ });
731
+ const write = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
732
+ write(this.snapshot());
733
+ for (const l of this.live.values()) {
734
+ for (const ev of l.channel.replay)
735
+ write(ev);
736
+ write(l.channel.stateEvent());
737
+ for (const t of l.terminals.values()) {
738
+ write({ t: "termopen", sid: l.id, tid: t.id, cwd: t.cwd });
739
+ if (t.buffer)
740
+ write({ t: "term", sid: l.id, tid: t.id, s: t.buffer });
741
+ }
742
+ }
743
+ this.clients.add(res);
744
+ req.on("close", () => this.clients.delete(res));
745
+ }
746
+ // ---- attachments ---------------------------------------------------------
747
+ uploadsDir(sid) {
748
+ return path.join(this.dataDir, "uploads", sid);
749
+ }
750
+ /** One file for a live session, sent as the raw request body with the name
751
+ * in the query string (a pasted screenshot has no name; the page makes one). */
752
+ receiveUpload(req, res, url) {
753
+ const reply = (code, body) => {
754
+ res.writeHead(code, { "content-type": "application/json" });
755
+ res.end(JSON.stringify(body));
756
+ };
757
+ const sid = String(url.searchParams.get("sid") ?? "");
758
+ const live = SESSION_ID_RE.test(sid) ? this.live.get(sid) : undefined;
759
+ if (!live) {
760
+ reply(404, { error: "no such session" });
761
+ req.resume();
762
+ return;
763
+ }
764
+ const name = (0, attachments_1.safeName)(url.searchParams.get("name"));
765
+ const mime = String(req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim().toLowerCase();
766
+ const chunks = [];
767
+ let size = 0;
768
+ let failed = false;
769
+ req.on("data", (d) => {
770
+ if (failed)
771
+ return;
772
+ size += d.length;
773
+ if (size > attachments_1.MAX_UPLOAD_BYTES) {
774
+ failed = true;
775
+ reply(413, { error: `"${name}" is larger than the ${attachments_1.MAX_UPLOAD_BYTES / 1024 / 1024} MB upload limit.` });
776
+ req.destroy();
777
+ return;
778
+ }
779
+ chunks.push(d);
780
+ });
781
+ req.on("end", () => {
782
+ if (failed)
783
+ return;
784
+ const bytes = Buffer.concat(chunks);
785
+ const verdict = (0, attachments_1.classifyUpload)(name, mime, bytes);
786
+ if ("error" in verdict) {
787
+ reply(415, { error: verdict.error });
788
+ return;
789
+ }
790
+ const id = crypto.randomBytes(8).toString("hex");
791
+ const dir = this.uploadsDir(sid);
792
+ const file = path.join(dir, `${id}.${(0, attachments_1.extOf)(name, mime)}`);
793
+ try {
794
+ fs.mkdirSync(dir, { recursive: true });
795
+ fs.writeFileSync(file, bytes);
796
+ }
797
+ catch (err) {
798
+ reply(500, { error: `could not store the upload: ${err?.message ?? err}` });
799
+ return;
800
+ }
801
+ const att = { id, name, kind: verdict.kind, mime: verdict.mime, size: bytes.length, path: file };
802
+ live.uploads.set(id, att);
803
+ const model = live.session?.chosen;
804
+ const warning = att.kind === "image" && model?.vision === false
805
+ ? `${model.id} cannot see images — switch to a vision-capable model with /models`
806
+ : undefined;
807
+ reply(200, { id, name, kind: att.kind, size: att.size, url: (0, channel_1.uploadUrl)(sid, id), ...(warning ? { warning } : {}) });
808
+ });
809
+ }
810
+ /** A stored upload, for the page's thumbnails and "open" links. */
811
+ serveUpload(res, url) {
812
+ const sid = String(url.searchParams.get("sid") ?? "");
813
+ const id = String(url.searchParams.get("id") ?? "");
814
+ const file = SESSION_ID_RE.test(sid) && UPLOAD_ID_RE.test(id) ? this.findUpload(sid, id) : null;
815
+ if (!file) {
816
+ res.writeHead(404);
817
+ res.end();
818
+ return;
819
+ }
820
+ res.writeHead(200, { "content-type": (0, attachments_1.mimeForExt)(path.extname(file).slice(1)), "content-length": fs.statSync(file).size });
821
+ fs.createReadStream(file).pipe(res);
822
+ }
823
+ findUpload(sid, id) {
824
+ const dir = this.uploadsDir(sid);
825
+ try {
826
+ const entry = fs.readdirSync(dir).find((f) => f.startsWith(id + "."));
827
+ return entry ? path.join(dir, entry) : null;
828
+ }
829
+ catch {
830
+ return null;
831
+ }
832
+ }
833
+ /** The user took a chip off the composer: forget the file. */
834
+ removeUpload(live, id) {
835
+ const att = live.uploads.get(id);
836
+ live.uploads.delete(id);
837
+ const file = att?.path ?? (UPLOAD_ID_RE.test(id) ? this.findUpload(live.id, id) : null);
838
+ if (!file)
839
+ return;
840
+ try {
841
+ fs.unlinkSync(file);
842
+ }
843
+ catch {
844
+ /* already gone */
845
+ }
846
+ }
847
+ checkDir(p) {
848
+ const s = String(p ?? "").trim();
849
+ if (!s)
850
+ throw new Error("path is required");
851
+ const abs = path.resolve(expandHome(s));
852
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
853
+ throw new Error(`not a folder: ${abs}`);
854
+ return abs;
855
+ }
856
+ handlePost(p, d) {
857
+ const sid = String(d.sid ?? "");
858
+ const live = () => {
859
+ const l = this.live.get(sid);
860
+ if (!l)
861
+ throw new Error("no such session");
862
+ return l;
863
+ };
864
+ switch (p) {
865
+ case "/msg": {
866
+ const l = live();
867
+ const ids = Array.isArray(d.attachments) ? d.attachments.map(String) : [];
868
+ const attachments = ids.map((id) => l.uploads.get(id));
869
+ if (attachments.some((a) => !a))
870
+ throw new Error("an attachment is no longer available — add it again");
871
+ for (const id of ids)
872
+ l.uploads.delete(id);
873
+ l.channel.handleMessage(String(d.text ?? ""), attachments);
874
+ return {};
875
+ }
876
+ case "/upload/remove":
877
+ this.removeUpload(live(), String(d.id ?? ""));
878
+ return {};
879
+ case "/confirm":
880
+ live().channel.handleAnswer(Number(d.id), d.answer);
881
+ return {};
882
+ case "/select":
883
+ live().channel.handleAnswer(Number(d.id), d.index);
884
+ return {};
885
+ case "/prompt":
886
+ live().channel.handleAnswer(Number(d.id), typeof d.value === "string" ? d.value : null);
887
+ return {};
888
+ case "/cycle": {
889
+ const l = live();
890
+ l.channel.onModeCycle?.();
891
+ l.channel.refresh();
892
+ return {};
893
+ }
894
+ case "/cancel":
895
+ live().channel.cancel();
896
+ return {};
897
+ case "/sessions/new":
898
+ return { id: this.openSession(this.checkDir(d.workspace)) };
899
+ case "/sessions/resume":
900
+ if (!this.resumeSession(String(d.id ?? "")))
901
+ throw new Error("unknown session");
902
+ return { id: d.id };
903
+ case "/sessions/close":
904
+ this.closeSession(String(d.id ?? ""));
905
+ return {};
906
+ case "/sessions/delete":
907
+ this.deleteSession(String(d.id ?? ""));
908
+ return {};
909
+ case "/sessions/rename":
910
+ this.renameSession(String(d.id ?? ""), String(d.title ?? ""));
911
+ return {};
912
+ case "/workspaces/add": {
913
+ const ws = this.checkDir(d.path);
914
+ this.workspaces.add(ws);
915
+ this.changed();
916
+ return d.start ? { path: ws, id: this.openSession(ws) } : { path: ws };
917
+ }
918
+ case "/workspaces/remove": {
919
+ const err = this.removeWorkspace(String(d.path ?? ""));
920
+ if (err)
921
+ throw new Error(err);
922
+ return {};
923
+ }
924
+ case "/term/open": {
925
+ const r = this.openTerminal(sid);
926
+ if (!r)
927
+ throw new Error("no such session");
928
+ return r;
929
+ }
930
+ case "/term/input": {
931
+ const t = live().terminals.get(String(d.tid ?? ""));
932
+ if (!t)
933
+ throw new Error("no such terminal");
934
+ t.write(String(d.text ?? ""));
935
+ return {};
936
+ }
937
+ case "/term/interrupt": {
938
+ const t = live().terminals.get(String(d.tid ?? ""));
939
+ if (!t)
940
+ throw new Error("no such terminal");
941
+ t.interrupt();
942
+ return {};
943
+ }
944
+ case "/term/close":
945
+ this.closeTerminal(sid, String(d.tid ?? ""));
946
+ return {};
947
+ default:
948
+ throw new Error(`unknown endpoint ${p}`);
949
+ }
950
+ }
951
+ }
952
+ exports.WebHub = WebHub;