@xingjisoft/neutrino 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
@@ -15,15 +15,19 @@ import fs from "node:fs";
15
15
  import http from "node:http";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
+ import { timingSafeEqual } from "node:crypto";
18
19
  import { fileURLToPath } from "node:url";
19
- import { SESSION_DIR, deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, } from "../config.js";
20
+ import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
20
21
  import { getWebSettings, updateWebSettings } from "./settings.js";
21
22
  import { WebAuth } from "./auth.js";
22
23
  import { normalizeCwd, sameCwd } from "../cwd.js";
23
24
  import { hub } from "./hub.js";
24
25
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { createWebSession, resumeSession } from "./session.js";
26
- import { listAgentSessions, loadAgentMessages } from "../agents.js";
26
+ import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
27
+ import { defaultProPreset, requireProPreset } from "../pro/config.js";
28
+ import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
29
+ import { migrateAgentSession, SessionMigrationError } from "../migration.js";
30
+ import { runningBackground } from "../background.js";
27
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
32
  // dist/web -> package root (mirrors the same trick used in config.ts).
29
33
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -43,6 +47,9 @@ function closeWebSession(key) {
43
47
  }
44
48
  /** Bind addresses that mean "every interface" rather than one host. */
45
49
  const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
50
+ /** The two interfaces the Web access switch moves between. */
51
+ const LOOPBACK_HOST = "127.0.0.1";
52
+ const WILDCARD_HOST = "0.0.0.0";
46
53
  /** True when `host` only accepts connections from this machine. */
