@xingjisoft/neutrino 0.1.3 → 0.1.5
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/README.md +398 -137
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +105 -4
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +87 -0
- package/dist/config.js +121 -13
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +323 -84
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +749 -44
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +201 -45
- package/dist/screens/start.js +64 -16
- package/dist/selection.js +25 -11
- package/dist/state.js +1 -0
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +72 -9
- package/dist/terminal/restore.js +147 -0
- package/dist/terminal/screen.js +15 -0
- package/dist/ui.js +19 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +208 -0
- package/web/app.js +999 -0
- package/web/index.html +87 -0
- package/web/settings.js +193 -0
- package/web/style.css +941 -0
package/dist/terminal/buffer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { C, EMPTY_STYLE } from "../colors.js";
|
|
2
2
|
import { displayWidth, wcwidth } from "../width.js";
|
|
3
|
+
import { isControlChar } from "../sanitize.js";
|
|
3
4
|
const emptyCell = () => ({ ch: " ", style: { bg: C.black } });
|
|
4
5
|
function normalize(style) {
|
|
5
6
|
return style.bg ? style : { ...style, bg: C.black };
|
|
@@ -56,6 +57,12 @@ export class Buffer {
|
|
|
56
57
|
row[x] = { ch: "", style: normalize(style) };
|
|
57
58
|
}
|
|
58
59
|
writeChar(x, y, ch, style) {
|
|
60
|
+
// A control character (ESC, TAB, CR, …) would be written verbatim by the
|
|
61
|
+
// renderer and interpreted by the terminal as a command. Neutralise it to a
|
|
62
|
+
// space, which matches the single-column width wcwidth() reports for it, so
|
|
63
|
+
// the layout stays exactly as the caller measured it.
|
|
64
|
+
if (isControlChar(ch))
|
|
65
|
+
ch = " ";
|
|
59
66
|
if (cellWidth(ch) === 2) {
|
|
60
67
|
if (x + 1 >= this.width) {
|
|
61
68
|
// No room for the tail: degrade to a space so the line never overflows
|
|
@@ -70,8 +77,6 @@ export class Buffer {
|
|
|
70
77
|
this.overwriteCell(x, y, ch, style);
|
|
71
78
|
}
|
|
72
79
|
set(x, y, ch, style = EMPTY_STYLE) {
|
|
73
|
-
if (ch === "\r" || ch === "\n")
|
|
74
|
-
return;
|
|
75
80
|
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
|
|
76
81
|
return;
|
|
77
82
|
this.writeChar(x, y, ch, style);
|
|
@@ -81,8 +86,14 @@ export class Buffer {
|
|
|
81
86
|
return;
|
|
82
87
|
let col = x;
|
|
83
88
|
for (const ch of text) {
|
|
89
|
+
// Measure the character as the caller will have measured it, *then*
|
|
90
|
+
// neutralise control codes, so the column bookkeeping stays identical to
|
|
91
|
+
// the wrapping done upstream (see sanitize.ts).
|
|
92
|
+
const ctrl = isControlChar(ch);
|
|
84
93
|
const w = wcwidth(ch.codePointAt(0) ?? 0);
|
|
85
94
|
if (w === 0) {
|
|
95
|
+
if (ctrl)
|
|
96
|
+
continue; // zero-width control (NUL): drop it
|
|
86
97
|
// Combining mark: attach it to the glyph immediately to the left.
|
|
87
98
|
const px = col - 1;
|
|
88
99
|
if (px >= 0 && px < this.width && this.cells[y][px].ch !== "") {
|
|
@@ -96,7 +107,7 @@ export class Buffer {
|
|
|
96
107
|
col += w;
|
|
97
108
|
continue;
|
|
98
109
|
}
|
|
99
|
-
this.writeChar(col, y, ch, style);
|
|
110
|
+
this.writeChar(col, y, ctrl ? " " : ch, style);
|
|
100
111
|
col += w;
|
|
101
112
|
}
|
|
102
113
|
}
|
|
@@ -124,6 +135,16 @@ export class Buffer {
|
|
|
124
135
|
}
|
|
125
136
|
this.writeText(x, y + h - 1, bottom, style);
|
|
126
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Flag a cell as non-copyable chrome (e.g. a scrollbar). The flag lives on
|
|
140
|
+
* the cell and is cleared automatically whenever the cell is written again,
|
|
141
|
+
* since every frame starts from a fresh buffer.
|
|
142
|
+
*/
|
|
143
|
+
markChrome(x, y) {
|
|
144
|
+
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
|
|
145
|
+
return;
|
|
146
|
+
this.cells[y][x].chrome = true;
|
|
147
|
+
}
|
|
127
148
|
clear() {
|
|
128
149
|
for (let j = 0; j < this.height; j++) {
|
|
129
150
|
const row = this.cells[j];
|
package/dist/terminal/input.js
CHANGED
|
@@ -54,9 +54,20 @@ export class InputReader {
|
|
|
54
54
|
stdin.removeListener("data", this.dataHandler);
|
|
55
55
|
if (this.escTimer)
|
|
56
56
|
clearTimeout(this.escTimer);
|
|
57
|
-
|
|
58
|
-
stdin.
|
|
59
|
-
|
|
57
|
+
try {
|
|
58
|
+
if (stdin.isTTY)
|
|
59
|
+
stdin.setRawMode(false);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The console already went away; leaving raw mode is best-effort here and
|
|
63
|
+
// failing must not abort the rest of the shutdown.
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
stdin.pause();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Same as above.
|
|
70
|
+
}
|
|
60
71
|
}
|
|
61
72
|
onData(chunk) {
|
|
62
73
|
this.pending += this.decoder.decode(chunk, { stream: true });
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
+
import { restoreTerminalSync, markTuiActive } from "./restore.js";
|
|
3
|
+
import { isControlChar } from "../sanitize.js";
|
|
2
4
|
function styleKey(st) {
|
|
3
5
|
let k = "";
|
|
4
6
|
if (st.fg)
|
|
@@ -43,23 +45,71 @@ function rowSignature(row) {
|
|
|
43
45
|
}
|
|
44
46
|
export class Renderer {
|
|
45
47
|
prevSig = [];
|
|
48
|
+
disposed = false;
|
|
46
49
|
prevW = 0;
|
|
47
50
|
prevH = 0;
|
|
48
51
|
out = process.stdout;
|
|
52
|
+
/**
|
|
53
|
+
* The most recent frame and the rows that were force-repainted for it. Kept
|
|
54
|
+
* so `repaint()` can re-emit the exact same frame — without re-running the
|
|
55
|
+
* screen's (potentially expensive) render — to heal any drift the terminal
|
|
56
|
+
* introduced behind our back.
|
|
57
|
+
*/
|
|
58
|
+
lastBuf = null;
|
|
59
|
+
lastForce = [];
|
|
49
60
|
init() {
|
|
61
|
+
// Tell the crash guard the terminal is ours *before* touching it, so a
|
|
62
|
+
// crash that skips dispose() can still undo every switch below.
|
|
63
|
+
markTuiActive();
|
|
50
64
|
this.write("\x1b[?1049h"); // alternate screen
|
|
51
65
|
this.write("\x1b[?25l"); // hide cursor
|
|
52
66
|
this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
|
|
53
67
|
this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
|
|
54
68
|
}
|
|
55
69
|
dispose() {
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
if (this.disposed)
|
|
71
|
+
return;
|
|
72
|
+
this.disposed = true;
|
|
73
|
+
// stdout.write may still be queued when App.quit calls process.exit.
|
|
74
|
+
// Use the same synchronous teardown as the crash/exit guard.
|
|
75
|
+
restoreTerminalSync();
|
|
61
76
|
}
|
|
62
|
-
|
|
77
|
+
/**
|
|
78
|
+
* @param forceRows rows to repaint unconditionally, even when their contents
|
|
79
|
+
* are unchanged. Screens use this for persistent chrome (input box, hints)
|
|
80
|
+
* so that terminal-side drift can never leave it stale (see
|
|
81
|
+
* `Screen.alwaysRepaintRows`).
|
|
82
|
+
*/
|
|
83
|
+
render(buf, forceRows = []) {
|
|
84
|
+
this.lastBuf = buf;
|
|
85
|
+
this.lastForce = forceRows;
|
|
86
|
+
this.draw(buf, forceRows);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Re-emit the last frame in full. Cheap (no screen re-render) and visually a
|
|
90
|
+
* no-op when nothing drifted, but it repairs rows the terminal moved on its
|
|
91
|
+
* own (resize reflow, stray scroll). Called periodically as a safety net.
|
|
92
|
+
*/
|
|
93
|
+
repaint() {
|
|
94
|
+
if (!this.lastBuf)
|
|
95
|
+
return;
|
|
96
|
+
const all = [];
|
|
97
|
+
for (let y = 0; y < this.lastBuf.height; y++)
|
|
98
|
+
all.push(y);
|
|
99
|
+
this.draw(this.lastBuf, all);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Ring the terminal bell.
|
|
103
|
+
*
|
|
104
|
+
* Written straight to the stream rather than into a frame: BEL has no visual
|
|
105
|
+
* effect, so it cannot desync the diffed rows, and it must never end up in
|
|
106
|
+
* `lastBuf` where repaint() would replay it.
|
|
107
|
+
*/
|
|
108
|
+
bell(times = 1) {
|
|
109
|
+
const count = Math.max(1, Math.trunc(times) || 1);
|
|
110
|
+
this.write("\x07".repeat(count));
|
|
111
|
+
}
|
|
112
|
+
draw(buf, forceRows) {
|
|
63
113
|
const w = buf.width;
|
|
64
114
|
const h = buf.height;
|
|
65
115
|
if (w !== this.prevW || h !== this.prevH) {
|
|
@@ -67,11 +117,12 @@ export class Renderer {
|
|
|
67
117
|
this.prevW = w;
|
|
68
118
|
this.prevH = h;
|
|
69
119
|
}
|
|
120
|
+
const force = forceRows.length ? new Set(forceRows) : null;
|
|
70
121
|
let out = "";
|
|
71
122
|
for (let y = 0; y < h; y++) {
|
|
72
123
|
const row = buf.cells[y];
|
|
73
124
|
const sig = rowSignature(row);
|
|
74
|
-
if (sig !== this.prevSig[y]) {
|
|
125
|
+
if (sig !== this.prevSig[y] || (force !== null && force.has(y))) {
|
|
75
126
|
out += this.renderRow(y, row);
|
|
76
127
|
this.prevSig[y] = sig;
|
|
77
128
|
}
|
|
@@ -96,12 +147,24 @@ export class Renderer {
|
|
|
96
147
|
s += sgr(cell.style);
|
|
97
148
|
lastKey = sk;
|
|
98
149
|
}
|
|
99
|
-
|
|
150
|
+
// Final safety net: the byte stream is the one place a stray control
|
|
151
|
+
// character would be executed by the terminal (cursor moves, screen
|
|
152
|
+
// clears, …). The Buffer already neutralises them; this guarantees it
|
|
153
|
+
// even if a cell is ever populated by some other path.
|
|
154
|
+
s += isControlChar(cell.ch) ? " " : cell.ch;
|
|
100
155
|
}
|
|
101
156
|
s += "\x1b[0m";
|
|
102
157
|
return s;
|
|
103
158
|
}
|
|
104
159
|
write(chunk) {
|
|
105
|
-
this.out.
|
|
160
|
+
if (this.disposed || this.out.destroyed || this.out.writableEnded)
|
|
161
|
+
return;
|
|
162
|
+
try {
|
|
163
|
+
this.out.write(chunk);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// A terminal that vanished (closed window, dying conhost) must not take
|
|
167
|
+
// the whole session down with it.
|
|
168
|
+
}
|
|
106
169
|
}
|
|
107
170
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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[?2004l" + // bracketed paste off
|
|
16
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l" + // mouse tracking off
|
|
17
|
+
"\x1b[?25h" + // cursor visible
|
|
18
|
+
"\x1b[0m" + // default attributes
|
|
19
|
+
"\x1b[?1049l"; // back to the main screen
|
|
20
|
+
/** Whether a TUI has claimed the terminal and its state still needs undoing. */
|
|
21
|
+
let active = false;
|
|
22
|
+
let restored = false;
|
|
23
|
+
let installed = false;
|
|
24
|
+
/** Called once the renderer has entered the alternate screen and raw mode. */
|
|
25
|
+
export function markTuiActive() {
|
|
26
|
+
// Unconditional: only the renderer calls this, and the renderer is only ever
|
|
27
|
+
// constructed once the app has decided it owns a real terminal.
|
|
28
|
+
active = true;
|
|
29
|
+
restored = false;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Puts the terminal back into a usable state, synchronously and at most once.
|
|
33
|
+
*
|
|
34
|
+
* Everything here has to work while the process is dying: no promises, no
|
|
35
|
+
* asynchronous writes, and every step individually guarded so one failure
|
|
36
|
+
* cannot skip the rest (a broken `setRawMode` must not leave the cursor
|
|
37
|
+
* hidden, for example).
|
|
38
|
+
*/
|
|
39
|
+
export function restoreTerminalSync() {
|
|
40
|
+
if (!active || restored)
|
|
41
|
+
return false;
|
|
42
|
+
let outputRestored = false;
|
|
43
|
+
let inputRestored = false;
|
|
44
|
+
try {
|
|
45
|
+
// fd 1 write is the only one guaranteed to be flushed by a dying process;
|
|
46
|
+
// `process.stdout.write` may queue the bytes and lose them at exit.
|
|
47
|
+
const bytes = Buffer.from(TUI_TEARDOWN);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
while (offset < bytes.length) {
|
|
50
|
+
const written = fs.writeSync(1, bytes, offset, bytes.length - offset);
|
|
51
|
+
if (written === 0)
|
|
52
|
+
throw new Error("terminal write made no progress");
|
|
53
|
+
offset += written;
|
|
54
|
+
}
|
|
55
|
+
outputRestored = true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
try {
|
|
59
|
+
process.stdout.write(TUI_TEARDOWN);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The terminal is gone; there is nothing left to restore.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
if (process.stdin.isTTY && process.stdin.isRaw)
|
|
67
|
+
process.stdin.setRawMode(false);
|
|
68
|
+
inputRestored = true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// stdin already closed, or not a TTY we can reconfigure.
|
|
72
|
+
}
|
|
73
|
+
// A queued or failed write must not disarm the synchronous exit retry.
|
|
74
|
+
restored = outputRestored && inputRestored;
|
|
75
|
+
return restored;
|
|
76
|
+
}
|
|
77
|
+
/** Appends a crash entry to `~/.neutrino/crash.log` and returns the path. */
|
|
78
|
+
export function writeCrashLog(kind, err) {
|
|
79
|
+
const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
|
|
80
|
+
const entry = `[${new Date().toISOString()}] ${kind} pid=${process.pid}\n${detail}\n\n`;
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
83
|
+
const file = path.join(NEUTRINO_DIR, "crash.log");
|
|
84
|
+
fs.appendFileSync(file, entry, "utf8");
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Installs process-wide handlers that restore the terminal before the process
|
|
93
|
+
* dies.
|
|
94
|
+
*
|
|
95
|
+
* Without this, any throw that escapes a timer, a stream callback or a floating
|
|
96
|
+
* promise kills the process with raw mode still on and the alternate screen
|
|
97
|
+
* still active. The visible result is a terminal that works but spews
|
|
98
|
+
* `[<b>;<x>;<y>M` mouse reports at the shell prompt, because the shell now
|
|
99
|
+
* receives the mouse events the TUI asked the terminal to send.
|
|
100
|
+
*/
|
|
101
|
+
export function installCrashGuard() {
|
|
102
|
+
if (installed)
|
|
103
|
+
return;
|
|
104
|
+
installed = true;
|
|
105
|
+
let flushed = false;
|
|
106
|
+
const finalize = () => {
|
|
107
|
+
restoreTerminalSync();
|
|
108
|
+
if (flushed)
|
|
109
|
+
return true;
|
|
110
|
+
flushed = true;
|
|
111
|
+
return flushSessionCheckpoints();
|
|
112
|
+
};
|
|
113
|
+
process.on("exit", () => {
|
|
114
|
+
if (!finalize()) {
|
|
115
|
+
try {
|
|
116
|
+
fs.writeSync(2, "\nneutrino: some conversation changes could not be saved; check disk access and crash.log.\n");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// The crash log is the remaining record if stderr is unavailable.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// These signals otherwise bypass Node's exit hook, including Ctrl+Break on
|
|
124
|
+
// Windows. SIGINT/SIGTERM are handled by App.quit (or the web server).
|
|
125
|
+
process.on("SIGHUP", () => process.exit(129));
|
|
126
|
+
process.on("SIGBREAK", () => process.exit(149));
|
|
127
|
+
const fatal = (kind, err) => {
|
|
128
|
+
// Restore first: the terminal must be usable even if logging itself fails.
|
|
129
|
+
const saved = finalize();
|
|
130
|
+
const file = writeCrashLog(kind, err);
|
|
131
|
+
const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
|
|
132
|
+
try {
|
|
133
|
+
const tail = file ? `\nFull log: ${file}` : "";
|
|
134
|
+
fs.writeSync(2, `\nneutrino stopped: ${kind}\n\n${detail}\n\n` +
|
|
135
|
+
(saved
|
|
136
|
+
? "Check history for the latest saved conversation snapshots."
|
|
137
|
+
: "Some conversation changes could not be saved; check disk access and crash.log.") +
|
|
138
|
+
`${tail}\n`);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// stderr is gone too; the crash log is the only record.
|
|
142
|
+
}
|
|
143
|
+
process.exit(1);
|
|
144
|
+
};
|
|
145
|
+
process.on("uncaughtException", (err) => fatal("uncaught exception", err));
|
|
146
|
+
process.on("unhandledRejection", (err) => fatal("unhandled rejection", err));
|
|
147
|
+
}
|
package/dist/terminal/screen.js
CHANGED
|
@@ -9,6 +9,21 @@ export class Screen {
|
|
|
9
9
|
scrollbar() {
|
|
10
10
|
return null;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Rows this screen draws as persistent "chrome" (an input box, key hints, …)
|
|
14
|
+
* that must be repainted on *every* frame, even when their contents are
|
|
15
|
+
* unchanged.
|
|
16
|
+
*
|
|
17
|
+
* The renderer normally emits only rows whose cells changed, which is what
|
|
18
|
+
* keeps redraws cheap. But the terminal can shift its own contents behind our
|
|
19
|
+
* back — ConPTY reflowing the buffer mid-resize, a size-race repaint, a stray
|
|
20
|
+
* scroll — and a diffed row would then stay stale forever (the classic
|
|
21
|
+
* "input box swallowed by the log" artefact). Redrawing this small strip by
|
|
22
|
+
* absolute position every frame makes it self-healing.
|
|
23
|
+
*/
|
|
24
|
+
alwaysRepaintRows() {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
12
27
|
/**
|
|
13
28
|
* Handle a bracketed (or detected) paste. `text` already has its newlines
|
|
14
29
|
* flattened to spaces, so it can be inserted as a single line. Screens that
|
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 = "";
|
|
@@ -199,6 +215,9 @@ export function drawScrollbar(buf, x, top, bottom, total, viewport, offset, opts
|
|
|
199
215
|
for (let i = 0; i < g.trackH; i++) {
|
|
200
216
|
const isThumb = i >= g.thumbTop && i < g.thumbTop + g.thumbH;
|
|
201
217
|
buf.set(x, top + i, isThumb ? "█" : "│", { fg: isThumb ? thumbColor : trackColor });
|
|
218
|
+
// The bar is chrome, not content: mark it so copying a selection never
|
|
219
|
+
// drags the scrollbar (or the padding before it) into the clipboard.
|
|
220
|
+
buf.markChrome(x, top + i);
|
|
202
221
|
}
|
|
203
222
|
}
|
|
204
223
|
export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
|
package/dist/web/auth.js
ADDED
|
@@ -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
|
+
}
|
package/dist/web/hub.js
ADDED
|
@@ -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();
|