@xingjisoft/neutrino 0.4.0 → 0.5.0

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.
@@ -0,0 +1,160 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import process from "node:process";
3
+ import { backgroundLog, clearBackgroundStatus, isProcessAlive, writeBackgroundStatus, } from "../background.js";
4
+ import { loadConfig, saveConfig } from "../config.js";
5
+ import { startBackgroundTray } from "../tray.js";
6
+ import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
7
+ import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
8
+ import { hub } from "./hub.js";
9
+ import { startWebServer } from "./server.js";
10
+ import { TerminalBridges } from "./terminalBridge.js";
11
+ /** The detached process owns browser conversations for their entire lifetime. */
12
+ export async function runWebService(options) {
13
+ const token = process.env.NEUTRINO_BACKGROUND_TOKEN?.trim() || randomBytes(32).toString("hex");
14
+ let resident = process.env.NEUTRINO_BACKGROUND_RESIDENT !== "0";
15
+ const owner = Number(process.env.NEUTRINO_BACKGROUND_OWNER);
16
+ let owners = new Set(Number.isSafeInteger(owner) && owner > 0 ? [owner] : []);
17
+ const startedAt = new Date().toISOString();
18
+ let handle = null;
19
+ let tray = null;
20
+ let monitor = null;
21
+ let stopping = false;
22
+ const bridges = new TerminalBridges();
23
+ const status = () => {
24
+ if (!handle)
25
+ throw new Error("the Web console is starting");
26
+ return {
27
+ pid: process.pid, port: handle.port, host: handle.host, url: handle.url,
28
+ auth_required: options.auth, token, started_at: startedAt,
29
+ resident, owner_pids: [...owners],
30
+ };
31
+ };
32
+ const syncTray = () => {
33
+ if (!resident) {
34
+ tray?.close();
35
+ tray = null;
36
+ }
37
+ else if (!tray && options.tray && handle) {
38
+ tray = startBackgroundTray({ url: handle.url, stopUrl: `${handle.url}/api/background/stop`, token, pid: process.pid });
39
+ }
40
+ };
41
+ const shutdown = () => {
42
+ if (stopping)
43
+ return;
44
+ stopping = true;
45
+ if (monitor)
46
+ clearInterval(monitor);
47
+ tray?.close();
48
+ bridges.close();
49
+ backgroundLog(`stopping pid=${process.pid}`);
50
+ clearBackgroundStatus(process.pid);
51
+ const finish = () => process.exit(0);
52
+ if (handle)
53
+ handle.close().finally(finish).catch(finish);
54
+ else
55
+ finish();
56
+ setTimeout(finish, 3000).unref();
57
+ };
58
+ const configure = (update) => {
59
+ if (stopping)
60
+ throw new Error("the Web console is shutting down");
61
+ if (update.ownerPid !== undefined && !isProcessAlive(update.ownerPid)) {
62
+ throw new Error("the terminal that owns this console has already exited");
63
+ }
64
+ const previousResident = resident;
65
+ const previousOwners = owners;
66
+ const config = loadConfig();
67
+ const changed = update.resident !== undefined;
68
+ if (changed)
69
+ saveConfig({ ...config, background: update.resident });
70
+ resident = update.resident ?? resident;
71
+ owners = new Set(owners);
72
+ if (update.ownerPid !== undefined)
73
+ owners.add(update.ownerPid);
74
+ try {
75
+ writeBackgroundStatus(status());
76
+ }
77
+ catch (error) {
78
+ resident = previousResident;
79
+ owners = previousOwners;
80
+ if (changed) {
81
+ try {
82
+ saveConfig(config);
83
+ }
84
+ catch { /* Report the original failure. */ }
85
+ }
86
+ throw error;
87
+ }
88
+ if (update.bridge && update.ownerPid)
89
+ bridges.add(update.ownerPid, update.bridge);
90
+ syncTray();
91
+ return status();
92
+ };
93
+ // Nothing ships with the package any more: reconcile the presets with the
94
+ // official copy now, so the first browser to open /pro already finds them and
95
+ // an outdated local set is replaced. A failure is logged and reported there,
96
+ // so this never holds up the console itself.
97
+ void syncProModels().then((result) => {
98
+ if (result.status === "updated")
99
+ backgroundLog(`pro presets: updated from ${PRO_MODELS_URL}`);
100
+ else if (result.status === "kept")
101
+ backgroundLog(`pro presets: kept the local copy (${result.detail})`);
102
+ }).catch((error) => {
103
+ backgroundLog(`pro presets: ${error instanceof Error ? error.message : String(error)}`);
104
+ });
105
+ // The registry lookup runs alongside the listener so a slow or unreachable
106
+ // registry never delays the console; the pages read the answer from the
107
+ // settings payload. Failures are logged, as with the presets above.
108
+ void checkForUpdate().then((result) => {
109
+ if (result.hasUpdate) {
110
+ backgroundLog(`${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`);
111
+ }
112
+ else if (result.error) {
113
+ backgroundLog(`update check: ${result.error}`);
114
+ }
115
+ });
116
+ try {
117
+ handle = await startWebServer({
118
+ port: options.port, host: options.host, auth: options.auth,
119
+ backgroundPrompt: !resident && process.env.NEUTRINO_BACKGROUND_PROMPT === "1",
120
+ backgroundService: { token, onStop: shutdown, status, configure },
121
+ onAddressChange: () => { if (handle)
122
+ writeBackgroundStatus(status()); },
123
+ });
124
+ writeBackgroundStatus(status());
125
+ }
126
+ catch (error) {
127
+ backgroundLog(`failed to start pid=${process.pid}: ${error instanceof Error ? error.message : String(error)}`);
128
+ if (handle)
129
+ await handle.close();
130
+ process.exit(1);
131
+ }
132
+ backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
133
+ syncTray();
134
+ // No helper processes: polling pids must remain invisible on Windows. An
135
+ // orphaned, non-resident console lets an in-flight browser turn finish before
136
+ // exiting, so disabling residency also cannot throw away running work.
137
+ monitor = setInterval(() => {
138
+ try {
139
+ const live = new Set([...owners].filter(isProcessAlive));
140
+ if (live.size !== owners.size) {
141
+ for (const owner of owners)
142
+ if (!live.has(owner))
143
+ bridges.remove(owner);
144
+ owners = live;
145
+ writeBackgroundStatus(status());
146
+ }
147
+ if (!resident && owners.size === 0 && !hub.list().some(entry => entry.busy || entry.paused || entry.queued || entry.compressing || entry.approval || entry.reviewing)) {
148
+ shutdown();
149
+ }
150
+ }
151
+ catch (error) {
152
+ backgroundLog(`lifetime check failed: ${error instanceof Error ? error.message : String(error)}`);
153
+ }
154
+ }, 500);
155
+ monitor.unref();
156
+ process.on("SIGINT", shutdown);
157
+ process.on("SIGTERM", shutdown);
158
+ if (process.platform !== "win32")
159
+ process.on("SIGHUP", () => { });
160
+ }
@@ -27,8 +27,6 @@ import { writeCrashLog } from "../terminal/restore.js";
27
27
  import { hub, liveApprovalDetails, } from "./hub.js";
