@xingjisoft/neutrino 0.5.0 → 0.5.2

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.
@@ -170,16 +170,20 @@ export function loadProModels() {
170
170
  throw new Error(`Pro model.json is missing; it is downloaded from ${PRO_MODELS_URL} on first use`);
171
171
  }
172
172
  const { tier } = readProModels(PRO_MODEL_FILE, "model.json");
173
- // Presets the user keeps in model-custom.json are merged in: a same-named entry
174
- // replaces the one from model.json in place, new ones are appended.
173
+ // Presets the user keeps in model-custom.json are merged in. Their place in the
174
+ // list is read as strength (the slider runs from weak to strong), so entries that
175
+ // are not official rungs lead it: a preset named after an official one edits that
176
+ // rung in place, every other one sits left of the graded presets.
175
177
  if (fs.existsSync(PRO_CUSTOM_MODEL_FILE)) {
178
+ const own = [];
176
179
  for (const preset of readProModels(PRO_CUSTOM_MODEL_FILE, "model-custom.json").tier) {
177
180
  const index = tier.findIndex((entry) => entry.name === preset.name);
178
181
  if (index === -1)
179
- tier.push(preset);
182
+ own.push(preset);
180
183
  else
181
184
  tier[index] = preset;
182
185
  }
186
+ tier.unshift(...own);
183
187
  }
184
188
  if (tier.length === 0)
185
189
  throw new Error("Pro model.json must contain a nonempty tier array");
