@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/compact.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for compressing a long conversation so it fits back inside the
|
|
3
|
+
* model's context window.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is pure: deciding *what* to summarise, and flattening that
|
|
6
|
+
* part into a plain transcript. The actual "ask the model to summarise" step
|
|
7
|
+
* lives in `LLMClient.summarize`, so this module stays trivially unit-testable.
|
|
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
|
+
}
|
|
28
|
+
/** How many trailing messages are always kept verbatim when compressing. */
|
|
29
|
+
export const COMPACT_KEEP = 4;
|
|
30
|
+
/** Per-message character budget fed to the summariser, to bound its own input. */
|
|
31
|
+
export const COMPACT_MAX_CHARS_PER_MESSAGE = 4000;
|
|
32
|
+
/**
|
|
33
|
+
* Split `messages` into an older prefix to summarise and a recent suffix to
|
|
34
|
+
* keep. The kept suffix never begins with a bare tool result — that would be an
|
|
35
|
+
* orphan without the assistant tool-call that produced it — so such messages
|
|
36
|
+
* are folded into the prefix instead.
|
|
37
|
+
*
|
|
38
|
+
* Returns null when there is nothing worth compressing (the conversation is
|
|
39
|
+
* already short enough).
|
|
40
|
+
*/
|
|
41
|
+
export function planCompaction(messages, keep = COMPACT_KEEP) {
|
|
42
|
+
if (keep < 1)
|
|
43
|
+
keep = 1;
|
|
44
|
+
const head = [];
|
|
45
|
+
const tail = [];
|
|
46
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
47
|
+
if (tail.length < keep)
|
|
48
|
+
tail.unshift(messages[i]);
|
|
49
|
+
else
|
|
50
|
+
head.push(messages[i]);
|
|
51
|
+
}
|
|
52
|
+
head.reverse();
|
|
53
|
+
// Never open the kept suffix with an orphan tool result.
|
|
54
|
+
while (tail.length > 1 && tail[0].role === "tool") {
|
|
55
|
+
head.push(tail.shift());
|
|
56
|
+
}
|
|
57
|
+
if (head.length === 0 || tail.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return { head, tail };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Flatten messages into a plain transcript for the summariser, clipping each
|
|
63
|
+
* message so a single huge tool result cannot dominate the summary request.
|
|
64
|
+
*/
|
|
65
|
+
export function transcriptFor(messages, maxCharsPerMessage = COMPACT_MAX_CHARS_PER_MESSAGE) {
|
|
66
|
+
const clip = (s) => {
|
|
67
|
+
const t = s.replace(/\r\n?/g, "\n").trim();
|
|
68
|
+
return t.length > maxCharsPerMessage ? t.slice(0, maxCharsPerMessage) + "\n…[truncated]" : t;
|
|
69
|
+
};
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const m of messages) {
|
|
72
|
+
const body = clip(m.content ?? "");
|
|
73
|
+
if (m.role === "tool") {
|
|
74
|
+
parts.push(`TOOL ${m.name ?? ""} RESULT:\n${body}`);
|
|
75
|
+
}
|
|
76
|
+
else if (m.role === "assistant") {
|
|
77
|
+
parts.push(`ASSISTANT:\n${body}`);
|
|
78
|
+
}
|
|
79
|
+
else if (m.role === "user") {
|
|
80
|
+
parts.push(`USER:\n${body}`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
parts.push(`${m.role.toUpperCase()}:\n${body}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return parts.join("\n\n");
|
|
87
|
+
}
|
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,6 +56,16 @@ export const DEFAULT_CONFIG = {
|
|
|
36
56
|
language: "en",
|
|
37
57
|
theme: "default",
|
|
38
58
|
default_intensity: "High",
|
|
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: "",
|
|
39
69
|
};
|
|
40
70
|
export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
|
|
41
71
|
export const INTENSITY_PARAMS = {
|
|
@@ -45,20 +75,28 @@ export const INTENSITY_PARAMS = {
|
|
|
45
75
|
Max: { temperature: 1.0, top_p: 1.0 },
|
|
46
76
|
Ultra: { temperature: 1.5, top_p: 1.0 },
|
|
47
77
|
};
|
|
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
|
+
}
|
|
48
85
|
export function ensureDirs() {
|
|
49
86
|
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
50
87
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
51
88
|
}
|
|
52
89
|
export function loadModels() {
|
|
53
|
-
ensureDirs();
|
|
54
|
-
if (!fs.existsSync(MODEL_FILE)) {
|
|
55
|
-
saveModels(DEFAULT_MODELS);
|
|
56
|
-
return DEFAULT_MODELS;
|
|
57
|
-
}
|
|
58
90
|
try {
|
|
91
|
+
ensureDirs();
|
|
92
|
+
if (!fs.existsSync(MODEL_FILE)) {
|
|
93
|
+
saveModels(DEFAULT_MODELS);
|
|
94
|
+
return DEFAULT_MODELS;
|
|
95
|
+
}
|
|
59
96
|
return JSON.parse(fs.readFileSync(MODEL_FILE, "utf-8"));
|
|
60
97
|
}
|
|
61
98
|
catch {
|
|
99
|
+
// Same reasoning as loadConfig: this sits on the approval path.
|
|
62
100
|
return DEFAULT_MODELS;
|
|
63
101
|
}
|
|
64
102
|
}
|
|
@@ -67,15 +105,17 @@ export function saveModels(data) {
|
|
|
67
105
|
fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
68
106
|
}
|
|
69
107
|
export function loadConfig() {
|
|
70
|
-
ensureDirs();
|
|
71
|
-
if (!fs.existsSync(CONFIG_FILE)) {
|
|
72
|
-
saveConfig(DEFAULT_CONFIG);
|
|
73
|
-
return { ...DEFAULT_CONFIG };
|
|
74
|
-
}
|
|
75
108
|
try {
|
|
109
|
+
ensureDirs();
|
|
110
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
111
|
+
saveConfig(DEFAULT_CONFIG);
|
|
112
|
+
return { ...DEFAULT_CONFIG };
|
|
113
|
+
}
|
|
76
114
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
77
115
|
}
|
|
78
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.
|
|
79
119
|
return { ...DEFAULT_CONFIG };
|
|
80
120
|
}
|
|
81
121
|
}
|
|
@@ -83,6 +123,18 @@ export function saveConfig(data) {
|
|
|
83
123
|
ensureDirs();
|
|
84
124
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
85
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
|
+
}
|
|
86
138
|
export function getModelsByProvider() {
|
|
87
139
|
const data = loadModels();
|
|
88
140
|
const result = {};
|
|
@@ -91,10 +143,30 @@ export function getModelsByProvider() {
|
|
|
91
143
|
}
|
|
92
144
|
return result;
|
|
93
145
|
}
|
|
94
|
-
|
|
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) {
|
|
95
167
|
ensureDirs();
|
|
96
168
|
const now = new Date();
|
|
97
|
-
const id = sessionId ?? now.toISOString().replace(/[:.]/g, "-")
|
|
169
|
+
const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
98
170
|
const existing = sessionId ? loadSession(sessionId) : null;
|
|
99
171
|
const created_at = existing ? existing.created_at : now.toISOString();
|
|
100
172
|
let title = "";
|
|
@@ -115,9 +187,37 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
|
|
|
115
187
|
title,
|
|
116
188
|
messages,
|
|
117
189
|
tokens,
|
|
190
|
+
approval_mode: normalizeApprovalMode(approvalMode ?? existing?.approval_mode),
|
|
118
191
|
};
|
|
192
|
+
const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
|
|
193
|
+
if (validContext)
|
|
194
|
+
session.context = validContext;
|
|
119
195
|
const filePath = path.join(SESSION_DIR, `${id}.json`);
|
|
120
|
-
|
|
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
|
+
}
|
|
121
221
|
return id;
|
|
122
222
|
}
|
|
123
223
|
export function loadSession(sessionId) {
|
|
@@ -171,6 +271,14 @@ export function listSessions() {
|
|
|
171
271
|
sessions.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at));
|
|
172
272
|
return sessions;
|
|
173
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
|
+
}
|
|
174
282
|
function readAsset(...candidates) {
|
|
175
283
|
for (const candidate of candidates) {
|
|
176
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.
|