@xingjisoft/neutrino 0.4.1 → 0.5.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/ui.js CHANGED
@@ -295,35 +295,51 @@ export function drawTabButton(buf, x, y, w, label, selected, align = "center") {
295
295
  }
296
296
  /** Label of the CLI entry point that opens the browser console's Pro workspace. */
297
297
  export const PRO_MODE_LABEL = "启用Pro模式";
298
+ /** Label of the CLI entry point that opens the browser console itself. */
299
+ export const WEB_MODE_LABEL = "打开Web端";
298
300
  /**
299
- * Geometry of the Pro-mode button: a small box in the top-left corner, directly
300
- * below the status bar (row 0). Rendering and hit testing share it so a click
301
- * always lands on the box that is drawn.
301
+ * Geometry of a box in the top-left corner, directly below the status bar
302
+ * (row 0), starting at column `x`. Rendering and hit testing share it so a
303
+ * click always lands on the box that is drawn.
302
304
  *
303
305
  * Returns null when the terminal is too small to fit the box with a little
304
- * breathing room around it. The height floor also keeps the start screen's
305
- * centered block (which is pushed below the box) clear of the approval toggle
306
- * on very short terminals, where the original layout is preserved instead.
306
+ * breathing room to its right. The height floor also keeps the start screen's
307
+ * centered block (which is pushed below the boxes) clear of the approval
308
+ * toggle on very short terminals, where the original layout is preserved.
307
309
  */