package/dist/update.js CHANGED
@@ -29,9 +29,15 @@ export function updateStatus() {
29
29
  * announce", which is what the next start is for. Concurrent callers share the
30
30
  * one lookup, and a successful answer is kept for the rest of the process so
31
31
  * the terminal and the Web console cannot disagree.
32
+ *
33
+ * Start-up asks once and is served from that answer from then on. A caller that
34
+ * passes `refresh` — the settings button, the background timer — queries the
35
+ * registry again, so an answer from an hour ago cannot make a check look idle.
36
+ * A failed refresh changes nothing: the last good answer stays, and the caller
37
+ * learns what went wrong from `error`.
32
38
  */
33
- export function checkForUpdate() {
34
- if (published)
39
+ export function checkForUpdate(options = {}) {
40
+ if (published && options.refresh !== true)
35
41
  return Promise.resolve(updateStatus());
36
42
  pending ??= lookupLatestVersion()
37
43
  .then((version) => {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The periodic update check.
3
+ *
4
+ * A Web console can stay open for days, so the start-up lookup alone would
5
+ * never tell its windows about a release published afterwards. This module asks
6
+ * the registry again on a timer, in the process that serves those windows, and
7
+ * hands a release it has not seen before to whoever is listening: the console
8
+ * forwards it to every open browser (GET /api/update/events) and the first-open
9
+ * dialog reads the same answer.
10
+ */
11
+ import { checkForUpdate, updateStatus } from "./update.js";
12
+ /** How often the background service asks the registry again. */
13
+ export const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000;
14
+ const listeners = new Set();
15
+ let running = null;
16
+ /**
17
+ * Listen for a release the periodic check finds. Returns the unsubscribe
18
+ * function.
19
+ *
20
+ * Only news is announced: a version this process already knows — from the
21
+ * start-up lookup, the settings button or an earlier tick — is not sent again,
22
+ * so the dialogs cannot reopen every half hour for a release the user has
23
+ * already declined.
24
+ */
25
+ export function subscribeUpdateAnnouncements(listener) {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ }
31
+ /**
32
+ * Start asking the registry every `intervalMs` (30 minutes by default).
33
+ *
34
+ * Idempotent, and the returned function stops the timer and leaves the module
35
+ * ready to start again. Checks run off the timer rather than at start-up: both
36
+ * the console and the terminal UI already look once when they start, and a
37
+ * second immediate lookup would only spend npm calls on the same answer.
38
+ */
39
+ export function startUpdateWatch(options = {}) {
40
+ if (running)
41
+ return running;
42
+ const timer = setInterval(() => {
43
+ // The remembered answer is read before the lookup, because the lookup itself
44
+ // updates it: what matters is whether this check is what discovered the
45
+ // release.
46
+ const known = updateStatus().latestVersion;
47
+ // `refresh` is what makes a timer useful: without it checkForUpdate keeps
48
+ // returning the answer this process already has and would never see a new
49
+ // release.
50
+ void checkForUpdate({ refresh: true }).then((check) => {
51
+ try {
52
+ options.onCheck?.(check);
53
+ }
54
+ catch {
55
+ // A failing log must not stop the check that follows it.
56
+ }
57
+ if (!check.hasUpdate || check.latestVersion === known)
58
+ return;
59
+ for (const listener of [...listeners]) {
60
+ try {
61
+ listener(check);
62
+ }
63
+ catch {
64
+ // One broken window must not starve the others.
65
+ }
66
+ }
67
+ });
68
+ }, options.intervalMs ?? UPDATE_CHECK_INTERVAL_MS);
69
+ // A check in flight must never keep a shutting-down process alive.
70
+ timer.unref?.();
71
+ running = () => {
72
+ clearInterval(timer);
73
+ running = null;
74
+ };
75
+ return running;
76
+ }
@@ -29,7 +29,8 @@ import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/conf
29
29
  import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
30
30
  import { migrateAgentSession, SessionMigrationError } from "../migration.js";
31
31
  import { runningBackground } from "../background.js";
32
- import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
32
+ import { MANUAL_UPDATE_COMMAND, checkForUpdate, installUpdate, updateStatus } from "../update.js";
33
+ import { subscribeUpdateAnnouncements } from "../updateWatch.js";
33
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
35
  // dist/web -> package root (mirrors the same trick used in config.ts).
35
36
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -604,7 +605,88 @@ function streamLive(key, req, res) {
604
605
  // Without this, a client reset during a write is an uncaught 'error' event.
605
606
  res.on("error", cleanup);
606
607
  }
607
- async function handleApi(req, res, url, auth, mode, pathname, binding) {
608
+ /**
609
+ * The windows watching for update announcements. The console owns one of these
610
+ * so closing it can end those streams: a browser reconnects an EventSource by
611
+ * itself, so an open tab would otherwise keep `close` waiting for a socket that
612
+ * will never go quiet on its own.
613
+ */
614
+ function createUpdateStreams() {
615
+ const open = new Set();
616
+ return {
617
+ add(res) {
618
+ open.add(res);
619
+ },
620
+ remove(res) {
621
+ open.delete(res);
622
+ },
623
+ closeAll() {
624
+ for (const res of [...open]) {
625
+ open.delete(res);
626
+ try {
627
+ res.end();
628
+ }
629
+ catch { /* the window is already gone */ }
630
+ }
631
+ },
632
+ };
633
+ }
634
+ /**
635
+ * The periodic check's findings, as an event stream. A window that opens after
636
+ * a release was already found still has to learn about it, so the answer this
637
+ * process holds goes out first: the same status /api/settings hands the
638
+ * first-open dialog, with the same fields the manual check returns.
639
+ *
640
+ * Both workspaces read it. An update belongs to the process, not to a
641
+ * conversation, so a Pro tab and a standard tab are told about the same release.
642
+ */
643
+ function streamUpdateEvents(req, res, streams) {
644
+ res.writeHead(200, {
645
+ "Content-Type": "text/event-stream; charset=utf-8",
646
+ "Cache-Control": "no-cache, no-transform",
647
+ Connection: "keep-alive",
648
+ "X-Accel-Buffering": "no",
649
+ });
650
+ // Same guard as streamLive: a browser that closed or reloaded the tab leaves
651
+ // a socket writes fail on, and an unhandled 'error' event would stop the process.
652
+ const write = (chunk) => {
653
+ if (res.destroyed || res.writableEnded)
654
+ return false;
655
+ try {
656
+ res.write(chunk);
657
+ return true;
658
+ }
659
+ catch {
660
+ return false;
661
+ }
662
+ };
663
+ const send = (status, error = "") => {
664
+ write(`event: update\ndata: ${JSON.stringify({
665
+ has_update: status.hasUpdate,
666
+ current_version: status.currentVersion,
667
+ latest_version: status.latestVersion,
668
+ error,
669
+ })}\n\n`);
670
+ };
671
+ write("retry: 2000\n\n");
672
+ send(updateStatus());
673
+ streams.add(res);
674
+ const unsubscribe = subscribeUpdateAnnouncements((check) => send(check, check.error ?? ""));
675
+ const keepAlive = setInterval(() => {
676
+ if (!write(": ping\n\n"))
677
+ cleanup();
678
+ }, 25_000);
679
+ keepAlive.unref?.();
680
+ function cleanup() {
681
+ clearInterval(keepAlive);
682
+ unsubscribe();
683
+ streams.remove(res);
684
+ }
685
+ req.on("close", cleanup);
686
+ req.on("error", cleanup);
687
+ res.on("error", cleanup);
688
+ }
689
+ async function handleApi(req, res, url, auth, mode, pathname, binding, updates) {
608
690
  if (!pathname.startsWith("/api/"))
609
691
  return false;
610
692
  // The Pro presets are not bundled either: the first Pro request makes sure they
@@ -748,6 +830,39 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
748
830
  }
749
831
  return true;
750
832
  }
833
+ // The 30-minute background check announces a release here, so a window that
834
+ // stays open learns about it without reloading. Both workspaces open it.
835
+ if (req.method === "GET" && pathname === "/api/update/events") {
836
+ streamUpdateEvents(req, res, updates);
837
+ return true;
838
+ }
839
+ // The settings page's "Check for updates" button. Start-up reads the registry
840
+ // once and the Web console is served from that answer, so a manual check has
841
+ // to ask again; otherwise the button would keep showing whatever was true
842
+ // when the process started. Like the install below it runs npm on this
843
+ // machine, so it sits behind the login cookie and the same-origin check.
844
+ //
845
+ // The answer is a status, not an HTTP failure: an unreachable registry leaves
846
+ // `error` set on a 200 response, which is what the browser shows next to the
847
+ // button. A failure also changes nothing — the last good answer stays.
848
+ if (pathname === "/api/update/check") {
849
+ if (req.method !== "POST") {
850
+ sendJson(res, 405, { error: "method not allowed" });
851
+ return true;
852
+ }
853
+ if (!isSameOrigin(req)) {
854
+ sendJson(res, 403, { error: "cross-origin request rejected" });
855
+ return true;
856
+ }
857
+ const check = await checkForUpdate({ refresh: true });
858
+ sendJson(res, 200, {
859
+ has_update: check.hasUpdate,
860
+ current_version: check.currentVersion,
861
+ latest_version: check.latestVersion,
862
+ error: check.error ?? "",
863
+ });
864
+ return true;
865
+ }
751
866
  // The update prompt's confirm button: npm runs on this machine, so the route
752
867
  // sits behind the login cookie and the same-origin check like every other
753
868
  // mutation, and only a release the registry announced can be installed.
@@ -1308,7 +1423,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1308
1423
  sendJson(res, 404, { error: "not found" });
1309
1424
  return true;
1310
1425
  }
1311
- async function handleRequest(req, res, auth, binding) {
1426
+ async function handleRequest(req, res, auth, binding, updates) {
1312
1427
  let url;
1313
1428
  try {
1314
1429
  url = new URL(req.url ?? "/", "http://localhost");
@@ -1341,7 +1456,7 @@ async function handleRequest(req, res, auth, binding) {
1341
1456
  mode = "pro";
1342
1457
  apiPathname = pathname.slice("/pro".length);
1343
1458
  }
1344
- if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
1459
+ if (await handleApi(req, res, url, auth, mode, apiPathname, binding, updates))
1345
1460
  return;
1346
1461
  if (mode === "pro") {
1347
1462
  // Nothing else lives under /pro but the page and its API.
@@ -1371,8 +1486,11 @@ export async function startWebServer(options) {
1371
1486
  service: options.backgroundService,
1372
1487
  updateSettings: (body, mode) => updateSettings(body, mode),
1373
1488
  };
1489
+ // Every window watching for an update belongs to this console, not the module,
1490
+ // so a second embedded server (a test, an embedding) keeps its own set.
1491
+ const updates = createUpdateStreams();
1374
1492
  const server = http.createServer((req, res) => {
1375
- handleRequest(req, res, auth, binding).catch((err) => {
1493
+ handleRequest(req, res, auth, binding, updates).catch((err) => {
1376
1494
  if (!res.headersSent) {
1377
1495
  sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
1378
1496
  }
@@ -1513,6 +1631,9 @@ export async function startWebServer(options) {
1513
1631
  setHost: (next) => rebind(next),
1514
1632
  close: () => new Promise((resolve) => {
1515
1633
  closed = true;
1634
+ // These streams never end on their own, so they are closed explicitly;
1635
+ // otherwise server.close() would wait for a window that stays open.
1636
+ updates.closeAll();
1516
1637
  for (const key of [...webSessions.keys()])
1517
1638
  closeWebSession(key);
1518
1639
  if (!server.listening) {
@@ -5,6 +5,7 @@ import { loadConfig, saveConfig } from "../config.js";
5
5
  import { startBackgroundTray } from "../tray.js";
6
6
  import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
7
7
  import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
8
+ import { startUpdateWatch } from "../updateWatch.js";
8
9
  import { hub } from "./hub.js";
9
10
  import { startWebServer } from "./server.js";
10
11
  import { TerminalBridges } from "./terminalBridge.js";
@@ -18,6 +19,7 @@ export async function runWebService(options) {
18
19
  let handle = null;
19
20
  let tray = null;
20
21
  let monitor = null;
22
+ let stopUpdateWatch = null;
21
23
  let stopping = false;
22
24
  const bridges = new TerminalBridges();
23
25
  const status = () => {
@@ -44,6 +46,8 @@ export async function runWebService(options) {
44
46
  stopping = true;
45
47
  if (monitor)
46
48
  clearInterval(monitor);
49
+ stopUpdateWatch?.();
50
+ stopUpdateWatch = null;
47
51
  tray?.close();
48
52
  bridges.close();
49
53
  backgroundLog(`stopping pid=${process.pid}`);
@@ -131,6 +135,24 @@ export async function runWebService(options) {
131
135
  }
132
136
  backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
133
137
  syncTray();
138
+ // The start-up lookup above is one answer; a console that stays open needs
139
+ // more than that. Every 30 minutes the registry is asked again and a release
140
+ // it has not seen before is announced to every open window
141
+ // (GET /api/update/events), which is the same status the first-open dialog
142
+ // reads, so both ways of telling the user come from one source.
143
+ let loggedUpdate = "";
144
+ stopUpdateWatch = startUpdateWatch({
145
+ onCheck: (result) => {
146
+ // Log a change, not the same answer every half hour.
147
+ const line = result.hasUpdate
148
+ ? `${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`
149
+ : result.error ? `update check: ${result.error}` : "";
150
+ if (!line || line === loggedUpdate)
151
+ return;
152
+ loggedUpdate = line;
153
+ backgroundLog(line);
154
+ },
155
+ });
134
156
  // No helper processes: polling pids must remain invisible on Windows. An
135
157
  // orphaned, non-resident console lets an in-flight browser turn finish before
136
158
  // exiting, so disabling residency also cannot throw away running work.
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "@xingjisoft/neutrino",
3
- "version": "0.5.0",
4
- "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
- "type": "module",
6
- "license": "Apache-2.0",
7
- "bin": {
8
- "neutrino": "dist/bin.js"
9
- },
10
- "main": "dist/app.js",
11
- "files": [
12
- "dist",
13
- "assets",
14
- "web"
15
- ],
16
- "engines": {
17
- "node": ">=18.0.0"
18
- },
19
- "scripts": {
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-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
- "prepare": "npm run build",
23
- "web": "node dist/bin.js web"
24
- },
25
- "keywords": [
26
- "cli",
27
- "agent",
28
- "ai",
29
- "terminal",
30
- "llm",
31
- "coding"
32
- ],
33
- "devDependencies": {
34
- "@types/node": "^22.0.0",
35
- "typescript": "^5.5.0"
36
- }
37
- }
1
+ {
2
+ "name": "@xingjisoft/neutrino",
3
+ "version": "0.5.2",
4
+ "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "bin": {
8
+ "neutrino": "dist/bin.js"
9
+ },
10
+ "main": "dist/app.js",
11
+ "files": [
12
+ "dist",
13
+ "assets",
14
+ "web"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "scripts": {
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-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-settings-update.mjs && node scripts/test-update-check.mjs && node scripts/test-update-watch.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
+ "prepare": "npm run build",
23
+ "web": "node dist/bin.js web"
24
+ },
25
+ "keywords": [
26
+ "cli",
27
+ "agent",
28
+ "ai",
29
+ "terminal",
30
+ "llm",
31
+ "coding"
32
+ ],
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "typescript": "^5.5.0"
36
+ }
37
+ }
package/web/app.js CHANGED
@@ -145,6 +145,7 @@ function showGate(message = "") {
145
145
  if (gateShown) return;
146
146
  gateShown = true;
147
147
  closeStream();
148
+ closeUpdateEvents();
148
149
  setStatus("locked", "offline");
149
150
  document.getElementById("settings-dialog").close();
150
151
  document.getElementById("background-dialog")?.close?.();
@@ -259,20 +260,57 @@ async function answerBackgroundPrompt(enable) {
259
260
 
260
261
  // ------------------------------------------------------------- update notice
261
262
  //
262
- // The server compares the version it runs with the one npm publishes, and both
263
- // workspaces read that from the settings payload: a Pro tab announces a new
264
- // release exactly like the regular console. Confirming installs it on this
265
- // machine; the running process keeps the old code either way, so a successful
266
- // update ends by telling the user to restart.
263
+ // The server compares the version it runs with the one npm publishes — at
264
+ // start-up, every 30 minutes and on demand from Settings — and every answer
265
+ // reaches the page through one announcement stream, so the notice shown when
266
+ // the console opens and the corner badge cannot disagree about the release. A
267
+ // Pro tab listens to the same stream under its own API prefix. Confirming
268
+ // installs it on this machine; the running process keeps the old code either
269
+ // way, so a successful update ends by telling the user to restart.
267
270
  //
268
- // Two dialogs never open at once: when an update is offered, the background
269
- // invitation waits for the next page load (it is not consumed by being skipped).
271
+ // Two dialogs never open at once: the background invitation is modal too, so a
272
+ // release announced while it is up is offered once it is out of the way.
270
273
 
271
274
  let updatePrompted = false;
272
275
 
273
- function maybePromptUpdate(config) {
274
- const update = config?.update;
275
- if (updatePrompted || !update?.has_update) return false;
276
+ // The release the page currently knows about. The notice and the corner badge
277
+ // read this one value, so the reminder can never name a different version.
278
+ let updateOffer = null;
279
+
280
+ // The console's own announcement stream, and the release that arrived while the
281
+ // background invitation was holding the screen.
282
+ let updateSource = null;
283
+ let pendingUpdate = null;
284
+
285
+ /**
286
+ * Remember a release and light the corner badge. The announcement stream and a
287
+ * check started in Settings both report here.
288
+ */
289
+ function noteUpdateAvailable(update) {
290
+ if (!update?.has_update) return false;
291
+ updateOffer = update;
292
+ if (el.updateBadge) {
293
+ const label = tr("A new version is available");
294
+ el.updateBadge.title = label;
295
+ el.updateBadge.setAttribute("aria-label", label);
296
+ el.updateBadge.classList.remove("hidden");
297
+ }
298
+ return true;
299
+ }
300
+
301
+ /** The release is installed or was never on offer: drop the reminder. */
302
+ function clearUpdateBadge() {
303
+ updateOffer = null;
304
+ pendingUpdate = null;
305
+ el.updateBadge?.classList.add("hidden");
306
+ }
307
+
308
+ /**
309
+ * Offer a known release. The notice shown on load and a check started from
310
+ * Settings both end up here, so an update is announced with the same words and
311
+ * the same buttons however the user found out about it.
312
+ */
313
+ function openUpdateDialog(update) {
276
314
  if (!el.updateDialog || typeof el.updateDialog.showModal !== "function") return false;
277
315
  updatePrompted = true;
278
316
  if (el.updateVersions) {
@@ -299,6 +337,56 @@ function maybePromptUpdate(config) {
299
337
  return true;
300
338
  }
301
339
 
340
+ /**
341
+ * Announce a release. The console pushes what it knows when a tab connects and
342
+ * again whenever the 30-minute background check finds a new one, so a window
343
+ * that stays open is told in the same words as one that has just loaded.
344
+ */
345
+ function applyUpdateStatus(update) {
346
+ // The badge is lit even when the notice was already shown, so a dismissed
347
+ // offer can still be picked up again from the corner.
348
+ if (!noteUpdateAvailable(update)) {
349
+ // The console reports no release any more, so the reminder has nothing
350
+ // left to stand for.
351
+ clearUpdateBadge();
352
+ return false;
353
+ }
354
+ if (updatePrompted) return false;
355
+ // The background invitation is a modal dialog too, and two of them must never
356
+ // stack: this release is offered once that one is out of the way.
357
+ if (el.backgroundDialog?.open) {
358
+ pendingUpdate = update;
359
+ return false;
360
+ }
361
+ return openUpdateDialog(update);
362
+ }
363
+
364
+ /**
365
+ * Listen for the console's announcements. EventSource reconnects by itself, so
366
+ * a dropped connection only leaves the page with the release it already knows.
367
+ */
368
+ function openUpdateEvents() {
369
+ if (updateSource) return;
370
+ const source = new EventSource(apiPath("/api/update/events"));
371
+ updateSource = source;
372
+ source.addEventListener("update", (event) => {
373
+ let update;
374
+ try {
375
+ update = JSON.parse(event.data);
376
+ } catch {
377
+ return;
378
+ }
379
+ applyUpdateStatus(update);
380
+ });
381
+ }
382
+
383
+ /** The console is locked behind the login gate: its announcements are moot. */
384
+ function closeUpdateEvents() {
385
+ if (!updateSource) return;
386
+ updateSource.close();
387
+ updateSource = null;
388
+ }
389
+
302
390
  /** Run npm through the console's own server and report what happened. */
303
391
  async function runUpdate() {
304
392
  if (!el.updateNow || el.updateNow.disabled) return;
@@ -322,6 +410,7 @@ async function runUpdate() {
322
410
  // The install is done: the only thing left is to close the notice.
323
411
  el.updateLater?.classList.add("hidden");
324
412
  el.updateNow?.classList.add("hidden");
413
+ clearUpdateBadge();
325
414
  } catch (error) {
326
415
  if (feedback) {
327
416
  feedback.textContent = tr("Update failed: {error}", { error: error.message });
@@ -422,6 +511,7 @@ const el = {
422
511
  updateClose: document.getElementById("update-close"),
423
512
  updateLater: document.getElementById("update-later"),
424
513
  updateNow: document.getElementById("update-now"),
514
+ updateBadge: document.getElementById("update-badge"),
425
515
  };
426
516
 
427
517
  if (el.backgroundCancel) {
@@ -435,6 +525,14 @@ if (el.backgroundDialog) {
435
525
  event.preventDefault();
436
526
  void answerBackgroundPrompt(false);
437
527
  });
528
+ // A release announced while the invitation was up is offered right after it.
529
+ el.backgroundDialog.addEventListener("close", () => {
530
+ const waiting = pendingUpdate;
531
+ pendingUpdate = null;
532
+ // The login gate closes this dialog on its way in, and nothing may sit on
533
+ // top of it.
534
+ if (waiting && !gateShown) openUpdateDialog(waiting);
535
+ });
438
536
  }
439
537
 
440
538
  if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
@@ -447,6 +545,12 @@ if (el.updateDialog) {
447
545
  el.updateDialog.close();
448
546
  });
449
547
  }
548
+ // The corner disc reopens the same notice, so a dismissal is never final.
549
+ if (el.updateBadge) {
550
+ el.updateBadge.addEventListener("click", () => {
551
+ if (updateOffer) openUpdateDialog(updateOffer);
552
+ });
553
+ }
450
554
 
451
555
  const state = {
452
556
  overview: null,
@@ -495,6 +599,12 @@ const preferences = createSettingsUI({
495
599
  proMode: PRO_MODE,
496
600
  onCatalogChange: (catalog) => proUI?.load(catalog),
497
601
  onLanguageChange: (language) => changeInterfaceLanguage(language),
602
+ // A check started from Settings offers its result through the same dialog as
603
+ // the notice shown on load, and lights the corner badge with it.
604
+ onUpdateFound: (update) => {
605
+ noteUpdateAvailable(update);
606
+ return openUpdateDialog(update);
607
+ },
498
608
  async applyModel(selection) {
499
609
  const current = state.selection;
500
610
  if (!current) throw new Error("Select a conversation first.");
@@ -3146,9 +3256,12 @@ preferences.load()
3146
3256
  if (language && language !== uiLanguage()) {
3147
3257
  await changeInterfaceLanguage(language, { persist: false });
3148
3258
  }
3149
- // Offer the update first, and only then the background invitation: two
3150
- // modal dialogs must never stack on top of each other.
3151
- if (!maybePromptUpdate(config)) maybePromptBackground(config);
3259
+ // The announcement stream needs the login cookie, so it opens once the
3260
+ // settings payload has loaded. A release it reports right away opens the
3261
+ // notice; one that lands while the background invitation is up waits for it
3262
+ // to close, because two modal dialogs must never stack on top of each other.
3263
+ openUpdateEvents();
3264
+ maybePromptBackground(config);
3152
3265
  })
3153
3266
  .catch(error => console.error(error));
3154
3267
  loadSeen().then(() => loadOverview()).then(() => {
package/web/i18n.js CHANGED
@@ -115,6 +115,13 @@ const ZH = {
115
115
  "Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
116
116
  "Update failed: {error}": "更新失败:{error}",
117
117
  "Update by hand with:": "也可以手动更新:",
118
+ "Updates": "更新",
119
+ "Check for updates": "检查更新",
120
+ "Checking for updates…": "正在检查更新…",
121
+ "Version {latest} is available. This install runs {current}.": "发现新版本 {latest},当前安装为 {current}。",
122
+ "Neutrino {version} is up to date.": "Neutrino {version} 已是最新版本。",
123
+ "Update check failed: {error}": "检查更新失败:{error}",
124
+ "Neutrino checks npm for new releases every 30 minutes. Both workspaces share that check.": "Neutrino 每 30 分钟通过 npm 检查一次新版本,普通模式与 Pro 模式共用该检查。",
118
125
  // -- chrome -------------------------------------------------------------
119
126
  "Neutrino": "Neutrino",
120
127
  "Switch to Pro mode": "切换至Pro模式",