@xingjisoft/neutrino 0.2.1 → 0.4.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 (43) hide show
  1. package/README.md +312 -436
  2. package/assets/pro/backend_output_model.txt +5 -0
  3. package/assets/pro/claude-module-prompt.txt +65 -0
  4. package/assets/pro/common.txt +9 -0
  5. package/assets/pro/confirm_model.txt +5 -0
  6. package/assets/pro/front_output_model.txt +9 -0
  7. package/assets/pro/genimage_model.txt +1 -0
  8. package/assets/pro/main_model.txt +5 -0
  9. package/assets/pro/model-template.json +376 -0
  10. package/assets/pro/modeling_model.txt +5 -0
  11. package/assets/pro/plan_model.txt +8 -0
  12. package/assets/pro/thinking_model.txt +3 -0
  13. package/dist/app.js +43 -7
  14. package/dist/bin.js +5 -2
  15. package/dist/browser.js +57 -0
  16. package/dist/config.js +179 -26
  17. package/dist/index.js +1 -1
  18. package/dist/llm/approval.js +6 -2
  19. package/dist/llm/client.js +197 -28
  20. package/dist/llm/subagent.js +37 -1
  21. package/dist/pro/agents.js +104 -0
  22. package/dist/pro/config.js +88 -0
  23. package/dist/pro/preflight.js +172 -0
  24. package/dist/screens/chat.js +54 -38
  25. package/dist/screens/modelSelect.js +15 -4
  26. package/dist/screens/settings.js +9 -7
  27. package/dist/screens/start.js +48 -15
  28. package/dist/ui.js +58 -0
  29. package/dist/web/hub.js +31 -4
  30. package/dist/web/server.js +611 -50
  31. package/dist/web/session.js +320 -20
  32. package/dist/web/settings.js +117 -24
  33. package/package.json +37 -37
  34. package/web/README.md +158 -16
  35. package/web/app.js +1603 -128
  36. package/web/i18n.js +382 -0
  37. package/web/index.html +289 -37
  38. package/web/markdown.js +710 -0
  39. package/web/neutrino-pro.gif +0 -0
  40. package/web/neutrino.png +0 -0
  41. package/web/pro.js +184 -0
  42. package/web/settings.js +322 -193
  43. package/web/style.css +1378 -42
@@ -16,13 +16,15 @@ import http from "node:http";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
- import { SESSION_DIR, listSessions, resolveModelSelection } from "../config.js";
19
+ import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
20
20
  import { getWebSettings, updateWebSettings } from "./settings.js";
21
21
  import { WebAuth } from "./auth.js";
22
- import { normalizeCwd } from "../cwd.js";
22
+ import { normalizeCwd, sameCwd } from "../cwd.js";
23
23
  import { hub } from "./hub.js";
24
24
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { resumeSession } from "./session.js";
25
+ import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
26
+ import { defaultProPreset, requireProPreset } from "../pro/config.js";
27
+ import { listAgentSessions, loadAgentMessages } from "../agents.js";
26
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
29
  // dist/web -> package root (mirrors the same trick used in config.ts).
28
30
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -42,6 +44,9 @@ function closeWebSession(key) {
42
44
  }
43
45
  /** Bind addresses that mean "every interface" rather than one host. */
44
46
  const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
47
+ /** The two interfaces the Web access switch moves between. */
48
+ const LOOPBACK_HOST = "127.0.0.1";
49
+ const WILDCARD_HOST = "0.0.0.0";
45
50
  /** True when `host` only accepts connections from this machine. */
