@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.
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import process from "node:process";
3
+ import { backgroundLog, clearBackgroundStatus, isProcessAlive, writeBackgroundStatus, } from "../background.js";
4
+ import { loadConfig, saveConfig } from "../config.js";
5
+ import { startBackgroundTray } from "../tray.js";
6
+ import { hub } from "./hub.js";
7
+ import { startWebServer } from "./server.js";
8
+ import { TerminalBridges } from "./terminalBridge.js";
9
+ /** The detached process owns browser conversations for their entire lifetime. */
10
+ export async function runWebService(options) {
11
+ const token = process.env.NEUTRINO_BACKGROUND_TOKEN?.trim() || randomBytes(32).toString("hex");
12
+ let resident = process.env.NEUTRINO_BACKGROUND_RESIDENT !== "0";
13
+ const owner = Number(process.env.NEUTRINO_BACKGROUND_OWNER);
14
+ let owners = new Set(Number.isSafeInteger(owner) && owner > 0 ? [owner] : []);
15
+ const startedAt = new Date().toISOString();
16
+ let handle = null;
17
+ let tray = null;
18
+ let monitor = null;
19
+ let stopping = false;
20
+ const bridges = new TerminalBridges();
21
+ const status = () => {
22
+ if (!handle)
23
+ throw new Error("the Web console is starting");
24
+ return {
25
+ pid: process.pid, port: handle.port, host: handle.host, url: handle.url,
26
+ auth_required: options.auth, token, started_at: startedAt,
27
+ resident, owner_pids: [...owners],
28
+ };
29
+ };
30
+ const syncTray = () => {
31
+ if (!resident) {
32
+ tray?.close();
33
+ tray = null;
34
+ }
35
+ else if (!tray && options.tray && handle) {
36
+ tray = startBackgroundTray({ url: handle.url, stopUrl: `${handle.url}/api/background/stop`, token, pid: process.pid });
37
+ }
38
+ };
39
+ const shutdown = () => {
40
+ if (stopping)
41
+ return;
42
+ stopping = true;
43
+ if (monitor)
44
+ clearInterval(monitor);
45
+ tray?.close();
46
+ bridges.close();
47
+ backgroundLog(`stopping pid=${process.pid}`);
48
+ clearBackgroundStatus(process.pid);
49
+ const finish = () => process.exit(0);
50
+ if (handle)
51
+ handle.close().finally(finish).catch(finish);
52
+ else
53
+ finish();
54
+ setTimeout(finish, 3000).unref();
55
+ };
56
+ const configure = (update) => {
57
+ if (stopping)
58
+ throw new Error("the Web console is shutting down");
59
+ if (update.ownerPid !== undefined && !isProcessAlive(update.ownerPid)) {
60
+ throw new Error("the terminal that owns this console has already exited");
61
+ }
62
+ const previousResident = resident;
63
+ const previousOwners = owners;
64
+ const config = loadConfig();
65
+ const changed = update.resident !== undefined;
66
+ if (changed)
67
+ saveConfig({ ...config, background: update.resident });
68
+ resident = update.resident ?? resident;
69
+ owners = new Set(owners);
70
+ if (update.ownerPid !== undefined)
71
+ owners.add(update.ownerPid);
72
+ try {
73
+ writeBackgroundStatus(status());
74
+ }
75
+ catch (error) {
76
+ resident = previousResident;
77
+ owners = previousOwners;
78
+ if (changed) {
79
+ try {
80
+ saveConfig(config);
81
+ }
82
+ catch { /* Report the original failure. */ }
83
+ }
84
+ throw error;
85
+ }
86
+ if (update.bridge && update.ownerPid)
87
+ bridges.add(update.ownerPid, update.bridge);
88
+ syncTray();
89
+ return status();
90
+ };
91
+ try {
92
+ handle = await startWebServer({
93
+ port: options.port, host: options.host, auth: options.auth,
94
+ backgroundPrompt: !resident && process.env.NEUTRINO_BACKGROUND_PROMPT === "1",
95
+ backgroundService: { token, onStop: shutdown, status, configure },
96
+ onAddressChange: () => { if (handle)
97
+ writeBackgroundStatus(status()); },
98
+ });
99
+ writeBackgroundStatus(status());
100
+ }
101
+ catch (error) {
102
+ backgroundLog(`failed to start pid=${process.pid}: ${error instanceof Error ? error.message : String(error)}`);
103
+ if (handle)
104
+ await handle.close();
105
+ process.exit(1);
106
+ }
107
+ backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
108
+ syncTray();
109
+ // No helper processes: polling pids must remain invisible on Windows. An
110
+ // orphaned, non-resident console lets an in-flight browser turn finish before
111
+ // exiting, so disabling residency also cannot throw away running work.
112
+ monitor = setInterval(() => {
113
+ try {
114
+ const live = new Set([...owners].filter(isProcessAlive));
115
+ if (live.size !== owners.size) {
116
+ for (const owner of owners)
117
+ if (!live.has(owner))
118
+ bridges.remove(owner);
119
+ owners = live;
120
+ writeBackgroundStatus(status());
121
+ }
122
+ if (!resident && owners.size === 0 && !hub.list().some(entry => entry.busy || entry.paused || entry.queued || entry.compressing || entry.approval || entry.reviewing)) {
123
+ shutdown();
124
+ }
125
+ }
126
+ catch (error) {
127
+ backgroundLog(`lifetime check failed: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }, 500);
130
+ monitor.unref();
131
+ process.on("SIGINT", shutdown);
132
+ process.on("SIGTERM", shutdown);
133
+ if (process.platform !== "win32")
134
+ process.on("SIGHUP", () => { });
135
+ }
@@ -27,8 +27,6 @@ import { writeCrashLog } from "../terminal/restore.js";
27
27
  import { hub, liveApprovalDetails, } from "./hub.js";
28
28
  /** Drop a resumed conversation nobody touched for this long. */
29
29
  const IDLE_DISPOSE_MS = 30 * 60 * 1000;
30
- /** Upper bound on tool round-trips inside a single turn. */
31
- const MAX_TOOL_ROUNDS = 24;
32
30
  /** Placeholder shown while a tool is still running. */
33
31
  const TOOL_PENDING = "…";
34
32
  /** A sub-agent gets the same tools minus delegation: one level, never a tree. */
@@ -710,9 +708,12 @@ export class WebSession {
710
708
  const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
711
709
  const team = this.proTeam;
712
710
  try {
713
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
711
+ while (true) {
714
712
  if (token !== this.turnToken || this.paused)
715
713
  return false;
714
+ // Match the terminal: long tasks can keep using tools until the model
715
+ // finishes, while older tool results are trimmed near the context limit.
716
+ apiMessages = this.trimApiMessages(apiMessages);
716
717
  const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
717
718
  let text = "";
718
719
  this.streamingContent = "";
@@ -825,9 +826,6 @@ export class WebSession {
825
826
  }
826
827
  this.save();
827
828
  }
828
- if (this.mode === "pro")
829
- this.messages.push({ role: "assistant", content: "[Error: Pro main agent reached its tool round limit before completion.]" });
830
- return false;
831
829
  }
832
830
  catch (err) {
833
831
  if (token !== this.turnToken)
@@ -976,6 +974,25 @@ export class WebSession {
976
974
  }
977
975
  return contextCompressionThreshold(this.provider, this.model, this.mode);
978
976
  }
977
+ /** Trim old tool results in a long turn without changing the saved history. */
978
+ trimApiMessages(apiMessages) {
979
+ const threshold = this.compressThreshold();
980
+ if (threshold <= 0 || this.lastPromptTokens < threshold)
981
+ return apiMessages;
982
+ const toolIdx = [];
983
+ for (let i = 0; i < apiMessages.length; i++) {
984
+ if (apiMessages[i].role === "tool")
985
+ toolIdx.push(i);
986
+ }
987
+ const dropCount = toolIdx.length - COMPACT_KEEP;
988
+ if (dropCount <= 0)
989
+ return apiMessages;
990
+ const copy = apiMessages.slice();
991
+ for (let k = 0; k < dropCount; k++) {
992
+ copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
993
+ }
994
+ return copy;
995
+ }
979
996
  async autoCompress(token) {
980
997
  const threshold = this.compressThreshold();
981
998
  if (threshold <= 0 || this.lastPromptTokens < threshold)
@@ -62,6 +62,7 @@ export function getWebSettings(mode = "standard") {
62
62
  approval_mode: normalizeApprovalMode(config.approval_mode),
63
63
  web_console: config.web_console !== false,
64
64
  web_lan: config.web_lan === true,
65
+ background: config.background === true,
65
66
  },
66
67
  };
67
68
  }
@@ -150,6 +151,11 @@ export function updateWebSettings(value, mode = "standard") {
150
151
  throw new Error("LAN access must be a boolean");
151
152
  config.web_lan = val;
152
153
  }
154
+ else if (key === "background") {
155
+ if (typeof val !== "boolean")
156
+ throw new Error("background residency must be a boolean");
157
+ config.background = val;
158
+ }
153
159
  else if (key === "web_password") {
154
160
  validateWebPassword(val);
155
161
  }
@@ -0,0 +1,250 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import http from "node:http";
3
+ import { resolveModelSelection } from "../config.js";
4
+ import { hub } from "./hub.js";
5
+ function sendJson(res, status, data) {
6
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
7
+ res.end(JSON.stringify(data));
8
+ }
9
+ function authorized(req, token) {
10
+ const provided = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
11
+ const expected = Buffer.from(token);
12
+ const actual = Buffer.from(provided);
13
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
14
+ }
15
+ async function readBody(req) {
16
+ const chunks = [];
17
+ let size = 0;
18
+ for await (const chunk of req) {
19
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
20
+ size += bytes.length;
21
+ if (size > 1_000_000)
22
+ throw new Error("request too large");
23
+ chunks.push(bytes);
24
+ }
25
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
26
+ if (!value || typeof value !== "object" || Array.isArray(value))
27
+ throw new Error("invalid request");
28
+ return value;
29
+ }
30
+ /** A private loopback bridge exposes only the terminal's live hub. */
31
+ export async function startTerminalBridge() {
32
+ const token = randomBytes(32).toString("hex");
33
+ const server = http.createServer((req, res) => {
34
+ void (async () => {
35
+ if (!authorized(req, token)) {
36
+ sendJson(res, 401, { error: "terminal token required" });
37
+ return;
38
+ }
39
+ if (req.method === "GET" && req.url === "/snapshots") {
40
+ const snapshots = [];
41
+ for (const entry of hub.list()) {
42
+ const snapshot = hub.snapshot(entry.key);
43
+ if (snapshot)
44
+ snapshots.push({ mode: hub.has(entry.key, "pro") ? "pro" : "standard", snapshot });
45
+ }
46
+ sendJson(res, 200, snapshots);
47
+ return;
48
+ }
49
+ if (req.method !== "POST" || req.url !== "/action") {
50
+ sendJson(res, 404, { error: "not found" });
51
+ return;
52
+ }
53
+ const input = await readBody(req);
54
+ const key = typeof input.key === "string" ? input.key : "";
55
+ const action = input.action;
56
+ const body = input.body && typeof input.body === "object" && !Array.isArray(input.body)
57
+ ? input.body : {};
58
+ if (!hub.has(key)) {
59
+ sendJson(res, 404, { error: "unknown live session" });
60
+ return;
61
+ }
62
+ if (action === "pause")
63
+ hub.pause(key);
64
+ else if (action === "resume")
65
+ hub.resume(key);
66
+ else if (action === "message") {
67
+ if (typeof body.text !== "string" || !body.text.trim())
68
+ throw new Error("empty message");
69
+ hub.send(key, body.text);
70
+ }
71
+ else if (action === "approve") {
72
+ if (!hub.approve(key, body.approved === true)) {
73
+ sendJson(res, 409, { error: "no approval is pending" });
74
+ return;
75
+ }
76
+ }
77
+ else if (action === "approval-mode") {
78
+ if (body.mode !== "request" && body.mode !== "auto")
79
+ throw new Error("mode must be request or auto");
80
+ if (!hub.setApprovalMode(key, body.mode)) {
81
+ sendJson(res, 409, { error: "this session cannot change approval mode" });
82
+ return;
83
+ }
84
+ }
85
+ else if (action === "model") {
86
+ const result = hub.configure(key, resolveModelSelection(body));
87
+ if (!result.ok) {
88
+ sendJson(res, 409, { error: result.error });
89
+ return;
90
+ }
91
+ }
92
+ else if (action === "pro-preset") {
93
+ if (typeof body.preset !== "string")
94
+ throw new Error("invalid Pro preset");
95
+ const result = hub.setProPreset(key, body.preset);
96
+ if (!result.ok) {
97
+ sendJson(res, 409, { error: result.error });
98
+ return;
99
+ }
100
+ }
101
+ else if (action === "clear") {
102
+ if (!hub.clear(key)) {
103
+ sendJson(res, 409, { error: "this session cannot be cleared" });
104
+ return;
105
+ }
106
+ }
107
+ else {
108
+ sendJson(res, 400, { error: "this session is owned by the terminal UI" });
109
+ return;
110
+ }
111
+ sendJson(res, 200, {
112
+ ok: true, snapshot: hub.snapshot(key),
113
+ ...(action === "pause" ? { paused: true } : action === "resume" ? { paused: false } : {}),
114
+ ...(action === "approve" ? { approved: body.approved === true } : {}),
115
+ });
116
+ })().catch(error => {
117
+ if (!res.headersSent)
118
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
119
+ else
120
+ res.end();
121
+ });
122
+ });
123
+ await new Promise((resolve, reject) => {
124
+ server.once("error", reject);
125
+ server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
126
+ });
127
+ server.unref();
128
+ const address = server.address();
129
+ if (!address || typeof address === "string")
130
+ throw new Error("could not open the terminal bridge");
131
+ return { url: `http://127.0.0.1:${address.port}`, token, close: () => { server.close(); } };
132
+ }
133
+ class RemoteTerminal {
134
+ bridge;
135
+ remoteKey;
136
+ mode;
137
+ current;
138
+ key = "";
139
+ constructor(bridge, remoteKey, mode, initial) {
140
+ this.bridge = bridge;
141
+ this.remoteKey = remoteKey;
142
+ this.mode = mode;
143
+ this.current = initial;
144
+ this.key = hub.register(this);
145
+ }
146
+ liveCwd() { return this.current.cwd; }
147
+ liveMeta() {
148
+ const { key: _key, cwd: _cwd, messages: _messages, streamingContent: _streaming, context: _context, plan: _plan, ...meta } = this.current;
149
+ return meta;
150
+ }
151
+ liveSnapshot(key) { return { ...this.current, key }; }
152
+ // The public server forwards actions through remoteAction, where failures can
153
+ // be returned to the browser rather than silently lost.
154
+ livePause() { }
155
+ liveResume() { }
156
+ liveSend(_text) { }
157
+ update(snapshot) {
158
+ this.current = snapshot;
159
+ hub.notify(this.key);
160
+ }
161
+ async remoteAction(action, body) {
162
+ try {
163
+ const response = await fetch(`${this.bridge.url}/action`, {
164
+ method: "POST", headers: { Authorization: `Bearer ${this.bridge.token}`, "Content-Type": "application/json" },
165
+ body: JSON.stringify({ key: this.remoteKey, action, body }), signal: AbortSignal.timeout(5000),
166
+ });
167
+ const data = await response.json();
168
+ if (data.snapshot) {
169
+ this.update(data.snapshot);
170
+ data.snapshot = this.liveSnapshot(this.key);
171
+ }
172
+ return { status: response.status, data };
173
+ }
174
+ catch {
175
+ return { status: 503, data: { error: "the terminal session is unavailable" } };
176
+ }
177
+ }
178
+ close() { hub.unregister(this.key); }
179
+ }
180
+ /** Mirror each attached terminal's live sessions into the Web service. */
181
+ export class TerminalBridges {
182
+ entries = new Map();
183
+ timer = setInterval(() => { for (const owner of this.entries.keys())
184
+ void this.poll(owner); }, 250);
185
+ constructor() { this.timer.unref(); }
186
+ add(owner, bridge) {
187
+ this.remove(owner);
188
+ this.entries.set(owner, { bridge, sessions: new Map(), polling: false, failures: 0 });
189
+ void this.poll(owner);
190
+ }
191
+ remove(owner) {
192
+ const entry = this.entries.get(owner);
193
+ if (!entry)
194
+ return;
195
+ this.entries.delete(owner);
196
+ for (const session of entry.sessions.values())
197
+ session.close();
198
+ }
199
+ close() {
200
+ clearInterval(this.timer);
201
+ for (const owner of this.entries.keys())
202
+ this.remove(owner);
203
+ }
204
+ async poll(owner) {
205
+ const entry = this.entries.get(owner);
206
+ if (!entry || entry.polling)
207
+ return;
208
+ entry.polling = true;
209
+ try {
210
+ const response = await fetch(`${entry.bridge.url}/snapshots`, {
211
+ headers: { Authorization: `Bearer ${entry.bridge.token}` }, signal: AbortSignal.timeout(1500),
212
+ });
213
+ if (!response.ok)
214
+ throw new Error("terminal bridge failed");
215
+ const snapshots = await response.json();
216
+ if (!Array.isArray(snapshots) || this.entries.get(owner) !== entry)
217
+ return;
218
+ entry.failures = 0;
219
+ const live = new Set();
220
+ for (const { mode, snapshot } of snapshots) {
221
+ if (!snapshot || typeof snapshot.key !== "string" || (mode !== "standard" && mode !== "pro"))
222
+ continue;
223
+ live.add(snapshot.key);
224
+ const session = entry.sessions.get(snapshot.key);
225
+ if (session)
226
+ session.update(snapshot);
227
+ else
228
+ entry.sessions.set(snapshot.key, new RemoteTerminal(entry.bridge, snapshot.key, mode, snapshot));
229
+ }
230
+ for (const [key, session] of entry.sessions) {
231
+ if (!live.has(key)) {
232
+ session.close();
233
+ entry.sessions.delete(key);
234
+ }
235
+ }
236
+ }
237
+ catch {
238
+ // Keep snapshots over brief disconnects, but never expose a dead terminal
239
+ // indefinitely if its process stays alive after the bridge fails.
240
+ if (++entry.failures >= 4) {
241
+ for (const session of entry.sessions.values())
242
+ session.close();
243
+ entry.sessions.clear();
244
+ }
245
+ }
246
+ finally {
247
+ entry.polling = false;
248
+ }
249
+ }
250
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
- "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-model-intensity.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
21
+ "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
22
22
  "prepare": "npm run build",
23
23
  "web": "node dist/bin.js web"
24
24
  },
package/web/README.md CHANGED
@@ -19,11 +19,13 @@ A small, dependency-free single page app that is served by the Neutrino CLI at
19
19
  access;
20
20
  - switch the interface between English and 简体中文, instantly and persistently;
21
21
  - open the same plan / token card as the terminal's `ⓘ`, clear a conversation,
22
- delete a saved transcript, and review other agents' read-only history;
22
+ delete a saved transcript, and review or migrate other agents' history;
23
23
  - drive the running conversation: pause / resume the current turn and send new
24
24
  messages (they queue while the agent is busy);
25
25
  - pick up a saved conversation in the browser and keep chatting, even when no
26
- terminal is running.
26
+ terminal is running;
27
+ - run the console as a detached background service, so conversations started in
28
+ the browser survive closing the terminal and the CLI process.
27
29
 
28
30
  Nothing needs to be built — these are plain `.html`, `.css` and `.js` files.
29
31
 
@@ -40,6 +42,10 @@ permanently in the terminal's **Settings → Web console**. LAN access can be
40
42
  switched on and off at any time in the browser's **Settings → Web access**
41
43
  without restarting anything.
42
44
 
45
+ To keep the console alive after the terminal exits, turn on **Settings →
46
+ Background residency** in either workspace, or use `neutrino background` — see
47
+ [Background residency](#background-residency).
48
+
43
49
  Or serve only the saved history, without a terminal:
44
50
 
45
51
  ```sh
@@ -50,6 +56,44 @@ npm run web
50
56
 
51
57
  Then open <http://127.0.0.1:8765>.
52
58
 
59
+ ## Background residency
60
+
61
+ Background residency is off by default. It is a property of the process, so the
62
+ regular and Pro workspaces share it; changing it in either page (or in the
63
+ terminal's **Settings → Background residency**) applies immediately, including
64
+ to browser tasks already in progress.
65
+
66
+ With it on, closing the terminal or exiting the CLI does not interrupt browser
67
+ conversations. With it off, the console follows its attached terminals and lets
68
+ current browser tasks finish before it shuts down. The terminal's own
69
+ conversations remain local to the terminal. Terminal and standalone launches
70
+ share one Web console; a standalone launch reports a conflict if its requested
71
+ port, interface or password setting differs from the running console. A ready
72
+ service can be managed without a terminal:
73
+
74
+ ```sh
75
+ neutrino background start # detach the web console
76
+ neutrino background status # show its pid and address
77
+ neutrino background open # open it in the default browser
78
+ neutrino background stop # stop it
79
+ ```
80
+
81
+ On Windows the service adds a notification-area icon using the same orbit badge
82
+ as the regular console's top-left logo. Click it to open the console; its menu
83
+ opens or stops the service. `--no-tray` suppresses it, and `NEUTRINO_NO_TRAY=1`
84
+ does the same for automation. The service records its pid, address and a random
85
+ management token in `~/.neutrino/background.json` (mode `0600`) and appends
86
+ startup/stop diagnostics to `~/.neutrino/background.log`. The token protects
87
+ `/api/background/ping` and `/api/background/stop`; it is never sent to a
88
+ browser.
89
+
90
+ When a console is mounted by the terminal and the setting is off, the first
91
+ browser to open it is offered **建议开启后台常驻,避免误触中断任务** with a
92
+ highlighted **开启** and **取消**. Either answer consumes the invitation for
93
+ that CLI run — a reload does not ask again. **开启** applies immediately to the
94
+ current browser tasks. The invitation is never shown when residency is already
95
+ on or by a bare `neutrino web` server.
96
+
53
97
  ## Using it from another device
54
98
 
55
99
  The server binds to loopback by default. To open it from your phone or another
@@ -193,15 +237,16 @@ removed can still be continued.
193
237
  The **Settings** (gear) button in the sidebar opens the interface language,
194
238
  plan-first turns, the default approval mode, defaults for new conversations, the
195
239
  context compression threshold, the approval reviewer model, sound alerts, the
196
- extreme-risk automatic approval option, LAN access, and the Web password. Save
197
- settings explicitly; cancelling leaves the saved values intact. Conversation
198
- model changes are separate from these defaults. Models come from the same
199
- catalog as the terminal, and API keys stay on the server. Configure missing keys
200
- in the terminal model screen first. Intensity uses the same Low–Ultra sampling
201
- settings as the terminal, and both only offer the levels the selected model
202
- declares in `model.json`. The regular and [Pro](#pro-workspace) workspaces keep
203
- their own copy of every setting here except the approval policy, the Web password
204
- and LAN access, which belong to the whole process.
240
+ extreme-risk automatic approval option, background residency, LAN access, and
241
+ the Web password. Save settings explicitly; cancelling leaves the saved values
242
+ intact. Conversation model changes are separate from these defaults. Models come
243
+ from the same catalog as the terminal, and API keys stay on the server. Configure
244
+ missing keys in the terminal model screen first. Intensity uses the same
245
+ Low–Ultra sampling settings as the terminal, and both only offer the levels the
246
+ selected model declares in `model.json`. The regular and [Pro](#pro-workspace)
247
+ workspaces keep their own copy of every setting here except the approval policy,
248
+ the Web password, LAN access and background residency, which belong to the whole
249
+ process.
205
250
 
206
251
  ## New conversations and working directories
207
252
 
@@ -272,6 +317,14 @@ the complete transcript in the conversation pane with a `read-only` badge. The
272
317
  sources are the same as the terminal's history browser; OpenCode requires Node.js
273
318
  22.13+ because it reads its SQLite database through `node:sqlite`.
274
319
 
320
+ Click **Migrate session** at the bottom right of an opened transcript to convert
321
+ its readable user/assistant text into a new, saved Neutrino conversation. The
322
+ copy opens immediately in the same working directory, using the current
323
+ Neutrino model (or the current Pro preset on `/pro`). The source is unchanged.
324
+ Foreign system prompts, tool records and non-text attachments are not imported.
325
+ The button is disabled while copying; a failed migration leaves the source open
326
+ with an error so you can retry.
327
+
275
328
  ## Plan-first turns
276
329
 
277
330
  A browser-owned turn is planned before it runs, exactly like a turn in the
@@ -328,13 +381,17 @@ to know whether a conversation is driven by the terminal or by the server.
328
381
  | GET | `/api/fs?path=…` | Folder listing for the picker (folders only, roots, parent) |
329
382
  | POST | `/api/fs/mkdir` | Create `{ "path": "…" }` and return the new path |
330
383
  | POST | `/api/conversations` | Start `{ cwd, selection?, approval_mode? }`, returns `{ key, snapshot }` |
331
- | GET | `/api/settings` | Model catalog (each model lists its supported intensities), languages, public settings, and the live bind address (`web_host`, `web_urls`); no credentials |
332
- | POST | `/api/settings` | Save validated `{ selection?, settings? }`, including `language`, `plan_first`, `approval_mode`, `web_lan` and `web_password` |
384
+ | GET | `/api/settings` | Model catalog (each model lists its supported intensities), languages, public settings, the live bind address (`web_host`, `web_urls`) and the background state (`background`, `background_running`, `background_url`, one-time `background_prompt`); no credentials |
385
+ | POST | `/api/settings` | Save validated `{ selection?, settings? }`, including `language`, `plan_first`, `approval_mode`, `web_lan`, `background` and `web_password` |
386
+ | POST | `/api/background/dismiss` | Consume the one-time background-residency invitation for this console run |
387
+ | GET | `/api/background/ping` | Management probe of a detached service; requires `Authorization: Bearer <background.json token>` |
388
+ | POST | `/api/background/stop` | Ask a detached service to shut down; same bearer token |
333
389
  | GET | `/api/session/:id` | One saved transcript |
334
390
  | POST | `/api/session/:id/continue` | Resume a transcript with optional `{ selection }`, returns `{ key }` |
335
391
  | POST | `/api/session/:id/delete` | Delete a saved, non-running transcript |
336
392
  | GET | `/api/agents/:agent/sessions?cwd=…` | Read-only sessions from Claude Code / Codex / OpenCode |
337
393
  | GET | `/api/agents/:agent/sessions/:id` | Read-only messages of one other-agent session |
394
+ | POST | `/api/agents/:agent/sessions/:id/migrate` | Copy a source in `cwd` into a saved native conversation and return its live key; accepts optional `selection` or Pro `pro_preset` |
338
395
  | GET | `/api/live/:key` | Snapshot of a live session |
339
396
  | GET | `/api/live/:key/stream` | SSE stream of a live session |
340
397
  | POST | `/api/live/:key/pause` | Pause the running turn |
@@ -351,7 +408,10 @@ to know whether a conversation is driven by the terminal or by the server.
351
408
 
352
409
  Conversation and settings routes require the login cookie; see
353
410
  [Authentication](#authentication). `OPTIONS` is answered unauthenticated so
354
- preflights are not blocked, and it returns no data.
411
+ preflights are not blocked, and it returns no data. The `/api/background/*`
412
+ management routes are the exception: they exist only in a detached service and
413
+ authenticate with the bearer token from `background.json`, because the CLI and
414
+ the tray icon have no browser cookie.
355
415
 
356
416
  ### Approval requests
357
417