@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/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
- function listClaude() {
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
- function listCodex() {
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
- try {
328
- const rows = db
329
- .prepare("SELECT id, title, agent, model, time_created, time_updated FROM session ORDER BY time_updated DESC")
330
- .all();
331
- return rows.map((r) => ({
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
- catch {
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
- export function listAgentSessions(agent) {
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
- this.input.stop();
482
+ // Disable mouse reporting before input returns to cooked/echo mode.
389
483
  this.renderer.dispose();
390
- process.exit(0);
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
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
5
- process.stderr.write("neutrino requires an interactive terminal.\n");
6
- process.exit(1);
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