@xingjisoft/neutrino 0.5.1 → 0.6.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,76 @@
1
+ /**
2
+ * The periodic update check.
3
+ *
4
+ * A Web console can stay open for days, so the start-up lookup alone would
5
+ * never tell its windows about a release published afterwards. This module asks
6
+ * the registry again on a timer, in the process that serves those windows, and
7
+ * hands a release it has not seen before to whoever is listening: the console
8
+ * forwards it to every open browser (GET /api/update/events) and the first-open
9
+ * dialog reads the same answer.
10
+ */
11
+ import { checkForUpdate, updateStatus } from "./update.js";
12
+ /** How often the background service asks the registry again. */
13
+ export const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000;
14
+ const listeners = new Set();
15
+ let running = null;
16
+ /**
17
+ * Listen for a release the periodic check finds. Returns the unsubscribe
18
+ * function.
19
+ *
20
+ * Only news is announced: a version this process already knows — from the
21
+ * start-up lookup, the settings button or an earlier tick — is not sent again,
22
+ * so the dialogs cannot reopen every half hour for a release the user has
23
+ * already declined.
24
+ */
25
+ export function subscribeUpdateAnnouncements(listener) {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ }
31
+ /**
32
+ * Start asking the registry every `intervalMs` (30 minutes by default).
33
+ *
34
+ * Idempotent, and the returned function stops the timer and leaves the module
35
+ * ready to start again. Checks run off the timer rather than at start-up: both
36
+ * the console and the terminal UI already look once when they start, and a
37
+ * second immediate lookup would only spend npm calls on the same answer.
38
+ */
39
+ export function startUpdateWatch(options = {}) {
40
+ if (running)
41
+ return running;
42
+ const timer = setInterval(() => {
43
+ // The remembered answer is read before the lookup, because the lookup itself
44
+ // updates it: what matters is whether this check is what discovered the
45
+ // release.
46
+ const known = updateStatus().latestVersion;
47
+ // `refresh` is what makes a timer useful: without it checkForUpdate keeps
48
+ // returning the answer this process already has and would never see a new
49
+ // release.
50
+ void checkForUpdate({ refresh: true }).then((check) => {
51
+ try {
52
+ options.onCheck?.(check);
53
+ }
54
+ catch {
55
+ // A failing log must not stop the check that follows it.
56
+ }
57
+ if (!check.hasUpdate || check.latestVersion === known)
58
+ return;
59
+ for (const listener of [...listeners]) {
60
+ try {
61
+ listener(check);
62
+ }
63
+ catch {
64
+ // One broken window must not starve the others.
65
+ }
66
+ }
67
+ });
68
+ }, options.intervalMs ?? UPDATE_CHECK_INTERVAL_MS);
69
+ // A check in flight must never keep a shutting-down process alive.
70
+ timer.unref?.();
71
+ running = () => {
72
+ clearInterval(timer);
73
+ running = null;
74
+ };
75
+ return running;
76
+ }
package/dist/web/hub.js CHANGED
@@ -189,6 +189,15 @@ class LiveHub {
189
189
  this.notify(key);
190
190
  return result;
191
191
  }
