@xingjisoft/neutrino 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
package/dist/compact.js CHANGED
@@ -6,6 +6,25 @@
6
6
  * part into a plain transcript. The actual "ask the model to summarise" step
7
7
  * lives in `LLMClient.summarize`, so this module stays trivially unit-testable.
8
8
  */
9
+ /** Ignore stale/corrupt metadata rather than hiding any original messages. */
10
+ export function normalizeContext(value, messageCount) {
11
+ if (!value || typeof value !== "object")
12
+ return null;
13
+ const context = value;
14
+ if (typeof context.end !== "number" || !Number.isInteger(context.end) ||
15
+ context.end <= 0 || context.end > messageCount ||
16
+ typeof context.summary !== "string" || !context.summary.trim())
17
+ return null;
18
+ return { end: context.end, summary: context.summary };
19
+ }
20
+ /** Derive a bounded model history while leaving the complete transcript intact. */
21
+ export function modelHistory(messages, context) {
22
+ const original = messages.filter((message) => message.role !== "system");
23
+ const valid = normalizeContext(context, original.length);
24
+ return valid
25
+ ? [{ role: "summary", content: valid.summary }, ...original.slice(valid.end)]
26
+ : original;
27
+ }
9
28
  /** How many trailing messages are always kept verbatim when compressing. */
10
29
  export const COMPACT_KEEP = 4;
11
30
  /** Per-message character budget fed to the summariser, to bound its own input. */
package/dist/config.js CHANGED
@@ -2,12 +2,32 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { randomUUID } from "node:crypto";
6
+ import { sameCwd } from "./cwd.js";
7
+ import { normalizeContext } from "./compact.js";
5
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
9
  const PKG_ROOT = path.resolve(__dirname, "..");
7
10
  export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
8
11
  export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
9
12
  export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
10
13
  export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
