@xingjisoft/neutrino 0.4.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.
- 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 +163 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +179 -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/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +243 -35
- package/dist/web/service.js +135 -0
- package/dist/web/session.js +23 -6
- package/dist/web/settings.js +6 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +74 -14
- package/web/app.js +186 -11
- package/web/i18n.js +14 -0
- package/web/index.html +30 -1
- package/web/settings.js +53 -2
- package/web/style.css +101 -0
package/dist/tray.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows notification-area icon for the background Web console.
|
|
3
|
+
*
|
|
4
|
+
* Node cannot create a tray icon by itself, and the project deliberately has
|
|
5
|
+
* no native dependencies, so a tiny Windows PowerShell process hosts a
|
|
6
|
+
* `System.Windows.Forms.NotifyIcon`. It uses the same orbit badge as the
|
|
7
|
+
* regular console's top-left mark. Everything is best-effort: an unavailable
|
|
8
|
+
* icon must never stop the service.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { BACKGROUND_LOG_FILE, backgroundLog } from "./background.js";
|
|
17
|
+
import { NEUTRINO_DIR } from "./config.js";
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
20
|
+
const ICON_FILE = path.join(PKG_ROOT, "assets", "neutrino.ico");
|
|
21
|
+
/** Quote a value as a PowerShell single-quoted literal. */
|
|
22
|
+
function psString(value) {
|
|
23
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
24
|
+
}
|
|
25
|
+
/** The icon itself. Only Windows has a notification area, and only it can run this. */
|
|
26
|
+
export function startBackgroundTray(options) {
|
|
27
|
+
if (process.platform !== "win32")
|
|
28
|
+
return null;
|
|
29
|
+
if (process.env.NEUTRINO_NO_TRAY === "1")
|
|
30
|
+
return null;
|
|
31
|
+
if (!fs.existsSync(ICON_FILE)) {
|
|
32
|
+
backgroundLog(`tray icon not found at ${ICON_FILE}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// Separate files let an old icon close cleanly even if residency is turned
|
|
36
|
+
// back on before its PowerShell message loop has processed the shutdown.
|
|
37
|
+
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
38
|
+
const scriptFile = path.join(NEUTRINO_DIR, `tray-${suffix}.ps1`);
|
|
39
|
+
const closeFile = path.join(NEUTRINO_DIR, `tray-${suffix}.stop`);
|
|
40
|
+
const script = `
|
|
41
|
+
$ErrorActionPreference = 'Continue'
|
|
42
|
+
$scriptFile = ${psString(scriptFile)}
|
|
43
|
+
$closeFile = ${psString(closeFile)}
|
|
44
|
+
try {
|
|
45
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
46
|
+
Add-Type -AssemblyName System.Drawing
|
|
47
|
+
$iconPath = ${psString(ICON_FILE)}
|
|
48
|
+
$url = ${psString(options.url)}
|
|
49
|
+
$stopUrl = ${psString(options.stopUrl)}
|
|
50
|
+
$token = ${psString(options.token)}
|
|
51
|
+
$parent = ${options.pid}
|
|
52
|
+
$notify = New-Object System.Windows.Forms.NotifyIcon
|
|
53
|
+
$notify.Icon = New-Object System.Drawing.Icon($iconPath)
|
|
54
|
+
$notify.Text = 'Neutrino - background console'
|
|
55
|
+
$notify.Visible = $true
|
|
56
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
57
|
+
$open = $menu.Items.Add('Open Neutrino console')
|
|
58
|
+
$open.add_Click({ Start-Process -FilePath $url })
|
|
59
|
+
$stop = $menu.Items.Add('Stop background service')
|
|
60
|
+
$stop.add_Click({
|
|
61
|
+
try {
|
|
62
|
+
Invoke-RestMethod -Method Post -Uri $stopUrl -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 5 | Out-Null
|
|
63
|
+
} catch {
|
|
64
|
+
Stop-Process -Id $parent -Force
|
|
65
|
+
}
|
|
66
|
+
$notify.Visible = $false
|
|
67
|
+
[System.Windows.Forms.Application]::Exit()
|
|
68
|
+
})
|
|
69
|
+
$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
|
|
70
|
+
$notify.ContextMenuStrip = $menu
|
|
71
|
+
$notify.add_MouseClick({ param($sender, $eventArgs) if ($eventArgs.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Start-Process -FilePath $url } })
|
|
72
|
+
$notify.add_DoubleClick({ Start-Process -FilePath $url })
|
|
73
|
+
$timer = New-Object System.Windows.Forms.Timer
|
|
74
|
+
$timer.Interval = 250
|
|
75
|
+
$timer.add_Tick({
|
|
76
|
+
if ((Test-Path -LiteralPath $closeFile) -or -not (Get-Process -Id $parent -ErrorAction SilentlyContinue)) {
|
|
77
|
+
$notify.Visible = $false
|
|
78
|
+
[System.Windows.Forms.Application]::Exit()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
$timer.Start()
|
|
82
|
+
[System.Windows.Forms.Application]::Run()
|
|
83
|
+
} finally {
|
|
84
|
+
if ($notify) { $notify.Visible = $false; $notify.Dispose() }
|
|
85
|
+
Remove-Item -LiteralPath $closeFile, $scriptFile -Force -ErrorAction SilentlyContinue
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
const cleanup = () => {
|
|
89
|
+
for (const file of [scriptFile, closeFile]) {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(file, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch { /* The helper also cleans up. */ }
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
98
|
+
fs.writeFileSync(scriptFile, `\ufeff${script}`, { encoding: "utf-8", mode: 0o600 });
|
|
99
|
+
const logFd = fs.openSync(BACKGROUND_LOG_FILE, "a");
|
|
100
|
+
let child;
|
|
101
|
+
try {
|
|
102
|
+
child = spawn("powershell.exe", [
|
|
103
|
+
"-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
|
|
104
|
+
"-File", scriptFile,
|
|
105
|
+
],
|
|
106
|
+
// Not detached: on Windows a detached PowerShell exits before its
|
|
107
|
+
// message loop starts. The icon outlives the service on its own; its
|
|
108
|
+
// timer notices the parent pid disappearing and shuts it down.
|
|
109
|
+
{ stdio: ["ignore", "ignore", logFd], windowsHide: true });
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
fs.closeSync(logFd);
|
|
113
|
+
}
|
|
114
|
+
let active = true;
|
|
115
|
+
child.once("error", (error) => { active = false; cleanup(); backgroundLog(`tray failed: ${error.message}`); });
|
|
116
|
+
child.once("exit", (code) => { active = false; cleanup(); backgroundLog(`tray exited (${code ?? "signal"})`); });
|
|
117
|
+
child.unref();
|
|
118
|
+
backgroundLog(`tray icon started (pid ${child.pid ?? "unknown"})`);
|
|
119
|
+
return { close: () => {
|
|
120
|
+
if (!active)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(closeFile, "", { mode: 0o600 });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
try {
|
|
127
|
+
child.kill();
|
|
128
|
+
}
|
|
129
|
+
catch { /* Already gone. */ }
|
|
130
|
+
}
|
|
131
|
+
} };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
cleanup();
|
|
135
|
+
backgroundLog(`tray failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/web/hub.js
CHANGED
|
@@ -58,6 +58,10 @@ class LiveHub {
|
|
|
58
58
|
const entry = this.entries.get(key);
|
|
59
59
|
return Boolean(entry) && (!mode || entry.mode === mode);
|
|
60
60
|
}
|
|
61
|
+
remoteAction(key) {
|
|
62
|
+
const agent = this.entries.get(key)?.agent;
|
|
63
|
+
return agent?.remoteAction ? agent.remoteAction.bind(agent) : null;
|
|
64
|
+
}
|
|
61
65
|
/** All live sessions, newest registration last. Optionally one workspace only. */
|
|
62
66
|
list(mode) {
|
|
63
67
|
const out = [];
|
package/dist/web/server.js
CHANGED
|
@@ -15,6 +15,7 @@ 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,9 +23,11 @@ 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, proInitialSelection, resumeSession } from "./session.js";
|
|
26
|
+
import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
|
|
26
27
|
import { defaultProPreset, requireProPreset } from "../pro/config.js";
|
|
27
|
-
import { listAgentSessions, loadAgentMessages } from "../agents.js";
|
|
28
|
+
import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
|
|
29
|
+
import { migrateAgentSession, SessionMigrationError } from "../migration.js";
|
|
30
|
+
import { runningBackground } from "../background.js";
|
|
28
31
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
32
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
30
33
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -119,10 +122,26 @@ export function webUrls(host, port) {
|
|
|
119
122
|
* is listening right now, so a run started with --lan shows as on even though
|
|
120
123
|
* nothing was saved yet.
|
|
121
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
|
+
}
|
|
122
140
|
function settingsPayload(mode, binding) {
|
|
123
141
|
const payload = getWebSettings(mode);
|
|
124
142
|
const lan = isWildcardHost(binding.host);
|
|
125
143
|
const { urls } = webUrls(binding.host, binding.port);
|
|
144
|
+
const background = binding.service?.status?.() ?? runningBackground();
|
|
126
145
|
return {
|
|
127
146
|
...payload,
|
|
128
147
|
settings: {
|
|
@@ -131,6 +150,12 @@ function settingsPayload(mode, binding) {
|
|
|
131
150
|
web_host: binding.host,
|
|
132
151
|
// Addresses another device can open; empty unless every interface is bound.
|
|
133
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,
|
|
134
159
|
},
|
|
135
160
|
};
|
|
136
161
|
}
|
|
@@ -194,7 +219,6 @@ function sendApiError(res, error) {
|
|
|
194
219
|
}
|
|
195
220
|
const MAX_RECENT_WORKSPACES = 12;
|
|
196
221
|
const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
|
|
197
|
-
const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
198
222
|
/** Expand `~` and require an absolute path; used by every folder route. */
|
|
199
223
|
function absolutePath(raw) {
|
|
200
224
|
if (typeof raw !== "string" || !raw.trim()) {
|
|
@@ -354,9 +378,6 @@ function createFolder(raw) {
|
|
|
354
378
|
function isAgentId(value) {
|
|
355
379
|
return AGENT_IDS.has(value);
|
|
356
380
|
}
|
|
357
|
-
function safeAgentSessionId(id) {
|
|
358
|
-
return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
|
|
359
|
-
}
|
|
360
381
|
/** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
|
|
361
382
|
function buildOverview(mode = "standard") {
|
|
362
383
|
const sessions = listSessions(mode);
|
|
@@ -484,6 +505,16 @@ function isSameOrigin(req) {
|
|
|
484
505
|
return false;
|
|
485
506
|
}
|
|
486
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
|
+
}
|
|
487
518
|
function serveStatic(res, pathname) {
|
|
488
519
|
let rel = pathname === "/" ? "/index.html" : pathname;
|
|
489
520
|
try {
|
|
@@ -576,6 +607,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
576
607
|
res.end();
|
|
577
608
|
return true;
|
|
578
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
|
+
}
|
|
579
663
|
if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
|
|
580
664
|
if (req.method !== "POST") {
|
|
581
665
|
sendJson(res, 405, { error: "method not allowed" });
|
|
@@ -610,6 +694,21 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
610
694
|
sendJson(res, 401, { error: "Password login required." });
|
|
611
695
|
return true;
|
|
612
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
|
+
}
|
|
613
712
|
if (pathname === "/api/settings") {
|
|
614
713
|
if (req.method === "GET") {
|
|
615
714
|
sendJson(res, 200, settingsPayload(mode, binding));
|
|
@@ -619,39 +718,15 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
619
718
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
620
719
|
return true;
|
|
621
720
|
}
|
|
622
|
-
const wasLan = loadConfig(mode).web_lan === true;
|
|
623
721
|
try {
|
|
624
722
|
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
|
-
}
|
|
723
|
+
await binding.updateSettings(body, mode);
|
|
637
724
|
if (body.settings?.web_password !== undefined) {
|
|
638
725
|
auth.remember(res);
|
|
639
726
|
}
|
|
640
727
|
sendJson(res, 200, settingsPayload(mode, binding));
|
|
641
728
|
}
|
|
642
729
|
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
730
|
sendJson(res, err?.code ? 500 : 400, {
|
|
656
731
|
error: err instanceof Error ? err.message : "invalid settings",
|
|
657
732
|
});
|
|
@@ -797,7 +872,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
797
872
|
sendJson(res, 200, { ok: true, deleted: true });
|
|
798
873
|
return true;
|
|
799
874
|
}
|
|
800
|
-
//
|
|
875
|
+
// History recorded by other agents (Claude Code, Codex, OpenCode).
|
|
801
876
|
const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
|
|
802
877
|
if (req.method === "GET" && agentSessionsMatch) {
|
|
803
878
|
const agent = decodeURIComponent(agentSessionsMatch[1]);
|
|
@@ -821,7 +896,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
821
896
|
sendJson(res, 400, { error: "invalid session id" });
|
|
822
897
|
return true;
|
|
823
898
|
}
|
|
824
|
-
|
|
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
|
+
}
|
|
825
952
|
return true;
|
|
826
953
|
}
|
|
827
954
|
const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
|
|
@@ -870,14 +997,29 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
870
997
|
// Already running (TUI or another tab)? Attach instead of duplicating.
|
|
871
998
|
const existing = hub.list(mode).find((entry) => entry.sessionId === id);
|
|
872
999
|
if (existing) {
|
|
873
|
-
|
|
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) {
|
|
874
1016
|
const changed = hub.setProPreset(existing.key, preset);
|
|
875
1017
|
if (!changed.ok) {
|
|
876
1018
|
sendJson(res, 409, { error: changed.error });
|
|
877
1019
|
return true;
|
|
878
1020
|
}
|
|
879
1021
|
}
|
|
880
|
-
if (selection) {
|
|
1022
|
+
if (!remote && selection) {
|
|
881
1023
|
const changed = hub.configure(existing.key, selection);
|
|
882
1024
|
if (!changed.ok) {
|
|
883
1025
|
sendJson(res, 409, { error: changed.error });
|
|
@@ -941,6 +1083,23 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
941
1083
|
sendJson(res, 404, { error: "unknown live session" });
|
|
942
1084
|
return true;
|
|
943
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
|
+
}
|
|
944
1103
|
if (action === "pro-preset") {
|
|
945
1104
|
if (mode !== "pro") {
|
|
946
1105
|
sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
|
|
@@ -1123,6 +1282,9 @@ export async function startWebServer(options) {
|
|
|
1123
1282
|
host,
|
|
1124
1283
|
port,
|
|
1125
1284
|
setHost: (next) => rebind(next),
|
|
1285
|
+
prompt: { pending: options.backgroundPrompt === true },
|
|
1286
|
+
service: options.backgroundService,
|
|
1287
|
+
updateSettings: (body, mode) => updateSettings(body, mode),
|
|
1126
1288
|
};
|
|
1127
1289
|
const server = http.createServer((req, res) => {
|
|
1128
1290
|
handleRequest(req, res, auth, binding).catch((err) => {
|
|
@@ -1170,6 +1332,7 @@ export async function startWebServer(options) {
|
|
|
1170
1332
|
handle.port = port;
|
|
1171
1333
|
handle.url = address.url;
|
|
1172
1334
|
handle.urls = address.urls;
|
|
1335
|
+
options.onAddressChange?.();
|
|
1173
1336
|
};
|
|
1174
1337
|
// Two tabs saving at once must not interleave close/listen on one socket.
|
|
1175
1338
|
let switches = Promise.resolve();
|
|
@@ -1211,6 +1374,51 @@ export async function startWebServer(options) {
|
|
|
1211
1374
|
switches = task.catch(() => undefined);
|
|
1212
1375
|
return task;
|
|
1213
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
|
+
}
|
|
1214
1422
|
const handle = {
|
|
1215
1423
|
host,
|
|
1216
1424
|
port,
|