192
+ setAgentMode(key, mode) {
193
+ const entry = this.entries.get(key);
194
+ if (!entry?.agent.liveSetAgentMode)
195
+ return { ok: false, error: "this session cannot change agent mode" };
196
+ const result = entry.agent.liveSetAgentMode(mode);
197
+ if (result.ok)
198
+ this.notify(key);
199
+ return result;
200
+ }
192
201
  setApprovalMode(key, mode) {
193
202
  const entry = this.entries.get(key);
194
203
  if (!entry?.agent.liveSetApprovalMode)
@@ -17,7 +17,7 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { timingSafeEqual } from "node:crypto";
19
19
  import { fileURLToPath } from "node:url";
20
- import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
20
+ import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, isAgentMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
21
21
  import { getWebSettings, updateWebSettings } from "./settings.js";
22
22
  import { isSeenIdentity, loadSeen, updateSeen, SEEN_LIMIT } from "./clientState.js";
23
23
  import { WebAuth } from "./auth.js";
@@ -29,7 +29,8 @@ import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/conf
29
29
  import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
30
30
  import { migrateAgentSession, SessionMigrationError } from "../migration.js";
31
31
  import { runningBackground } from "../background.js";
32
- import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
32
+ import { MANUAL_UPDATE_COMMAND, checkForUpdate, installUpdate, updateStatus } from "../update.js";
33
+ import { subscribeUpdateAnnouncements } from "../updateWatch.js";
33
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
35
  // dist/web -> package root (mirrors the same trick used in config.ts).
35
36
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -537,18 +538,20 @@ function serveStatic(res, pathname) {
537
538
  sendText(res, 403, "Forbidden");
538
539
  return;
539
540
  }
540
- fs.stat(filePath, (err, stat) => {
541
- if (err || !stat.isFile()) {
541
+ // Send an immutable snapshot: the length and body must agree even while
542
+ // the development checkout changes or a filesystem stream adapter retries.
543
+ fs.readFile(filePath, (err, contents) => {
544
+ if (err) {
542
545
  sendText(res, 404, "Not found");
543
546
  return;
544
547
  }
545
548
  const type = MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
546
549
  res.writeHead(200, {
547
550
  "Content-Type": type,
548
- "Content-Length": stat.size,
551
+ "Content-Length": contents.length,
549
552
  "Cache-Control": "no-cache",
550
553
  });
551
- fs.createReadStream(filePath).pipe(res);
554
+ res.end(contents);
552
555
  });
553
556
  }
554
557
  function streamLive(key, req, res) {
@@ -604,7 +607,88 @@ function streamLive(key, req, res) {
604
607
  // Without this, a client reset during a write is an uncaught 'error' event.
605
608
  res.on("error", cleanup);
606
609
  }
607
- async function handleApi(req, res, url, auth, mode, pathname, binding) {
610
+ /**
611
+ * The windows watching for update announcements. The console owns one of these
612
+ * so closing it can end those streams: a browser reconnects an EventSource by
613
+ * itself, so an open tab would otherwise keep `close` waiting for a socket that
614
+ * will never go quiet on its own.
615
+ */
616
+ function createUpdateStreams() {
617
+ const open = new Set();
618
+ return {
619
+ add(res) {
620
+ open.add(res);
621
+ },
622
+ remove(res) {
623
+ open.delete(res);
624
+ },
625
+ closeAll() {
626
+ for (const res of [...open]) {
627
+ open.delete(res);
628
+ try {
629
+ res.end();
630
+ }
631
+ catch { /* the window is already gone */ }
632
+ }
633
+ },
634
+ };
635
+ }
636
+ /**
637
+ * The periodic check's findings, as an event stream. A window that opens after
638
+ * a release was already found still has to learn about it, so the answer this
639
+ * process holds goes out first: the same status /api/settings hands the
640
+ * first-open dialog, with the same fields the manual check returns.
641
+ *
642
+ * Both workspaces read it. An update belongs to the process, not to a
643
+ * conversation, so a Pro tab and a standard tab are told about the same release.
644
+ */
645
+ function streamUpdateEvents(req, res, streams) {
646
+ res.writeHead(200, {
647
+ "Content-Type": "text/event-stream; charset=utf-8",
648
+ "Cache-Control": "no-cache, no-transform",
649
+ Connection: "keep-alive",
650
+ "X-Accel-Buffering": "no",
651
+ });
652
+ // Same guard as streamLive: a browser that closed or reloaded the tab leaves
653
+ // a socket writes fail on, and an unhandled 'error' event would stop the process.
654
+ const write = (chunk) => {
655
+ if (res.destroyed || res.writableEnded)
656
+ return false;
657
+ try {
658
+ res.write(chunk);
659
+ return true;
660
+ }
661
+ catch {
662
+ return false;
663
+ }
664
+ };
665
+ const send = (status, error = "") => {
666
+ write(`event: update\ndata: ${JSON.stringify({
667
+ has_update: status.hasUpdate,
668
+ current_version: status.currentVersion,
669
+ latest_version: status.latestVersion,
670
+ error,
671
+ })}\n\n`);
672
+ };
673
+ write("retry: 2000\n\n");
674
+ send(updateStatus());
675
+ streams.add(res);
676
+ const unsubscribe = subscribeUpdateAnnouncements((check) => send(check, check.error ?? ""));
677
+ const keepAlive = setInterval(() => {
678
+ if (!write(": ping\n\n"))
679
+ cleanup();
680
+ }, 25_000);
681
+ keepAlive.unref?.();
682
+ function cleanup() {
683
+ clearInterval(keepAlive);
684
+ unsubscribe();
685
+ streams.remove(res);
686
+ }
687
+ req.on("close", cleanup);
688
+ req.on("error", cleanup);
689
+ res.on("error", cleanup);
690
+ }
691
+ async function handleApi(req, res, url, auth, mode, pathname, binding, updates) {
608
692
  if (!pathname.startsWith("/api/"))
609
693
  return false;
610
694
  // The Pro presets are not bundled either: the first Pro request makes sure they
@@ -748,6 +832,39 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
748
832
  }
749
833
  return true;
750
834
  }
835
+ // The 30-minute background check announces a release here, so a window that
836
+ // stays open learns about it without reloading. Both workspaces open it.
837
+ if (req.method === "GET" && pathname === "/api/update/events") {
838
+ streamUpdateEvents(req, res, updates);
839
+ return true;
840
+ }
841
+ // The settings page's "Check for updates" button. Start-up reads the registry
842
+ // once and the Web console is served from that answer, so a manual check has
843
+ // to ask again; otherwise the button would keep showing whatever was true
844
+ // when the process started. Like the install below it runs npm on this
845
+ // machine, so it sits behind the login cookie and the same-origin check.
846
+ //
847
+ // The answer is a status, not an HTTP failure: an unreachable registry leaves
848
+ // `error` set on a 200 response, which is what the browser shows next to the
849
+ // button. A failure also changes nothing — the last good answer stays.
850
+ if (pathname === "/api/update/check") {
851
+ if (req.method !== "POST") {
852
+ sendJson(res, 405, { error: "method not allowed" });
853
+ return true;
854
+ }
855
+ if (!isSameOrigin(req)) {
856
+ sendJson(res, 403, { error: "cross-origin request rejected" });
857
+ return true;
858
+ }
859
+ const check = await checkForUpdate({ refresh: true });
860
+ sendJson(res, 200, {
861
+ has_update: check.hasUpdate,
862
+ current_version: check.currentVersion,
863
+ latest_version: check.latestVersion,
864
+ error: check.error ?? "",
865
+ });
866
+ return true;
867
+ }
751
868
  // The update prompt's confirm button: npm runs on this machine, so the route
752
869
  // sits behind the login cookie and the same-origin check like every other
753
870
  // mutation, and only a release the registry announced can be installed.
@@ -924,7 +1041,10 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
924
1041
  }
925
1042
  approvalMode = normalizeApprovalMode(input.approval_mode);
926
1043
  }
927
- const session = createWebSession(cwd, selection, approvalMode, mode, preset);
1044
+ if (input.agent_mode !== undefined && (!isAgentMode(input.agent_mode) || (mode === "pro" && input.agent_mode !== "standard"))) {
1045
+ throw new ApiError(400, "invalid agent mode", "invalid agent mode");
1046
+ }
1047
+ const session = createWebSession(cwd, selection, approvalMode, mode, preset, input.agent_mode);
928
1048
  const key = session.start();
929
1049
  webSessions.set(key, session);
930
1050
  recordWorkspace(cwd, mode);
@@ -1064,6 +1184,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1064
1184
  }
1065
1185
  let selection;
1066
1186
  let preset;
1187
+ let agentMode;
1067
1188
  try {
1068
1189
  const body = await readJsonBody(req);
1069
1190
  if (!body || typeof body !== "object" || Array.isArray(body))
@@ -1074,6 +1195,12 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1074
1195
  const requestedPreset = body.pro_preset;
1075
1196
  if (requestedPreset !== undefined && mode === "pro")
1076
1197
  preset = requireProPreset(requestedPreset);
1198
+ const requestedMode = body.agent_mode;
1199
+ if (requestedMode !== undefined) {
1200
+ if (!isAgentMode(requestedMode) || (mode === "pro" && requestedMode !== "standard"))
1201
+ throw new Error("invalid agent mode");
1202
+ agentMode = requestedMode;
1203
+ }
1077
1204
  }
1078
1205
  catch (err) {
1079
1206
  sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
@@ -1083,6 +1210,22 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1083
1210
  const existing = hub.list(mode).find((entry) => entry.sessionId === id);
1084
1211
  if (existing) {
1085
1212
  const remote = hub.remoteAction(existing.key);
1213
+ if (agentMode !== undefined && mode !== "pro") {
1214
+ if (remote) {
1215
+ const result = await remote("agent-mode", { mode: agentMode });
1216
+ if (result.status !== 200) {
1217
+ sendJson(res, result.status, result.data);
1218
+ return true;
1219
+ }
1220
+ }
1221
+ else {
1222
+ const changed = hub.setAgentMode(existing.key, agentMode);
1223
+ if (!changed.ok) {
1224
+ sendJson(res, 409, { error: changed.error });
1225
+ return true;
1226
+ }
1227
+ }
1228
+ }
1086
1229
  if (remote && preset !== undefined) {
1087
1230
  const result = await remote("pro-preset", { preset });
1088
1231
  if (result.status !== 200) {
@@ -1121,6 +1264,8 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1121
1264
  }
1122
1265
  if (preset !== undefined)
1123
1266
  data.pro_preset = preset;
1267
+ if (agentMode !== undefined)
1268
+ data.agent_mode = agentMode;
1124
1269
  const requested = selection ? {
1125
1270
  provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
1126
1271
  } : undefined;
@@ -1146,7 +1291,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1146
1291
  streamLive(key, req, res);
1147
1292
  return true;
1148
1293
  }
1149
- const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
1294
+ const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|agent-mode|clear))?$/.exec(pathname);
1150
1295
  if (liveMatch) {
1151
1296
  const key = decodeURIComponent(liveMatch[1]);
1152
1297
  const action = liveMatch[2];
@@ -1175,7 +1320,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1175
1320
  return true;
1176
1321
  }
1177
1322
  try {
1178
- const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
1323
+ const body = ["approve", "approval-mode", "agent-mode", "message", "model", "pro-preset"].includes(action)
1179
1324
  ? await readJsonBody(req) : {};
1180
1325
  const result = await remote(action, body);
1181
1326
  sendJson(res, result.status, result.data);
@@ -1204,6 +1349,25 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1204
1349
  }
1205
1350
  return true;
1206
1351
  }
