@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/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 { startWebServer } from "./web/server.js";
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 = { mode: "tui", port: null, lan: false, noWeb: false, noAuth: false };
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 every address a client can open, plus what it takes to open one. */
125
- function reportAccess(handle) {
126
- process.stdout.write(`Neutrino web UI listening on ${handle.host}:${handle.port}\n`);
127
- const multiple = handle.urls.length > 1;
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 handle.urls)
187
+ for (const url of urls)
130
188
  process.stdout.write(` ${url}\n`);
131
- const wildcard = handle.host === "0.0.0.0" || handle.host === "::";
132
- if (wildcard && !handle.urls.some((url) => !url.includes("//127.0.0.1:"))) {
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 (handle.authRequired) {
139
- process.stdout.write("Sign in with your permanent Web password (default: 114514). Change it in Settings.\n");
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
- installCrashGuard();
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
- // Headless: serve the saved history (and any live sessions in this process,
162
- // which for a standalone server is none).
163
- const port = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
164
- try {
165
- const handle = await startWebServer({ port, host, auth });
166
- reportAccess(handle);
167
- process.stdout.write("Serving saved conversations. Press Ctrl+C to stop.\n");
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
- // Remember where the UI should live even when it is off right now, so the
188
- // Settings toggle can mount it without restarting the terminal.
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
- const enabledByDefault = loadConfig().web_console !== false;
195
- if (!options.noWeb && (requested || enabledByDefault)) {
330
+ if (!options.noWeb && (requested || saved.web_console !== false)) {
196
331
  try {
197
- const handle = await app.enableWeb(webPort, host, auth);
198
- reportAccess(handle);
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 and the console's interface are properties of this process,
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
- text += ` · web ${state.webUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
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, { cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4 }, (error, stdout, stderr) => {
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
- constructor(title, messages) {
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 - 2;
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
- drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
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 = [];
@@ -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
- ? `On · ${this.app.state.webUrl}`
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
@@ -11,6 +11,7 @@ export function defaultState() {
11
11
  cursorOn: true,
12
12
  approval: null,
13
13
  webUrl: null,
14
+ webBackground: false,
14
15
  selection: null,
15
16
  };
16
17
  }
@@ -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
- process.on("SIGHUP", () => process.exit(129));
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.