@xingjisoft/neutrino 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +218 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +200 -42
- package/dist/config.js +7 -3
- package/dist/header.js +2 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/config.js +135 -9
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +330 -37
- package/dist/web/service.js +160 -0
- package/dist/web/session.js +24 -6
- package/dist/web/settings.js +15 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +102 -23
- package/web/app.js +558 -22
- package/web/i18n.js +34 -0
- package/web/index.html +71 -9
- package/web/settings.js +223 -46
- package/web/style.css +145 -78
- package/assets/pro/model-template.json +0 -376
package/dist/web/server.js
CHANGED
|
@@ -15,16 +15,21 @@ 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
20
|
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
20
21
|
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
22
|
+
import { isSeenIdentity, loadSeen, updateSeen, SEEN_LIMIT } from "./clientState.js";
|
|
21
23
|
import { WebAuth } from "./auth.js";
|
|
22
24
|
import { normalizeCwd, sameCwd } from "../cwd.js";
|
|
23
25
|
import { hub } from "./hub.js";
|
|
24
26
|
import { writeCrashLog } from "../terminal/restore.js";
|
|
25
|
-
import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
|
|
26
|
-
import { defaultProPreset, requireProPreset } from "../pro/config.js";
|
|
27
|
-
import { listAgentSessions, loadAgentMessages } from "../agents.js";
|
|
27
|
+
import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
|
|
28
|
+
import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/config.js";
|
|
29
|
+
import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
|
|
30
|
+
import { migrateAgentSession, SessionMigrationError } from "../migration.js";
|
|
31
|
+
import { runningBackground } from "../background.js";
|
|
32
|
+
import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
|
|
28
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
34
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
30
35
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -119,10 +124,26 @@ export function webUrls(host, port) {
|
|
|
119
124
|
* is listening right now, so a run started with --lan shows as on even though
|
|
120
125
|
* nothing was saved yet.
|
|
121
126
|
*/
|
|
127
|
+
function validTerminalBridge(bridge, ownerPid) {
|
|
128
|
+
if (!bridge || typeof bridge !== "object" || Array.isArray(bridge) || typeof ownerPid !== "number")
|
|
129
|
+
return false;
|
|
130
|
+
const info = bridge;
|
|
131
|
+
if (typeof info.url !== "string" || typeof info.token !== "string" || !/^[a-f0-9]{64}$/.test(info.token))
|
|
132
|
+
return false;
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(info.url);
|
|
135
|
+
return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port)
|
|
136
|
+
&& url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
122
142
|
function settingsPayload(mode, binding) {
|
|
123
143
|
const payload = getWebSettings(mode);
|
|
124
144
|
const lan = isWildcardHost(binding.host);
|
|
125
145
|
const { urls } = webUrls(binding.host, binding.port);
|
|
146
|
+
const background = binding.service?.status?.() ?? runningBackground();
|
|
126
147
|
return {
|
|
127
148
|
...payload,
|
|
128
149
|
settings: {
|
|
@@ -131,6 +152,12 @@ function settingsPayload(mode, binding) {
|
|
|
131
152
|
web_host: binding.host,
|
|
132
153
|
// Addresses another device can open; empty unless every interface is bound.
|
|
133
154
|
web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
|
|
155
|
+
// The background service is a process, not a workspace: both pages report
|
|
156
|
+
// the same state. The prompt is never shown once the setting is on.
|
|
157
|
+
background: background?.pid === process.pid ? background.resident : payload.settings.background,
|
|
158
|
+
background_running: background?.resident === true,
|
|
159
|
+
background_url: background?.url ?? "",
|
|
160
|
+
background_prompt: binding.prompt.pending && payload.settings.background !== true,
|
|
134
161
|
},
|
|
135
162
|
};
|
|
136
163
|
}
|
|
@@ -194,7 +221,6 @@ function sendApiError(res, error) {
|
|
|
194
221
|
}
|
|
195
222
|
const MAX_RECENT_WORKSPACES = 12;
|
|
196
223
|
const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
|
|
197
|
-
const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
198
224
|
/** Expand `~` and require an absolute path; used by every folder route. */
|
|
199
225
|
function absolutePath(raw) {
|
|
200
226
|
if (typeof raw !== "string" || !raw.trim()) {
|
|
@@ -354,9 +380,6 @@ function createFolder(raw) {
|
|
|
354
380
|
function isAgentId(value) {
|
|
355
381
|
return AGENT_IDS.has(value);
|
|
356
382
|
}
|
|
357
|
-
function safeAgentSessionId(id) {
|
|
358
|
-
return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
|
|
359
|
-
}
|
|
360
383
|
/** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
|
|
361
384
|
function buildOverview(mode = "standard") {
|
|
362
385
|
const sessions = listSessions(mode);
|
|
@@ -384,10 +407,15 @@ function buildOverview(mode = "standard") {
|
|
|
384
407
|
seen.add(session.id);
|
|
385
408
|
}
|
|
386
409
|
// Live conversations that have not been saved yet (or whose newest save has
|
|
387
|
-
// not landed) still deserve a spot at the top of their directory group.
|
|
410
|
+
// not landed) still deserve a spot at the top of their directory group. A
|
|
411
|
+
// conversation nobody has said anything in yet is not history: it stays out
|
|
412
|
+
// of the list (but in `live`, so the browser keeps its stream) until it has
|
|
413
|
+
// content.
|
|
388
414
|
for (const live of lives) {
|
|
389
415
|
if (live.sessionId && seen.has(live.sessionId))
|
|
390
416
|
continue;
|
|
417
|
+
if (live.empty)
|
|
418
|
+
continue;
|
|
391
419
|
const key = normalizeCwd(live.cwd) || "(unknown)";
|
|
392
420
|
let group = groups.get(key);
|
|
393
421
|
if (!group) {
|
|
@@ -484,6 +512,16 @@ function isSameOrigin(req) {
|
|
|
484
512
|
return false;
|
|
485
513
|
}
|
|
486
514
|
}
|
|
515
|
+
/** Constant-time comparison of the service's management token. */
|
|
516
|
+
function hasServiceToken(req, token) {
|
|
517
|
+
const header = String(req.headers.authorization ?? "");
|
|
518
|
+
const prefix = "Bearer ";
|
|
519
|
+
if (!header.startsWith(prefix))
|
|
520
|
+
return false;
|
|
521
|
+
const supplied = Buffer.from(header.slice(prefix.length));
|
|
522
|
+
const expected = Buffer.from(token);
|
|
523
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
524
|
+
}
|
|
487
525
|
function serveStatic(res, pathname) {
|
|
488
526
|
let rel = pathname === "/" ? "/index.html" : pathname;
|
|
489
527
|
try {
|
|
@@ -569,6 +607,10 @@ function streamLive(key, req, res) {
|
|
|
569
607
|
async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
570
608
|
if (!pathname.startsWith("/api/"))
|
|
571
609
|
return false;
|
|
610
|
+
// The Pro presets are not bundled either: the first Pro request makes sure they
|
|
611
|
+
// are on disk. When the download fails the route below reports the missing file.
|
|
612
|
+
if (mode === "pro")
|
|
613
|
+
await ensureProModels().catch(() => undefined);
|
|
572
614
|
if (req.method === "OPTIONS") {
|
|
573
615
|
// Same-origin only: no CORS headers, so cross-site preflights are rejected.
|
|
574
616
|
// Answered before the login check because it reveals nothing.
|
|
@@ -576,6 +618,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
576
618
|
res.end();
|
|
577
619
|
return true;
|
|
578
620
|
}
|
|
621
|
+
// Management endpoints of the detached service. They are called by the CLI
|
|
622
|
+
// and the tray icon, which have the token from ~/.neutrino/background.json
|
|
623
|
+
// but not necessarily a browser login cookie.
|
|
624
|
+
if (["/api/background/ping", "/api/background/stop", "/api/background/configure"].includes(pathname)) {
|
|
625
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
626
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
if (!binding.service) {
|
|
630
|
+
sendJson(res, 404, { error: "this console is not running in the background" });
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
if (!hasServiceToken(req, binding.service.token)) {
|
|
634
|
+
sendJson(res, 401, { error: "background service token required" });
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
if (pathname.endsWith("/ping")) {
|
|
638
|
+
sendJson(res, 200, { ok: true, pid: process.pid, url: webUrls(binding.host, binding.port).url });
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
if (pathname.endsWith("/configure")) {
|
|
642
|
+
if (req.method !== "POST") {
|
|
643
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
if (!binding.service.configure) {
|
|
647
|
+
sendJson(res, 409, { error: "Restart the console to update background residency." });
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const body = await readJsonBody(req, 4096);
|
|
652
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
653
|
+
|| Object.keys(body).some(key => key !== "resident" && key !== "ownerPid" && key !== "bridge")
|
|
654
|
+
|| (body.resident !== undefined && typeof body.resident !== "boolean")
|
|
655
|
+
|| (body.ownerPid !== undefined && (!Number.isSafeInteger(body.ownerPid) || body.ownerPid <= 0))
|
|
656
|
+
|| (body.bridge !== undefined && !validTerminalBridge(body.bridge, body.ownerPid))) {
|
|
657
|
+
throw new Error("invalid background settings");
|
|
658
|
+
}
|
|
659
|
+
const updated = binding.service.configure(body);
|
|
660
|
+
if (updated.resident)
|
|
661
|
+
binding.prompt.pending = false;
|
|
662
|
+
sendJson(res, 200, updated);
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid background settings" });
|
|
666
|
+
}
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
sendJson(res, 200, { ok: true, stopping: true });
|
|
670
|
+
// Let the response reach the caller before the socket closes.
|
|
671
|
+
setImmediate(() => binding.service?.onStop());
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
579
674
|
if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
|
|
580
675
|
if (req.method !== "POST") {
|
|
581
676
|
sendJson(res, 405, { error: "method not allowed" });
|
|
@@ -610,6 +705,21 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
610
705
|
sendJson(res, 401, { error: "Password login required." });
|
|
611
706
|
return true;
|
|
612
707
|
}
|
|
708
|
+
// The first browser to see the background-residency prompt consumes it for
|
|
709
|
+
// this console run, whether it accepted or dismissed it.
|
|
710
|
+
if (pathname === "/api/background/dismiss") {
|
|
711
|
+
if (req.method !== "POST") {
|
|
712
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
713
|
+
}
|
|
714
|
+
else if (!isSameOrigin(req)) {
|
|
715
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
binding.prompt.pending = false;
|
|
719
|
+
sendJson(res, 200, { ok: true });
|
|
720
|
+
}
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
613
723
|
if (pathname === "/api/settings") {
|
|
614
724
|
if (req.method === "GET") {
|
|
615
725
|
sendJson(res, 200, settingsPayload(mode, binding));
|
|
@@ -619,39 +729,15 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
619
729
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
620
730
|
return true;
|
|
621
731
|
}
|
|
622
|
-
const wasLan = loadConfig(mode).web_lan === true;
|
|
623
732
|
try {
|
|
624
733
|
const body = await readJsonBody(req);
|
|
625
|
-
|
|
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
|
-
}
|
|
734
|
+
await binding.updateSettings(body, mode);
|
|
637
735
|
if (body.settings?.web_password !== undefined) {
|
|
638
736
|
auth.remember(res);
|
|
639
737
|
}
|
|
640
738
|
sendJson(res, 200, settingsPayload(mode, binding));
|
|
641
739
|
}
|
|
642
740
|
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
|
-
}
|
|
655
741
|
sendJson(res, err?.code ? 500 : 400, {
|
|
656
742
|
error: err instanceof Error ? err.message : "invalid settings",
|
|
657
743
|
});
|
|
@@ -662,6 +748,33 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
662
748
|
}
|
|
663
749
|
return true;
|
|
664
750
|
}
|
|
751
|
+
// The update prompt's confirm button: npm runs on this machine, so the route
|
|
752
|
+
// sits behind the login cookie and the same-origin check like every other
|
|
753
|
+
// mutation, and only a release the registry announced can be installed.
|
|
754
|
+
if (pathname === "/api/update") {
|
|
755
|
+
if (req.method !== "POST") {
|
|
756
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
if (!isSameOrigin(req)) {
|
|
760
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
if (!updateStatus().hasUpdate) {
|
|
764
|
+
sendJson(res, 409, { error: "No newer version is published." });
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
// Never rejects: a failure comes back as a result, carrying the command the
|
|
768
|
+
// browser shows so the user can finish the update by hand.
|
|
769
|
+
const result = await installUpdate();
|
|
770
|
+
sendJson(res, result.ok ? 200 : 500, {
|
|
771
|
+
ok: result.ok,
|
|
772
|
+
version: result.version,
|
|
773
|
+
error: result.error ?? "",
|
|
774
|
+
command: MANUAL_UPDATE_COMMAND,
|
|
775
|
+
});
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
665
778
|
if (req.method === "GET" && pathname === "/api/health") {
|
|
666
779
|
sendJson(res, 200, { ok: true, live: hub.list(mode).length });
|
|
667
780
|
return true;
|
|
@@ -670,6 +783,53 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
670
783
|
sendJson(res, 200, buildOverview(mode));
|
|
671
784
|
return true;
|
|
672
785
|
}
|
|
786
|
+
// The sidebar's blue dot is the browser's own memory of what it has read. It
|
|
787
|
+
// belongs to the console rather than to a conversation, so it is stored in
|
|
788
|
+
// ~/.neutrino/web.json (see clientState.ts) and never in the config or the
|
|
789
|
+
// transcript. Each workspace keeps its own record.
|
|
790
|
+
if (pathname === "/api/seen") {
|
|
791
|
+
if (req.method === "GET") {
|
|
792
|
+
sendJson(res, 200, { seen: loadSeen(mode) });
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
if (req.method === "POST") {
|
|
796
|
+
if (!isSameOrigin(req)) {
|
|
797
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
802
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
803
|
+
throw new ApiError(400, "invalid request", "invalid request");
|
|
804
|
+
}
|
|
805
|
+
const input = body;
|
|
806
|
+
if (Object.keys(body).some((key) => key !== "identities" && key !== "seen")) {
|
|
807
|
+
throw new ApiError(400, "unknown field", "unknown field");
|
|
808
|
+
}
|
|
809
|
+
const identities = input.identities;
|
|
810
|
+
if (!Array.isArray(identities) || identities.length === 0 || identities.length > SEEN_LIMIT) {
|
|
811
|
+
throw new ApiError(400, `identities must be a list of 1 to ${SEEN_LIMIT}`, "invalid identities");
|
|
812
|
+
}
|
|
813
|
+
if (!identities.every(isSeenIdentity)) {
|
|
814
|
+
throw new ApiError(400, "invalid conversation id", "invalid conversation id");
|
|
815
|
+
}
|
|
816
|
+
if (typeof input.seen !== "boolean") {
|
|
817
|
+
throw new ApiError(400, "seen must be a boolean", "seen must be a boolean");
|
|
818
|
+
}
|
|
819
|
+
sendJson(res, 200, { ok: true, seen: updateSeen(mode, identities, input.seen) });
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
// A rejected body is the caller's fault; a file that cannot be written
|
|
823
|
+
// is ours, and must not read as a malformed request.
|
|
824
|
+
sendJson(res, err instanceof ApiError ? err.status : 500, {
|
|
825
|
+
error: err instanceof Error ? err.message : "invalid request",
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
673
833
|
if (req.method === "GET" && pathname === "/api/sessions") {
|
|
674
834
|
sendJson(res, 200, { sessions: listSessions(mode) });
|
|
675
835
|
return true;
|
|
@@ -797,7 +957,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
797
957
|
sendJson(res, 200, { ok: true, deleted: true });
|
|
798
958
|
return true;
|
|
799
959
|
}
|
|
800
|
-
//
|
|
960
|
+
// History recorded by other agents (Claude Code, Codex, OpenCode).
|
|
801
961
|
const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
|
|
802
962
|
if (req.method === "GET" && agentSessionsMatch) {
|
|
803
963
|
const agent = decodeURIComponent(agentSessionsMatch[1]);
|
|
@@ -821,7 +981,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
821
981
|
sendJson(res, 400, { error: "invalid session id" });
|
|
822
982
|
return true;
|
|
823
983
|
}
|
|
824
|
-
|
|
984
|
+
const cwd = url.searchParams.get("cwd");
|
|
985
|
+
if (cwd) {
|
|
986
|
+
const source = loadAgentSession(agent, id, cwd);
|
|
987
|
+
if (!source) {
|
|
988
|
+
sendJson(res, 404, { error: "session not found in this working directory" });
|
|
989
|
+
return true;
|
|
990
|
+
}
|
|
991
|
+
sendJson(res, 200, { ...source, agent });
|
|
992
|
+
}
|
|
993
|
+
else {
|
|
994
|
+
sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
|
|
995
|
+
}
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
const migrateMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)\/migrate$/.exec(pathname);
|
|
999
|
+
if (migrateMatch) {
|
|
1000
|
+
if (req.method !== "POST") {
|
|
1001
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
if (!isSameOrigin(req)) {
|
|
1005
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
const agent = decodeURIComponent(migrateMatch[1]);
|
|
1009
|
+
const id = decodeURIComponent(migrateMatch[2]);
|
|
1010
|
+
if (!isAgentId(agent) || !safeAgentSessionId(id)) {
|
|
1011
|
+
sendJson(res, 400, { error: !isAgentId(agent) ? "unknown agent" : "invalid session id" });
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
const body = await readJsonBody(req);
|
|
1016
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
1017
|
+
throw new Error("invalid JSON body");
|
|
1018
|
+
const input = body;
|
|
1019
|
+
const cwd = existingDirectory(input.cwd ?? process.cwd());
|
|
1020
|
+
const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
|
|
1021
|
+
const selection = mode === "pro" ? proInitialSelection(preset)
|
|
1022
|
+
: resolveModelSelection(input.selection ?? getWebSettings(mode).selection, false);
|
|
1023
|
+
const migrated = migrateAgentSession(agent, id, cwd, {
|
|
1024
|
+
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
1025
|
+
}, mode, preset);
|
|
1026
|
+
const session = new WebSession(migrated, selection.model, mode);
|
|
1027
|
+
const key = session.start();
|
|
1028
|
+
webSessions.set(key, session);
|
|
1029
|
+
sendJson(res, 201, { ok: true, id: migrated.id, key, cwd, snapshot: hub.snapshot(key) });
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
if (err instanceof SessionMigrationError)
|
|
1033
|
+
sendJson(res, err.status, { error: err.message });
|
|
1034
|
+
else
|
|
1035
|
+
sendApiError(res, err);
|
|
1036
|
+
}
|
|
825
1037
|
return true;
|
|
826
1038
|
}
|
|
827
1039
|
const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
|
|
@@ -870,14 +1082,29 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
870
1082
|
// Already running (TUI or another tab)? Attach instead of duplicating.
|
|
871
1083
|
const existing = hub.list(mode).find((entry) => entry.sessionId === id);
|
|
872
1084
|
if (existing) {
|
|
873
|
-
|
|
1085
|
+
const remote = hub.remoteAction(existing.key);
|
|
1086
|
+
if (remote && preset !== undefined) {
|
|
1087
|
+
const result = await remote("pro-preset", { preset });
|
|
1088
|
+
if (result.status !== 200) {
|
|
1089
|
+
sendJson(res, result.status, result.data);
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
else if (remote && selection) {
|
|
1094
|
+
const result = await remote("model", { provider: selection.provider, model: selection.model.name, intensity: selection.intensity });
|
|
1095
|
+
if (result.status !== 200) {
|
|
1096
|
+
sendJson(res, result.status, result.data);
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (!remote && preset !== undefined) {
|
|
874
1101
|
const changed = hub.setProPreset(existing.key, preset);
|
|
875
1102
|
if (!changed.ok) {
|
|
876
1103
|
sendJson(res, 409, { error: changed.error });
|
|
877
1104
|
return true;
|
|
878
1105
|
}
|
|
879
1106
|
}
|
|
880
|
-
if (selection) {
|
|
1107
|
+
if (!remote && selection) {
|
|
881
1108
|
const changed = hub.configure(existing.key, selection);
|
|
882
1109
|
if (!changed.ok) {
|
|
883
1110
|
sendJson(res, 409, { error: changed.error });
|
|
@@ -941,6 +1168,23 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
941
1168
|
sendJson(res, 404, { error: "unknown live session" });
|
|
942
1169
|
return true;
|
|
943
1170
|
}
|
|
1171
|
+
const remote = hub.remoteAction(key);
|
|
1172
|
+
if (remote) {
|
|
1173
|
+
if (action === "close") {
|
|
1174
|
+
sendJson(res, 400, { error: "this session is owned by the terminal UI" });
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
|
|
1179
|
+
? await readJsonBody(req) : {};
|
|
1180
|
+
const result = await remote(action, body);
|
|
1181
|
+
sendJson(res, result.status, result.data);
|
|
1182
|
+
}
|
|
1183
|
+
catch (error) {
|
|
1184
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
|
|
1185
|
+
}
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
944
1188
|
if (action === "pro-preset") {
|
|
945
1189
|
if (mode !== "pro") {
|
|
946
1190
|
sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
|
|
@@ -1123,6 +1367,9 @@ export async function startWebServer(options) {
|
|
|
1123
1367
|
host,
|
|
1124
1368
|
port,
|
|
1125
1369
|
setHost: (next) => rebind(next),
|
|
1370
|
+
prompt: { pending: options.backgroundPrompt === true },
|
|
1371
|
+
service: options.backgroundService,
|
|
1372
|
+
updateSettings: (body, mode) => updateSettings(body, mode),
|
|
1126
1373
|
};
|
|
1127
1374
|
const server = http.createServer((req, res) => {
|
|
1128
1375
|
handleRequest(req, res, auth, binding).catch((err) => {
|
|
@@ -1170,6 +1417,7 @@ export async function startWebServer(options) {
|
|
|
1170
1417
|
handle.port = port;
|
|
1171
1418
|
handle.url = address.url;
|
|
1172
1419
|
handle.urls = address.urls;
|
|
1420
|
+
options.onAddressChange?.();
|
|
1173
1421
|
};
|
|
1174
1422
|
// Two tabs saving at once must not interleave close/listen on one socket.
|
|
1175
1423
|
let switches = Promise.resolve();
|
|
@@ -1211,6 +1459,51 @@ export async function startWebServer(options) {
|
|
|
1211
1459
|
switches = task.catch(() => undefined);
|
|
1212
1460
|
return task;
|
|
1213
1461
|
}
|
|
1462
|
+
// Serialize saves from both workspaces so a failed switch cannot roll back
|
|
1463
|
+
// another tab's successful change.
|
|
1464
|
+
let settingsChanges = Promise.resolve();
|
|
1465
|
+
function updateSettings(body, mode) {
|
|
1466
|
+
const task = settingsChanges.then(async () => {
|
|
1467
|
+
const previous = loadConfig(mode);
|
|
1468
|
+
const wasHost = binding.host;
|
|
1469
|
+
try {
|
|
1470
|
+
updateWebSettings(body, mode);
|
|
1471
|
+
const patch = body?.settings;
|
|
1472
|
+
const desiredHost = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
|
|
1473
|
+
? patch.web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST
|
|
1474
|
+
: binding.host;
|
|
1475
|
+
if (desiredHost !== binding.host)
|
|
1476
|
+
await binding.setHost(desiredHost);
|
|
1477
|
+
if (patch && Object.prototype.hasOwnProperty.call(patch, "background")) {
|
|
1478
|
+
if (!binding.service?.configure) {
|
|
1479
|
+
throw new Error("Background residency could not be applied. Restart the console and try again.");
|
|
1480
|
+
}
|
|
1481
|
+
binding.service.configure({ resident: patch.background === true });
|
|
1482
|
+
if (patch.background === true)
|
|
1483
|
+
binding.prompt.pending = false;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
catch (error) {
|
|
1487
|
+
// A failed lifetime or address change must not look saved or active.
|
|
1488
|
+
if (binding.host !== wasHost) {
|
|
1489
|
+
try {
|
|
1490
|
+
await binding.setHost(wasHost);
|
|
1491
|
+
}
|
|
1492
|
+
catch { /* Report the original failure. */ }
|
|
1493
|
+
}
|
|
1494
|
+
const config = loadConfig(mode);
|
|
1495
|
+
config.web_lan = previous.web_lan;
|
|
1496
|
+
config.background = previous.background;
|
|
1497
|
+
try {
|
|
1498
|
+
saveConfig(config, mode);
|
|
1499
|
+
}
|
|
1500
|
+
catch { /* Report the original failure. */ }
|
|
1501
|
+
throw error;
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
settingsChanges = task.catch(() => undefined);
|
|
1505
|
+
return task;
|
|
1506
|
+
}
|
|
1214
1507
|
const handle = {
|
|
1215
1508
|
host,
|
|
1216
1509
|
port,
|