@xingjisoft/neutrino 0.1.4 → 0.1.6

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.
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import { restoreTerminalSync, markTuiActive } from "./restore.js";
2
3
  import { isControlChar } from "../sanitize.js";
3
4
  function styleKey(st) {
4
5
  let k = "";
@@ -44,29 +45,44 @@ function rowSignature(row) {
44
45
  }
45
46
  export class Renderer {
46
47
  prevSig = [];
48
+ disposed = false;
47
49
  prevW = 0;
48
50
  prevH = 0;
49
51
  out = process.stdout;
50
52
  /**
51
- * The most recent frame and the rows that were force-repainted for it. Kept
52
- * so `repaint()` can re-emit the exact same frame — without re-running the
53
+ * The most recent frame, kept so `repaint()` can re-emit it without re-running the
53
54
  * screen's (potentially expensive) render — to heal any drift the terminal
54
55
  * introduced behind our back.
55
56
  */
56
57
  lastBuf = null;
57
- lastForce = [];
58
+ blocked = false;
59
+ pendingForce = new Set();
60
+ drainHandler = () => {
61
+ this.blocked = false;
62
+ const forceRows = [...this.pendingForce];
63
+ this.pendingForce.clear();
64
+ if (!this.disposed && this.lastBuf)
65
+ this.draw(this.lastBuf, forceRows);
66
+ };
58
67
  init() {
68
+ // Tell the crash guard the terminal is ours *before* touching it, so a
69
+ // crash that skips dispose() can still undo every switch below.
70
+ markTuiActive();
59
71
  this.write("\x1b[?1049h"); // alternate screen
60
72
  this.write("\x1b[?25l"); // hide cursor
61
73
  this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
62
74
  this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
75
+ this.write("\x1b[>4;2m\x1b[>1u"); // xterm/kitty modified keys, including Ctrl+Shift+C
63
76
  }
64
77
  dispose() {
65
- this.write("\x1b[?2004l");
66
- this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
67
- this.write("\x1b[?25h");
68
- this.write("\x1b[?1049l");
69
- this.write("\x1b[0m");
78
+ if (this.disposed)
79
+ return;
80
+ this.disposed = true;
81
+ this.out.removeListener?.("drain", this.drainHandler);
82
+ this.pendingForce.clear();
83
+ // stdout.write may still be queued when App.quit calls process.exit.
84
+ // Use the same synchronous teardown as the crash/exit guard.
85
+ restoreTerminalSync();
70
86
  }
71
87
  /**
72
88
  * @param forceRows rows to repaint unconditionally, even when their contents
@@ -76,7 +92,6 @@ export class Renderer {
76
92
  */
77
93
  render(buf, forceRows = []) {
78
94
  this.lastBuf = buf;
79
- this.lastForce = forceRows;
80
95
  this.draw(buf, forceRows);
81
96
  }
82
97
  /**
@@ -92,7 +107,25 @@ export class Renderer {
92
107
  all.push(y);
93
108
  this.draw(this.lastBuf, all);
94
109
  }
110
+ /**
111
+ * Ring the terminal bell.
112
+ *
113
+ * Written straight to the stream rather than into a frame: BEL has no visual
114
+ * effect, so it cannot desync the diffed rows, and it must never end up in
115
+ * `lastBuf` where repaint() would replay it.
116
+ */
117
+ bell(times = 1) {
118
+ const count = Math.max(1, Math.trunc(times) || 1);
119
+ this.write("\x07".repeat(count));
120
+ }
95
121
  draw(buf, forceRows) {
122
+ if (this.disposed)
123
+ return;
124
+ if (this.blocked) {
125
+ for (const y of forceRows)
126
+ this.pendingForce.add(y);
127
+ return;
128
+ }
96
129
  const w = buf.width;
97
130
  const h = buf.height;
98
131
  if (w !== this.prevW || h !== this.prevH) {
@@ -110,8 +143,12 @@ export class Renderer {
110
143
  this.prevSig[y] = sig;
111
144
  }
112
145
  }
113
- if (out)
114
- this.write(out);
146
+ if (out && !this.write(out) && typeof this.out.once === "function") {
147
+ // The stream already accepted this frame. Wait for it to drain, then
148
+ // diff straight to the latest frame instead of queueing old mouse moves.
149
+ this.blocked = true;
150
+ this.out.once("drain", this.drainHandler);
151
+ }
115
152
  }
116
153
  renderRow(y, row) {
117
154
  // Move to the row, reset attributes and erase the whole line first. The
@@ -140,6 +177,15 @@ export class Renderer {
140
177
  return s;
141
178
  }
142
179
  write(chunk) {
143
- this.out.write(chunk);
180
+ if (this.disposed || this.out.destroyed || this.out.writableEnded)
181
+ return true;
182
+ try {
183
+ return this.out.write(chunk) !== false;
184
+ }
185
+ catch {
186
+ // A terminal that vanished (closed window, dying conhost) must not take
187
+ // the whole session down with it.
188
+ return true;
189
+ }
144
190
  }
145
191
  }
@@ -0,0 +1,148 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { flushSessionCheckpoints } from "../persistence.js";
6
+ const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
7
+ /**
8
+ * Escape sequences that undo everything the TUI switches on.
9
+ *
10
+ * Mouse tracking is disabled before the alternate screen is left, and the
11
+ * cursor is restored before the screen swap, so the shell never inherits a
12
+ * half-configured terminal. Order matters: `?1049l` must come last or the
13
+ * remaining writes would land on the main screen.
14
+ */
15
+ export const TUI_TEARDOWN = "\x1b[<u\x1b[>4m" + // restore kitty keyboard flags and xterm modified keys
16
+ "\x1b[?2004l" + // bracketed paste off
17
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l" + // mouse tracking off
18
+ "\x1b[?25h" + // cursor visible
19
+ "\x1b[0m" + // default attributes
20
+ "\x1b[?1049l"; // back to the main screen
21
+ /** Whether a TUI has claimed the terminal and its state still needs undoing. */
22
+ let active = false;
23
+ let restored = false;
24
+ let installed = false;
25
+ /** Called once the renderer has entered the alternate screen and raw mode. */
26
+ export function markTuiActive() {
27
+ // Unconditional: only the renderer calls this, and the renderer is only ever
28
+ // constructed once the app has decided it owns a real terminal.
29
+ active = true;
30
+ restored = false;
31
+ }
32
+ /**
33
+ * Puts the terminal back into a usable state, synchronously and at most once.
34
+ *
35
+ * Everything here has to work while the process is dying: no promises, no
36
+ * asynchronous writes, and every step individually guarded so one failure
37
+ * cannot skip the rest (a broken `setRawMode` must not leave the cursor
38
+ * hidden, for example).
39
+ */
40
+ export function restoreTerminalSync() {
41
+ if (!active || restored)
42
+ return false;
43
+ let outputRestored = false;
44
+ let inputRestored = false;
45
+ try {
46
+ // fd 1 write is the only one guaranteed to be flushed by a dying process;
47
+ // `process.stdout.write` may queue the bytes and lose them at exit.
48
+ const bytes = Buffer.from(TUI_TEARDOWN);
49
+ let offset = 0;
50
+ while (offset < bytes.length) {
51
+ const written = fs.writeSync(1, bytes, offset, bytes.length - offset);
52
+ if (written === 0)
53
+ throw new Error("terminal write made no progress");
54
+ offset += written;
55
+ }
56
+ outputRestored = true;
57
+ }
58
+ catch {
59
+ try {
60
+ process.stdout.write(TUI_TEARDOWN);
61
+ }
62
+ catch {
63
+ // The terminal is gone; there is nothing left to restore.
64
+ }
65
+ }
66
+ try {
67
+ if (process.stdin.isTTY && process.stdin.isRaw)
68
+ process.stdin.setRawMode(false);
69
+ inputRestored = true;
70
+ }
71
+ catch {
72
+ // stdin already closed, or not a TTY we can reconfigure.
73
+ }
74
+ // A queued or failed write must not disarm the synchronous exit retry.
75
+ restored = outputRestored && inputRestored;
76
+ return restored;
77
+ }
78
+ /** Appends a crash entry to `~/.neutrino/crash.log` and returns the path. */
79
+ export function writeCrashLog(kind, err) {
80
+ const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
81
+ const entry = `[${new Date().toISOString()}] ${kind} pid=${process.pid}\n${detail}\n\n`;
82
+ try {
83
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
84
+ const file = path.join(NEUTRINO_DIR, "crash.log");
85
+ fs.appendFileSync(file, entry, "utf8");
86
+ return file;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Installs process-wide handlers that restore the terminal before the process
94
+ * dies.
95
+ *
96
+ * Without this, any throw that escapes a timer, a stream callback or a floating
97
+ * promise kills the process with raw mode still on and the alternate screen
98
+ * still active. The visible result is a terminal that works but spews
99
+ * `[<b>;<x>;<y>M` mouse reports at the shell prompt, because the shell now
100
+ * receives the mouse events the TUI asked the terminal to send.
101
+ */
102
+ export function installCrashGuard() {
103
+ if (installed)
104
+ return;
105
+ installed = true;
106
+ let flushed = false;
107
+ const finalize = () => {
108
+ restoreTerminalSync();
109
+ if (flushed)
110
+ return true;
111
+ flushed = true;
112
+ return flushSessionCheckpoints();
113
+ };
114
+ process.on("exit", () => {
115
+ if (!finalize()) {
116
+ try {
117
+ fs.writeSync(2, "\nneutrino: some conversation changes could not be saved; check disk access and crash.log.\n");
118
+ }
119
+ catch {
120
+ // The crash log is the remaining record if stderr is unavailable.
121
+ }
122
+ }
123
+ });
124
+ // These signals otherwise bypass Node's exit hook, including Ctrl+Break on
125
+ // Windows. The app handles SIGINT as an interrupt and SIGTERM as a quit.
126
+ process.on("SIGHUP", () => process.exit(129));
127
+ process.on("SIGBREAK", () => process.exit(149));
128
+ const fatal = (kind, err) => {
129
+ // Restore first: the terminal must be usable even if logging itself fails.
130
+ const saved = finalize();
131
+ const file = writeCrashLog(kind, err);
132
+ const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
133
+ try {
134
+ const tail = file ? `\nFull log: ${file}` : "";
135
+ fs.writeSync(2, `\nneutrino stopped: ${kind}\n\n${detail}\n\n` +
136
+ (saved
137
+ ? "Check history for the latest saved conversation snapshots."
138
+ : "Some conversation changes could not be saved; check disk access and crash.log.") +
139
+ `${tail}\n`);
140
+ }
141
+ catch {
142
+ // stderr is gone too; the crash log is the only record.
143
+ }
144
+ process.exit(1);
145
+ };
146
+ process.on("uncaughtException", (err) => fatal("uncaught exception", err));
147
+ process.on("unhandledRejection", (err) => fatal("unhandled rejection", err));
148
+ }
package/dist/ui.js CHANGED
@@ -1,5 +1,21 @@
1
1
  import { C } from "./colors.js";
2
2
  import { displayWidth, truncateByWidth, wcwidth } from "./width.js";
3
+ /** One terminal row; shared geometry keeps rendering and hit testing aligned. */
4
+ export function approvalToggle(width, y, mode) {
5
+ const label = mode === "auto" ? "[ Auto Approval ]" : "[ Request Approval ]";
6
+ const text = truncateByWidth(label, Math.max(0, width - 2));
7
+ return { x: Math.max(0, Math.floor((width - displayWidth(text)) / 2)), y, w: displayWidth(text), h: 1, text };
8
+ }
9
+ export function drawApprovalToggle(buf, y, mode, focused = false) {
10
+ if (y < 1 || y >= buf.height)
11
+ return;
12
+ const button = approvalToggle(buf.width, y, mode);
13
+ buf.writeText(button.x, y, button.text, {
14
+ fg: focused ? C.black : mode === "auto" ? C.green : C.accent,
15
+ bg: focused ? C.accent : C.highlightBg,
16
+ bold: true,
17
+ });
18
+ }
3
19
  function chunkByWidth(text, width) {
4
20
  const chunks = [];
5
21
  let cur = "";
@@ -0,0 +1,82 @@
1
+ import crypto from "node:crypto";
2
+ import { promisify } from "node:util";
3
+ import { loadConfig, saveConfig } from "../config.js";
4
+ export const DEFAULT_WEB_PASSWORD = "114514";
5
+ const COOKIE = "neutrino_session";
6
+ const MAX_AGE = 365 * 24 * 60 * 60;
7
+ const scrypt = promisify(crypto.scrypt);
8
+ export function validateWebPassword(password) {
9
+ if (typeof password !== "string" || !password.trim() || password.length > 256) {
10
+ throw new Error("Password must contain 1–256 characters and cannot be blank.");
11
+ }
12
+ }
13
+ /** Only a salted password hash is persisted; settings never return it. */
14
+ export function hashWebPassword(password) {
15
+ validateWebPassword(password);
16
+ const salt = crypto.randomBytes(16).toString("hex");
17
+ return `scrypt:${salt}:${crypto.scryptSync(password, salt, 64).toString("hex")}`;
18
+ }
19
+ function passwordHash() {
20
+ const config = loadConfig();
21
+ if (typeof config.web_password_hash === "string" && config.web_password_hash)
22
+ return config.web_password_hash;
23
+ const hash = hashWebPassword(DEFAULT_WEB_PASSWORD);
24
+ config.web_password_hash = hash;
25
+ saveConfig(config);
26
+ return hash;
27
+ }
28
+ function signature(value, hash) {
29
+ return crypto.createHmac("sha256", hash).update(value).digest("base64url");
30
+ }
31
+ function matches(a, b) {
32
+ const left = Buffer.from(a);
33
+ const right = Buffer.from(b);
34
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
35
+ }
36
+ /** Signed cookies survive restarts, but a password change invalidates all of them. */
37
+ export class WebAuth {
38
+ enabled;
39
+ constructor(enabled) {
40
+ this.enabled = enabled;
41
+ if (enabled)
42
+ passwordHash();
43
+ }
44
+ authenticated(req) {
45
+ if (!this.enabled)
46
+ return true;
47
+ const value = req.headers.cookie?.split(";").map(part => part.trim())
48
+ .find(part => part.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
49
+ if (!value)
50
+ return false;
51
+ const [expires, nonce, mac, extra] = value.split(".");
52
+ if (extra !== undefined || !/^\d+$/.test(expires) || !nonce || !mac || Number(expires) <= Date.now())
53
+ return false;
54
+ return matches(mac, signature(`${expires}.${nonce}`, passwordHash()));
55
+ }
56
+ async login(password) {
57
+ if (!this.enabled)
58
+ return true;
59
+ try {
60
+ validateWebPassword(password);
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ const hash = passwordHash();
66
+ const [kind, salt, expected] = hash.split(":");
67
+ if (kind !== "scrypt" || !salt || !expected)
68
+ return false;
69
+ const derived = await scrypt(password, salt, 64);
70
+ // A concurrent settings change must not issue a session for the old password.
71
+ return hash === passwordHash() && matches(derived.toString("hex"), expected);
72
+ }
73
+ remember(res) {
74
+ if (!this.enabled)
75
+ return;
76
+ const value = `${Date.now() + MAX_AGE * 1000}.${crypto.randomBytes(24).toString("base64url")}`;
77
+ res.setHeader("Set-Cookie", `${COOKIE}=${value}.${signature(value, passwordHash())}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${MAX_AGE}`);
78
+ }
79
+ forget(res) {
80
+ res.setHeader("Set-Cookie", `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`);
81
+ }
82
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The live-session hub.
3
+ *
4
+ * The terminal UI is the source of truth for a conversation: while a
5
+ * {@link ../screens/chat.js ChatScreen} is open it owns the message list, the
6
+ * streaming buffer and the busy/paused flags. The web server, however, must be
7
+ * able to (a) show *every* conversation live and (b) drive the running one
8
+ * (pause, resume, send). This hub is the bridge between the two.
9
+ *
10
+ * A screen registers itself once it is activated and unregisters when it is
11
+ * popped. Every time the screen changes something meaningful it calls
12
+ * {@link LiveHub.notify}, which coalesces rapid-fire updates (one per streamed
13
+ * token) and pushes a snapshot to any connected browser.
14
+ */
15
+ export function liveApprovalDetails(tool, args, risk, cwd) {
16
+ return { tool, level: risk.level, arguments: args, cwd, summary: risk.summary, reasons: risk.reasons };
17
+ }
18
+ // Streaming emits tokens very quickly; coalescing to ~12fps keeps the browser
19
+ // smooth without serialising the whole conversation on every token.
20
+ const FLUSH_MS = 80;
21
+ class LiveHub {
22
+ entries = new Map();
23
+ seq = 0;
24
+ /** Register a screen. Returns the stable key used to address it over HTTP. */
25
+ register(agent) {
26
+ for (const entry of this.entries.values()) {
27
+ if (entry.agent === agent)
28
+ return entry.key;
29
+ }
30
+ const key = `L${++this.seq}`;
31
+ this.entries.set(key, {
32
+ key,
33
+ agent,
34
+ cwd: agent.liveCwd(),
35
+ listeners: new Set(),
36
+ timer: null,
37
+ lastSent: "",
38
+ });
39
+ return key;
40
+ }
41
+ unregister(key) {
42
+ if (!key)
43
+ return;
44
+ const entry = this.entries.get(key);
45
+ if (!entry)
46
+ return;
47
+ if (entry.timer)
48
+ clearTimeout(entry.timer);
49
+ this.entries.delete(key);
50
+ }
51
+ has(key) {
52
+ return this.entries.has(key);
53
+ }
54
+ /** All live sessions, newest registration last. */
55
+ list() {
56
+ const out = [];
57
+ for (const entry of this.entries.values()) {
58
+ out.push({ ...entry.agent.liveMeta(), key: entry.key, cwd: entry.cwd });
59
+ }
60
+ return out;
61
+ }
62
+ /** One-off snapshot, or null when the key is unknown. */
63
+ snapshot(key) {
64
+ const entry = this.entries.get(key);
65
+ return entry ? entry.agent.liveSnapshot(key) : null;
66
+ }
67
+ /**
68
+ * Subscribe to a live session. The current snapshot is delivered immediately
69
+ * so a freshly-connected browser never has to wait for the next change.
70
+ */
71
+ subscribe(key, fn) {
72
+ const entry = this.entries.get(key);
73
+ if (!entry)
74
+ return null;
75
+ entry.listeners.add(fn);
76
+ try {
77
+ fn(entry.agent.liveSnapshot(key));
78
+ }
79
+ catch {
80
+ // A broken listener must not break registration.
81
+ }
82
+ return () => {
83
+ entry.listeners.delete(fn);
84
+ };
85
+ }
86
+ /**
87
+ * Mark a session as changed. Snapshots are only produced when somebody is
88
+ * listening, and duplicate snapshots (e.g. a cursor blink) are dropped.
89
+ */
90
+ notify(key) {
91
+ if (!key)
92
+ return;
93
+ const entry = this.entries.get(key);
94
+ if (!entry || entry.listeners.size === 0 || entry.timer)
95
+ return;
96
+ entry.timer = setTimeout(() => {
97
+ entry.timer = null;
98
+ const live = this.entries.get(key);
99
+ if (live)
100
+ this.flush(live);
101
+ }, FLUSH_MS);
102
+ // Don't let a pending flush keep a shutting-down process alive.
103
+ entry.timer.unref?.();
104
+ }
105
+ flush(entry) {
106
+ if (entry.listeners.size === 0)
107
+ return;
108
+ let snapshot;
109
+ let data;
110
+ try {
111
+ snapshot = entry.agent.liveSnapshot(entry.key);
112
+ data = JSON.stringify(snapshot);
113
+ }
114
+ catch {
115
+ return;
116
+ }
117
+ if (data === entry.lastSent)
118
+ return;
119
+ entry.lastSent = data;
120
+ for (const fn of [...entry.listeners]) {
121
+ try {
122
+ fn(snapshot);
123
+ }
124
+ catch {
125
+ // Ignore a failing listener; others still get the update.
126
+ }
127
+ }
128
+ }
129
+ pause(key) {
130
+ const entry = this.entries.get(key);
131
+ if (!entry)
132
+ return false;
133
+ entry.agent.livePause();
134
+ this.notify(key);
135
+ return true;
136
+ }
137
+ resume(key) {
138
+ const entry = this.entries.get(key);
139
+ if (!entry)
140
+ return false;
141
+ entry.agent.liveResume();
142
+ this.notify(key);
143
+ return true;
144
+ }
145
+ send(key, text) {
146
+ const entry = this.entries.get(key);
147
+ if (!entry)
148
+ return false;
149
+ entry.agent.liveSend(text);
150
+ this.notify(key);
151
+ return true;
152
+ }
153
+ approve(key, approved) {
154
+ const entry = this.entries.get(key);
155
+ if (!entry || !entry.agent.liveApprove)
156
+ return false;
157
+ const handled = entry.agent.liveApprove(approved);
158
+ this.notify(key);
159
+ return handled;
160
+ }
161
+ configure(key, selection) {
162
+ const entry = this.entries.get(key);
163
+ if (!entry?.agent.liveConfigure)
164
+ return { ok: false, error: "this session cannot change models" };
165
+ const result = entry.agent.liveConfigure(selection);
166
+ if (result.ok)
167
+ this.notify(key);
168
+ return result;
169
+ }
170
+ setApprovalMode(key, mode) {
171
+ const entry = this.entries.get(key);
172
+ if (!entry?.agent.liveSetApprovalMode)
173
+ return false;
174
+ entry.agent.liveSetApprovalMode(mode);
175
+ this.notify(key);
176
+ return true;
177
+ }
178
+ }
179
+ /** Process-wide singleton shared by the TUI and the web server. */
180
+ export const hub = new LiveHub();