@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.
@@ -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];
@@ -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
- if (stdin.isTTY)
58
- stdin.setRawMode(false);
59
- stdin.pause();
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.write("\x1b[?2004l");
57
- this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
58
- this.write("\x1b[?25h");
59
- this.write("\x1b[?1049l");
60
- this.write("\x1b[0m");
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
- render(buf) {
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
- s += cell.ch;
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.write(chunk);
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
+ }
@@ -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 = {}) {
@@ -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();