@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/bin.js
CHANGED
|
@@ -3,7 +3,10 @@ import process from "node:process";
|
|
|
3
3
|
import { App } from "./app.js";
|
|
4
4
|
import { loadConfig } from "./config.js";
|
|
5
5
|
import { installCrashGuard } from "./terminal/restore.js";
|
|
6
|
-
import {
|
|
6
|
+
import { webUrls } from "./web/server.js";
|
|
7
|
+
import { runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
|
|
8
|
+
import { runWebService } from "./web/service.js";
|
|
9
|
+
import { openInBrowser } from "./browser.js";
|
|
7
10
|
const DEFAULT_WEB_PORT = 8765;
|
|
8
11
|
/** Binding here makes the UI reachable from every interface. */
|
|
9
12
|
const WILDCARD_HOST = "0.0.0.0";
|
|
@@ -35,6 +38,11 @@ Usage:
|
|
|
35
38
|
neutrino Start the terminal UI (with the web UI).
|
|
36
39
|
neutrino --lan Start both, reachable from other devices.
|
|
37
40
|
neutrino web [--port <port>] Start only the web UI (serves saved history).
|
|
41
|
+
neutrino background start Run the web UI in the background; conversations
|
|
42
|
+
started there survive the terminal/CLI exiting.
|
|
43
|
+
neutrino background stop Stop the background service.
|
|
44
|
+
neutrino background status Show whether it is running and where.
|
|
45
|
+
neutrino background open Open its console in the default browser.
|
|
38
46
|
|
|
39
47
|
Options:
|
|
40
48
|
-p, --port <port> Web UI port. Default: ${DEFAULT_WEB_PORT}
|
|
@@ -43,12 +51,16 @@ Options:
|
|
|
43
51
|
devices on your LAN or VPN.
|
|
44
52
|
--no-web Do not start the web UI with the terminal UI.
|
|
45
53
|
--no-auth Serve without password login.
|
|
54
|
+
--no-tray Do not show the Windows notification-area icon of the
|
|
55
|
+
background service.
|
|
46
56
|
--web-port <n> Deprecated alias for --port while the TUI runs.
|
|
47
57
|
-h, --help Show this help.
|
|
48
58
|
|
|
49
59
|
Environment:
|
|
50
60
|
NEUTRINO_WEB_PORT Same as --port in either mode.
|
|
51
61
|
NEUTRINO_WEB_HOST Same as --host.
|
|
62
|
+
NEUTRINO_NO_TRAY Set to 1 to keep the background service out of the
|
|
63
|
+
Windows notification area.
|
|
52
64
|
|
|
53
65
|
The web UI starts by default alongside the terminal UI on loopback. Turn it
|
|
54
66
|
off for one run with --no-web, or permanently in Settings → Web console. It can
|
|
@@ -58,6 +70,13 @@ on, and off, in the browser's Settings → Web access; the choice is saved for t
|
|
|
58
70
|
next launch, while --host/--lan win for the current one. Log in with the
|
|
59
71
|
permanent password (default: 114514). Change it in Settings. --no-auth disables
|
|
60
72
|
password login.
|
|
73
|
+
|
|
74
|
+
Background residency is off by default. Turn it on in Settings → Background
|
|
75
|
+
(shared by the regular and Pro workspaces). Switching it takes effect immediately,
|
|
76
|
+
including for browser tasks already running. The background service keeps
|
|
77
|
+
running when this terminal closes, so tasks started
|
|
78
|
+
from the browser are not interrupted. Stop it with 'neutrino background stop',
|
|
79
|
+
the tray icon, or Settings → Web console in the terminal.
|
|
61
80
|
`);
|
|
62
81
|
}
|
|
63
82
|
/** Read the value that follows a flag, so `--host` alone is an error. */
|
|
@@ -68,13 +87,28 @@ function requireValue(argv, index, flag) {
|
|
|
68
87
|
}
|
|
69
88
|
return value;
|
|
70
89
|
}
|
|
90
|
+
const BACKGROUND_ACTIONS = new Set(["run", "start", "stop", "status", "open"]);
|
|
71
91
|
function parseArgs(argv) {
|
|
72
|
-
const options = {
|
|
92
|
+
const options = {
|
|
93
|
+
mode: "tui", backgroundAction: null, port: null, lan: false, noWeb: false, noAuth: false, noTray: false,
|
|
94
|
+
};
|
|
73
95
|
for (let i = 0; i < argv.length; i += 1) {
|
|
74
96
|
const arg = argv[i];
|
|
75
97
|
if (arg === "web") {
|
|
76
98
|
options.mode = "web";
|
|
77
99
|
}
|
|
100
|
+
else if (arg === "background") {
|
|
101
|
+
options.mode = "background";
|
|
102
|
+
options.backgroundAction = "status";
|
|
103
|
+
const next = argv[i + 1];
|
|
104
|
+
if (next && !next.startsWith("-")) {
|
|
105
|
+
if (!BACKGROUND_ACTIONS.has(next)) {
|
|
106
|
+
throw new Error(`unknown background action "${next}"`);
|
|
107
|
+
}
|
|
108
|
+
options.backgroundAction = next;
|
|
109
|
+
i += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
78
112
|
else if (arg === "--port" || arg === "-p") {
|
|
79
113
|
options.port = parsePort(argv[++i]);
|
|
80
114
|
}
|
|
@@ -102,6 +136,25 @@ function parseArgs(argv) {
|
|
|
102
136
|
else if (arg === "--no-auth") {
|
|
103
137
|
options.noAuth = true;
|
|
104
138
|
}
|
|
139
|
+
else if (arg === "--no-tray") {
|
|
140
|
+
options.noTray = true;
|
|
141
|
+
}
|
|
142
|
+
else if (arg === "--start") {
|
|
143
|
+
options.mode = "background";
|
|
144
|
+
options.backgroundAction = "start";
|
|
145
|
+
}
|
|
146
|
+
else if (arg === "--stop") {
|
|
147
|
+
options.mode = "background";
|
|
148
|
+
options.backgroundAction = "stop";
|
|
149
|
+
}
|
|
150
|
+
else if (arg === "--status") {
|
|
151
|
+
options.mode = "background";
|
|
152
|
+
options.backgroundAction = "status";
|
|
153
|
+
}
|
|
154
|
+
else if (arg === "--open") {
|
|
155
|
+
options.mode = "background";
|
|
156
|
+
options.backgroundAction = "open";
|
|
157
|
+
}
|
|
105
158
|
else if (arg === "--help" || arg === "-h") {
|
|
106
159
|
printHelp();
|
|
107
160
|
process.exit(0);
|
|
@@ -116,31 +169,64 @@ function parseArgs(argv) {
|
|
|
116
169
|
if (options.noWeb && options.mode === "web") {
|
|
117
170
|
throw new Error("--no-web cannot be used with the 'web' command");
|
|
118
171
|
}
|
|
172
|
+
if (options.noWeb && options.mode === "background") {
|
|
173
|
+
throw new Error("--no-web cannot be used with the 'background' command");
|
|
174
|
+
}
|
|
119
175
|
if (options.noWeb && (options.port !== null || options.host !== undefined || options.lan)) {
|
|
120
176
|
throw new Error("use either --no-web or web UI options, not both");
|
|
121
177
|
}
|
|
122
178
|
return options;
|
|
123
179
|
}
|
|
124
|
-
/** Print
|
|
125
|
-
function
|
|
126
|
-
process.stdout.write(`Neutrino
|
|
127
|
-
|
|
180
|
+
/** Print where an already-running detached console can be opened. */
|
|
181
|
+
function reportBackgroundAccess(status, started) {
|
|
182
|
+
process.stdout.write(`Neutrino ${status.resident ? "background " : ""}Web console ${started ? "started" : "already running"} `
|
|
183
|
+
+ `(pid ${status.pid}) on ${status.host}:${status.port}\n`);
|
|
184
|
+
const { urls } = webUrls(status.host, status.port);
|
|
185
|
+
const multiple = urls.length > 1;
|
|
128
186
|
process.stdout.write(multiple ? "Open the address that matches your device:\n" : "Open:\n");
|
|
129
|
-
for (const url of
|
|
187
|
+
for (const url of urls)
|
|
130
188
|
process.stdout.write(` ${url}\n`);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Bound to every interface, yet nothing outside this machine is reachable:
|
|
134
|
-
// usually Wi-Fi is off, the VPN is down, or the only adapters left are
|
|
135
|
-
// virtual ones we deliberately hide.
|
|
189
|
+
if ((status.host === "0.0.0.0" || status.host === "::")
|
|
190
|
+
&& !urls.some((url) => !url.includes("//127.0.0.1:"))) {
|
|
136
191
|
process.stdout.write("note: no reachable network address found, so only this machine can connect.\n");
|
|
137
192
|
}
|
|
138
|
-
if (
|
|
139
|
-
process.stdout.write("Sign in with your permanent Web password (default: 114514)
|
|
193
|
+
if (status.auth_required) {
|
|
194
|
+
process.stdout.write("Sign in with your permanent Web password (default: 114514).\n");
|
|
140
195
|
}
|
|
141
196
|
else {
|
|
142
197
|
process.stdout.write("Password login is disabled.\n");
|
|
143
198
|
}
|
|
199
|
+
if (status.resident) {
|
|
200
|
+
process.stdout.write("It keeps running after this terminal exits. Stop it with 'neutrino background stop'"
|
|
201
|
+
+ (process.platform === "win32" ? " or the tray icon" : "") + ".\n");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The detached service itself (`neutrino background run`).
|
|
206
|
+
*
|
|
207
|
+
* It owns the Web console and every browser conversation for as long as the
|
|
208
|
+
* machine's user session keeps it alive. The token written into the status
|
|
209
|
+
* file is the only way another local process can ask it to stop.
|
|
210
|
+
*/
|
|
211
|
+
async function runBackgroundService(host, auth) {
|
|
212
|
+
await runWebService({ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT, host, auth, tray: !options.noTray });
|
|
213
|
+
}
|
|
214
|
+
function printBackgroundStatus() {
|
|
215
|
+
const status = runningBackground();
|
|
216
|
+
if (!status) {
|
|
217
|
+
process.stdout.write("No background service is running.\n");
|
|
218
|
+
process.stdout.write("Start one with 'neutrino background start'.\n");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
process.stdout.write(`Neutrino background service: running (pid ${status.pid})\n`);
|
|
222
|
+
process.stdout.write(`Residency: ${status.resident ? "on" : "off (follows the terminal)"}\n`);
|
|
223
|
+
process.stdout.write(`Started: ${status.started_at}\n`);
|
|
224
|
+
process.stdout.write(`Address: ${status.url}\n`);
|
|
225
|
+
const { urls } = webUrls(status.host, status.port);
|
|
226
|
+
for (const url of urls) {
|
|
227
|
+
if (url.replace(/\/$/, "") !== status.url)
|
|
228
|
+
process.stdout.write(`Also: ${url}\n`);
|
|
229
|
+
}
|
|
144
230
|
}
|
|
145
231
|
let options;
|
|
146
232
|
try {
|
|
@@ -152,29 +238,82 @@ catch (err) {
|
|
|
152
238
|
process.exit(2);
|
|
153
239
|
}
|
|
154
240
|
// Installed before either mode starts: a crash must leave a usable terminal
|
|
155
|
-
// (TUI) and a log line (both), never a shell flooded with mouse reports.
|
|
156
|
-
|
|
241
|
+
// (TUI) and a log line (both), never a shell flooded with mouse reports. A
|
|
242
|
+
// detached service keeps its own SIGHUP handler so closing the terminal cannot
|
|
243
|
+
// take it down.
|
|
244
|
+
installCrashGuard({ surviveHangup: options.mode === "background" });
|
|
157
245
|
const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
|
|
158
246
|
const auth = !options.noAuth;
|
|
159
247
|
const portFromEnv = envPort();
|
|
160
248
|
if (options.mode === "web") {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
process.
|
|
168
|
-
const shutdown = () => {
|
|
169
|
-
handle.close().finally(() => process.exit(0));
|
|
170
|
-
};
|
|
171
|
-
process.on("SIGINT", shutdown);
|
|
172
|
-
process.on("SIGTERM", shutdown);
|
|
173
|
-
}
|
|
174
|
-
catch (err) {
|
|
175
|
-
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
249
|
+
const result = await startBackgroundService({
|
|
250
|
+
port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT, host, auth,
|
|
251
|
+
tray: !options.noTray, resident: loadConfig().background === true, ownerPid: process.pid,
|
|
252
|
+
strictPort: options.port !== null,
|
|
253
|
+
});
|
|
254
|
+
if (!result.ok) {
|
|
255
|
+
process.stderr.write(`neutrino: failed to start web server: ${result.error}\n`);
|
|
176
256
|
process.exit(1);
|
|
177
257
|
}
|
|
258
|
+
reportBackgroundAccess(result.status, result.started);
|
|
259
|
+
process.stdout.write("Serving saved conversations. Press Ctrl+C to close this terminal.\n");
|
|
260
|
+
// The owner monitor in the service also handles an abrupt terminal close.
|
|
261
|
+
const keepAlive = setInterval(() => { }, 60_000);
|
|
262
|
+
const shutdown = () => { clearInterval(keepAlive); process.exit(0); };
|
|
263
|
+
process.on("SIGINT", shutdown);
|
|
264
|
+
process.on("SIGTERM", shutdown);
|
|
265
|
+
}
|
|
266
|
+
else if (options.mode === "background") {
|
|
267
|
+
const action = options.backgroundAction ?? "status";
|
|
268
|
+
if (action === "run") {
|
|
269
|
+
// The detached child. It does not own a terminal, so its stdio is ignored
|
|
270
|
+
// by the parent and every diagnostic goes to background.log.
|
|
271
|
+
await runBackgroundService(host, auth);
|
|
272
|
+
}
|
|
273
|
+
else if (action === "start") {
|
|
274
|
+
const result = await startBackgroundService({
|
|
275
|
+
port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT,
|
|
276
|
+
host,
|
|
277
|
+
auth,
|
|
278
|
+
tray: !options.noTray,
|
|
279
|
+
});
|
|
280
|
+
if (!result.ok) {
|
|
281
|
+
process.stderr.write(`neutrino: ${result.error}\n`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
reportBackgroundAccess(result.status, result.started);
|
|
285
|
+
process.exitCode = 0;
|
|
286
|
+
}
|
|
287
|
+
else if (action === "stop") {
|
|
288
|
+
const result = await stopBackgroundService();
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
process.stderr.write(`neutrino: ${result.error}\n`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
process.stdout.write(result.stopped ? "Background service stopped.\n" : "No background service is running.\n");
|
|
294
|
+
process.exitCode = 0;
|
|
295
|
+
}
|
|
296
|
+
else if (action === "open") {
|
|
297
|
+
const started = await startBackgroundService({
|
|
298
|
+
port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT,
|
|
299
|
+
host,
|
|
300
|
+
auth,
|
|
301
|
+
tray: !options.noTray,
|
|
302
|
+
});
|
|
303
|
+
if (!started.ok) {
|
|
304
|
+
process.stderr.write(`neutrino: ${started.error}\n`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
if (started.started)
|
|
308
|
+
reportBackgroundAccess(started.status, true);
|
|
309
|
+
openInBrowser(started.status.url);
|
|
310
|
+
process.stdout.write(`Opening ${started.status.url}\n`);
|
|
311
|
+
process.exitCode = 0;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
printBackgroundStatus();
|
|
315
|
+
process.exitCode = 0;
|
|
316
|
+
}
|
|
178
317
|
}
|
|
179
318
|
else {
|
|
180
319
|
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
@@ -184,22 +323,20 @@ else {
|
|
|
184
323
|
}
|
|
185
324
|
const app = new App();
|
|
186
325
|
const webPort = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
app.configureWeb(webPort, host, auth);
|
|
190
|
-
// Explicit web settings win over the saved preference; otherwise the web UI
|
|
191
|
-
// is part of a normal launch unless the user turned it off in Settings.
|
|
326
|
+
const saved = loadConfig();
|
|
327
|
+
app.configureWeb(webPort, host, auth, true, !options.noTray);
|
|
192
328
|
const requested = options.port !== null || options.host !== undefined || options.lan
|
|
193
329
|
|| options.noAuth || portFromEnv !== null || Boolean(process.env.NEUTRINO_WEB_HOST);
|
|
194
|
-
|
|
195
|
-
if (!options.noWeb && (requested || enabledByDefault)) {
|
|
330
|
+
if (!options.noWeb && (requested || saved.web_console !== false)) {
|
|
196
331
|
try {
|
|
197
|
-
const
|
|
198
|
-
|
|
332
|
+
const previous = runningBackground();
|
|
333
|
+
const status = await app.enableWeb(webPort, host, auth);
|
|
334
|
+
if (options.port !== null && options.port !== 0 && status.port !== webPort) {
|
|
335
|
+
process.stderr.write(`note: the Web console is already running on port ${status.port}; --port was not applied.\n`);
|
|
336
|
+
}
|
|
337
|
+
reportBackgroundAccess(status, previous?.pid !== status.pid);
|
|
199
338
|
}
|
|
200
339
|
catch (err) {
|
|
201
|
-
// A console that cannot bind (usually a second instance on 8765) must
|
|
202
|
-
// never take the terminal down with it.
|
|
203
340
|
const detail = err instanceof Error ? err.message : String(err);
|
|
204
341
|
process.stderr.write(`neutrino: web UI not started: ${detail}\n`);
|
|
205
342
|
process.stderr.write("The terminal UI will keep running. Pass --port <port> to use another port, or --no-web to skip the web UI.\n");
|
package/dist/config.js
CHANGED
|
@@ -24,10 +24,10 @@ export const PRO_CONFIG_FILE = path.join(PRO_DIR, "config.json");
|
|
|
24
24
|
export const PRO_SESSION_DIR = path.join(PRO_DIR, "session");
|
|
25
25
|
/**
|
|
26
26
|
* Settings that intentionally apply to both workspaces. The Web password, the
|
|
27
|
-
* approval policy
|
|
28
|
-
* not of a conversation workspace.
|
|
27
|
+
* approval policy, the console's interface/LAN access and background residency
|
|
28
|
+
* are properties of this process, not of a conversation workspace.
|
|
29
29
|
*/
|
|
30
|
-
export const SHARED_CONFIG_KEYS = ["web_password_hash", "approval_mode", "auto_approve_extreme_risk", "web_lan"];
|
|
30
|
+
export const SHARED_CONFIG_KEYS = ["web_password_hash", "approval_mode", "auto_approve_extreme_risk", "web_lan", "background"];
|
|
31
31
|
export function normalizeMode(value) {
|
|
32
32
|
return typeof value === "string" && value.trim().toLowerCase() === "pro" ? "pro" : "standard";
|
|
33
33
|
}
|
|
@@ -136,6 +136,10 @@ export const DEFAULT_CONFIG = {
|
|
|
136
136
|
// Listen on every interface (the equivalent of --lan) instead of loopback.
|
|
137
137
|
// The listener belongs to the process, so both workspaces share this value.
|
|
138
138
|
web_lan: false,
|
|
139
|
+
// Keep the Web console and the conversations it runs alive after the
|
|
140
|
+
// terminal UI exits. This is a property of the machine, not of a workspace,
|
|
141
|
+
// so the regular site and Pro share it.
|
|
142
|
+
background: false,
|
|
139
143
|
// Working directories opened from the browser, newest first. They remain
|
|
140
144
|
// listed even when they hold no conversations yet.
|
|
141
145
|
web_workspaces: [],
|
package/dist/header.js
CHANGED
|
@@ -26,7 +26,8 @@ export function drawHeader(buf, state) {
|
|
|
26
26
|
if (state.webUrl) {
|
|
27
27
|
// A short reminder that the browser console is live; the startup message
|
|
28
28
|
// prints the full addresses when more than one works.
|
|
29
|
-
|
|
29
|
+
const label = state.webBackground ? "web (background)" : "web";
|
|
30
|
+
text += ` · ${label} ${state.webUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
|
|
30
31
|
}
|
|
31
32
|
text = truncateByWidth(text, w);
|
|
32
33
|
buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
|
package/dist/llm/tools.js
CHANGED
|
@@ -157,7 +157,9 @@ function runCommand(command, cwd) {
|
|
|
157
157
|
resolve("[Error: empty command]");
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
160
|
-
exec(command,
|
|
160
|
+
exec(command,
|
|
161
|
+
// A detached Windows service has no console for its tool shells to inherit.
|
|
162
|
+
{ cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4, windowsHide: true }, (error, stdout, stderr) => {
|
|
161
163
|
const parts = [];
|
|
162
164
|
if (stdout)
|
|
163
165
|
parts.push(stdout);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadAgentSession, safeAgentSessionId } from "./agents.js";
|
|
2
|
+
import { loadConfig, loadSession, normalizeApprovalMode, saveSession, } from "./config.js";
|
|
3
|
+
export class SessionMigrationError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(status, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Copy the readable conversation into a fresh native transcript, ready to resume. */
|
|
11
|
+
export function migrateAgentSession(agent, sourceId, cwd, selection, mode = "standard", proPreset) {
|
|
12
|
+
if (!safeAgentSessionId(sourceId))
|
|
13
|
+
throw new SessionMigrationError(400, "invalid session id");
|
|
14
|
+
const source = loadAgentSession(agent, sourceId, cwd);
|
|
15
|
+
if (!source)
|
|
16
|
+
throw new SessionMigrationError(404, "session not found in this working directory");
|
|
17
|
+
// Preserve the roles and text exposed by the history viewer. Resumed native
|
|
18
|
+
// sessions supply their own system prompt and model/tool configuration.
|
|
19
|
+
const messages = source.messages.map(({ role, content }) => ({ role, content }));
|
|
20
|
+
if (!messages.length)
|
|
21
|
+
throw new SessionMigrationError(400, "this session has no messages to migrate");
|
|
22
|
+
let id;
|
|
23
|
+
try {
|
|
24
|
+
id = saveSession(messages, selection.model, selection.provider, selection.intensity, source.cwd, undefined, { prompt: 0, completion: 0 }, undefined, normalizeApprovalMode(loadConfig(mode).approval_mode), undefined, mode, proPreset);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new SessionMigrationError(500, error instanceof Error ? error.message : String(error));
|
|
28
|
+
}
|
|
29
|
+
const saved = loadSession(id, mode);
|
|
30
|
+
if (!saved)
|
|
31
|
+
throw new SessionMigrationError(500, "could not read the migrated conversation");
|
|
32
|
+
return saved;
|
|
33
|
+
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { drawHeader } from "../header.js";
|
|
4
|
-
import { drawHint, drawScrollbar } from "../ui.js";
|
|
4
|
+
import { drawHint, drawScrollbar, inRect } from "../ui.js";
|
|
5
5
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
6
|
-
import { ellipsize } from "../width.js";
|
|
6
|
+
import { displayWidth, ellipsize, truncateByWidth } from "../width.js";
|
|
7
|
+
import { migrateAgentSession } from "../migration.js";
|
|
7
8
|
export class AgentConversationScreen extends Screen {
|
|
9
|
+
source;
|
|
8
10
|
title;
|
|
9
11
|
messages;
|
|
10
12
|
scrollTop = 0;
|
|
11
13
|
followBottom = true;
|
|
12
14
|
scrollbarGeom = null;
|
|
13
|
-
|
|
15
|
+
migrateButton = null;
|
|
16
|
+
migrationError = "";
|
|
17
|
+
constructor(title, messages, source) {
|
|
14
18
|
super();
|
|
19
|
+
this.source = source;
|
|
15
20
|
this.title = title;
|
|
16
21
|
this.messages = messages;
|
|
17
22
|
}
|
|
@@ -37,6 +42,10 @@ export class AgentConversationScreen extends Screen {
|
|
|
37
42
|
else if (key.name === "escape") {
|
|
38
43
|
this.app.popScreen();
|
|
39
44
|
}
|
|
45
|
+
else if (key.name === "enter" || key.name === "return" ||
|
|
46
|
+
(!key.ctrl && !key.meta && key.char.toLowerCase() === "m")) {
|
|
47
|
+
this.migrate();
|
|
48
|
+
}
|
|
40
49
|
}
|
|
41
50
|
onMouse(ev) {
|
|
42
51
|
if (ev.type === "wheel") {
|
|
@@ -52,6 +61,28 @@ export class AgentConversationScreen extends Screen {
|
|
|
52
61
|
else if (ev.type === "click" && ev.button === 2) {
|
|
53
62
|
this.app.popScreen();
|
|
54
63
|
}
|
|
64
|
+
else if (ev.type === "click" && ev.button === 0 && this.migrateButton &&
|
|
65
|
+
inRect(ev.x, ev.y, this.migrateButton)) {
|
|
66
|
+
this.migrate();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
migrate() {
|
|
70
|
+
if (!this.messages.length)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
const state = this.app.state;
|
|
74
|
+
const session = migrateAgentSession(this.source.agent, this.source.id, this.source.cwd, {
|
|
75
|
+
provider: state.selectedProvider, model: state.selectedModelName, intensity: state.selectedIntensity,
|
|
76
|
+
});
|
|
77
|
+
this.app.loadSession(session);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
this.migrationError = error instanceof Error ? error.message : String(error);
|
|
81
|
+
this.app.render();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
alwaysRepaintRows() {
|
|
85
|
+
return [this.app.height - 3, this.app.height - 2, this.app.height - 1];
|
|
55
86
|
}
|
|
56
87
|
scrollbar() {
|
|
57
88
|
const g = this.scrollbarGeom;
|
|
@@ -93,7 +124,7 @@ export class AgentConversationScreen extends Screen {
|
|
|
93
124
|
const h = buf.height;
|
|
94
125
|
buf.centerText(1, ellipsize(this.title, Math.max(10, w - 4)), { fg: C.dim });
|
|
95
126
|
const logTop = 3;
|
|
96
|
-
const logBottom = h -
|
|
127
|
+
const logBottom = h - 4;
|
|
97
128
|
const viewH = Math.max(1, logBottom - logTop + 1);
|
|
98
129
|
const lines = this.buildLines(Math.max(10, w - 4));
|
|
99
130
|
const maxScroll = Math.max(0, lines.length - viewH);
|
|
@@ -118,6 +149,18 @@ export class AgentConversationScreen extends Screen {
|
|
|
118
149
|
}
|
|
119
150
|
// Scrollbar on the right edge, visible only when the log overflows.
|
|
120
151
|
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
121
|
-
|
|
152
|
+
if (this.migrationError) {
|
|
153
|
+
buf.writeText(2, h - 3, ellipsize(`迁移失败:${this.migrationError}`, Math.max(0, w - 4)), { fg: C.red });
|
|
154
|
+
}
|
|
155
|
+
const label = truncateByWidth("[ 迁移会话 ]", Math.max(0, w - 2));
|
|
156
|
+
const buttonWidth = displayWidth(label);
|
|
157
|
+
this.migrateButton = { x: Math.max(0, w - buttonWidth - 2), y: h - 2, w: buttonWidth, h: 1 };
|
|
158
|
+
buf.writeText(this.migrateButton.x, this.migrateButton.y, label, {
|
|
159
|
+
fg: this.messages.length ? C.accent : C.dim, bg: C.highlightBg, bold: true,
|
|
160
|
+
});
|
|
161
|
+
for (let x = this.migrateButton.x; x < this.migrateButton.x + buttonWidth; x++) {
|
|
162
|
+
buf.markChrome(x, this.migrateButton.y);
|
|
163
|
+
}
|
|
164
|
+
drawHint(buf, h - 1, "↑/↓ scroll · M/Enter migrate · right-click back · Esc back");
|
|
122
165
|
}
|
|
123
166
|
}
|
|
@@ -97,8 +97,10 @@ export class AgentHistoryScreen extends Screen {
|
|
|
97
97
|
if (this.selectedIdx < 0 || this.selectedIdx >= this.sessions.length)
|
|
98
98
|
return;
|
|
99
99
|
const s = this.sessions[this.selectedIdx];
|
|
100
|
-
const messages = loadAgentMessages(this.agent, s.id);
|
|
101
|
-
this.app.pushScreen(new AgentConversationScreen(`${this.label} · ${s.title || s.id}`, messages
|
|
100
|
+
const messages = loadAgentMessages(this.agent, s.id, this.cwd);
|
|
101
|
+
this.app.pushScreen(new AgentConversationScreen(`${this.label} · ${s.title || s.id}`, messages, {
|
|
102
|
+
agent: this.agent, id: s.id, cwd: this.cwd,
|
|
103
|
+
}));
|
|
102
104
|
}
|
|
103
105
|
buildRows() {
|
|
104
106
|
const rows = [];
|
package/dist/screens/settings.js
CHANGED
|
@@ -4,8 +4,9 @@ import { CONTEXT_WINDOW_OPTIONS, DEFAULT_CONTEXT_WINDOW, contextWindow, CONFIG_F
|
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { centerVertically, drawHint } from "../ui.js";
|
|
6
6
|
import { hashWebPassword } from "../web/auth.js";
|
|
7
|
+
import { runningBackground } from "../background.js";
|
|
7
8
|
/** The settings that can be changed here, top to bottom. */
|
|
8
|
-
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "web_password"];
|
|
9
|
+
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "background", "web_password"];
|
|
9
10
|
export class SettingsScreen extends Screen {
|
|
10
11
|
intensityIdx = 2;
|
|
11
12
|
/** Levels the current chat model accepts, in canonical order. */
|
|
@@ -20,8 +21,11 @@ export class SettingsScreen extends Screen {
|
|
|
20
21
|
soundIdx = 1;
|
|
21
22
|
/** 0 = web UI off, 1 = mounted alongside the terminal. */
|
|
22
23
|
webIdx = 1;
|
|
24
|
+
/** 0 = terminal only, 1 = the Web console is run by a detached service. */
|
|
25
|
+
backgroundIdx = 0;
|
|
23
26
|
/** Only a row the user actually touched rewrites the saved default. */
|
|
24
27
|
webChanged = false;
|
|
28
|
+
backgroundPending = false;
|
|
25
29
|
models = [];
|
|
26
30
|
rowIdx = 0;
|
|
27
31
|
lastHoverX = -1;
|
|
@@ -48,6 +52,9 @@ export class SettingsScreen extends Screen {
|
|
|
48
52
|
// off, so reflect what is actually running first.
|
|
49
53
|
this.webIdx = (this.app.state.webUrl || config.web_console !== false) ? 1 : 0;
|
|
50
54
|
this.webChanged = false;
|
|
55
|
+
const background = runningBackground();
|
|
56
|
+
this.backgroundIdx = (state.webUrl ? state.webBackground : background?.resident ?? config.background === true) ? 1 : 0;
|
|
57
|
+
this.backgroundPending = false;
|
|
51
58
|
}
|
|
52
59
|
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
53
60
|
cycle(row, delta) {
|
|
@@ -81,6 +88,13 @@ export class SettingsScreen extends Screen {
|
|
|
81
88
|
void this.app.setWebConsole(this.webIdx === 1);
|
|
82
89
|
return;
|
|
83
90
|
}
|
|
91
|
+
else if (row === "background") {
|
|
92
|
+
if (this.backgroundPending)
|
|
93
|
+
return;
|
|
94
|
+
const service = runningBackground();
|
|
95
|
+
const current = this.app.state.webUrl ? this.app.state.webBackground : service?.resident ?? this.backgroundIdx === 1;
|
|
96
|
+
return this.applyBackground(!current);
|
|
97
|
+
}
|
|
84
98
|
else {
|
|
85
99
|
const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
|
|
86
100
|
const i = options.indexOf(this.contextTokens);
|
|
@@ -89,6 +103,23 @@ export class SettingsScreen extends Screen {
|
|
|
89
103
|
}
|
|
90
104
|
this.save();
|
|
91
105
|
}
|
|
106
|
+
async applyBackground(enabled) {
|
|
107
|
+
this.backgroundPending = true;
|
|
108
|
+
this.app.render();
|
|
109
|
+
try {
|
|
110
|
+
const result = await this.app.setBackgroundResidency(enabled);
|
|
111
|
+
if (result.ok)
|
|
112
|
+
this.backgroundIdx = enabled ? 1 : 0;
|
|
113
|
+
else
|
|
114
|
+
this.backgroundIdx = loadConfig().background === true ? 1 : 0;
|
|
115
|
+
if (this.app.state.webUrl)
|
|
116
|
+
this.webIdx = 1;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.backgroundPending = false;
|
|
120
|
+
this.app.render();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
92
123
|
onKey(key) {
|
|
93
124
|
if (this.editingPassword) {
|
|
94
125
|
if (key.name === "escape") {
|
|
@@ -233,9 +264,19 @@ export class SettingsScreen extends Screen {
|
|
|
233
264
|
value: this.webIdx === 0
|
|
234
265
|
? "Off"
|
|
235
266
|
: this.app.state.webUrl
|
|
236
|
-
?
|
|
267
|
+
? this.app.state.webBackground
|
|
268
|
+
? `On (background) · ${this.app.state.webUrl}`
|
|
269
|
+
: `On · ${this.app.state.webUrl}`
|
|
237
270
|
: "On (not running)",
|
|
238
271
|
},
|
|
272
|
+
{
|
|
273
|
+
label: "Background residency",
|
|
274
|
+
value: this.backgroundPending
|
|
275
|
+
? "Applying…"
|
|
276
|
+
: this.app.state.webUrl
|
|
277
|
+
? this.app.state.webBackground ? "On (running)" : "Off"
|
|
278
|
+
: runningBackground()?.resident ? "On (running)" : this.backgroundIdx === 1 ? "On (not running)" : "Off",
|
|
279
|
+
},
|
|
239
280
|
{
|
|
240
281
|
label: "Web password",
|
|
241
282
|
value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
|
package/dist/state.js
CHANGED
package/dist/terminal/restore.js
CHANGED
|
@@ -89,17 +89,7 @@ export function writeCrashLog(kind, err) {
|
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
* Installs process-wide handlers that restore the terminal before the process
|
|
94
|
-
* dies.
|
|
95
|
-
*
|
|
96
|
-
* Without this, any throw that escapes a timer, a stream callback or a floating
|
|
97
|
-
* promise kills the process with raw mode still on and the alternate screen
|
|
98
|
-
* still active. The visible result is a terminal that works but spews
|
|
99
|
-
* `[<b>;<x>;<y>M` mouse reports at the shell prompt, because the shell now
|
|
100
|
-
* receives the mouse events the TUI asked the terminal to send.
|
|
101
|
-
*/
|
|
102
|
-
export function installCrashGuard() {
|
|
92
|
+
export function installCrashGuard(options = {}) {
|
|
103
93
|
if (installed)
|
|
104
94
|
return;
|
|
105
95
|
installed = true;
|
|
@@ -123,7 +113,8 @@ export function installCrashGuard() {
|
|
|
123
113
|
});
|
|
124
114
|
// These signals otherwise bypass Node's exit hook, including Ctrl+Break on
|
|
125
115
|
// Windows. The app handles SIGINT as an interrupt and SIGTERM as a quit.
|
|
126
|
-
|
|
116
|
+
if (!options.surviveHangup)
|
|
117
|
+
process.on("SIGHUP", () => process.exit(129));
|
|
127
118
|
process.on("SIGBREAK", () => process.exit(149));
|
|
128
119
|
const fatal = (kind, err) => {
|
|
129
120
|
// Restore first: the terminal must be usable even if logging itself fails.
|