28
28
  /** Drop a resumed conversation nobody touched for this long. */
29
29
  const IDLE_DISPOSE_MS = 30 * 60 * 1000;
30
- /** Upper bound on tool round-trips inside a single turn. */
31
- const MAX_TOOL_ROUNDS = 24;
32
30
  /** Placeholder shown while a tool is still running. */
33
31
  const TOOL_PENDING = "…";
34
32
  /** A sub-agent gets the same tools minus delegation: one level, never a tree. */
@@ -264,6 +262,7 @@ export class WebSession {
264
262
  } } : {}),
265
263
  sessionId: this.sessionId,
266
264
  title: this.title(),
265
+ empty: this.messages.length === 0 && !this.streamingContent,
267
266
  provider: this.provider,
268
267
  model: this.model.name,
269
268
  intensity: this.intensity,
@@ -710,9 +709,12 @@ export class WebSession {
710
709
  const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
711
710
  const team = this.proTeam;
712
711
  try {
713
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
712
+ while (true) {
714
713
  if (token !== this.turnToken || this.paused)
715
714
  return false;
715
+ // Match the terminal: long tasks can keep using tools until the model
716
+ // finishes, while older tool results are trimmed near the context limit.
717
+ apiMessages = this.trimApiMessages(apiMessages);
716
718
  const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
717
719
  let text = "";
718
720
  this.streamingContent = "";
@@ -825,9 +827,6 @@ export class WebSession {
825
827
  }
826
828
  this.save();
827
829
  }
828
- if (this.mode === "pro")
829
- this.messages.push({ role: "assistant", content: "[Error: Pro main agent reached its tool round limit before completion.]" });
830
- return false;
831
830
  }
832
831
  catch (err) {
833
832
  if (token !== this.turnToken)
@@ -976,6 +975,25 @@ export class WebSession {
976
975
  }
977
976
  return contextCompressionThreshold(this.provider, this.model, this.mode);
978
977
  }
978
+ /** Trim old tool results in a long turn without changing the saved history. */
979
+ trimApiMessages(apiMessages) {
980
+ const threshold = this.compressThreshold();
981
+ if (threshold <= 0 || this.lastPromptTokens < threshold)
982
+ return apiMessages;
983
+ const toolIdx = [];
984
+ for (let i = 0; i < apiMessages.length; i++) {
985
+ if (apiMessages[i].role === "tool")
986
+ toolIdx.push(i);
987
+ }
988
+ const dropCount = toolIdx.length - COMPACT_KEEP;
989
+ if (dropCount <= 0)
990
+ return apiMessages;
991
+ const copy = apiMessages.slice();
992
+ for (let k = 0; k < dropCount; k++) {
993
+ copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
994
+ }
995
+ return copy;
996
+ }
979
997
  async autoCompress(token) {
980
998
  const threshold = this.compressThreshold();
981
999
  if (threshold <= 0 || this.lastPromptTokens < threshold)
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, intensityForModel, isContextWindow, loadConfig, loadModels, modelContextWindow, modelContextWindowKey, modelIntensities, normalizeApprovalMode, normalizeIntensity, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
2
2
  import { hashWebPassword, validateWebPassword } from "./auth.js";
3
3
  import { defaultProPreset, loadProModels, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
4
+ import { updateStatus } from "../update.js";
4
5
  /**
5
6
  * Public settings use a whitelist: API keys and cached credentials stay on disk.
6
7
  *
@@ -31,7 +32,15 @@ export function getWebSettings(mode = "standard") {
31
32
  ? catalog.providers.find((p) => p.name === provider.name)?.models.find((m) => m.name === model.name) ?? null
32
33
  : null;
33
34
  const intensity = intensityForModel(configuredModel, config.selected_intensity ?? config.default_intensity, config.default_intensity);
35
+ // Read-only runtime state for the update notice: which version runs, which
36
+ // one is published, and whether the two differ. Never persisted.
37
+ const update = updateStatus();
34
38
  return {
39
+ update: {
40
+ current_version: update.currentVersion,
41
+ latest_version: update.latestVersion,
42
+ has_update: update.hasUpdate,
43
+ },
35
44
  ...(mode === "pro" ? { pro: {
36
45
  presets: loadProModels().tier.map((preset) => ({
37
46
  name: preset.name, chinese_name: preset.chinese_name,
@@ -62,6 +71,7 @@ export function getWebSettings(mode = "standard") {
62
71
  approval_mode: normalizeApprovalMode(config.approval_mode),
63
72
  web_console: config.web_console !== false,
64
73
  web_lan: config.web_lan === true,
74
+ background: config.background === true,
65
75
  },
66
76
  };
67
77
  }
@@ -150,6 +160,11 @@ export function updateWebSettings(value, mode = "standard") {
150
160
  throw new Error("LAN access must be a boolean");
151
161
  config.web_lan = val;
152
162
  }
163
+ else if (key === "background") {
164
+ if (typeof val !== "boolean")
165
+ throw new Error("background residency must be a boolean");
166
+ config.background = val;
167
+ }
153
168
  else if (key === "web_password") {
154
169
  validateWebPassword(val);
155
170
  }
@@ -0,0 +1,250 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import http from "node:http";
3
+ import { resolveModelSelection } from "../config.js";
4
+ import { hub } from "./hub.js";
5
+ function sendJson(res, status, data) {
6
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
7
+ res.end(JSON.stringify(data));
8
+ }
9
+ function authorized(req, token) {
10
+ const provided = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
11
+ const expected = Buffer.from(token);
12
+ const actual = Buffer.from(provided);
13
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
14
+ }
15
+ async function readBody(req) {
16
+ const chunks = [];
17
+ let size = 0;
18
+ for await (const chunk of req) {
19
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
20
+ size += bytes.length;
21
+ if (size > 1_000_000)
22
+ throw new Error("request too large");
23
+ chunks.push(bytes);
24
+ }
25
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
26
+ if (!value || typeof value !== "object" || Array.isArray(value))
27
+ throw new Error("invalid request");
28
+ return value;
29
+ }
30
+ /** A private loopback bridge exposes only the terminal's live hub. */
31
+ export async function startTerminalBridge() {
32
+ const token = randomBytes(32).toString("hex");
33
+ const server = http.createServer((req, res) => {
34
+ void (async () => {
35
+ if (!authorized(req, token)) {
36
+ sendJson(res, 401, { error: "terminal token required" });
37
+ return;
38
+ }
39
+ if (req.method === "GET" && req.url === "/snapshots") {
40
+ const snapshots = [];
41
+ for (const entry of hub.list()) {
42
+ const snapshot = hub.snapshot(entry.key);
43
+ if (snapshot)
44
+ snapshots.push({ mode: hub.has(entry.key, "pro") ? "pro" : "standard", snapshot });
45
+ }
46
+ sendJson(res, 200, snapshots);
47
+ return;
48
+ }
49
+ if (req.method !== "POST" || req.url !== "/action") {
50
+ sendJson(res, 404, { error: "not found" });
51
+ return;
52
+ }
53
+ const input = await readBody(req);
54
+ const key = typeof input.key === "string" ? input.key : "";
55
+ const action = input.action;
56
+ const body = input.body && typeof input.body === "object" && !Array.isArray(input.body)
57
+ ? input.body : {};
58
+ if (!hub.has(key)) {
59
+ sendJson(res, 404, { error: "unknown live session" });
60
+ return;
61
+ }
62
+ if (action === "pause")
63
+ hub.pause(key);
64
+ else if (action === "resume")
65
+ hub.resume(key);
66
+ else if (action === "message") {
67
+ if (typeof body.text !== "string" || !body.text.trim())
68
+ throw new Error("empty message");
69
+ hub.send(key, body.text);
70
+ }
71
+ else if (action === "approve") {
72
+ if (!hub.approve(key, body.approved === true)) {
73
+ sendJson(res, 409, { error: "no approval is pending" });
74
+ return;
75
+ }
76
+ }
77
+ else if (action === "approval-mode") {
78
+ if (body.mode !== "request" && body.mode !== "auto")
79
+ throw new Error("mode must be request or auto");
80
+ if (!hub.setApprovalMode(key, body.mode)) {
81
+ sendJson(res, 409, { error: "this session cannot change approval mode" });
82
+ return;
83
+ }
84
+ }
85
+ else if (action === "model") {
86
+ const result = hub.configure(key, resolveModelSelection(body));
87
+ if (!result.ok) {
88
+ sendJson(res, 409, { error: result.error });
89
+ return;
90
+ }
91
+ }
92
+ else if (action === "pro-preset") {
93
+ if (typeof body.preset !== "string")
94
+ throw new Error("invalid Pro preset");
95
+ const result = hub.setProPreset(key, body.preset);
96
+ if (!result.ok) {
97
+ sendJson(res, 409, { error: result.error });
98
+ return;
99
+ }
100
+ }
101
+ else if (action === "clear") {
102
+ if (!hub.clear(key)) {
103
+ sendJson(res, 409, { error: "this session cannot be cleared" });
104
+ return;
105
+ }
106
+ }
107
+ else {
108
+ sendJson(res, 400, { error: "this session is owned by the terminal UI" });
109
+ return;
110
+ }
111
+ sendJson(res, 200, {
112
+ ok: true, snapshot: hub.snapshot(key),
113
+ ...(action === "pause" ? { paused: true } : action === "resume" ? { paused: false } : {}),
114
+ ...(action === "approve" ? { approved: body.approved === true } : {}),
115
+ });
116
+ })().catch(error => {
117
+ if (!res.headersSent)
118
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
119
+ else
120
+ res.end();
121
+ });
122
+ });
123
+ await new Promise((resolve, reject) => {
124
+ server.once("error", reject);
125
+ server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
126
+ });
127
+ server.unref();
128
+ const address = server.address();
129
+ if (!address || typeof address === "string")
130
+ throw new Error("could not open the terminal bridge");
131
+ return { url: `http://127.0.0.1:${address.port}`, token, close: () => { server.close(); } };
132
+ }
133
+ class RemoteTerminal {
134
+ bridge;
135
+ remoteKey;
136
+ mode;
137
+ current;
138
+ key = "";
139
+ constructor(bridge, remoteKey, mode, initial) {
140
+ this.bridge = bridge;
141
+ this.remoteKey = remoteKey;
142
+ this.mode = mode;
143
+ this.current = initial;
144
+ this.key = hub.register(this);
145
+ }
146
+ liveCwd() { return this.current.cwd; }
147
+ liveMeta() {
148
+ const { key: _key, cwd: _cwd, messages: _messages, streamingContent: _streaming, context: _context, plan: _plan, ...meta } = this.current;
149
+ return meta;
150
+ }
151
+ liveSnapshot(key) { return { ...this.current, key }; }
152
+ // The public server forwards actions through remoteAction, where failures can
153
+ // be returned to the browser rather than silently lost.
154
+ livePause() { }
155
+ liveResume() { }
156
+ liveSend(_text) { }
157
+ update(snapshot) {
158
+ this.current = snapshot;
159
+ hub.notify(this.key);
160
+ }
161
+ async remoteAction(action, body) {
162
+ try {
163
+ const response = await fetch(`${this.bridge.url}/action`, {
164
+ method: "POST", headers: { Authorization: `Bearer ${this.bridge.token}`, "Content-Type": "application/json" },
165
+ body: JSON.stringify({ key: this.remoteKey, action, body }), signal: AbortSignal.timeout(5000),
166
+ });
167
+ const data = await response.json();
168
+ if (data.snapshot) {
169
+ this.update(data.snapshot);
170
+ data.snapshot = this.liveSnapshot(this.key);
171
+ }
172
+ return { status: response.status, data };
173
+ }
174
+ catch {
175
+ return { status: 503, data: { error: "the terminal session is unavailable" } };
176
+ }
177
+ }
178
+ close() { hub.unregister(this.key); }
179
+ }
180
+ /** Mirror each attached terminal's live sessions into the Web service. */
181
+ export class TerminalBridges {
182
+ entries = new Map();
183
+ timer = setInterval(() => { for (const owner of this.entries.keys())
184
+ void this.poll(owner); }, 250);
185
+ constructor() { this.timer.unref(); }
186
+ add(owner, bridge) {
187
+ this.remove(owner);
188
+ this.entries.set(owner, { bridge, sessions: new Map(), polling: false, failures: 0 });
189
+ void this.poll(owner);
190
+ }
191
+ remove(owner) {
192
+ const entry = this.entries.get(owner);
193
+ if (!entry)
194
+ return;
195
+ this.entries.delete(owner);
196
+ for (const session of entry.sessions.values())
197
+ session.close();
198
+ }
199
+ close() {
200
+ clearInterval(this.timer);
201
+ for (const owner of this.entries.keys())
202
+ this.remove(owner);
203
+ }
204
+ async poll(owner) {
205
+ const entry = this.entries.get(owner);
206
+ if (!entry || entry.polling)
207
+ return;
208
+ entry.polling = true;
209
+ try {
210
+ const response = await fetch(`${entry.bridge.url}/snapshots`, {
211
+ headers: { Authorization: `Bearer ${entry.bridge.token}` }, signal: AbortSignal.timeout(1500),
212
+ });
213
+ if (!response.ok)
214
+ throw new Error("terminal bridge failed");
215
+ const snapshots = await response.json();
216
+ if (!Array.isArray(snapshots) || this.entries.get(owner) !== entry)
217
+ return;
218
+ entry.failures = 0;
219
+ const live = new Set();
220
+ for (const { mode, snapshot } of snapshots) {
221
+ if (!snapshot || typeof snapshot.key !== "string" || (mode !== "standard" && mode !== "pro"))
222
+ continue;
223
+ live.add(snapshot.key);
224
+ const session = entry.sessions.get(snapshot.key);
225
+ if (session)
226
+ session.update(snapshot);
227
+ else
228
+ entry.sessions.set(snapshot.key, new RemoteTerminal(entry.bridge, snapshot.key, mode, snapshot));
229
+ }
230
+ for (const [key, session] of entry.sessions) {
231
+ if (!live.has(key)) {
232
+ session.close();
233
+ entry.sessions.delete(key);
234
+ }
235
+ }
236
+ }
237
+ catch {
238
+ // Keep snapshots over brief disconnects, but never expose a dead terminal
239
+ // indefinitely if its process stays alive after the bridge fails.
240
+ if (++entry.failures >= 4) {
241
+ for (const session of entry.sessions.values())
242
+ session.close();
243
+ entry.sessions.clear();
244
+ }
245
+ }
246
+ finally {
247
+ entry.polling = false;
248
+ }
249
+ }
250
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
- "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-model-intensity.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
21
+ "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-mobile-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-web-sidebar-status.mjs && node scripts/test-web-seen.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
22
22
  "prepare": "npm run build",
23
23
  "web": "node dist/bin.js web"
24
24
  },