@xingjisoft/neutrino 0.4.0 → 0.5.0

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
+ }
package/dist/web/hub.js CHANGED
@@ -58,6 +58,10 @@ class LiveHub {
58
58
  const entry = this.entries.get(key);
59
59
  return Boolean(entry) && (!mode || entry.mode === mode);
60
60
  }
61
+ remoteAction(key) {
62
+ const agent = this.entries.get(key)?.agent;
63
+ return agent?.remoteAction ? agent.remoteAction.bind(agent) : null;
64
+ }
61
65
  /** All live sessions, newest registration last. Optionally one workspace only. */
62
66
  list(mode) {
63
67
  const out = [];