1352
+ if (action === "agent-mode") {
1353
+ try {
1354
+ const body = await readJsonBody(req);
1355
+ const value = body?.mode;
1356
+ if (!isAgentMode(value) || mode === "pro") {
1357
+ sendJson(res, 400, { error: "invalid agent mode" });
1358
+ return true;
1359
+ }
1360
+ const changed = hub.setAgentMode(key, value);
1361
+ if (!changed.ok)
1362
+ sendJson(res, 409, { error: changed.error });
1363
+ else
1364
+ sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
1365
+ }
1366
+ catch (error) {
1367
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid agent mode" });
1368
+ }
1369
+ return true;
1370
+ }
1207
1371
  if (action === "approval-mode") {
1208
1372
  let body;
1209
1373
  try {
@@ -1308,7 +1472,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
1308
1472
  sendJson(res, 404, { error: "not found" });
1309
1473
  return true;
1310
1474
  }
1311
- async function handleRequest(req, res, auth, binding) {
1475
+ async function handleRequest(req, res, auth, binding, updates) {
1312
1476
  let url;
1313
1477
  try {
1314
1478
  url = new URL(req.url ?? "/", "http://localhost");
@@ -1341,7 +1505,7 @@ async function handleRequest(req, res, auth, binding) {
1341
1505
  mode = "pro";
1342
1506
  apiPathname = pathname.slice("/pro".length);
1343
1507
  }
1344
- if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
1508
+ if (await handleApi(req, res, url, auth, mode, apiPathname, binding, updates))
1345
1509
  return;
1346
1510
  if (mode === "pro") {
1347
1511
  // Nothing else lives under /pro but the page and its API.
@@ -1371,8 +1535,11 @@ export async function startWebServer(options) {
1371
1535
  service: options.backgroundService,
1372
1536
  updateSettings: (body, mode) => updateSettings(body, mode),
1373
1537
  };
1538
+ // Every window watching for an update belongs to this console, not the module,
1539
+ // so a second embedded server (a test, an embedding) keeps its own set.
1540
+ const updates = createUpdateStreams();
1374
1541
  const server = http.createServer((req, res) => {
1375
- handleRequest(req, res, auth, binding).catch((err) => {
1542
+ handleRequest(req, res, auth, binding, updates).catch((err) => {
1376
1543
  if (!res.headersSent) {
1377
1544
  sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
1378
1545
  }
@@ -1513,6 +1680,9 @@ export async function startWebServer(options) {
1513
1680
  setHost: (next) => rebind(next),
1514
1681
  close: () => new Promise((resolve) => {
1515
1682
  closed = true;
1683
+ // These streams never end on their own, so they are closed explicitly;
1684
+ // otherwise server.close() would wait for a window that stays open.
1685
+ updates.closeAll();
1516
1686
  for (const key of [...webSessions.keys()])
1517
1687
  closeWebSession(key);
1518
1688
  if (!server.listening) {
@@ -5,6 +5,7 @@ import { loadConfig, saveConfig } from "../config.js";
5
5
  import { startBackgroundTray } from "../tray.js";
6
6
  import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
7
7
  import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
8
+ import { startUpdateWatch } from "../updateWatch.js";
8
9
  import { hub } from "./hub.js";
9
10
  import { startWebServer } from "./server.js";
10
11
  import { TerminalBridges } from "./terminalBridge.js";
@@ -18,6 +19,7 @@ export async function runWebService(options) {
18
19
  let handle = null;
19
20
  let tray = null;
20
21
  let monitor = null;
22
+ let stopUpdateWatch = null;
21
23
  let stopping = false;
22
24
  const bridges = new TerminalBridges();
23
25
  const status = () => {
@@ -44,6 +46,8 @@ export async function runWebService(options) {
44
46
  stopping = true;
45
47
  if (monitor)
46
48
  clearInterval(monitor);
49
+ stopUpdateWatch?.();
50
+ stopUpdateWatch = null;
47
51
  tray?.close();
48
52
  bridges.close();
49
53
  backgroundLog(`stopping pid=${process.pid}`);
@@ -131,6 +135,24 @@ export async function runWebService(options) {
131
135
  }
132
136
  backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
133
137
  syncTray();
138
+ // The start-up lookup above is one answer; a console that stays open needs
139
+ // more than that. Every 30 minutes the registry is asked again and a release
140
+ // it has not seen before is announced to every open window
141
+ // (GET /api/update/events), which is the same status the first-open dialog
142
+ // reads, so both ways of telling the user come from one source.
143
+ let loggedUpdate = "";
144
+ stopUpdateWatch = startUpdateWatch({
145
+ onCheck: (result) => {
146
+ // Log a change, not the same answer every half hour.
147
+ const line = result.hasUpdate
148
+ ? `${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`
149
+ : result.error ? `update check: ${result.error}` : "";
150
+ if (!line || line === loggedUpdate)
151
+ return;
152
+ loggedUpdate = line;
153
+ backgroundLog(line);
154
+ },
155
+ });
134
156
  // No helper processes: polling pids must remain invisible on Windows. An
135
157
  // orphaned, non-resident console lets an in-flight browser turn finish before
136
158
  // exiting, so disabling residency also cannot throw away running work.