14
+ export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
15
+ export const CONTEXT_COMPRESSION_RATIO = 0.8;
16
+ export const CONTEXT_WINDOW_OPTIONS = [32_000, 64_000, 128_000, 200_000, 256_000, 512_000, 1_000_000, 2_000_000, 4_000_000];
17
+ export function isContextWindow(value) {
18
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
19
+ }
20
+ export function contextWindow(value) {
21
+ return isContextWindow(value) ? value : DEFAULT_CONTEXT_WINDOW;
22
+ }
23
+ /** Reload the exact provider/model so settings also apply to active chats. */
24
+ export function modelContextWindow(provider, model) {
25
+ const saved = loadModels().providers.find((p) => p.name === provider)?.models.find((m) => m.name === model.name);
26
+ return contextWindow((saved ?? model).context_window);
27
+ }
28
+ export function contextCompressionThreshold(provider, model) {
29
+ return Math.max(1, Math.floor(modelContextWindow(provider, model) * CONTEXT_COMPRESSION_RATIO));
30
+ }
11
31
  export const DEFAULT_MODELS = {
12
32
  providers: [
13
33
  {
@@ -36,9 +56,16 @@ export const DEFAULT_CONFIG = {
36
56
  language: "en",
37
57
  theme: "default",
38
58
  default_intensity: "High",
39
- // Estimated prompt tokens above which the conversation is auto-compressed.
40
- // 0 disables auto-compression.
41
- context_compress_threshold: 32000,
59
+ // Default for new conversations; each conversation can override it.
60
+ approval_mode: "request",
61
+ // Delete-class commands need a human unless this and Auto Approval are on.
62
+ auto_approve_extreme_risk: false,
63
+ // Audible cue when a turn ends or an approval request is waiting.
64
+ sound_alerts: true,
65
+ // Model that reviews approval requests. Empty = same model as the chat.
66
+ approval_model: "",
67
+ // Initialized as a salted hash of the default Web password on first use.
68
+ web_password_hash: "",
42
69
  };
43
70
  export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
44
71
  export const INTENSITY_PARAMS = {
@@ -48,32 +75,28 @@ export const INTENSITY_PARAMS = {
48
75
  Max: { temperature: 1.0, top_p: 1.0 },
49
76
  Ultra: { temperature: 1.5, top_p: 1.0 },
50
77
  };
51
- /**
52
- * Selectable auto-compression thresholds, in estimated prompt tokens.
53
- * `0` turns the feature off. Rendered as-is in the settings screen.
54
- */
55
- export const COMPRESS_THRESHOLD_OPTIONS = [
56
- { value: 0, label: "Off" },
57
- { value: 8000, label: "8K tokens" },
58
- { value: 16000, label: "16K tokens" },
59
- { value: 32000, label: "32K tokens" },
60
- { value: 64000, label: "64K tokens" },
61
- { value: 128000, label: "128K tokens" },
62
- ];
78
+ export function normalizeApprovalMode(value) {
79
+ // Config files are hand-edited: "Auto" or " autO " must not silently
80
+ // downgrade the conversation to a prompt on every tool call.
81
+ return typeof value === "string" && value.trim().toLowerCase() === "auto"
82
+ ? "auto"
83
+ : "request";
84
+ }
63
85
  export function ensureDirs() {
64
86
  fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
65
87
  fs.mkdirSync(SESSION_DIR, { recursive: true });
66
88
  }
67
89
  export function loadModels() {
68
- ensureDirs();
69
- if (!fs.existsSync(MODEL_FILE)) {
70
- saveModels(DEFAULT_MODELS);
71
- return DEFAULT_MODELS;
72
- }
73
90
  try {
91
+ ensureDirs();
92
+ if (!fs.existsSync(MODEL_FILE)) {
93
+ saveModels(DEFAULT_MODELS);
94
+ return DEFAULT_MODELS;
95
+ }
74
96
  return JSON.parse(fs.readFileSync(MODEL_FILE, "utf-8"));
75
97
  }
76
98
  catch {
99
+ // Same reasoning as loadConfig: this sits on the approval path.
77
100
  return DEFAULT_MODELS;
78
101
  }
79
102
  }
@@ -82,15 +105,17 @@ export function saveModels(data) {
82
105
  fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
83
106
  }
84
107
  export function loadConfig() {
85
- ensureDirs();
86
- if (!fs.existsSync(CONFIG_FILE)) {
87
- saveConfig(DEFAULT_CONFIG);
88
- return { ...DEFAULT_CONFIG };
89
- }
90
108
  try {
109
+ ensureDirs();
110
+ if (!fs.existsSync(CONFIG_FILE)) {
111
+ saveConfig(DEFAULT_CONFIG);
112
+ return { ...DEFAULT_CONFIG };
113
+ }
91
114
  return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
92
115
  }
93
116
  catch {
117
+ // The approval gate reads this on every tool call; a directory we cannot
118
+ // create or a file we cannot parse must fall back, not throw.
94
119
  return { ...DEFAULT_CONFIG };
95
120
  }
96
121
  }
@@ -98,6 +123,18 @@ export function saveConfig(data) {
98
123
  ensureDirs();
99
124
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
100
125
  }
126
+ /** Look a model up by name across every configured provider. */
127
+ export function findModel(name) {
128
+ const wanted = name.trim();
129
+ if (!wanted)
130
+ return null;
131
+ for (const [provider, models] of Object.entries(getModelsByProvider())) {
132
+ const model = models.find((m) => m.name === wanted);
133
+ if (model)
134
+ return { provider, model };
135
+ }
136
+ return null;
137
+ }
101
138
  export function getModelsByProvider() {
102
139
  const data = loadModels();
103
140
  const result = {};
@@ -106,10 +143,30 @@ export function getModelsByProvider() {
106
143
  }
107
144
  return result;
108
145
  }
109
- export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens) {
146
+ /** Resolve a provider/model pair exactly, including when names repeat across providers. */
147
+ export function resolveModelSelection(value, requireKey = true) {
148
+ if (!value || typeof value !== "object" || Array.isArray(value))
149
+ throw new Error("invalid model selection");
150
+ const input = value;
151
+ if (typeof input.provider !== "string" || typeof input.model !== "string" ||
152
+ typeof input.intensity !== "string")
153
+ throw new Error("provider, model and intensity are required");
154
+ if (!INTENSITY_LEVELS.includes(input.intensity))
155
+ throw new Error("invalid thinking intensity");
156
+ const provider = loadModels().providers.find((p) => p.name === input.provider);
157
+ if (!provider)
158
+ throw new Error("unknown provider");
159
+ const model = provider.models.find((m) => m.name === input.model);
160
+ if (!model)
161
+ throw new Error("unknown model for this provider");
162
+ if (requireKey && !model.api_key?.trim())
163
+ throw new Error("this model has no API key configured");
164
+ return { provider: provider.name, model: { ...model }, intensity: input.intensity };
165
+ }
166
+ export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode) {
110
167
  ensureDirs();
111
168
  const now = new Date();
112
- const id = sessionId ?? now.toISOString().replace(/[:.]/g, "-");
169
+ const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
113
170
  const existing = sessionId ? loadSession(sessionId) : null;
114
171
  const created_at = existing ? existing.created_at : now.toISOString();
115
172
  let title = "";
@@ -130,9 +187,37 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
130
187
  title,
131
188
  messages,
132
189
  tokens,
190
+ approval_mode: normalizeApprovalMode(approvalMode ?? existing?.approval_mode),
133
191
  };
192
+ const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
193
+ if (validContext)
194
+ session.context = validContext;
134
195
  const filePath = path.join(SESSION_DIR, `${id}.json`);
135
- fs.writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8");
196
+ // Keep the last complete transcript intact if writing or replacing fails.
197
+ // The temporary file shares the destination directory so rename is atomic.
198
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
199
+ const contents = JSON.stringify(session, null, 2);
200
+ let fd;
201
+ try {
202
+ fd = fs.openSync(tempPath, "wx", 0o600);
203
+ fs.writeFileSync(fd, contents, "utf-8");
204
+ fs.fsyncSync(fd);
205
+ fs.closeSync(fd);
206
+ fd = undefined;
207
+ fs.renameSync(tempPath, filePath);
208
+ }
209
+ finally {
210
+ if (fd !== undefined) {
211
+ try {
212
+ fs.closeSync(fd);
213
+ }
214
+ catch { /* Preserve the write error. */ }
215
+ }
216
+ try {
217
+ fs.rmSync(tempPath, { force: true });
218
+ }
219
+ catch { /* Best-effort cleanup. */ }
220
+ }
136
221
  return id;
137
222
  }
138
223
  export function loadSession(sessionId) {
@@ -186,6 +271,14 @@ export function listSessions() {
186
271
  sessions.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at));
187
272
  return sessions;
188
273
  }
274
+ /**
275
+ * Sessions that were started in `cwd` (the directory neutrino runs in).
276
+ * The history menu is scoped to the current project so sessions started in
277
+ * other directories do not clutter it.
278
+ */
279
+ export function listSessionsForCwd(cwd = process.cwd()) {
280
+ return listSessions().filter((s) => sameCwd(s.cwd, cwd));
281
+ }
189
282
  function readAsset(...candidates) {
190
283
  for (const candidate of candidates) {
191
284
  try {
package/dist/cwd.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Helpers for deciding whether a stored session belongs to the directory the
3
+ * agent is currently running in.
4
+ *
5
+ * Every agent records the working directory in its own flavour — `C:\a\b`,
6
+ * `C:/a/b`, a trailing slash, a differently cased drive letter, or a WSL style
7
+ * `/mnt/c/a/b` — so the comparison has to be forgiving about the spelling and
8
+ * strict about the actual location.
9
+ */
10
+ /** Reduce a directory path to a canonical form that can be compared for equality. */
11
+ export function normalizeCwd(dir) {
12
+ let p = (dir ?? "").trim();
13
+ if (!p)
14
+ return "";
15
+ // The Windows long-path prefix ("\\?\C:\a") carries no meaning for us.
16
+ p = p.replace(/^\\\\\?\\/, "");
17
+ p = p.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
18
+ let drive = "";
19
+ let rest = p;
20
+ const wslDrive = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(p);
21
+ const winDrive = /^([a-zA-Z]):(\/.*)?$/.exec(p);
22
+ if (wslDrive) {
23
+ drive = wslDrive[1];
24
+ rest = wslDrive[2] ?? "/";
25
+ }
26
+ else if (winDrive) {
27
+ drive = winDrive[1];
28
+ rest = winDrive[2] ?? "/";
29
+ }
30
+ if (drive) {
31
+ if (rest.length > 1)
32
+ rest = rest.replace(/\/+$/, "");
33
+ // Drive paths are Windows paths, which are case-insensitive.
34
+ return `${drive.toLowerCase()}:${(rest || "/").toLowerCase()}`;
35
+ }
36
+ // POSIX paths are case-sensitive: only drop trailing separators.
37
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
38
+ }
39
+ /** True when both paths point at the same directory. Empty paths never match. */
40
+ export function sameCwd(a, b) {
41
+ const na = normalizeCwd(a);
42
+ const nb = normalizeCwd(b);
43
+ return na !== "" && na === nb;
44
+ }
45
+ /**
46
+ * Claude Code names each project directory after the working directory with
47
+ * every non-alphanumeric character replaced by a dash. That lets us attribute
48
+ * transcripts that do not carry an explicit `cwd` field.
49
+ */
50
+ export function claudeProjectKey(dir) {
51
+ return normalizeCwd(dir)
52
+ .replace(/[^a-zA-Z0-9]/g, "-")
53
+ .toLowerCase();
54
+ }
package/dist/header.js CHANGED
@@ -2,6 +2,14 @@ import { C } from "./colors.js";
2
2
  import { truncateByWidth } from "./width.js";
3
3
  export function drawHeader(buf, state) {
4
4
  const w = buf.width;
5
+ if (state.approval) {
6
+ // A tool call is waiting for an answer: the whole bar turns orange so it
7
+ // cannot be missed while the agent sits paused mid-turn.
8
+ const bg = C.orange;
9
+ buf.fillRect(0, 0, w, 1, { bg });
10
+ buf.centerText(0, "Request Approval", { fg: C.black, bg, bold: true });
11
+ return;
12
+ }
5
13
  if (state.isThinking) {
6
14
  // Flashing indicator: the whole bar alternates between highlighted and
7
15
  // dark while the agent is working, so the user always notices it.