@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/agents.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { claudeProjectKey, sameCwd } from "./cwd.js";
|
|
5
6
|
const HOME = os.homedir();
|
|
6
7
|
function parseJsonlLines(filePath) {
|
|
7
8
|
let raw;
|
|
@@ -27,6 +28,37 @@ function parseJsonlLines(filePath) {
|
|
|
27
28
|
}
|
|
28
29
|
return out;
|
|
29
30
|
}
|
|
31
|
+
const HEAD_BYTES = 64 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* Read at most `maxBytes` from the start of a file.
|
|
34
|
+
*
|
|
35
|
+
* Session transcripts can be several megabytes, but the working directory is
|
|
36
|
+
* always recorded up front, so scanning just the head keeps the history list
|
|
37
|
+
* fast when a project holds many sessions.
|
|
38
|
+
*/
|
|
39
|
+
function readHead(filePath, maxBytes = HEAD_BYTES) {
|
|
40
|
+
let fd = null;
|
|
41
|
+
try {
|
|
42
|
+
fd = fs.openSync(filePath, "r");
|
|
43
|
+
const len = Math.min(fs.fstatSync(fd).size, maxBytes);
|
|
44
|
+
if (len <= 0)
|
|
45
|
+
return "";
|
|
46
|
+
const buf = Buffer.alloc(len);
|
|
47
|
+
fs.readSync(fd, buf, 0, len, 0);
|
|
48
|
+
return buf.toString("utf-8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (fd !== null) {
|
|
55
|
+
try {
|
|
56
|
+
fs.closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
30
62
|
function contentText(content) {
|
|
31
63
|
if (typeof content === "string")
|
|
32
64
|
return content;
|
|
@@ -78,9 +110,27 @@ function claudeTitle(lines) {
|
|
|
78
110
|
}
|
|
79
111
|
return "";
|
|
80
112
|
}
|
|
81
|
-
|
|
113
|
+
const CWD_FIELD_RE = /"cwd"\s*:\s*("(?:[^"\\]|\\.)*")/;
|
|
114
|
+
/** Working directory recorded in a Claude Code transcript ("" when unknown). */
|
|
115
|
+
function claudeSessionCwd(filePath) {
|
|
116
|
+
const m = CWD_FIELD_RE.exec(readHead(filePath));
|
|
117
|
+
if (!m)
|
|
118
|
+
return "";
|
|
119
|
+
try {
|
|
120
|
+
const v = JSON.parse(m[1]);
|
|
121
|
+
return typeof v === "string" ? v : "";
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function listClaude(cwd) {
|
|
82
128
|
const metas = [];
|
|
129
|
+
const wantKey = claudeProjectKey(cwd);
|
|
83
130
|
for (const dir of claudeProjectDirs()) {
|
|
131
|
+
// Claude names each project dir after its cwd, so the dir name still tells
|
|
132
|
+
// us the directory when a transcript predates the `cwd` field.
|
|
133
|
+
const dirKey = path.basename(dir).toLowerCase();
|
|
84
134
|
let files;
|
|
85
135
|
try {
|
|
86
136
|
files = fs.readdirSync(dir);
|
|
@@ -99,6 +149,10 @@ function listClaude() {
|
|
|
99
149
|
catch {
|
|
100
150
|
continue;
|
|
101
151
|
}
|
|
152
|
+
// History is scoped to the current project: skip other directories.
|
|
153
|
+
const sessionCwd = claudeSessionCwd(fp);
|
|
154
|
+
if (sessionCwd ? !sameCwd(sessionCwd, cwd) : dirKey !== wantKey)
|
|
155
|
+
continue;
|
|
102
156
|
const id = f.slice(0, -".jsonl".length);
|
|
103
157
|
const title = cleanTitle(claudeTitle(parseJsonlLines(fp)) || id.slice(0, 8));
|
|
104
158
|
metas.push({
|
|
@@ -216,11 +270,41 @@ function codexMessageText(payload) {
|
|
|
216
270
|
.join("")
|
|
217
271
|
.trim();
|
|
218
272
|
}
|
|
219
|
-
|
|
273
|
+
/** Working directory recorded in a Codex rollout file ("" when unknown). */
|
|
274
|
+
function codexSessionCwd(filePath) {
|
|
275
|
+
let fallback = "";
|
|
276
|
+
for (const line of readHead(filePath).split("\n")) {
|
|
277
|
+
const t = line.trim();
|
|
278
|
+
if (!t)
|
|
279
|
+
continue;
|
|
280
|
+
let o;
|
|
281
|
+
try {
|
|
282
|
+
o = JSON.parse(t);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const payload = o.payload;
|
|
288
|
+
const c = typeof payload?.cwd === "string" ? payload.cwd : typeof o.cwd === "string" ? o.cwd : "";
|
|
289
|
+
if (!c)
|
|
290
|
+
continue;
|
|
291
|
+
// session_meta is authoritative; other records (turn_context) only serve
|
|
292
|
+
// as a fallback for older rollouts.
|
|
293
|
+
if (o.type === "session_meta")
|
|
294
|
+
return c;
|
|
295
|
+
if (!fallback)
|
|
296
|
+
fallback = c;
|
|
297
|
+
}
|
|
298
|
+
return fallback;
|
|
299
|
+
}
|
|
300
|
+
function listCodex(cwd) {
|
|
220
301
|
const index = codexIndex();
|
|
221
302
|
const files = codexRolloutFiles();
|
|
222
303
|
const metas = [];
|
|
223
304
|
for (const [id, fp] of files) {
|
|
305
|
+
// History is scoped to the current project: skip other directories.
|
|
306
|
+
if (!sameCwd(codexSessionCwd(fp), cwd))
|
|
307
|
+
continue;
|
|
224
308
|
const info = index.get(id);
|
|
225
309
|
let title = info?.title ?? "";
|
|
226
310
|
let updated = info?.updated_at ?? "";
|
|
@@ -320,15 +404,28 @@ function opencodeModelName(modelJson) {
|
|
|
320
404
|
return "";
|
|
321
405
|
}
|
|
322
406
|
}
|
|
323
|
-
function listOpencode() {
|
|
407
|
+
function listOpencode(cwd) {
|
|
324
408
|
const db = opencodeDbHandle();
|
|
325
409
|
if (!db)
|
|
326
410
|
return [];
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
411
|
+
// Current schemas store the project directory on the session row; older ones
|
|
412
|
+
// keep it on the related project row. Try both, newest schema first.
|
|
413
|
+
const queries = [
|
|
414
|
+
"SELECT id, title, agent, model, time_created, time_updated, directory AS dir FROM session ORDER BY time_updated DESC",
|
|
415
|
+
"SELECT s.id AS id, s.title AS title, s.agent AS agent, s.model AS model, s.time_created AS time_created, s.time_updated AS time_updated, p.worktree AS dir FROM session s LEFT JOIN project p ON p.id = s.project_id ORDER BY s.time_updated DESC",
|
|
416
|
+
];
|
|
417
|
+
for (const sql of queries) {
|
|
418
|
+
let rows;
|
|
419
|
+
try {
|
|
420
|
+
rows = db.prepare(sql).all();
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
continue; // this schema has no project path column, try the next query
|
|
424
|
+
}
|
|
425
|
+
// History is scoped to the current project: skip other directories.
|
|
426
|
+
return rows
|
|
427
|
+
.filter((r) => sameCwd(typeof r.dir === "string" ? r.dir : "", cwd))
|
|
428
|
+
.map((r) => ({
|
|
332
429
|
id: String(r.id ?? ""),
|
|
333
430
|
title: cleanTitle(typeof r.title === "string" ? r.title : "").slice(0, 80),
|
|
334
431
|
created_at: iso(r.time_created),
|
|
@@ -336,9 +433,7 @@ function listOpencode() {
|
|
|
336
433
|
model: opencodeModelName(r.model) || (typeof r.agent === "string" ? r.agent : ""),
|
|
337
434
|
}));
|
|
338
435
|
}
|
|
339
|
-
|
|
340
|
-
return [];
|
|
341
|
-
}
|
|
436
|
+
return [];
|
|
342
437
|
}
|
|
343
438
|
function loadOpencode(id) {
|
|
344
439
|
const db = opencodeDbHandle();
|
|
@@ -380,12 +475,16 @@ function loadOpencode(id) {
|
|
|
380
475
|
}
|
|
381
476
|
}
|
|
382
477
|
// ---------------- Public API ----------------
|
|
383
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Sessions of `agent` that were started in `cwd` (the directory neutrino runs
|
|
480
|
+
* in), newest first. Sessions from other directories are not shown.
|
|
481
|
+
*/
|
|
482
|
+
export function listAgentSessions(agent, cwd = process.cwd()) {
|
|
384
483
|
if (agent === "claude")
|
|
385
|
-
return listClaude();
|
|
484
|
+
return listClaude(cwd);
|
|
386
485
|
if (agent === "codex")
|
|
387
|
-
return listCodex();
|
|
388
|
-
return listOpencode();
|
|
486
|
+
return listCodex(cwd);
|
|
487
|
+
return listOpencode(cwd);
|
|
389
488
|
}
|
|
390
489
|
export function loadAgentMessages(agent, id) {
|
|
391
490
|
if (agent === "claude")
|
package/dist/app.js
CHANGED
|
@@ -2,6 +2,7 @@ import process from "node:process";
|
|
|
2
2
|
import { Buffer } from "./terminal/buffer.js";
|
|
3
3
|
import { InputReader } from "./terminal/input.js";
|
|
4
4
|
import { Renderer } from "./terminal/renderer.js";
|
|
5
|
+
import { installCrashGuard, writeCrashLog } from "./terminal/restore.js";
|
|
5
6
|
import { defaultState } from "./state.js";
|
|
6
7
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
8
|
import { C } from "./colors.js";
|
|
@@ -14,8 +15,13 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
|
|
|
14
15
|
import { ModelSelectScreen } from "./screens/modelSelect.js";
|
|
15
16
|
import { SettingsScreen } from "./screens/settings.js";
|
|
16
17
|
import { StartScreen } from "./screens/start.js";
|
|
18
|
+
import { startWebServer } from "./web/server.js";
|
|
17
19
|
const CURSOR_BLINK_MS = 530;
|
|
18
20
|
const TOAST_MS = 1600;
|
|
21
|
+
// How often the whole frame is re-emitted as a safety net, so terminal-side
|
|
22
|
+
// drift (ConPTY reflow, stray scroll) can never leave any row stale for long.
|
|
23
|
+
// Re-emitting the cached frame is a no-op when nothing drifted.
|
|
24
|
+
const REPAINT_MS = 2000;
|
|
19
25
|
export class App {
|
|
20
26
|
state = defaultState();
|
|
21
27
|
width;
|
|
@@ -28,7 +34,11 @@ export class App {
|
|
|
28
34
|
blinkTimer = null;
|
|
29
35
|
cursorTimer = null;
|
|
30
36
|
toastTimer = null;
|
|
37
|
+
repaintTimer = null;
|
|
31
38
|
toast = "";
|
|
39
|
+
// Consecutive failed renders. A screen that cannot draw itself is fatal;
|
|
40
|
+
// a single bad frame is not.
|
|
41
|
+
renderFailures = 0;
|
|
32
42
|
// Most recently rendered frame. Kept so a selection can be turned back into
|
|
33
43
|
// text without having to re-run the screen's render.
|
|
34
44
|
lastBuffer = null;
|
|
@@ -42,6 +52,7 @@ export class App {
|
|
|
42
52
|
// landed, so the thumb stays under the pointer as it moves.
|
|
43
53
|
barDrag = null;
|
|
44
54
|
resizeHandler = () => this.onResize();
|
|
55
|
+
webHandle = null;
|
|
45
56
|
constructor() {
|
|
46
57
|
this.width = process.stdout.columns || 80;
|
|
47
58
|
this.height = process.stdout.rows || 24;
|
|
@@ -71,7 +82,21 @@ export class App {
|
|
|
71
82
|
this.state.selectedModelApiKey = String(config.selected_model_api_key ?? this.state.selectedModelApiKey);
|
|
72
83
|
this.state.selectedModelStreaming = Boolean(config.selected_model_streaming ?? true);
|
|
73
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Mount the browser UI on the given port. Safe to call once; subsequent calls
|
|
87
|
+
* return the already-running server.
|
|
88
|
+
*/
|
|
89
|
+
async enableWeb(port, host, auth = true) {
|
|
90
|
+
if (this.webHandle)
|
|
91
|
+
return this.webHandle;
|
|
92
|
+
this.webHandle = await startWebServer({ port, host, auth });
|
|
93
|
+
return this.webHandle;
|
|
94
|
+
}
|
|
74
95
|
async start() {
|
|
96
|
+
// Installed before anything touches the terminal: from here on, a crash
|
|
97
|
+
// restores mouse tracking, raw mode and the alternate screen instead of
|
|
98
|
+
// handing the shell a terminal that types mouse reports.
|
|
99
|
+
installCrashGuard();
|
|
75
100
|
this.renderer.init();
|
|
76
101
|
this.input.start((ev) => this.handleEvent(ev));
|
|
77
102
|
process.stdout.on("resize", this.resizeHandler);
|
|
@@ -81,6 +106,7 @@ export class App {
|
|
|
81
106
|
if (this.syncSize())
|
|
82
107
|
this.render();
|
|
83
108
|
}, 200);
|
|
109
|
+
this.repaintTimer = setInterval(() => this.renderer.repaint(), REPAINT_MS);
|
|
84
110
|
this.pushScreen(new StartScreen());
|
|
85
111
|
}
|
|
86
112
|
onResize() {
|
|
@@ -106,6 +132,29 @@ export class App {
|
|
|
106
132
|
this.render();
|
|
107
133
|
}
|
|
108
134
|
render() {
|
|
135
|
+
try {
|
|
136
|
+
this.renderFrame();
|
|
137
|
+
this.renderFailures = 0;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// Rendering runs from timers and stream callbacks too, so an escaping
|
|
141
|
+
// throw here is equally fatal.
|
|
142
|
+
this.renderFailures++;
|
|
143
|
+
if (this.renderFailures === 1)
|
|
144
|
+
this.noteInternalError("render", err);
|
|
145
|
+
// A screen that cannot be drawn at all means there is no UI left; stop
|
|
146
|
+
// instead of spinning on a broken frame every 60 ms.
|
|
147
|
+
if (this.renderFailures > 30)
|
|
148
|
+
this.quit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
noteInternalError(kind, err) {
|
|
152
|
+
const file = writeCrashLog(kind, err);
|
|
153
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
154
|
+
const where = file ? ` - details in ${file}` : "";
|
|
155
|
+
this.showToast(`internal error (${kind}): ${detail}${where}`);
|
|
156
|
+
}
|
|
157
|
+
renderFrame() {
|
|
109
158
|
// Start/stop the "thinking" blink animation. While the agent is thinking,
|
|
110
159
|
// a timer toggles the blink phase and re-renders so the top status bar
|
|
111
160
|
// flashes to draw the user's attention.
|
|
@@ -133,7 +182,7 @@ export class App {
|
|
|
133
182
|
if (this.state.selection)
|
|
134
183
|
applySelection(buf, this.state.selection);
|
|
135
184
|
this.drawToast(buf);
|
|
136
|
-
this.renderer.render(buf);
|
|
185
|
+
this.renderer.render(buf, this.top?.alwaysRepaintRows() ?? []);
|
|
137
186
|
}
|
|
138
187
|
drawToast(buf) {
|
|
139
188
|
if (!this.toast)
|
|
@@ -161,6 +210,16 @@ export class App {
|
|
|
161
210
|
this.state.cursorOn = true;
|
|
162
211
|
}
|
|
163
212
|
handleEvent(ev) {
|
|
213
|
+
try {
|
|
214
|
+
this.dispatchEvent(ev);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// This runs inside a stdin 'data' callback, where a throw is an uncaught
|
|
218
|
+
// exception that would kill the app mid-keystroke.
|
|
219
|
+
this.noteInternalError("input handler", err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
dispatchEvent(ev) {
|
|
164
223
|
if (ev.type === "paste") {
|
|
165
224
|
// Pasted text arrives with its newlines already flattened to spaces, so
|
|
166
225
|
// it is inserted into the input instead of being submitted.
|
|
@@ -240,6 +299,15 @@ export class App {
|
|
|
240
299
|
return;
|
|
241
300
|
}
|
|
242
301
|
if (ev.type === "move") {
|
|
302
|
+
// A release can be lost (the pointer left the window, the terminal did
|
|
303
|
+
// not report button-up, …). Motion with no button held means the gesture
|
|
304
|
+
// is over, so drop any stale press/drag instead of letting a bare hover
|
|
305
|
+
// keep scrolling the log or grow a phantom selection.
|
|
306
|
+
if (!ev.drag) {
|
|
307
|
+
this.pressed = false;
|
|
308
|
+
this.pressStart = null;
|
|
309
|
+
this.barDrag = null;
|
|
310
|
+
}
|
|
243
311
|
if (this.barDrag) {
|
|
244
312
|
const bar = this.top?.scrollbar();
|
|
245
313
|
if (bar) {
|
|
@@ -301,6 +369,24 @@ export class App {
|
|
|
301
369
|
copyToClipboard(text);
|
|
302
370
|
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
303
371
|
}
|
|
372
|
+
// Public so screens can surface transient status (e.g. "reviewing 3/3").
|
|
373
|
+
/**
|
|
374
|
+
* Audible cue for something the user is waiting on.
|
|
375
|
+
*
|
|
376
|
+
* Silent when `sound_alerts` is off, so the bell stays a preference rather
|
|
377
|
+
* than unavoidable noise. A pending approval rings twice: it is the one event
|
|
378
|
+
* that actually blocks the agent, and a single blip is easy to miss.
|
|
379
|
+
*/
|
|
380
|
+
bell(kind = "turn") {
|
|
381
|
+
try {
|
|
382
|
+
if (loadConfig().sound_alerts === false)
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// A config we cannot read must not swallow the cue.
|
|
387
|
+
}
|
|
388
|
+
this.renderer.bell(kind === "approval" ? 2 : 1);
|
|
389
|
+
}
|
|
304
390
|
showToast(text) {
|
|
305
391
|
this.toast = text;
|
|
306
392
|
if (this.toastTimer)
|
|
@@ -329,16 +415,20 @@ export class App {
|
|
|
329
415
|
saveConfig(config);
|
|
330
416
|
}
|
|
331
417
|
startNewChat(text) {
|
|
418
|
+
this.restoreSelection();
|
|
332
419
|
this.pushScreen(new ChatScreen(text));
|
|
333
420
|
}
|
|
334
421
|
loadSession(data) {
|
|
335
422
|
const screen = new ChatScreen("");
|
|
336
423
|
screen.sessionId = data.id;
|
|
337
424
|
screen.loadedMessages = data.messages ?? [];
|
|
425
|
+
screen.loadedCwd = data.cwd ?? null;
|
|
338
426
|
screen.loadedProvider = data.provider || null;
|
|
339
427
|
screen.loadedModel = data.model || null;
|
|
340
428
|
screen.loadedIntensity = data.intensity || null;
|
|
341
429
|
screen.loadedTokens = data.tokens ?? null;
|
|
430
|
+
screen.loadedContext = data.context ?? null;
|
|
431
|
+
screen.loadedApprovalMode = data.approval_mode ?? null;
|
|
342
432
|
if (data.provider)
|
|
343
433
|
this.state.selectedProvider = data.provider;
|
|
344
434
|
if (data.model)
|
|
@@ -367,12 +457,16 @@ export class App {
|
|
|
367
457
|
return;
|
|
368
458
|
this.pushScreen(new SettingsScreen());
|
|
369
459
|
}
|
|
370
|
-
quit() {
|
|
460
|
+
quit(exitCode = 0) {
|
|
371
461
|
if (this.running)
|
|
372
462
|
return;
|
|
373
463
|
this.running = true;
|
|
374
464
|
if (this.sizeTimer)
|
|
375
465
|
clearInterval(this.sizeTimer);
|
|
466
|
+
if (this.repaintTimer) {
|
|
467
|
+
clearInterval(this.repaintTimer);
|
|
468
|
+
this.repaintTimer = null;
|
|
469
|
+
}
|
|
376
470
|
if (this.blinkTimer) {
|
|
377
471
|
clearInterval(this.blinkTimer);
|
|
378
472
|
this.blinkTimer = null;
|
|
@@ -385,8 +479,15 @@ export class App {
|
|
|
385
479
|
clearTimeout(this.toastTimer);
|
|
386
480
|
this.toastTimer = null;
|
|
387
481
|
}
|
|
388
|
-
|
|
482
|
+
// Disable mouse reporting before input returns to cooked/echo mode.
|
|
389
483
|
this.renderer.dispose();
|
|
390
|
-
|
|
484
|
+
try {
|
|
485
|
+
this.input.stop();
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// Terminal restoration has already run, even if stdin is unusable.
|
|
489
|
+
}
|
|
490
|
+
void this.webHandle?.close();
|
|
491
|
+
process.exit(exitCode);
|
|
391
492
|
}
|
|
392
493
|
}
|
package/dist/bin.js
CHANGED
|
@@ -1,9 +1,181 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { App } from "./app.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { installCrashGuard } from "./terminal/restore.js";
|
|
5
|
+
import { startWebServer } from "./web/server.js";
|
|
6
|
+
const DEFAULT_WEB_PORT = 8765;
|
|
7
|
+
/** Binding here makes the UI reachable from every interface. */
|
|
8
|
+
const WILDCARD_HOST = "0.0.0.0";
|
|
9
|
+
function parsePort(raw) {
|
|
10
|
+
if (!raw)
|
|
11
|
+
throw new Error("missing port number");
|
|
12
|
+
if (!/^\d+$/.test(raw))
|
|
13
|
+
throw new Error(`invalid port "${raw}"`);
|
|
14
|
+
const port = Number(raw);
|
|
15
|
+
if (port < 0 || port > 65535)
|
|
16
|
+
throw new Error(`port out of range: ${raw}`);
|
|
17
|
+
return port;
|
|
18
|
+
}
|
|
19
|
+
function envPort() {
|
|
20
|
+
const raw = process.env.NEUTRINO_WEB_PORT;
|
|
21
|
+
if (!raw)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return parsePort(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function printHelp() {
|
|
31
|
+
process.stdout.write(`Neutrino
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
neutrino Start the terminal UI.
|
|
35
|
+
neutrino --web-port <port> Start the terminal UI and mount the web UI.
|
|
36
|
+
neutrino web [--port <port>] Start only the web UI (serves saved history).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-p, --port <port> Web server port (web mode). Default: ${DEFAULT_WEB_PORT}
|
|
40
|
+
--host <host> Interface to bind. Default: 127.0.0.1
|
|
41
|
+
--lan Short for --host ${WILDCARD_HOST}: reachable from other
|
|
42
|
+
devices on your LAN or VPN.
|
|
43
|
+
--no-auth Serve without password login.
|
|
44
|
+
--web-port <n> Also mount the web UI while the TUI runs.
|
|
45
|
+
-h, --help Show this help.
|
|
46
|
+
|
|
47
|
+
Environment:
|
|
48
|
+
NEUTRINO_WEB_PORT Same as --web-port for the TUI, or the port in web mode.
|
|
49
|
+
NEUTRINO_WEB_HOST Same as --host.
|
|
50
|
+
|
|
51
|
+
The web UI can read every saved conversation and drive an agent with shell
|
|
52
|
+
access, so it binds to loopback by default. Log in with the permanent password
|
|
53
|
+
(default: 114514). Change it in Settings. --no-auth disables password login.
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
/** Read the value that follows a flag, so `--host` alone is an error. */
|
|
57
|
+
function requireValue(argv, index, flag) {
|
|
58
|
+
const value = argv[index];
|
|
59
|
+
if (value === undefined || value.trim() === "") {
|
|
60
|
+
throw new Error(`missing value for ${flag}`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const options = { mode: "tui", port: null, lan: false, noAuth: false };
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === "web") {
|
|
69
|
+
options.mode = "web";
|
|
70
|
+
}
|
|
71
|
+
else if (arg === "--port" || arg === "-p") {
|
|
72
|
+
options.port = parsePort(argv[++i]);
|
|
73
|
+
}
|
|
74
|
+
else if (arg.startsWith("--port=")) {
|
|
75
|
+
options.port = parsePort(arg.slice("--port=".length));
|
|
76
|
+
}
|
|
77
|
+
else if (arg === "--web-port") {
|
|
78
|
+
options.port = parsePort(argv[++i]);
|
|
79
|
+
}
|
|
80
|
+
else if (arg.startsWith("--web-port=")) {
|
|
81
|
+
options.port = parsePort(arg.slice("--web-port=".length));
|
|
82
|
+
}
|
|
83
|
+
else if (arg === "--host") {
|
|
84
|
+
options.host = requireValue(argv, ++i, "--host");
|
|
85
|
+
}
|
|
86
|
+
else if (arg.startsWith("--host=")) {
|
|
87
|
+
options.host = arg.slice("--host=".length);
|
|
88
|
+
}
|
|
89
|
+
else if (arg === "--lan") {
|
|
90
|
+
options.lan = true;
|
|
91
|
+
}
|
|
92
|
+
else if (arg === "--no-auth") {
|
|
93
|
+
options.noAuth = true;
|
|
94
|
+
}
|
|
95
|
+
else if (arg === "--help" || arg === "-h") {
|
|
96
|
+
printHelp();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
throw new Error(`unknown argument "${arg}"`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (options.lan && options.host) {
|
|
104
|
+
throw new Error("use either --lan or --host, not both");
|
|
105
|
+
}
|
|
106
|
+
return options;
|
|
107
|
+
}
|
|
108
|
+
/** Print every address a client can open, plus what it takes to open one. */
|
|
109
|
+
function reportAccess(handle) {
|
|
110
|
+
process.stdout.write(`Neutrino web UI listening on ${handle.host}:${handle.port}\n`);
|
|
111
|
+
const multiple = handle.urls.length > 1;
|
|
112
|
+
process.stdout.write(multiple ? "Open the address that matches your device:\n" : "Open:\n");
|
|
113
|
+
for (const url of handle.urls)
|
|
114
|
+
process.stdout.write(` ${url}\n`);
|
|
115
|
+
const wildcard = handle.host === "0.0.0.0" || handle.host === "::";
|
|
116
|
+
if (wildcard && !handle.urls.some((url) => !url.includes("//127.0.0.1:"))) {
|
|
117
|
+
// Bound to every interface, yet nothing outside this machine is reachable:
|
|
118
|
+
// usually Wi-Fi is off, the VPN is down, or the only adapters left are
|
|
119
|
+
// virtual ones we deliberately hide.
|
|
120
|
+
process.stdout.write("note: no reachable network address found, so only this machine can connect.\n");
|
|
121
|
+
}
|
|
122
|
+
if (handle.authRequired) {
|
|
123
|
+
process.stdout.write("Sign in with your permanent Web password (default: 114514). Change it in Settings.\n");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.stdout.write("Password login is disabled.\n");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let options;
|
|
130
|
+
try {
|
|
131
|
+
options = parseArgs(process.argv.slice(2));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
process.stderr.write(`neutrino: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
135
|
+
process.stderr.write("Run 'neutrino --help' for usage.\n");
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
// Installed before either mode starts: a crash must leave a usable terminal
|
|
139
|
+
// (TUI) and a log line (both), never a shell flooded with mouse reports.
|
|
140
|
+
installCrashGuard();
|
|
141
|
+
const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
|
|
142
|
+
const auth = !options.noAuth;
|
|
143
|
+
if (options.mode === "web") {
|
|
144
|
+
// Headless: serve the saved history (and any live sessions in this process,
|
|
145
|
+
// which for a standalone server is none).
|
|
146
|
+
const port = options.port ?? envPort() ?? DEFAULT_WEB_PORT;
|
|
147
|
+
try {
|
|
148
|
+
const handle = await startWebServer({ port, host, auth });
|
|
149
|
+
reportAccess(handle);
|
|
150
|
+
process.stdout.write("Serving saved conversations. Press Ctrl+C to stop.\n");
|
|
151
|
+
const shutdown = () => {
|
|
152
|
+
handle.close().finally(() => process.exit(0));
|
|
153
|
+
};
|
|
154
|
+
process.on("SIGINT", shutdown);
|
|
155
|
+
process.on("SIGTERM", shutdown);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
164
|
+
process.stderr.write("neutrino requires an interactive terminal.\n");
|
|
165
|
+
process.stderr.write("To serve the web UI without a terminal, run 'neutrino web --port <port>'.\n");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const app = new App();
|
|
169
|
+
const webPort = options.port ?? envPort();
|
|
170
|
+
if (webPort !== null) {
|
|
171
|
+
try {
|
|
172
|
+
const handle = await app.enableWeb(webPort, host, auth);
|
|
173
|
+
reportAccess(handle);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await app.start();
|
|
7
181
|
}
|
|
8
|
-
const app = new App();
|
|
9
|
-
await app.start();
|
package/dist/colors.js
CHANGED
|
@@ -8,6 +8,8 @@ export const C = {
|
|
|
8
8
|
toolHeader: [153, 217, 234], // cyan
|
|
9
9
|
amber: [224, 175, 104], // #E0AF68
|
|
10
10
|
white: [226, 232, 240],
|
|
11
|
+
orange: [255, 158, 61], // #FF9E3D approval highlight
|
|
12
|
+
orangeBg: [61, 36, 14], // #3D240E dim orange behind approval cards
|
|
11
13
|
green: [115, 218, 202],
|
|
12
14
|
red: [247, 118, 142],
|
|
13
15
|
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|