308
- export function proModeButton(width, height) {
309
- // One blank column at the very edge, then the border: "flush to the edge
310
- // with a single space", as the start screen expects.
311
- const x = 1;
310
+ function headerButton(width, height, x, label) {
312
311
  const y = 1;
313
312
  const h = 3;
314
313
  // Two cells of padding, one inside each border.
315
- const w = displayWidth(PRO_MODE_LABEL) + 4;
316
- if (width < w + 4 || height < 22)
314
+ const w = displayWidth(label) + 4;
315
+ if (width < x + w + 3 || height < 22)
316
+ return null;
317
+ return { x, y, w, h, label };
318
+ }
319
+ /**
320
+ * The Pro-mode button: flush to the left edge with a single blank column, as
321
+ * the start screen expects.
322
+ */
323
+ export function proModeButton(width, height) {
324
+ return headerButton(width, height, 1, PRO_MODE_LABEL);
325
+ }
326
+ /**
327
+ * The Web-mode button: glued to the right border of the Pro-mode button so the
328
+ * two read as one row of entry points. It starts where the Pro box ends, which
329
+ * also means it is hidden together with it on terminals too small for the row.
330
+ */
331
+ export function webModeButton(width, height) {
332
+ const pro = proModeButton(width, height);
333
+ if (!pro)
317
334
  return null;
318
- return { x, y, w, h, label: PRO_MODE_LABEL };
335
+ return headerButton(width, height, pro.x + pro.w, WEB_MODE_LABEL);
319
336
  }
320
337
  /**
321
- * Draw the Pro-mode button. It is painted as opaque chrome so content behind it
338
+ * Draw one top-left button. It is painted as opaque chrome so content behind it
322
339
  * can never bleed through; the label row turns into the highlight background
323
340
  * while the pointer is over it.
324
341
  */
325
- export function drawProModeButton(buf, hovered = false) {
326
- const button = proModeButton(buf.width, buf.height);
342
+ export function drawHeaderButton(buf, button, hovered = false) {
327
343
  if (!button)
328
344
  return;
329
345
  const { x, y, w, h, label } = button;
package/dist/update.js ADDED
@@ -0,0 +1,213 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ import { loadVersion } from "./config.js";
4
+ /** The published package whose registry version decides whether an update exists. */
5
+ export const UPDATE_PACKAGE = "@xingjisoft/neutrino";
6
+ /** What a user can run by hand when the automatic update fails. */
7
+ export const MANUAL_UPDATE_COMMAND = `npm install -g ${UPDATE_PACKAGE}`;
8
+ const LOOKUP_TIMEOUT_MS = 20_000;
9
+ const INSTALL_TIMEOUT_MS = 300_000;
10
+ let published = "";
11
+ let pending = null;
12
+ let installing = null;
13
+ /**
14
+ * The last known answer, for the terminal screen and the Web console payload.
15
+ * Reading this never touches the network: the lookup runs once per process (see
16
+ * checkForUpdate) and costs nothing afterwards.
17
+ */
18
+ export function updateStatus() {
19
+ const currentVersion = loadVersion();
20
+ return {
21
+ currentVersion,
22
+ latestVersion: published,
23
+ hasUpdate: isNewerVersion(published, currentVersion),
24
+ };
25
+ }
26
+ /**
27
+ * Ask npm which version is published and remember it. This never rejects: an
28
+ * offline machine, a missing npm or a registry hiccup only means "no update to
29
+ * announce", which is what the next start is for. Concurrent callers share the
30
+ * one lookup, and a successful answer is kept for the rest of the process so
31
+ * the terminal and the Web console cannot disagree.
32
+ */
33
+ export function checkForUpdate() {
34
+ if (published)
35
+ return Promise.resolve(updateStatus());
36
+ pending ??= lookupLatestVersion()
37
+ .then((version) => {
38
+ published = version;
39
+ return updateStatus();
40
+ })
41
+ .catch((error) => ({
42
+ ...updateStatus(),
43
+ error: error instanceof Error ? error.message : String(error),
44
+ }))
45
+ .finally(() => { pending = null; });
46
+ return pending;
47
+ }
48
+ /**
49
+ * Install the latest published release with npm.
50
+ *
51
+ * The terminal and both Web workspaces can ask for this, so requests share one
52
+ * install instead of running npm twice over the same files. It never rejects:
53
+ * whoever asked gets a result and, on failure, the command to run by hand.
54
+ *
55
+ * The version this process runs is not replaced — the running code stays on
56
+ * disk until the user restarts — so callers must tell the user to restart.
57
+ */
58
+ export function installUpdate() {
59
+ installing ??= runNpm(["install", "-g", UPDATE_PACKAGE], INSTALL_TIMEOUT_MS)
60
+ .then((run) => npmResult(run, `install -g ${UPDATE_PACKAGE}`))
61
+ .catch((error) => ({
62
+ ok: false,
63
+ version: updateStatus().latestVersion,
64
+ error: error instanceof Error ? error.message : String(error),
65
+ }))
66
+ .finally(() => { installing = null; });
67
+ return installing;
68
+ }
69
+ /** `npm view <package> version`, with a deadline so a hung npm cannot hold a start-up. */
70
+ async function lookupLatestVersion() {
71
+ const run = await runNpm(["view", UPDATE_PACKAGE, "version"], LOOKUP_TIMEOUT_MS);
72
+ const version = firstVersionLine(run.stdout);
73
+ if (version)
74
+ return version;
75
+ throw new Error(npmFailure(run, `view ${UPDATE_PACKAGE} version`));
76
+ }
77
+ /** Turn one finished npm run into the answer callers see. */
78
+ function npmResult(run, what) {
79
+ const version = updateStatus().latestVersion;
80
+ return run.code === 0
81
+ ? { ok: true, version }
82
+ : { ok: false, version, error: npmFailure(run, what) };
83
+ }
84
+ /** The last line npm printed is the one that says what went wrong. */
85
+ function npmFailure(run, what) {
86
+ const reason = run.stderr.trim().split("\n").pop()?.trim();
87
+ return `npm ${what} failed${run.code === null ? " (timed out)" : run.code === 0 ? "" : ` with code ${run.code}`}`
88
+ + (reason ? `: ${reason}` : "");
89
+ }
90
+ /**
91
+ * Run npm and collect a bounded amount of output. Rejects only when npm cannot
92
+ * be started at all or does not finish in time; a failing npm resolves with its
93
+ * exit code so the caller decides what it means.
94
+ */
95
+ function runNpm(args, timeoutMs) {
96
+ return new Promise((resolve, reject) => {
97
+ let child;
98
+ try {
99
+ // On Windows npm is a .cmd shim, which Node only starts through a shell;
100
+ // the command is built from fixed literals, so nothing needs quoting.
101
+ const windows = process.platform === "win32";
102
+ child = spawn(windows ? process.env.ComSpec || "cmd.exe" : "npm", windows ? ["/d", "/s", "/c", `npm ${args.join(" ")}`] : args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
103
+ }
104
+ catch (error) {
105
+ reject(new Error(`could not run npm: ${error instanceof Error ? error.message : String(error)}`));
106
+ return;
107
+ }
108
+ let stdout = "";
109
+ let stderr = "";
110
+ let settled = false;
111
+ const finish = (error, run) => {
112
+ if (settled)
113
+ return;
114
+ settled = true;
115
+ clearTimeout(timer);
116
+ if (error)
117
+ reject(error);
118
+ else
119
+ resolve(run);
120
+ };
121
+ const timer = setTimeout(() => {
122
+ // Stop waiting on a hung npm: its output is discarded either way.
123
+ child.kill();
124
+ finish(new Error(`npm ${args.join(" ")} timed out after ${timeoutMs / 1000}s`));
125
+ }, timeoutMs);
126
+ timer.unref();
127
+ child.stdout?.setEncoding("utf8");
128
+ child.stderr?.setEncoding("utf8");
129
+ child.stdout?.on("data", (chunk) => {
130
+ if (stdout.length < 8192)
131
+ stdout += chunk;
132
+ });
133
+ child.stderr?.on("data", (chunk) => {
134
+ if (stderr.length < 8192)
135
+ stderr += chunk;
136
+ });
137
+ // A missing npm is reported asynchronously; without this listener the
138
+ // 'error' event would crash the whole process.
139
+ child.on("error", (error) => finish(new Error(`could not run npm: ${error.message}`)));
140
+ child.on("close", (code) => finish(null, { code, stdout, stderr }));
141
+ });
142
+ }
143
+ /** npm prints the version alone, but warnings on stdout must not be mistaken for one. */
144
+ function firstVersionLine(text) {
145
+ for (const line of text.split(/\r?\n/)) {
146
+ const value = line.trim();
147
+ if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(value))
148
+ return value;
149
+ }
150
+ return "";
151
+ }
152
+ function parseVersion(value) {
153
+ const text = value.trim().replace(/^v/i, "").split("+")[0];
154
+ if (!text)
155
+ return null;
156
+ const dash = text.indexOf("-");
157
+ const core = (dash === -1 ? text : text.slice(0, dash)).split(".");
158
+ if (!core.length || core.some((part) => !/^\d+$/.test(part)))
159
+ return null;
160
+ const prerelease = dash === -1 ? null : text.slice(dash + 1).split(".");
161
+ if (prerelease && prerelease.some((part) => part === ""))
162
+ return null;
163
+ return { core: core.map(Number), prerelease };
164
+ }
165
+ /**
166
+ * Semantic-version order: returns 1 when `left` is newer, -1 when older and 0
167
+ * when they are the same release. A version that cannot be parsed compares as
168
+ * equal, so a malformed registry answer can never claim an update is due.
169
+ */
170
+ export function compareVersions(left, right) {
171
+ const a = parseVersion(left);
172
+ const b = parseVersion(right);
173
+ if (!a || !b)
174
+ return 0;
175
+ const length = Math.max(a.core.length, b.core.length);
176
+ for (let i = 0; i < length; i++) {
177
+ const difference = (a.core[i] ?? 0) - (b.core[i] ?? 0);
178
+ if (difference !== 0)
179
+ return difference > 0 ? 1 : -1;
180
+ }
181
+ // Numeric identifiers compare numerically and rank below alphanumeric ones.
182
+ const compareIdentifiers = (one, other) => {
183
+ const numeric = /^\d+$/.test(one);
184
+ const otherNumeric = /^\d+$/.test(other);
185
+ if (numeric && otherNumeric)
186
+ return Math.sign(Number(one) - Number(other));
187
+ if (numeric !== otherNumeric)
188
+ return numeric ? -1 : 1;
189
+ return one < other ? -1 : one > other ? 1 : 0;
190
+ };
191
+ if (!a.prerelease && !b.prerelease)
192
+ return 0;
193
+ if (!a.prerelease)
194
+ return 1;
195
+ if (!b.prerelease)
196
+ return -1;
197
+ for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
198
+ const one = a.prerelease[i];
199
+ const other = b.prerelease[i];
200
+ if (one === undefined)
201
+ return -1;
202
+ if (other === undefined)
203
+ return 1;
204
+ const difference = compareIdentifiers(one, other);
205
+ if (difference !== 0)
206
+ return difference;
207
+ }
208
+ return 0;
209
+ }
210
+ /** Whether `candidate` is a strictly newer release than `current`. */
211
+ export function isNewerVersion(candidate, current) {
212
+ return compareVersions(candidate, current) > 0;
213
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * State the browser console keeps about itself.
3
+ *
4
+ * The sidebar's blue dot asks for attention once: it marks a conversation that
5
+ * finished after the reader last opened it. Remembering that answer is neither a
6
+ * conversation setting (config.json is read by the terminal UI on every tool
7
+ * call) nor part of a transcript, so the console owns a file of its own:
8
+ *
9
+ * ~/.neutrino/web.json
10
+ * { "seen": { "standard": ["id:2025-01-02T03-04-05-678Z", "live:L3"], "pro": [] } }
11
+ *
12
+ * Both workspaces keep their own record, because each has its own sidebar. The
13
+ * file is a convenience: a missing, unreadable or hand-mangled one costs a dot,
14
+ * never a request.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { NEUTRINO_DIR, ensureDirs } from "../config.js";
19
+ export const WEB_STATE_FILE = path.join(NEUTRINO_DIR, "web.json");
20
+ /**
21
+ * Identities are `id:<session id>` for a conversation with history and
22
+ * `live:<key>` for one that has nothing saved yet (see `sessionIdentity` in
23
+ * web/app.js). Anything else is refused, so a crafted request body cannot grow
24
+ * the file without bound.
25
+ */
26
+ const IDENTITY_RE = /^(?:id|live):[A-Za-z0-9._-]{1,200}$/;
27
+ /** How many finished conversations one workspace remembers, newest last. */
28
+ export const SEEN_LIMIT = 300;
29
+ /** Whether a value from a request is an identity this file can hold. */
30
+ export function isSeenIdentity(value) {
31
+ return typeof value === "string" && IDENTITY_RE.test(value);
32
+ }
33
+ /** The whole file, never throwing: an unreadable record simply holds nothing. */
34
+ function readState() {
35
+ try {
36
+ const parsed = JSON.parse(fs.readFileSync(WEB_STATE_FILE, "utf-8"));
37
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
38
+ ? parsed
39
+ : {};
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ }
45
+ /** The `seen` map of the file, or an empty one when it is missing or malformed. */
46
+ function seenState(state) {
47
+ const seen = state.seen;
48
+ return seen && typeof seen === "object" && !Array.isArray(seen)
49
+ ? seen
50
+ : {};
51
+ }
52
+ /** Identities of one workspace, oldest first, with unusable entries dropped. */
53
+ function workspaceSeen(state, mode) {
54
+ const list = seenState(state)[mode];
55
+ if (!Array.isArray(list))
56
+ return [];
57
+ return list.filter(isSeenIdentity).slice(-SEEN_LIMIT);
58
+ }
59
+ /** The conversations this workspace has already shown the reader, oldest first. */
60
+ export function loadSeen(mode = "standard") {
61
+ return workspaceSeen(readState(), mode);
62
+ }
63
+ /**
64
+ * Record (`seen: true`) or forget (`seen: false`) the given conversations and
65
+ * write the file back. Returns the workspace's new record.
66
+ *
67
+ * Identities that are not valid are ignored rather than rejected: the route
68
+ * already validated them, so this is only a guard on what reaches the disk.
69
+ */
70
+ export function updateSeen(mode, identities, seen) {
71
+ const wanted = new Set(identities.filter(isSeenIdentity));
72
+ if (wanted.size === 0)
73
+ return loadSeen(mode);
74
+ const state = readState();
75
+ // A recorded conversation moves to the end, so trimming keeps the newest.
76
+ const kept = workspaceSeen(state, mode).filter((identity) => !wanted.has(identity));
77
+ // Trim after the merge: recording a new conversation can push the oldest out.
78
+ const next = (seen ? [...kept, ...wanted] : kept).slice(-SEEN_LIMIT);
79
+ ensureDirs();
80
+ fs.writeFileSync(WEB_STATE_FILE, JSON.stringify({ ...state, seen: { ...seenState(state), [mode]: next } }, null, 2), "utf-8");
81
+ return next;
82
+ }
@@ -19,15 +19,17 @@ import { timingSafeEqual } from "node:crypto";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
21
21
  import { getWebSettings, updateWebSettings } from "./settings.js";
22
+ import { isSeenIdentity, loadSeen, updateSeen, SEEN_LIMIT } from "./clientState.js";
22
23
  import { WebAuth } from "./auth.js";
23
24
  import { normalizeCwd, sameCwd } from "../cwd.js";
24
25
  import { hub } from "./hub.js";
25
26
  import { writeCrashLog } from "../terminal/restore.js";
26
27
  import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
27
- import { defaultProPreset, requireProPreset } from "../pro/config.js";
28
+ import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/config.js";
28
29
  import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
29
30
  import { migrateAgentSession, SessionMigrationError } from "../migration.js";
30
31
  import { runningBackground } from "../background.js";
32
+ import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
31
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
34
  // dist/web -> package root (mirrors the same trick used in config.ts).
33
35
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -405,10 +407,15 @@ function buildOverview(mode = "standard") {
405
407
  seen.add(session.id);
406
408
  }
407
409
  // Live conversations that have not been saved yet (or whose newest save has
408
- // not landed) still deserve a spot at the top of their directory group.
410
+ // not landed) still deserve a spot at the top of their directory group. A
411
+ // conversation nobody has said anything in yet is not history: it stays out
412
+ // of the list (but in `live`, so the browser keeps its stream) until it has
413
+ // content.
409
414
  for (const live of lives) {
410
415
  if (live.sessionId && seen.has(live.sessionId))
411
416
  continue;
417
+ if (live.empty)
418
+ continue;
412
419
  const key = normalizeCwd(live.cwd) || "(unknown)";
413
420
  let group = groups.get(key);
414
421
  if (!group) {
@@ -600,6 +607,10 @@ function streamLive(key, req, res) {
600
607
  async function handleApi(req, res, url, auth, mode, pathname, binding) {
601
608
  if (!pathname.startsWith("/api/"))
602
609
  return false;
610
+ // The Pro presets are not bundled either: the first Pro request makes sure they
611
+ // are on disk. When the download fails the route below reports the missing file.
612
+ if (mode === "pro")
613
+ await ensureProModels().catch(() => undefined);
603
614
  if (req.method === "OPTIONS") {
604
615
  // Same-origin only: no CORS headers, so cross-site preflights are rejected.
605
616
  // Answered before the login check because it reveals nothing.
@@ -737,6 +748,33 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
737
748
  }
738
749
  return true;
739
750
  }
751
+ // The update prompt's confirm button: npm runs on this machine, so the route
752
+ // sits behind the login cookie and the same-origin check like every other
753
+ // mutation, and only a release the registry announced can be installed.
754
+ if (pathname === "/api/update") {
755
+ if (req.method !== "POST") {
756
+ sendJson(res, 405, { error: "method not allowed" });
757
+ return true;
758
+ }
759
+ if (!isSameOrigin(req)) {
760
+ sendJson(res, 403, { error: "cross-origin request rejected" });
761
+ return true;
762
+ }
763
+ if (!updateStatus().hasUpdate) {
764
+ sendJson(res, 409, { error: "No newer version is published." });
765
+ return true;
766
+ }
767
+ // Never rejects: a failure comes back as a result, carrying the command the
768
+ // browser shows so the user can finish the update by hand.
769
+ const result = await installUpdate();
770
+ sendJson(res, result.ok ? 200 : 500, {
771
+ ok: result.ok,
772
+ version: result.version,
773
+ error: result.error ?? "",
774
+ command: MANUAL_UPDATE_COMMAND,
775
+ });
776
+ return true;
777
+ }
740
778
  if (req.method === "GET" && pathname === "/api/health") {
741
779
  sendJson(res, 200, { ok: true, live: hub.list(mode).length });
742
780
  return true;
@@ -745,6 +783,53 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
745
783
  sendJson(res, 200, buildOverview(mode));
746
784
  return true;
747
785
  }
786
+ // The sidebar's blue dot is the browser's own memory of what it has read. It
787
+ // belongs to the console rather than to a conversation, so it is stored in
788
+ // ~/.neutrino/web.json (see clientState.ts) and never in the config or the
789
+ // transcript. Each workspace keeps its own record.
790
+ if (pathname === "/api/seen") {
791
+ if (req.method === "GET") {
792
+ sendJson(res, 200, { seen: loadSeen(mode) });
793
+ return true;
794
+ }
795
+ if (req.method === "POST") {
796
+ if (!isSameOrigin(req)) {
797
+ sendJson(res, 403, { error: "cross-origin request rejected" });
798
+ return true;
799
+ }
800
+ try {
801
+ const body = await readJsonBody(req, 64 * 1024);
802
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
803
+ throw new ApiError(400, "invalid request", "invalid request");
804
+ }
805
+ const input = body;
806
+ if (Object.keys(body).some((key) => key !== "identities" && key !== "seen")) {
807
+ throw new ApiError(400, "unknown field", "unknown field");
808
+ }
809
+ const identities = input.identities;
810
+ if (!Array.isArray(identities) || identities.length === 0 || identities.length > SEEN_LIMIT) {
811
+ throw new ApiError(400, `identities must be a list of 1 to ${SEEN_LIMIT}`, "invalid identities");
812
+ }
813
+ if (!identities.every(isSeenIdentity)) {
814
+ throw new ApiError(400, "invalid conversation id", "invalid conversation id");
815
+ }
816
+ if (typeof input.seen !== "boolean") {
817
+ throw new ApiError(400, "seen must be a boolean", "seen must be a boolean");
818
+ }
819
+ sendJson(res, 200, { ok: true, seen: updateSeen(mode, identities, input.seen) });
820
+ }
821
+ catch (err) {
822
+ // A rejected body is the caller's fault; a file that cannot be written
823
+ // is ours, and must not read as a malformed request.
824
+ sendJson(res, err instanceof ApiError ? err.status : 500, {
825
+ error: err instanceof Error ? err.message : "invalid request",
826
+ });
827
+ }
828
+ return true;
829
+ }
830
+ sendJson(res, 405, { error: "method not allowed" });
831
+ return true;
832
+ }
748
833
  if (req.method === "GET" && pathname === "/api/sessions") {
749
834
  sendJson(res, 200, { sessions: listSessions(mode) });
750
835
  return true;
@@ -3,6 +3,8 @@ import process from "node:process";
3
3
  import { backgroundLog, clearBackgroundStatus, isProcessAlive, writeBackgroundStatus, } from "../background.js";
4
4
  import { loadConfig, saveConfig } from "../config.js";
5
5
  import { startBackgroundTray } from "../tray.js";
6
+ import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
7
+ import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
6
8
  import { hub } from "./hub.js";
7
9
  import { startWebServer } from "./server.js";
8
10
  import { TerminalBridges } from "./terminalBridge.js";
@@ -88,6 +90,29 @@ export async function runWebService(options) {
88
90
  syncTray();
89
91
  return status();
90
92
  };
93
+ // Nothing ships with the package any more: reconcile the presets with the
94
+ // official copy now, so the first browser to open /pro already finds them and
95
+ // an outdated local set is replaced. A failure is logged and reported there,
96
+ // so this never holds up the console itself.
97
+ void syncProModels().then((result) => {
98
+ if (result.status === "updated")
99
+ backgroundLog(`pro presets: updated from ${PRO_MODELS_URL}`);
100
+ else if (result.status === "kept")
101
+ backgroundLog(`pro presets: kept the local copy (${result.detail})`);
102
+ }).catch((error) => {
103
+ backgroundLog(`pro presets: ${error instanceof Error ? error.message : String(error)}`);
104
+ });
105
+ // The registry lookup runs alongside the listener so a slow or unreachable
106
+ // registry never delays the console; the pages read the answer from the
107
+ // settings payload. Failures are logged, as with the presets above.
108
+ void checkForUpdate().then((result) => {
109
+ if (result.hasUpdate) {
110
+ backgroundLog(`${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`);
111
+ }
112
+ else if (result.error) {
113
+ backgroundLog(`update check: ${result.error}`);
114
+ }
115
+ });
91
116
  try {
92
117
  handle = await startWebServer({
93
118
  port: options.port, host: options.host, auth: options.auth,
@@ -262,6 +262,7 @@ export class WebSession {
262
262
  } } : {}),
263
263
  sessionId: this.sessionId,
264
264
  title: this.title(),
265
+ empty: this.messages.length === 0 && !this.streamingContent,
265
266
  provider: this.provider,
266
267
  model: this.model.name,
267
268
  intensity: this.intensity,
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, intensityForModel, isContextWindow, loadConfig, loadModels, modelContextWindow, modelContextWindowKey, modelIntensities, normalizeApprovalMode, normalizeIntensity, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
2
2
  import { hashWebPassword, validateWebPassword } from "./auth.js";
3
3
  import { defaultProPreset, loadProModels, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
4
+ import { updateStatus } from "../update.js";
4
5
  /**
5
6
  * Public settings use a whitelist: API keys and cached credentials stay on disk.
6
7
  *
@@ -31,7 +32,15 @@ export function getWebSettings(mode = "standard") {
31
32
  ? catalog.providers.find((p) => p.name === provider.name)?.models.find((m) => m.name === model.name) ?? null
32
33
  : null;
33
34
  const intensity = intensityForModel(configuredModel, config.selected_intensity ?? config.default_intensity, config.default_intensity);
35
+ // Read-only runtime state for the update notice: which version runs, which
36
+ // one is published, and whether the two differ. Never persisted.
37
+ const update = updateStatus();
34
38
  return {
39
+ update: {
40
+ current_version: update.currentVersion,
41
+ latest_version: update.latestVersion,
42
+ has_update: update.hasUpdate,
43
+ },
35
44
  ...(mode === "pro" ? { pro: {
36
45
  presets: loadProModels().tier.map((preset) => ({
37
46
  name: preset.name, chinese_name: preset.chinese_name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.4.1",
3
+ "version": "0.5.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-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",
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-mobile-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-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.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-web-sidebar-status.mjs && node scripts/test-web-seen.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
@@ -116,6 +116,11 @@ in-page switch takes over from there for the running process.
116
116
  The console can read every transcript and drive an agent with shell access, so
117
117
  keep the Web password on: anyone who can reach the port can use it.
118
118
 
119
+ On a narrow screen the console shows one pane at a time: the conversation list,
120
+ and then the open conversation. The back arrow in the header and the browser's
121
+ own Back button both return to the list, and Forward reopens the conversation
122
+ that was open.
123
+
119
124
  Startup prints the addresses to open:
120
125
 
121
126
  ```
@@ -223,16 +228,15 @@ login/logout routes are shared and stay unprefixed.
223
228
 
224
229
  ## Models and settings
225
230
 
226
- Open a conversation to see the **Provider**, **Model** and **Thinking**
227
- selectors. Choose a configured model and press **Apply to conversation**. The
228
- next turn uses that selection, and it is saved with the transcript. The same
229
- controls also update a conversation running in the terminal. Pause an active
230
- turn before applying a change; a running turn returns a clear conflict message.
231
+ Open the model card at the lower right of the composer to choose a **Provider**
232
+ and **Model**, and adjust **Thinking intensity** with the slider. Changes to an
233
+ idle or paused conversation apply immediately to its next turn and are saved
234
+ with the transcript. The same controls update a conversation running in the
235
+ terminal. Pause an active turn before changing its model.
231
236
 
232
- For saved history, **Continue with model** resumes the transcript with the chosen
233
- selection. Changing the selectors and then using **Continue here** or sending a
234
- message also uses the draft selection, so a transcript whose old model was
235
- removed can still be continued.
237
+ For saved history or a new workspace conversation, the card keeps the selection
238
+ as a draft. **Continue here** or sending a message starts the conversation with
239
+ that selection, so a transcript whose old model was removed can still continue.
236
240
 
237
241
  The **Settings** (gear) button in the sidebar opens the interface language,
238
242
  plan-first turns, the default approval mode, defaults for new conversations, the
@@ -277,6 +281,21 @@ regular and Pro workspaces remember their own choice. The terminal UI stays in
277
281
  English. New conversation content, of course, remains in the language the model
278
282
  writes.
279
283
 
284
+ ## Sidebar status marks
285
+
286
+ Every row in the sidebar carries one mark. A **blue dot** means the conversation
287
+ finished and has not been opened since, a **turning ring** means a turn is
288
+ running, and an **amber dot** means a tool call is waiting for your answer. The
289
+ amber mark only appears while the conversation really asks for approval — with
290
+ automatic review the reviewers answer for you, so the row keeps showing the
291
+ running ring.
292
+
293
+ The blue dot asks once: opening the row answers it, and the next turn that
294
+ finishes asks again. The console remembers those answers in a file of its own,
295
+ `~/.neutrino/web.json` (the regular and Pro workspaces each keep their own
296
+ record), so the terminal UI never carries data it does not read, and a reload, a
297
+ restart of the console or a change of port does not bring every dot back.
298
+
280
299
  ## History vs. live sessions
281
300
 
282
301
  A saved transcript on disk is a finished record: nothing is running, so the