47
54
  export function isLoopbackHost(host) {
48
55
  const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
@@ -50,6 +57,17 @@ export function isLoopbackHost(host) {
50
57
  return false;
51
58
  return h === "localhost" || h === "::1" || h.startsWith("127.");
52
59
  }
60
+ /** True when `host` listens on every interface, i.e. what --lan selects. */
61
+ export function isWildcardHost(host) {
62
+ return WILDCARD_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
63
+ }
64
+ /**
65
+ * The interface a fresh listener uses when the command line did not pick one.
66
+ * The saved Web access switch behaves exactly like `--lan`.
67
+ */
68
+ function defaultWebHost() {
69
+ return loadConfig().web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST;
70
+ }
53
71
  /**
54
72
  * Adapters that only ever talk to this machine: hypervisor host-only switches,
55
73
  * container bridges and loopback variants. Listing them would bury the address
@@ -99,6 +117,48 @@ export function webUrls(host, port) {
99
117
  urls: hosts.map((h) => `http://${h}:${port}/`),
100
118
  };
101
119
  }
120
+ /**
121
+ * Settings plus the live socket. The `web_lan` switch reports and changes what
122
+ * is listening right now, so a run started with --lan shows as on even though
123
+ * nothing was saved yet.
124
+ */
125
+ function validTerminalBridge(bridge, ownerPid) {
126
+ if (!bridge || typeof bridge !== "object" || Array.isArray(bridge) || typeof ownerPid !== "number")
127
+ return false;
128
+ const info = bridge;
129
+ if (typeof info.url !== "string" || typeof info.token !== "string" || !/^[a-f0-9]{64}$/.test(info.token))
130
+ return false;
131
+ try {
132
+ const url = new URL(info.url);
133
+ return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port)
134
+ && url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ function settingsPayload(mode, binding) {
141
+ const payload = getWebSettings(mode);
142
+ const lan = isWildcardHost(binding.host);
143
+ const { urls } = webUrls(binding.host, binding.port);
144
+ const background = binding.service?.status?.() ?? runningBackground();
145
+ return {
146
+ ...payload,
147
+ settings: {
148
+ ...payload.settings,
149
+ web_lan: lan,
150
+ web_host: binding.host,
151
+ // Addresses another device can open; empty unless every interface is bound.
152
+ web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
153
+ // The background service is a process, not a workspace: both pages report
154
+ // the same state. The prompt is never shown once the setting is on.
155
+ background: background?.pid === process.pid ? background.resident : payload.settings.background,
156
+ background_running: background?.resident === true,
157
+ background_url: background?.url ?? "",
158
+ background_prompt: binding.prompt.pending && payload.settings.background !== true,
159
+ },
160
+ };
161
+ }
102
162
  const MIME_TYPES = {
103
163
  ".html": "text/html; charset=utf-8",
104
164
  ".css": "text/css; charset=utf-8",
@@ -120,11 +180,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
120
180
  function isSafeSessionId(id) {
121
181
  return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
122
182
  }
123
- function loadSessionSafe(id) {
183
+ function loadSessionSafe(id, mode = "standard") {
124
184
  if (!isSafeSessionId(id))
125
185
  return null;
126
- const filePath = path.join(SESSION_DIR, `${id}.json`);
127
- if (path.dirname(filePath) !== SESSION_DIR)
186
+ const dir = sessionDir(mode);
187
+ const filePath = path.join(dir, `${id}.json`);
188
+ if (path.dirname(filePath) !== dir)
128
189
  return null;
129
190
  try {
130
191
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@@ -158,7 +219,6 @@ function sendApiError(res, error) {
158
219
  }
159
220
  const MAX_RECENT_WORKSPACES = 12;
160
221
  const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
161
- const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
162
222
  /** Expand `~` and require an absolute path; used by every folder route. */
163
223
  function absolutePath(raw) {
164
224
  if (typeof raw !== "string" || !raw.trim()) {
@@ -242,12 +302,12 @@ function readFolder(raw) {
242
302
  };
243
303
  }
244
304
  /** Recent working directories: explicitly opened ones first, then saved-session folders. */
245
- function recentWorkspacePaths() {
246
- const config = loadConfig();
305
+ function recentWorkspacePaths(mode = "standard") {
306
+ const config = loadConfig(mode);
247
307
  const stored = Array.isArray(config.web_workspaces)
248
308
  ? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
249
309
  : [];
250
- const saved = listSessions().map((session) => session.cwd).filter(Boolean);
310
+ const saved = listSessions(mode).map((session) => session.cwd).filter(Boolean);
251
311
  const seen = new Set();
252
312
  const out = [];
253
313
  for (const dir of [...stored, ...saved, process.cwd()]) {
@@ -261,9 +321,9 @@ function recentWorkspacePaths() {
261
321
  }
262
322
  return out;
263
323
  }
264
- function workspaceList() {
265
- const sessions = listSessions();
266
- return recentWorkspacePaths().map((dir) => {
324
+ function workspaceList(mode = "standard") {
325
+ const sessions = listSessions(mode);
326
+ return recentWorkspacePaths(mode).map((dir) => {
267
327
  let exists = false;
268
328
  try {
269
329
  exists = fs.statSync(dir).isDirectory();
@@ -280,15 +340,15 @@ function workspaceList() {
280
340
  });
281
341
  }
282
342
  /** Best-effort persistence: failing to remember a folder must not fail the conversation. */
283
- function recordWorkspace(dir) {
343
+ function recordWorkspace(dir, mode = "standard") {
284
344
  try {
285
- const config = loadConfig();
345
+ const config = loadConfig(mode);
286
346
  const stored = Array.isArray(config.web_workspaces)
287
347
  ? config.web_workspaces.filter((entry) => typeof entry === "string")
288
348
  : [];
289
349
  config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
290
350
  .slice(0, MAX_RECENT_WORKSPACES);
291
- saveConfig(config);
351
+ saveConfig(config, mode);
292
352
  }
293
353
  catch {
294
354
  // The picked folder still works for this conversation.
@@ -318,13 +378,10 @@ function createFolder(raw) {
318
378
  function isAgentId(value) {
319
379
  return AGENT_IDS.has(value);
320
380
  }
321
- function safeAgentSessionId(id) {
322
- return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
323
- }
324
- /** Build the sidebar payload: every session grouped by working directory. */
325
- function buildOverview() {
326
- const sessions = listSessions();
327
- const lives = hub.list();
381
+ /** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
382
+ function buildOverview(mode = "standard") {
383
+ const sessions = listSessions(mode);
384
+ const lives = hub.list(mode);
328
385
  const liveBySession = new Map();
329
386
  for (const live of lives) {
330
387
  if (live.sessionId)
@@ -448,6 +505,16 @@ function isSameOrigin(req) {
448
505
  return false;
449
506
  }
450
507
  }
508
+ /** Constant-time comparison of the service's management token. */
509
+ function hasServiceToken(req, token) {
510
+ const header = String(req.headers.authorization ?? "");
511
+ const prefix = "Bearer ";
512
+ if (!header.startsWith(prefix))
513
+ return false;
514
+ const supplied = Buffer.from(header.slice(prefix.length));
515
+ const expected = Buffer.from(token);
516
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
517
+ }
451
518
  function serveStatic(res, pathname) {
452
519
  let rel = pathname === "/" ? "/index.html" : pathname;
453
520
  try {
@@ -530,8 +597,7 @@ function streamLive(key, req, res) {
530
597
  // Without this, a client reset during a write is an uncaught 'error' event.
531
598
  res.on("error", cleanup);
532
599
  }
533
- async function handleApi(req, res, url, auth) {
534
- const { pathname } = url;
600
+ async function handleApi(req, res, url, auth, mode, pathname, binding) {
535
601
  if (!pathname.startsWith("/api/"))
536
602
  return false;
537
603
  if (req.method === "OPTIONS") {
@@ -541,6 +607,59 @@ async function handleApi(req, res, url, auth) {
541
607
  res.end();
542
608
  return true;
543
609
  }
610
+ // Management endpoints of the detached service. They are called by the CLI
611
+ // and the tray icon, which have the token from ~/.neutrino/background.json
612
+ // but not necessarily a browser login cookie.
613
+ if (["/api/background/ping", "/api/background/stop", "/api/background/configure"].includes(pathname)) {
614
+ if (req.method !== "GET" && req.method !== "POST") {
615
+ sendJson(res, 405, { error: "method not allowed" });
616
+ return true;
617
+ }
618
+ if (!binding.service) {
619
+ sendJson(res, 404, { error: "this console is not running in the background" });
620
+ return true;
621
+ }
622
+ if (!hasServiceToken(req, binding.service.token)) {
623
+ sendJson(res, 401, { error: "background service token required" });
624
+ return true;
625
+ }
626
+ if (pathname.endsWith("/ping")) {
627
+ sendJson(res, 200, { ok: true, pid: process.pid, url: webUrls(binding.host, binding.port).url });
628
+ return true;
629
+ }
630
+ if (pathname.endsWith("/configure")) {
631
+ if (req.method !== "POST") {
632
+ sendJson(res, 405, { error: "method not allowed" });
633
+ return true;
634
+ }
635
+ if (!binding.service.configure) {
636
+ sendJson(res, 409, { error: "Restart the console to update background residency." });
637
+ return true;
638
+ }
639
+ try {
640
+ const body = await readJsonBody(req, 4096);
641
+ if (!body || typeof body !== "object" || Array.isArray(body)
642
+ || Object.keys(body).some(key => key !== "resident" && key !== "ownerPid" && key !== "bridge")
643
+ || (body.resident !== undefined && typeof body.resident !== "boolean")
644
+ || (body.ownerPid !== undefined && (!Number.isSafeInteger(body.ownerPid) || body.ownerPid <= 0))
645
+ || (body.bridge !== undefined && !validTerminalBridge(body.bridge, body.ownerPid))) {
646
+ throw new Error("invalid background settings");
647
+ }
648
+ const updated = binding.service.configure(body);
649
+ if (updated.resident)
650
+ binding.prompt.pending = false;
651
+ sendJson(res, 200, updated);
652
+ }
653
+ catch (error) {
654
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid background settings" });
655
+ }
656
+ return true;
657
+ }
658
+ sendJson(res, 200, { ok: true, stopping: true });
659
+ // Let the response reach the caller before the socket closes.
660
+ setImmediate(() => binding.service?.onStop());
661
+ return true;
662
+ }
544
663
  if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
545
664
  if (req.method !== "POST") {
546
665
  sendJson(res, 405, { error: "method not allowed" });
@@ -575,9 +694,24 @@ async function handleApi(req, res, url, auth) {
575
694
  sendJson(res, 401, { error: "Password login required." });
576
695
  return true;
577
696
  }
697
+ // The first browser to see the background-residency prompt consumes it for
698
+ // this console run, whether it accepted or dismissed it.
699
+ if (pathname === "/api/background/dismiss") {
700
+ if (req.method !== "POST") {
701
+ sendJson(res, 405, { error: "method not allowed" });
702
+ }
703
+ else if (!isSameOrigin(req)) {
704
+ sendJson(res, 403, { error: "cross-origin request rejected" });
705
+ }
706
+ else {
707
+ binding.prompt.pending = false;
708
+ sendJson(res, 200, { ok: true });
709
+ }
710
+ return true;
711
+ }
578
712
  if (pathname === "/api/settings") {
579
713
  if (req.method === "GET") {
580
- sendJson(res, 200, getWebSettings());
714
+ sendJson(res, 200, settingsPayload(mode, binding));
581
715
  }
582
716
  else if (req.method === "POST") {
583
717
  if (!isSameOrigin(req)) {
@@ -586,11 +720,11 @@ async function handleApi(req, res, url, auth) {
586
720
  }
587
721
  try {
588
722
  const body = await readJsonBody(req);
589
- updateWebSettings(body);
723
+ await binding.updateSettings(body, mode);
590
724
  if (body.settings?.web_password !== undefined) {
591
725
  auth.remember(res);
592
726
  }
593
- sendJson(res, 200, getWebSettings());
727
+ sendJson(res, 200, settingsPayload(mode, binding));
594
728
  }
595
729
  catch (err) {
596
730
  sendJson(res, err?.code ? 500 : 400, {
@@ -604,15 +738,15 @@ async function handleApi(req, res, url, auth) {
604
738
  return true;
605
739
  }
606
740
  if (req.method === "GET" && pathname === "/api/health") {
607
- sendJson(res, 200, { ok: true, live: hub.list().length });
741
+ sendJson(res, 200, { ok: true, live: hub.list(mode).length });
608
742
  return true;
609
743
  }
610
744
  if (req.method === "GET" && pathname === "/api/overview") {
611
- sendJson(res, 200, buildOverview());
745
+ sendJson(res, 200, buildOverview(mode));
612
746
  return true;
613
747
  }
614
748
  if (req.method === "GET" && pathname === "/api/sessions") {
615
- sendJson(res, 200, { sessions: listSessions() });
749
+ sendJson(res, 200, { sessions: listSessions(mode) });
616
750
  return true;
617
751
  }
618
752
  if (pathname === "/api/workspaces") {
@@ -620,7 +754,7 @@ async function handleApi(req, res, url, auth) {
620
754
  sendJson(res, 200, {
621
755
  cwd: process.cwd(),
622
756
  home: os.homedir(),
623
- workspaces: workspaceList(),
757
+ workspaces: workspaceList(mode),
624
758
  });
625
759
  return true;
626
760
  }
@@ -632,8 +766,8 @@ async function handleApi(req, res, url, auth) {
632
766
  try {
633
767
  const body = await readJsonBody(req);
634
768
  const dir = existingDirectory(body?.path);
635
- recordWorkspace(dir);
636
- sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList() });
769
+ recordWorkspace(dir, mode);
770
+ sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList(mode) });
637
771
  }
638
772
  catch (err) {
639
773
  sendApiError(res, err);
@@ -694,9 +828,10 @@ async function handleApi(req, res, url, auth) {
694
828
  const input = body;
695
829
  const cwd = existingDirectory(input.cwd ?? process.cwd());
696
830
  const requested = input.selection === undefined
697
- ? getWebSettings().selection
831
+ ? getWebSettings(mode).selection
698
832
  : input.selection;
699
- const selection = resolveModelSelection(requested, false);
833
+ const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
834
+ const selection = mode === "pro" ? proInitialSelection(preset) : resolveModelSelection(requested, false);
700
835
  let approvalMode;
701
836
  if (input.approval_mode !== undefined) {
702
837
  if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
@@ -704,10 +839,10 @@ async function handleApi(req, res, url, auth) {
704
839
  }
705
840
  approvalMode = normalizeApprovalMode(input.approval_mode);
706
841
  }
707
- const session = createWebSession(cwd, selection, approvalMode);
842
+ const session = createWebSession(cwd, selection, approvalMode, mode, preset);
708
843
  const key = session.start();
709
844
  webSessions.set(key, session);
710
- recordWorkspace(cwd);
845
+ recordWorkspace(cwd, mode);
711
846
  sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
712
847
  }
713
848
  catch (err) {
@@ -726,18 +861,18 @@ async function handleApi(req, res, url, auth) {
726
861
  sendJson(res, 400, { error: "invalid session id" });
727
862
  return true;
728
863
  }
729
- if (hub.list().some((entry) => entry.sessionId === id)) {
864
+ if (hub.list(mode).some((entry) => entry.sessionId === id)) {
730
865
  sendJson(res, 409, { error: "cannot delete a running conversation" });
731
866
  return true;
732
867
  }
733
- if (!deleteSession(id)) {
868
+ if (!deleteSession(id, mode)) {
734
869
  sendJson(res, 404, { error: "session not found" });
735
870
  return true;
736
871
  }
737
872
  sendJson(res, 200, { ok: true, deleted: true });
738
873
  return true;
739
874
  }
740
- // Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
875
+ // History recorded by other agents (Claude Code, Codex, OpenCode).
741
876
  const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
742
877
  if (req.method === "GET" && agentSessionsMatch) {
743
878
  const agent = decodeURIComponent(agentSessionsMatch[1]);
@@ -761,7 +896,59 @@ async function handleApi(req, res, url, auth) {
761
896
  sendJson(res, 400, { error: "invalid session id" });
762
897
  return true;
763
898
  }
764
- sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
899
+ const cwd = url.searchParams.get("cwd");
900
+ if (cwd) {
901
+ const source = loadAgentSession(agent, id, cwd);
902
+ if (!source) {
903
+ sendJson(res, 404, { error: "session not found in this working directory" });
904
+ return true;
905
+ }
906
+ sendJson(res, 200, { ...source, agent });
907
+ }
908
+ else {
909
+ sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
910
+ }
911
+ return true;
912
+ }
913
+ const migrateMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)\/migrate$/.exec(pathname);
914
+ if (migrateMatch) {
915
+ if (req.method !== "POST") {
916
+ sendJson(res, 405, { error: "method not allowed" });
917
+ return true;
918
+ }
919
+ if (!isSameOrigin(req)) {
920
+ sendJson(res, 403, { error: "cross-origin request rejected" });
921
+ return true;
922
+ }
923
+ const agent = decodeURIComponent(migrateMatch[1]);
924
+ const id = decodeURIComponent(migrateMatch[2]);
925
+ if (!isAgentId(agent) || !safeAgentSessionId(id)) {
926
+ sendJson(res, 400, { error: !isAgentId(agent) ? "unknown agent" : "invalid session id" });
927
+ return true;
928
+ }
929
+ try {
930
+ const body = await readJsonBody(req);
931
+ if (!body || typeof body !== "object" || Array.isArray(body))
932
+ throw new Error("invalid JSON body");
933
+ const input = body;
934
+ const cwd = existingDirectory(input.cwd ?? process.cwd());
935
+ const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
936
+ const selection = mode === "pro" ? proInitialSelection(preset)
937
+ : resolveModelSelection(input.selection ?? getWebSettings(mode).selection, false);
938
+ const migrated = migrateAgentSession(agent, id, cwd, {
939
+ provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
940
+ }, mode, preset);
941
+ const session = new WebSession(migrated, selection.model, mode);
942
+ const key = session.start();
943
+ webSessions.set(key, session);
944
+ sendJson(res, 201, { ok: true, id: migrated.id, key, cwd, snapshot: hub.snapshot(key) });
945
+ }
946
+ catch (err) {
947
+ if (err instanceof SessionMigrationError)
948
+ sendJson(res, err.status, { error: err.message });
949
+ else
950
+ sendApiError(res, err);
951
+ }
765
952
  return true;
766
953
  }
767
954
  const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
@@ -771,7 +958,7 @@ async function handleApi(req, res, url, auth) {
771
958
  sendJson(res, 400, { error: "invalid session id" });
772
959
  return true;
773
960
  }
774
- const data = loadSessionSafe(id);
961
+ const data = loadSessionSafe(id, mode);
775
962
  if (!data) {
776
963
  sendJson(res, 404, { error: "session not found" });
777
964
  return true;
@@ -791,22 +978,48 @@ async function handleApi(req, res, url, auth) {
791
978
  return true;
792
979
  }
793
980
  let selection;
981
+ let preset;
794
982
  try {
795
983
  const body = await readJsonBody(req);
796
984
  if (!body || typeof body !== "object" || Array.isArray(body))
797
985
  throw new Error("invalid JSON body");
798
986
  const requested = body.selection;
799
- if (requested !== undefined)
987
+ if (requested !== undefined && mode !== "pro")
800
988
  selection = resolveModelSelection(requested);
989
+ const requestedPreset = body.pro_preset;
990
+ if (requestedPreset !== undefined && mode === "pro")
991
+ preset = requireProPreset(requestedPreset);
801
992
  }
802
993
  catch (err) {
803
994
  sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
804
995
  return true;
805
996
  }
806
997
  // Already running (TUI or another tab)? Attach instead of duplicating.
807
- const existing = hub.list().find((entry) => entry.sessionId === id);
998
+ const existing = hub.list(mode).find((entry) => entry.sessionId === id);
808
999
  if (existing) {
809
- if (selection) {
1000
+ const remote = hub.remoteAction(existing.key);
1001
+ if (remote && preset !== undefined) {
1002
+ const result = await remote("pro-preset", { preset });
1003
+ if (result.status !== 200) {
1004
+ sendJson(res, result.status, result.data);
1005
+ return true;
1006
+ }
1007
+ }
1008
+ else if (remote && selection) {
1009
+ const result = await remote("model", { provider: selection.provider, model: selection.model.name, intensity: selection.intensity });
1010
+ if (result.status !== 200) {
1011
+ sendJson(res, result.status, result.data);
1012
+ return true;
1013
+ }
1014
+ }
1015
+ if (!remote && preset !== undefined) {
1016
+ const changed = hub.setProPreset(existing.key, preset);
1017
+ if (!changed.ok) {
1018
+ sendJson(res, 409, { error: changed.error });
1019
+ return true;
1020
+ }
1021
+ }
1022
+ if (!remote && selection) {
810
1023
  const changed = hub.configure(existing.key, selection);
811
1024
  if (!changed.ok) {
812
1025
  sendJson(res, 409, { error: changed.error });
@@ -816,15 +1029,17 @@ async function handleApi(req, res, url, auth) {
816
1029
  sendJson(res, 200, { key: existing.key, attached: true });
817
1030
  return true;
818
1031
  }
819
- const data = loadSessionSafe(id);
1032
+ const data = loadSessionSafe(id, mode);
820
1033
  if (!data) {
821
1034
  sendJson(res, 404, { error: "session not found" });
822
1035
  return true;
823
1036
  }
1037
+ if (preset !== undefined)
1038
+ data.pro_preset = preset;
824
1039
  const requested = selection ? {
825
1040
  provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
826
1041
  } : undefined;
827
- const outcome = resumeSession(data, requested);
1042
+ const outcome = resumeSession(data, requested, mode);
828
1043
  if (!outcome.ok) {
829
1044
  sendJson(res, 400, { error: outcome.error });
830
1045
  return true;
@@ -836,15 +1051,22 @@ async function handleApi(req, res, url, auth) {
836
1051
  }
837
1052
  const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
838
1053
  if (req.method === "GET" && streamMatch) {
839
- streamLive(decodeURIComponent(streamMatch[1]), req, res);
1054
+ const key = decodeURIComponent(streamMatch[1]);
1055
+ // A Pro tab must never observe (or be handed) a session from the other
1056
+ // workspace just because it guessed the key.
1057
+ if (!hub.has(key, mode)) {
1058
+ sendJson(res, 404, { error: "unknown live session" });
1059
+ return true;
1060
+ }
1061
+ streamLive(key, req, res);
840
1062
  return true;
841
1063
  }
842
- const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode|clear))?$/.exec(pathname);
1064
+ const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
843
1065
  if (liveMatch) {
844
1066
  const key = decodeURIComponent(liveMatch[1]);
845
1067
  const action = liveMatch[2];
846
1068
  if (req.method === "GET" && !action) {
847
- const snapshot = hub.snapshot(key);
1069
+ const snapshot = hub.has(key, mode) ? hub.snapshot(key) : null;
848
1070
  if (!snapshot) {
849
1071
  sendJson(res, 404, { error: "unknown live session" });
850
1072
  return true;
@@ -857,10 +1079,46 @@ async function handleApi(req, res, url, auth) {
857
1079
  sendJson(res, 403, { error: "cross-origin request rejected" });
858
1080
  return true;
859
1081
  }
860
- if (!hub.has(key)) {
1082
+ if (!hub.has(key, mode)) {
861
1083
  sendJson(res, 404, { error: "unknown live session" });
862
1084
  return true;
863
1085
  }
1086
+ const remote = hub.remoteAction(key);
1087
+ if (remote) {
1088
+ if (action === "close") {
1089
+ sendJson(res, 400, { error: "this session is owned by the terminal UI" });
1090
+ return true;
1091
+ }
1092
+ try {
1093
+ const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
1094
+ ? await readJsonBody(req) : {};
1095
+ const result = await remote(action, body);
1096
+ sendJson(res, result.status, result.data);
1097
+ }
1098
+ catch (error) {
1099
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
1100
+ }
1101
+ return true;
1102
+ }
1103
+ if (action === "pro-preset") {
1104
+ if (mode !== "pro") {
1105
+ sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
1106
+ return true;
1107
+ }
1108
+ try {
1109
+ const body = await readJsonBody(req);
1110
+ const preset = requireProPreset(body?.preset);
1111
+ const changed = hub.setProPreset(key, preset);
1112
+ if (!changed.ok)
1113
+ sendJson(res, 409, { error: changed.error });
1114
+ else
1115
+ sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
1116
+ }
1117
+ catch (error) {
1118
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid Pro preset" });
1119
+ }
1120
+ return true;
1121
+ }
864
1122
  if (action === "approval-mode") {
865
1123
  let body;
866
1124
  try {
@@ -965,7 +1223,7 @@ async function handleApi(req, res, url, auth) {
965
1223
  sendJson(res, 404, { error: "not found" });
966
1224
  return true;
967
1225
  }
968
- async function handleRequest(req, res, auth) {
1226
+ async function handleRequest(req, res, auth, binding) {
969
1227
  let url;
970
1228
  try {
971
1229
  url = new URL(req.url ?? "/", "http://localhost");
@@ -974,20 +1232,62 @@ async function handleRequest(req, res, auth) {
974
1232
  sendText(res, 400, "Bad request");
975
1233
  return;
976
1234
  }
977
- if (await handleApi(req, res, url, auth))
1235
+ // The Pro workspace is the same single-page app at /pro, talking to an
1236
+ // isolated API under /pro/api. Static assets stay at the root (the app uses
1237
+ // root-absolute URLs), so only the page route and the API prefix are special.
1238
+ const pathname = url.pathname;
1239
+ if (pathname === "/pro" || pathname === "/pro/") {
1240
+ if (req.method !== "GET" && req.method !== "HEAD") {
1241
+ sendText(res, 405, "Method not allowed");
1242
+ return;
1243
+ }
1244
+ if (pathname === "/pro/") {
1245
+ // Canonical URL without the trailing slash keeps relative links rooted.
1246
+ res.writeHead(308, { Location: `/pro${url.search}` });
1247
+ res.end();
1248
+ return;
1249
+ }
1250
+ serveStatic(res, "/index.html");
1251
+ return;
1252
+ }
1253
+ let mode = "standard";
1254
+ let apiPathname = pathname;
1255
+ if (pathname.startsWith("/pro/api/")) {
1256
+ mode = "pro";
1257
+ apiPathname = pathname.slice("/pro".length);
1258
+ }
1259
+ if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
1260
+ return;
1261
+ if (mode === "pro") {
1262
+ // Nothing else lives under /pro but the page and its API.
1263
+ sendText(res, 404, "Not found");
978
1264
  return;
1265
+ }
979
1266
  if (req.method !== "GET" && req.method !== "HEAD") {
980
1267
  sendText(res, 405, "Method not allowed");
981
1268
  return;
982
1269
  }
983
- serveStatic(res, url.pathname);
1270
+ serveStatic(res, pathname);
984
1271
  }
985
1272
  /** Start the web UI. Resolves once the socket is actually listening. */
986
1273
  export async function startWebServer(options) {
987
- const host = options.host ?? "127.0.0.1";
988
1274
  const auth = new WebAuth(options.auth !== false);
1275
+ // An explicit --host/--lan wins for this run; otherwise the saved Web access
1276
+ // switch is applied exactly as if `--lan` had been passed.
1277
+ let host = options.host?.trim() || defaultWebHost();
1278
+ let port = options.port;
1279
+ let closed = false;
1280
+ // The settings page reads and changes this binding while the process runs.
1281
+ const binding = {
1282
+ host,
1283
+ port,
1284
+ setHost: (next) => rebind(next),
1285
+ prompt: { pending: options.backgroundPrompt === true },
1286
+ service: options.backgroundService,
1287
+ updateSettings: (body, mode) => updateSettings(body, mode),
1288
+ };
989
1289
  const server = http.createServer((req, res) => {
990
- handleRequest(req, res, auth).catch((err) => {
1290
+ handleRequest(req, res, auth, binding).catch((err) => {
991
1291
  if (!res.headersSent) {
992
1292
  sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
993
1293
  }
@@ -996,41 +1296,162 @@ export async function startWebServer(options) {
996
1296
  }
997
1297
  });
998
1298
  });
999
- await new Promise((resolve, reject) => {
1299
+ /** Wait for the socket to actually listen, or for it to fail. */
1300
+ const listen = (onHost) => new Promise((resolve, reject) => {
1000
1301
  const onError = (err) => {
1001
1302
  server.removeListener("listening", onListening);
1002
1303
  reject(err);
1003
1304
  };
1004
1305
  const onListening = () => {
1005
1306
  server.removeListener("error", onError);
1006
- // Once listening, the server must tolerate connection-level failures
1007
- // (resets, EMFILE, half-open sockets) without the process dying. An
1008
- // 'error' event with no listener is fatal in Node.
1009
- server.on("error", (err) => {
1010
- writeCrashLog("web server", err);
1011
- });
1012
- server.on("clientError", (_err, socket) => {
1013
- if (socket.writable)
1014
- socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
1015
- socket.destroy();
1016
- });
1017
1307
  resolve();
1018
1308
  };
1019
1309
  server.once("error", onError);
1020
1310
  server.once("listening", onListening);
1021
- server.listen(options.port, host);
1311
+ server.listen(port, onHost);
1022
1312
  });
1023
- const address = server.address();
1024
- const port = typeof address === "object" && address ? address.port : options.port;
1025
- return {
1313
+ /** A just-closed port can need a moment to reappear: retry only that race. */
1314
+ async function listenRetry(onHost) {
1315
+ for (let attempt = 0;; attempt += 1) {
1316
+ try {
1317
+ await listen(onHost);
1318
+ return;
1319
+ }
1320
+ catch (err) {
1321
+ if (err.code !== "EADDRINUSE" || attempt >= 20)
1322
+ throw err;
1323
+ await new Promise((done) => setTimeout(done, 10));
1324
+ }
1325
+ }
1326
+ }
1327
+ const syncAddress = () => {
1328
+ const address = webUrls(host, port);
1329
+ binding.host = host;
1330
+ binding.port = port;
1331
+ handle.host = host;
1332
+ handle.port = port;
1333
+ handle.url = address.url;
1334
+ handle.urls = address.urls;
1335
+ options.onAddressChange?.();
1336
+ };
1337
+ // Two tabs saving at once must not interleave close/listen on one socket.
1338
+ let switches = Promise.resolve();
1339
+ function rebind(next) {
1340
+ const target = next.trim() || LOOPBACK_HOST;
1341
+ const task = switches.then(async () => {
1342
+ if (closed)
1343
+ throw new Error("the Web console is shutting down");
1344
+ if (target === host && server.listening)
1345
+ return;
1346
+ // `close` ends acceptance, not the connections already accepted. The
1347
+ // listener is released immediately, which is all the switch needs, so a
1348
+ // streaming turn and the tab that asked for the change stay connected.
1349
+ if (server.listening)
1350
+ server.close();
1351
+ try {
1352
+ await listenRetry(target);
1353
+ }
1354
+ catch (err) {
1355
+ // Put the console back where it was rather than leaving it dark.
1356
+ if (!closed) {
1357
+ try {
1358
+ await listenRetry(host);
1359
+ }
1360
+ catch {
1361
+ // The settings response still reports the original failure.
1362
+ }
1363
+ }
1364
+ throw err;
1365
+ }
1366
+ // A concurrent shutdown wins: never resurrect a console being closed.
1367
+ if (closed) {
1368
+ server.close();
1369
+ throw new Error("the Web Console is shutting down");
1370
+ }
1371
+ host = target;
1372
+ syncAddress();
1373
+ });
1374
+ switches = task.catch(() => undefined);
1375
+ return task;
1376
+ }
1377
+ // Serialize saves from both workspaces so a failed switch cannot roll back
1378
+ // another tab's successful change.
1379
+ let settingsChanges = Promise.resolve();
1380
+ function updateSettings(body, mode) {
1381
+ const task = settingsChanges.then(async () => {
1382
+ const previous = loadConfig(mode);
1383
+ const wasHost = binding.host;
1384
+ try {
1385
+ updateWebSettings(body, mode);
1386
+ const patch = body?.settings;
1387
+ const desiredHost = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
1388
+ ? patch.web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST
1389
+ : binding.host;
1390
+ if (desiredHost !== binding.host)
1391
+ await binding.setHost(desiredHost);
1392
+ if (patch && Object.prototype.hasOwnProperty.call(patch, "background")) {
1393
+ if (!binding.service?.configure) {
1394
+ throw new Error("Background residency could not be applied. Restart the console and try again.");
1395
+ }
1396
+ binding.service.configure({ resident: patch.background === true });
1397
+ if (patch.background === true)
1398
+ binding.prompt.pending = false;
1399
+ }
1400
+ }
1401
+ catch (error) {
1402
+ // A failed lifetime or address change must not look saved or active.
1403
+ if (binding.host !== wasHost) {
1404
+ try {
1405
+ await binding.setHost(wasHost);
1406
+ }
1407
+ catch { /* Report the original failure. */ }
1408
+ }
1409
+ const config = loadConfig(mode);
1410
+ config.web_lan = previous.web_lan;
1411
+ config.background = previous.background;
1412
+ try {
1413
+ saveConfig(config, mode);
1414
+ }
1415
+ catch { /* Report the original failure. */ }
1416
+ throw error;
1417
+ }
1418
+ });
1419
+ settingsChanges = task.catch(() => undefined);
1420
+ return task;
1421
+ }
1422
+ const handle = {
1026
1423
  host,
1027
1424
  port,
1028
1425
  authRequired: auth.enabled,
1029
- ...webUrls(host, port),
1426
+ url: "",
1427
+ urls: [],
1428
+ setHost: (next) => rebind(next),
1030
1429
  close: () => new Promise((resolve) => {
1430
+ closed = true;
1031
1431
  for (const key of [...webSessions.keys()])
1032
1432
  closeWebSession(key);
1433
+ if (!server.listening) {
1434
+ resolve();
1435
+ return;
1436
+ }
1033
1437
  server.close(() => resolve());
1034
1438
  }),
1035
1439
  };
1440
+ await listenRetry(host);
1441
+ // Port 0 means "let the OS pick": remember the real one for every URL.
1442
+ const address = server.address();
1443
+ port = typeof address === "object" && address ? address.port : options.port;
1444
+ syncAddress();
1445
+ // Once listening, the server must tolerate connection-level failures
1446
+ // (resets, EMFILE, half-open sockets) without the process dying: an 'error'
1447
+ // event with no listener is fatal in Node.
1448
+ server.on("error", (err) => {
1449
+ writeCrashLog("web server", err);
1450
+ });
1451
+ server.on("clientError", (_err, socket) => {
1452
+ if (socket.writable)
1453
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
1454
+ socket.destroy();
1455
+ });
1456
+ return handle;
1036
1457
  }