46
51
  export function isLoopbackHost(host) {
47
52
  const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
@@ -49,6 +54,17 @@ export function isLoopbackHost(host) {
49
54
  return false;
50
55
  return h === "localhost" || h === "::1" || h.startsWith("127.");
51
56
  }
57
+ /** True when `host` listens on every interface, i.e. what --lan selects. */
58
+ export function isWildcardHost(host) {
59
+ return WILDCARD_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
60
+ }
61
+ /**
62
+ * The interface a fresh listener uses when the command line did not pick one.
63
+ * The saved Web access switch behaves exactly like `--lan`.
64
+ */
65
+ function defaultWebHost() {
66
+ return loadConfig().web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST;
67
+ }
52
68
  /**
53
69
  * Adapters that only ever talk to this machine: hypervisor host-only switches,
54
70
  * container bridges and loopback variants. Listing them would bury the address
@@ -98,6 +114,26 @@ export function webUrls(host, port) {
98
114
  urls: hosts.map((h) => `http://${h}:${port}/`),
99
115
  };
100
116
  }
117
+ /**
118
+ * Settings plus the live socket. The `web_lan` switch reports and changes what
119
+ * is listening right now, so a run started with --lan shows as on even though
120
+ * nothing was saved yet.
121
+ */
122
+ function settingsPayload(mode, binding) {
123
+ const payload = getWebSettings(mode);
124
+ const lan = isWildcardHost(binding.host);
125
+ const { urls } = webUrls(binding.host, binding.port);
126
+ return {
127
+ ...payload,
128
+ settings: {
129
+ ...payload.settings,
130
+ web_lan: lan,
131
+ web_host: binding.host,
132
+ // Addresses another device can open; empty unless every interface is bound.
133
+ web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
134
+ },
135
+ };
136
+ }
101
137
  const MIME_TYPES = {
102
138
  ".html": "text/html; charset=utf-8",
103
139
  ".css": "text/css; charset=utf-8",
@@ -119,11 +155,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
119
155
  function isSafeSessionId(id) {
120
156
  return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
121
157
  }
122
- function loadSessionSafe(id) {
158
+ function loadSessionSafe(id, mode = "standard") {
123
159
  if (!isSafeSessionId(id))
124
160
  return null;
125
- const filePath = path.join(SESSION_DIR, `${id}.json`);
126
- if (path.dirname(filePath) !== SESSION_DIR)
161
+ const dir = sessionDir(mode);
162
+ const filePath = path.join(dir, `${id}.json`);
163
+ if (path.dirname(filePath) !== dir)
127
164
  return null;
128
165
  try {
129
166
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@@ -132,10 +169,198 @@ function loadSessionSafe(id) {
132
169
  return null;
133
170
  }
134
171
  }
135
- /** Build the sidebar payload: every session grouped by working directory. */
136
- function buildOverview() {
137
- const sessions = listSessions();
138
- const lives = hub.list();
172
+ // ------------------------------------------------------- working directories
173
+ //
174
+ // The browser console can open any folder on this machine, which is exactly
175
+ // what the terminal can do by being launched from that folder. Every route
176
+ // below still sits behind the login cookie: browsing the filesystem is only as
177
+ // exposed as the authenticated console itself.
178
+ class ApiError extends Error {
179
+ status;
180
+ code;
181
+ constructor(status, code, message) {
182
+ super(message);
183
+ this.status = status;
184
+ this.code = code;
185
+ }
186
+ }
187
+ function sendApiError(res, error) {
188
+ if (error instanceof ApiError) {
189
+ sendJson(res, error.status, { error: error.message, code: error.code });
190
+ return;
191
+ }
192
+ const message = error instanceof Error ? error.message : "invalid request";
193
+ sendJson(res, 400, { error: message });
194
+ }
195
+ const MAX_RECENT_WORKSPACES = 12;
196
+ const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
197
+ const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
198
+ /** Expand `~` and require an absolute path; used by every folder route. */
199
+ function absolutePath(raw) {
200
+ if (typeof raw !== "string" || !raw.trim()) {
201
+ throw new ApiError(400, "working directory is required", "working directory is required");
202
+ }
203
+ let value = raw.trim();
204
+ if (value === "~")
205
+ value = os.homedir();
206
+ else if (/^~[\\/]/.test(value))
207
+ value = path.join(os.homedir(), value.slice(2));
208
+ if (!path.isAbsolute(value)) {
209
+ throw new ApiError(400, "working directory must be an absolute path", "working directory must be an absolute path");
210
+ }
211
+ return path.resolve(value);
212
+ }
213
+ function existingDirectory(raw) {
214
+ const dir = absolutePath(raw);
215
+ let stat;
216
+ try {
217
+ stat = fs.statSync(dir);
218
+ }
219
+ catch {
220
+ throw new ApiError(400, "working directory does not exist", "working directory does not exist");
221
+ }
222
+ if (!stat.isDirectory()) {
223
+ throw new ApiError(400, "working directory is not a folder", "working directory is not a folder");
224
+ }
225
+ return dir;
226
+ }
227
+ let cachedRoots = null;
228
+ /** Filesystem roots worth offering as shortcuts (every existing drive on Windows). */
229
+ function filesystemRoots() {
230
+ if (cachedRoots)
231
+ return cachedRoots;
232
+ if (process.platform !== "win32") {
233
+ cachedRoots = ["/"];
234
+ return cachedRoots;
235
+ }
236
+ const roots = [];
237
+ for (let code = 65; code <= 90; code += 1) {
238
+ const root = `${String.fromCharCode(code)}:\\`;
239
+ try {
240
+ if (fs.statSync(root).isDirectory())
241
+ roots.push(root);
242
+ }
243
+ catch {
244
+ // A drive letter that does not exist is simply not offered.
245
+ }
246
+ }
247
+ cachedRoots = roots.length ? roots : [path.parse(process.cwd()).root];
248
+ return cachedRoots;
249
+ }
250
+ /** One directory listing for the folder picker. Files are hidden: the picker chooses directories. */
251
+ function readFolder(raw) {
252
+ const dir = existingDirectory(raw);
253
+ let entries;
254
+ try {
255
+ entries = fs.readdirSync(dir, { withFileTypes: true });
256
+ }
257
+ catch (err) {
258
+ throw new ApiError(403, "cannot read this folder", err instanceof Error ? err.message : "cannot read this folder");
259
+ }
260
+ const folders = entries
261
+ .filter((entry) => entry.isDirectory())
262
+ .map((entry) => ({
263
+ name: entry.name,
264
+ path: path.join(dir, entry.name),
265
+ hidden: entry.name.startsWith("."),
266
+ }))
267
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
268
+ const parent = path.dirname(dir);
269
+ return {
270
+ path: dir,
271
+ name: path.basename(dir) || dir,
272
+ parent: parent === dir ? null : parent,
273
+ separator: path.sep,
274
+ home: os.homedir(),
275
+ cwd: process.cwd(),
276
+ roots: filesystemRoots(),
277
+ entries: folders,
278
+ };
279
+ }
280
+ /** Recent working directories: explicitly opened ones first, then saved-session folders. */
281
+ function recentWorkspacePaths(mode = "standard") {
282
+ const config = loadConfig(mode);
283
+ const stored = Array.isArray(config.web_workspaces)
284
+ ? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
285
+ : [];
286
+ const saved = listSessions(mode).map((session) => session.cwd).filter(Boolean);
287
+ const seen = new Set();
288
+ const out = [];
289
+ for (const dir of [...stored, ...saved, process.cwd()]) {
290
+ const key = normalizeCwd(dir);
291
+ if (!key || seen.has(key))
292
+ continue;
293
+ seen.add(key);
294
+ out.push(dir);
295
+ if (out.length >= MAX_RECENT_WORKSPACES)
296
+ break;
297
+ }
298
+ return out;
299
+ }
300
+ function workspaceList(mode = "standard") {
301
+ const sessions = listSessions(mode);
302
+ return recentWorkspacePaths(mode).map((dir) => {
303
+ let exists = false;
304
+ try {
305
+ exists = fs.statSync(dir).isDirectory();
306
+ }
307
+ catch {
308
+ // A folder that disappeared stays listed, greyed out, so it can be removed by choosing another.
309
+ }
310
+ return {
311
+ path: dir,
312
+ name: path.basename(dir) || dir,
313
+ sessions: sessions.filter((session) => sameCwd(session.cwd, dir)).length,
314
+ exists,
315
+ };
316
+ });
317
+ }
318
+ /** Best-effort persistence: failing to remember a folder must not fail the conversation. */
319
+ function recordWorkspace(dir, mode = "standard") {
320
+ try {
321
+ const config = loadConfig(mode);
322
+ const stored = Array.isArray(config.web_workspaces)
323
+ ? config.web_workspaces.filter((entry) => typeof entry === "string")
324
+ : [];
325
+ config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
326
+ .slice(0, MAX_RECENT_WORKSPACES);
327
+ saveConfig(config, mode);
328
+ }
329
+ catch {
330
+ // The picked folder still works for this conversation.
331
+ }
332
+ }
333
+ /** Create one folder inside an existing parent. */
334
+ function createFolder(raw) {
335
+ const target = absolutePath(raw);
336
+ if (target === path.parse(target).root) {
337
+ throw new ApiError(400, "cannot create a folder here", "cannot create a folder here");
338
+ }
339
+ const parent = path.dirname(target);
340
+ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) {
341
+ throw new ApiError(400, "parent folder does not exist", "parent folder does not exist");
342
+ }
343
+ if (fs.existsSync(target)) {
344
+ throw new ApiError(409, "folder already exists", "folder already exists");
345
+ }
346
+ try {
347
+ fs.mkdirSync(target);
348
+ }
349
+ catch (err) {
350
+ throw new ApiError(400, "cannot create a folder here", err instanceof Error ? err.message : "cannot create a folder here");
351
+ }
352
+ return target;
353
+ }
354
+ function isAgentId(value) {
355
+ return AGENT_IDS.has(value);
356
+ }
357
+ function safeAgentSessionId(id) {
358
+ return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
359
+ }
360
+ /** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
361
+ function buildOverview(mode = "standard") {
362
+ const sessions = listSessions(mode);
363
+ const lives = hub.list(mode);
139
364
  const liveBySession = new Map();
140
365
  for (const live of lives) {
141
366
  if (live.sessionId)
@@ -178,7 +403,9 @@ function buildOverview() {
178
403
  provider: live.provider,
179
404
  intensity: live.intensity,
180
405
  cwd: live.cwd,
181
- title: live.title || "New conversation",
406
+ // An unnamed conversation keeps an empty title: the browser translates
407
+ // its own fallback, and the terminal/server never impose one language.
408
+ title: live.title || "",
182
409
  message_count: 0,
183
410
  live: true,
184
411
  liveKey: live.key,
@@ -339,8 +566,7 @@ function streamLive(key, req, res) {
339
566
  // Without this, a client reset during a write is an uncaught 'error' event.
340
567
  res.on("error", cleanup);
341
568
  }
342
- async function handleApi(req, res, url, auth) {
343
- const { pathname } = url;
569
+ async function handleApi(req, res, url, auth, mode, pathname, binding) {
344
570
  if (!pathname.startsWith("/api/"))
345
571
  return false;
346
572
  if (req.method === "OPTIONS") {
@@ -386,22 +612,46 @@ async function handleApi(req, res, url, auth) {
386
612
  }
387
613
  if (pathname === "/api/settings") {
388
614
  if (req.method === "GET") {
389
- sendJson(res, 200, getWebSettings());
615
+ sendJson(res, 200, settingsPayload(mode, binding));
390
616
  }
391
617
  else if (req.method === "POST") {
392
618
  if (!isSameOrigin(req)) {
393
619
  sendJson(res, 403, { error: "cross-origin request rejected" });
394
620
  return true;
395
621
  }
622
+ const wasLan = loadConfig(mode).web_lan === true;
396
623
  try {
397
624
  const body = await readJsonBody(req);
398
- updateWebSettings(body);
625
+ updateWebSettings(body, mode);
626
+ // `web_lan` is both a saved preference and a live switch: apply it to
627
+ // the socket now so the console starts or stops answering on the LAN
628
+ // without a restart. Other settings never move the listener, and an
629
+ // explicit --host still decides the interface until this is changed.
630
+ const patch = body?.settings;
631
+ const desired = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
632
+ ? patch.web_lan === true
633
+ : isWildcardHost(binding.host);
634
+ if (desired !== isWildcardHost(binding.host)) {
635
+ await binding.setHost(desired ? WILDCARD_HOST : LOOPBACK_HOST);
636
+ }
399
637
  if (body.settings?.web_password !== undefined) {
400
638
  auth.remember(res);
401
639
  }
402
- sendJson(res, 200, getWebSettings());
640
+ sendJson(res, 200, settingsPayload(mode, binding));
403
641
  }
404
642
  catch (err) {
643
+ // A switch that could not bind must not pretend the socket moved:
644
+ // restore the saved preference so the file matches what is running.
645
+ if ((loadConfig(mode).web_lan === true) !== wasLan) {
646
+ try {
647
+ const config = loadConfig(mode);
648
+ config.web_lan = wasLan;
649
+ saveConfig(config, mode);
650
+ }
651
+ catch {
652
+ // Reporting the original failure matters more than the rollback.
653
+ }
654
+ }
405
655
  sendJson(res, err?.code ? 500 : 400, {
406
656
  error: err instanceof Error ? err.message : "invalid settings",
407
657
  });
@@ -413,15 +663,165 @@ async function handleApi(req, res, url, auth) {
413
663
  return true;
414
664
  }
415
665
  if (req.method === "GET" && pathname === "/api/health") {
416
- sendJson(res, 200, { ok: true, live: hub.list().length });
666
+ sendJson(res, 200, { ok: true, live: hub.list(mode).length });
417
667
  return true;
418
668
  }
419
669
  if (req.method === "GET" && pathname === "/api/overview") {
420
- sendJson(res, 200, buildOverview());
670
+ sendJson(res, 200, buildOverview(mode));
421
671
  return true;
422
672
  }
423
673
  if (req.method === "GET" && pathname === "/api/sessions") {
424
- sendJson(res, 200, { sessions: listSessions() });
674
+ sendJson(res, 200, { sessions: listSessions(mode) });
675
+ return true;
676
+ }
677
+ if (pathname === "/api/workspaces") {
678
+ if (req.method === "GET") {
679
+ sendJson(res, 200, {
680
+ cwd: process.cwd(),
681
+ home: os.homedir(),
682
+ workspaces: workspaceList(mode),
683
+ });
684
+ return true;
685
+ }
686
+ if (req.method === "POST") {
687
+ if (!isSameOrigin(req)) {
688
+ sendJson(res, 403, { error: "cross-origin request rejected" });
689
+ return true;
690
+ }
691
+ try {
692
+ const body = await readJsonBody(req);
693
+ const dir = existingDirectory(body?.path);
694
+ recordWorkspace(dir, mode);
695
+ sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList(mode) });
696
+ }
697
+ catch (err) {
698
+ sendApiError(res, err);
699
+ }
700
+ return true;
701
+ }
702
+ sendJson(res, 405, { error: "method not allowed" });
703
+ return true;
704
+ }
705
+ if (pathname === "/api/fs") {
706
+ if (req.method !== "GET") {
707
+ sendJson(res, 405, { error: "method not allowed" });
708
+ return true;
709
+ }
710
+ try {
711
+ sendJson(res, 200, readFolder(url.searchParams.get("path") ?? process.cwd()));
712
+ }
713
+ catch (err) {
714
+ sendApiError(res, err);
715
+ }
716
+ return true;
717
+ }
718
+ if (pathname === "/api/fs/mkdir") {
719
+ if (req.method !== "POST") {
720
+ sendJson(res, 405, { error: "method not allowed" });
721
+ return true;
722
+ }
723
+ if (!isSameOrigin(req)) {
724
+ sendJson(res, 403, { error: "cross-origin request rejected" });
725
+ return true;
726
+ }
727
+ try {
728
+ const body = await readJsonBody(req);
729
+ const created = createFolder(body?.path);
730
+ sendJson(res, 200, { ok: true, path: created });
731
+ }
732
+ catch (err) {
733
+ sendApiError(res, err);
734
+ }
735
+ return true;
736
+ }
737
+ // Start a brand-new conversation in a working directory chosen by the
738
+ // browser. Unlike `/continue`, there is no transcript to load first.
739
+ if (pathname === "/api/conversations") {
740
+ if (req.method !== "POST") {
741
+ sendJson(res, 405, { error: "method not allowed" });
742
+ return true;
743
+ }
744
+ if (!isSameOrigin(req)) {
745
+ sendJson(res, 403, { error: "cross-origin request rejected" });
746
+ return true;
747
+ }
748
+ try {
749
+ const body = await readJsonBody(req);
750
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
751
+ throw new ApiError(400, "invalid request", "invalid request");
752
+ }
753
+ const input = body;
754
+ const cwd = existingDirectory(input.cwd ?? process.cwd());
755
+ const requested = input.selection === undefined
756
+ ? getWebSettings(mode).selection
757
+ : input.selection;
758
+ const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
759
+ const selection = mode === "pro" ? proInitialSelection(preset) : resolveModelSelection(requested, false);
760
+ let approvalMode;
761
+ if (input.approval_mode !== undefined) {
762
+ if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
763
+ throw new ApiError(400, "invalid approval mode", "invalid approval mode");
764
+ }
765
+ approvalMode = normalizeApprovalMode(input.approval_mode);
766
+ }
767
+ const session = createWebSession(cwd, selection, approvalMode, mode, preset);
768
+ const key = session.start();
769
+ webSessions.set(key, session);
770
+ recordWorkspace(cwd, mode);
771
+ sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
772
+ }
773
+ catch (err) {
774
+ sendApiError(res, err);
775
+ }
776
+ return true;
777
+ }
778
+ const deleteMatch = /^\/api\/session\/([^/]+)\/delete$/.exec(pathname);
779
+ if (req.method === "POST" && deleteMatch) {
780
+ if (!isSameOrigin(req)) {
781
+ sendJson(res, 403, { error: "cross-origin request rejected" });
782
+ return true;
783
+ }
784
+ const id = decodeURIComponent(deleteMatch[1]);
785
+ if (!isSafeSessionId(id)) {
786
+ sendJson(res, 400, { error: "invalid session id" });
787
+ return true;
788
+ }
789
+ if (hub.list(mode).some((entry) => entry.sessionId === id)) {
790
+ sendJson(res, 409, { error: "cannot delete a running conversation" });
791
+ return true;
792
+ }
793
+ if (!deleteSession(id, mode)) {
794
+ sendJson(res, 404, { error: "session not found" });
795
+ return true;
796
+ }
797
+ sendJson(res, 200, { ok: true, deleted: true });
798
+ return true;
799
+ }
800
+ // Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
801
+ const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
802
+ if (req.method === "GET" && agentSessionsMatch) {
803
+ const agent = decodeURIComponent(agentSessionsMatch[1]);
804
+ if (!isAgentId(agent)) {
805
+ sendJson(res, 400, { error: "unknown agent" });
806
+ return true;
807
+ }
808
+ const cwd = url.searchParams.get("cwd") || process.cwd();
809
+ sendJson(res, 200, { agent, cwd, sessions: listAgentSessions(agent, cwd) });
810
+ return true;
811
+ }
812
+ const agentSessionMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)$/.exec(pathname);
813
+ if (req.method === "GET" && agentSessionMatch) {
814
+ const agent = decodeURIComponent(agentSessionMatch[1]);
815
+ const id = decodeURIComponent(agentSessionMatch[2]);
816
+ if (!isAgentId(agent)) {
817
+ sendJson(res, 400, { error: "unknown agent" });
818
+ return true;
819
+ }
820
+ if (!safeAgentSessionId(id)) {
821
+ sendJson(res, 400, { error: "invalid session id" });
822
+ return true;
823
+ }
824
+ sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
425
825
  return true;
426
826
  }
427
827
  const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
@@ -431,7 +831,7 @@ async function handleApi(req, res, url, auth) {
431
831
  sendJson(res, 400, { error: "invalid session id" });
432
832
  return true;
433
833
  }
434
- const data = loadSessionSafe(id);
834
+ const data = loadSessionSafe(id, mode);
435
835
  if (!data) {
436
836
  sendJson(res, 404, { error: "session not found" });
437
837
  return true;
@@ -451,21 +851,32 @@ async function handleApi(req, res, url, auth) {
451
851
  return true;
452
852
  }
453
853
  let selection;
854
+ let preset;
454
855
  try {
455
856
  const body = await readJsonBody(req);
456
857
  if (!body || typeof body !== "object" || Array.isArray(body))
457
858
  throw new Error("invalid JSON body");
458
859
  const requested = body.selection;
459
- if (requested !== undefined)
860
+ if (requested !== undefined && mode !== "pro")
460
861
  selection = resolveModelSelection(requested);
862
+ const requestedPreset = body.pro_preset;
863
+ if (requestedPreset !== undefined && mode === "pro")
864
+ preset = requireProPreset(requestedPreset);
461
865
  }
462
866
  catch (err) {
463
867
  sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
464
868
  return true;
465
869
  }
466
870
  // Already running (TUI or another tab)? Attach instead of duplicating.
467
- const existing = hub.list().find((entry) => entry.sessionId === id);
871
+ const existing = hub.list(mode).find((entry) => entry.sessionId === id);
468
872
  if (existing) {
873
+ if (preset !== undefined) {
874
+ const changed = hub.setProPreset(existing.key, preset);
875
+ if (!changed.ok) {
876
+ sendJson(res, 409, { error: changed.error });
877
+ return true;
878
+ }
879
+ }
469
880
  if (selection) {
470
881
  const changed = hub.configure(existing.key, selection);
471
882
  if (!changed.ok) {
@@ -476,15 +887,17 @@ async function handleApi(req, res, url, auth) {
476
887
  sendJson(res, 200, { key: existing.key, attached: true });
477
888
  return true;
478
889
  }
479
- const data = loadSessionSafe(id);
890
+ const data = loadSessionSafe(id, mode);
480
891
  if (!data) {
481
892
  sendJson(res, 404, { error: "session not found" });
482
893
  return true;
483
894
  }
895
+ if (preset !== undefined)
896
+ data.pro_preset = preset;
484
897
  const requested = selection ? {
485
898
  provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
486
899
  } : undefined;
487
- const outcome = resumeSession(data, requested);
900
+ const outcome = resumeSession(data, requested, mode);
488
901
  if (!outcome.ok) {
489
902
  sendJson(res, 400, { error: outcome.error });
490
903
  return true;
@@ -496,15 +909,22 @@ async function handleApi(req, res, url, auth) {
496
909
  }
497
910
  const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
498
911
  if (req.method === "GET" && streamMatch) {
499
- streamLive(decodeURIComponent(streamMatch[1]), req, res);
912
+ const key = decodeURIComponent(streamMatch[1]);
913
+ // A Pro tab must never observe (or be handed) a session from the other
914
+ // workspace just because it guessed the key.
915
+ if (!hub.has(key, mode)) {
916
+ sendJson(res, 404, { error: "unknown live session" });
917
+ return true;
918
+ }
919
+ streamLive(key, req, res);
500
920
  return true;
501
921
  }
502
- const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode))?$/.exec(pathname);
922
+ const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
503
923
  if (liveMatch) {
504
924
  const key = decodeURIComponent(liveMatch[1]);
505
925
  const action = liveMatch[2];
506
926
  if (req.method === "GET" && !action) {
507
- const snapshot = hub.snapshot(key);
927
+ const snapshot = hub.has(key, mode) ? hub.snapshot(key) : null;
508
928
  if (!snapshot) {
509
929
  sendJson(res, 404, { error: "unknown live session" });
510
930
  return true;
@@ -517,10 +937,29 @@ async function handleApi(req, res, url, auth) {
517
937
  sendJson(res, 403, { error: "cross-origin request rejected" });
518
938
  return true;
519
939
  }
520
- if (!hub.has(key)) {
940
+ if (!hub.has(key, mode)) {
521
941
  sendJson(res, 404, { error: "unknown live session" });
522
942
  return true;
523
943
  }
944
+ if (action === "pro-preset") {
945
+ if (mode !== "pro") {
946
+ sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
947
+ return true;
948
+ }
949
+ try {
950
+ const body = await readJsonBody(req);
951
+ const preset = requireProPreset(body?.preset);
952
+ const changed = hub.setProPreset(key, preset);
953
+ if (!changed.ok)
954
+ sendJson(res, 409, { error: changed.error });
955
+ else
956
+ sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
957
+ }
958
+ catch (error) {
959
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid Pro preset" });
960
+ }
961
+ return true;
962
+ }
524
963
  if (action === "approval-mode") {
525
964
  let body;
526
965
  try {
@@ -593,6 +1032,14 @@ async function handleApi(req, res, url, auth) {
593
1032
  sendJson(res, 200, { ok: true, closed: true });
594
1033
  return true;
595
1034
  }
1035
+ if (action === "clear") {
1036
+ if (!hub.clear(key)) {
1037
+ sendJson(res, 409, { error: "this session cannot be cleared" });
1038
+ return true;
1039
+ }
1040
+ sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
1041
+ return true;
1042
+ }
596
1043
  // action === "message"
597
1044
  let body;
598
1045
  try {
@@ -617,7 +1064,7 @@ async function handleApi(req, res, url, auth) {
617
1064
  sendJson(res, 404, { error: "not found" });
618
1065
  return true;
619
1066
  }
620
- async function handleRequest(req, res, auth) {
1067
+ async function handleRequest(req, res, auth, binding) {
621
1068
  let url;
622
1069
  try {
623
1070
  url = new URL(req.url ?? "/", "http://localhost");
@@ -626,20 +1073,59 @@ async function handleRequest(req, res, auth) {
626
1073
  sendText(res, 400, "Bad request");
627
1074
  return;
628
1075
  }
629
- if (await handleApi(req, res, url, auth))
1076
+ // The Pro workspace is the same single-page app at /pro, talking to an
1077
+ // isolated API under /pro/api. Static assets stay at the root (the app uses
1078
+ // root-absolute URLs), so only the page route and the API prefix are special.
1079
+ const pathname = url.pathname;
1080
+ if (pathname === "/pro" || pathname === "/pro/") {
1081
+ if (req.method !== "GET" && req.method !== "HEAD") {
1082
+ sendText(res, 405, "Method not allowed");
1083
+ return;
1084
+ }
1085
+ if (pathname === "/pro/") {
1086
+ // Canonical URL without the trailing slash keeps relative links rooted.
1087
+ res.writeHead(308, { Location: `/pro${url.search}` });
1088
+ res.end();
1089
+ return;
1090
+ }
1091
+ serveStatic(res, "/index.html");
1092
+ return;
1093
+ }
1094
+ let mode = "standard";
1095
+ let apiPathname = pathname;
1096
+ if (pathname.startsWith("/pro/api/")) {
1097
+ mode = "pro";
1098
+ apiPathname = pathname.slice("/pro".length);
1099
+ }
1100
+ if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
630
1101
  return;
1102
+ if (mode === "pro") {
1103
+ // Nothing else lives under /pro but the page and its API.
1104
+ sendText(res, 404, "Not found");
1105
+ return;
1106
+ }
631
1107
  if (req.method !== "GET" && req.method !== "HEAD") {
632
1108
  sendText(res, 405, "Method not allowed");
633
1109
  return;
634
1110
  }
635
- serveStatic(res, url.pathname);
1111
+ serveStatic(res, pathname);
636
1112
  }
637
1113
  /** Start the web UI. Resolves once the socket is actually listening. */
638
1114
  export async function startWebServer(options) {
639
- const host = options.host ?? "127.0.0.1";
640
1115
  const auth = new WebAuth(options.auth !== false);
1116
+ // An explicit --host/--lan wins for this run; otherwise the saved Web access
1117
+ // switch is applied exactly as if `--lan` had been passed.
1118
+ let host = options.host?.trim() || defaultWebHost();
1119
+ let port = options.port;
1120
+ let closed = false;
1121
+ // The settings page reads and changes this binding while the process runs.
1122
+ const binding = {
1123
+ host,
1124
+ port,
1125
+ setHost: (next) => rebind(next),
1126
+ };
641
1127
  const server = http.createServer((req, res) => {
642
- handleRequest(req, res, auth).catch((err) => {
1128
+ handleRequest(req, res, auth, binding).catch((err) => {
643
1129
  if (!res.headersSent) {
644
1130
  sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
645
1131
  }
@@ -648,41 +1134,116 @@ export async function startWebServer(options) {
648
1134
  }
649
1135
  });
650
1136
  });
651
- await new Promise((resolve, reject) => {
1137
+ /** Wait for the socket to actually listen, or for it to fail. */
1138
+ const listen = (onHost) => new Promise((resolve, reject) => {
652
1139
  const onError = (err) => {
653
1140
  server.removeListener("listening", onListening);
654
1141
  reject(err);
655
1142
  };
656
1143
  const onListening = () => {
657
1144
  server.removeListener("error", onError);
658
- // Once listening, the server must tolerate connection-level failures
659
- // (resets, EMFILE, half-open sockets) without the process dying. An
660
- // 'error' event with no listener is fatal in Node.
661
- server.on("error", (err) => {
662
- writeCrashLog("web server", err);
663
- });
664
- server.on("clientError", (_err, socket) => {
665
- if (socket.writable)
666
- socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
667
- socket.destroy();
668
- });
669
1145
  resolve();
670
1146
  };
671
1147
  server.once("error", onError);
672
1148
  server.once("listening", onListening);
673
- server.listen(options.port, host);
1149
+ server.listen(port, onHost);
674
1150
  });
675
- const address = server.address();
676
- const port = typeof address === "object" && address ? address.port : options.port;
677
- return {
1151
+ /** A just-closed port can need a moment to reappear: retry only that race. */
1152
+ async function listenRetry(onHost) {
1153
+ for (let attempt = 0;; attempt += 1) {
1154
+ try {
1155
+ await listen(onHost);
1156
+ return;
1157
+ }
1158
+ catch (err) {
1159
+ if (err.code !== "EADDRINUSE" || attempt >= 20)
1160
+ throw err;
1161
+ await new Promise((done) => setTimeout(done, 10));
1162
+ }
1163
+ }
1164
+ }
1165
+ const syncAddress = () => {
1166
+ const address = webUrls(host, port);
1167
+ binding.host = host;
1168
+ binding.port = port;
1169
+ handle.host = host;
1170
+ handle.port = port;
1171
+ handle.url = address.url;
1172
+ handle.urls = address.urls;
1173
+ };
1174
+ // Two tabs saving at once must not interleave close/listen on one socket.
1175
+ let switches = Promise.resolve();
1176
+ function rebind(next) {
1177
+ const target = next.trim() || LOOPBACK_HOST;
1178
+ const task = switches.then(async () => {
1179
+ if (closed)
1180
+ throw new Error("the Web console is shutting down");
1181
+ if (target === host && server.listening)
1182
+ return;
1183
+ // `close` ends acceptance, not the connections already accepted. The
1184
+ // listener is released immediately, which is all the switch needs, so a
1185
+ // streaming turn and the tab that asked for the change stay connected.
1186
+ if (server.listening)
1187
+ server.close();
1188
+ try {
1189
+ await listenRetry(target);
1190
+ }
1191
+ catch (err) {
1192
+ // Put the console back where it was rather than leaving it dark.
1193
+ if (!closed) {
1194
+ try {
1195
+ await listenRetry(host);
1196
+ }
1197
+ catch {
1198
+ // The settings response still reports the original failure.
1199
+ }
1200
+ }
1201
+ throw err;
1202
+ }
1203
+ // A concurrent shutdown wins: never resurrect a console being closed.
1204
+ if (closed) {
1205
+ server.close();
1206
+ throw new Error("the Web Console is shutting down");
1207
+ }
1208
+ host = target;
1209
+ syncAddress();
1210
+ });
1211
+ switches = task.catch(() => undefined);
1212
+ return task;
1213
+ }
1214
+ const handle = {
678
1215
  host,
679
1216
  port,
680
1217
  authRequired: auth.enabled,
681
- ...webUrls(host, port),
1218
+ url: "",
1219
+ urls: [],
1220
+ setHost: (next) => rebind(next),
682
1221
  close: () => new Promise((resolve) => {
1222
+ closed = true;
683
1223
  for (const key of [...webSessions.keys()])
684
1224
  closeWebSession(key);
1225
+ if (!server.listening) {
1226
+ resolve();
1227
+ return;
1228
+ }
685
1229
  server.close(() => resolve());
686
1230
  }),
687
1231
  };
1232
+ await listenRetry(host);
1233
+ // Port 0 means "let the OS pick": remember the real one for every URL.
1234
+ const address = server.address();
1235
+ port = typeof address === "object" && address ? address.port : options.port;
1236
+ syncAddress();
1237
+ // Once listening, the server must tolerate connection-level failures
1238
+ // (resets, EMFILE, half-open sockets) without the process dying: an 'error'
1239
+ // event with no listener is fatal in Node.
1240
+ server.on("error", (err) => {
1241
+ writeCrashLog("web server", err);
1242
+ });
1243
+ server.on("clientError", (_err, socket) => {
1244
+ if (socket.writable)
1245
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
1246
+ socket.destroy();
1247
+ });
1248
+ return handle;
688
1249
  }