@xingjisoft/neutrino 0.2.0 → 0.3.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.
package/dist/web/hub.js CHANGED
@@ -175,6 +175,15 @@ class LiveHub {
175
175
  this.notify(key);
176
176
  return true;
177
177
  }
178
+ /** Empty a live conversation in place, keeping its key and working folder. */
179
+ clear(key) {
180
+ const entry = this.entries.get(key);
181
+ if (!entry?.agent.liveClear)
182
+ return false;
183
+ entry.agent.liveClear();
184
+ this.notify(key);
185
+ return true;
186
+ }
178
187
  }
179
188
  /** Process-wide singleton shared by the TUI and the web server. */
180
189
  export const hub = new LiveHub();
@@ -16,13 +16,14 @@ import http from "node:http";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
- import { SESSION_DIR, listSessions, resolveModelSelection } from "../config.js";
19
+ import { SESSION_DIR, deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, } from "../config.js";
20
20
  import { getWebSettings, updateWebSettings } from "./settings.js";
21
21
  import { WebAuth } from "./auth.js";
22
- import { normalizeCwd } from "../cwd.js";
22
+ import { normalizeCwd, sameCwd } from "../cwd.js";
23
23
  import { hub } from "./hub.js";
24
24
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { resumeSession } from "./session.js";
25
+ import { createWebSession, resumeSession } from "./session.js";
26
+ import { listAgentSessions, loadAgentMessages } from "../agents.js";
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
  // dist/web -> package root (mirrors the same trick used in config.ts).
28
29
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -132,6 +133,194 @@ function loadSessionSafe(id) {
132
133
  return null;
133
134
  }
134
135
  }
136
+ // ------------------------------------------------------- working directories
137
+ //
138
+ // The browser console can open any folder on this machine, which is exactly
139
+ // what the terminal can do by being launched from that folder. Every route
140
+ // below still sits behind the login cookie: browsing the filesystem is only as
141
+ // exposed as the authenticated console itself.
142
+ class ApiError extends Error {
143
+ status;
144
+ code;
145
+ constructor(status, code, message) {
146
+ super(message);
147
+ this.status = status;
148
+ this.code = code;
149
+ }
150
+ }
151
+ function sendApiError(res, error) {
152
+ if (error instanceof ApiError) {
153
+ sendJson(res, error.status, { error: error.message, code: error.code });
154
+ return;
155
+ }
156
+ const message = error instanceof Error ? error.message : "invalid request";
157
+ sendJson(res, 400, { error: message });
158
+ }
159
+ const MAX_RECENT_WORKSPACES = 12;
160
+ const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
161
+ const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
162
+ /** Expand `~` and require an absolute path; used by every folder route. */
163
+ function absolutePath(raw) {
164
+ if (typeof raw !== "string" || !raw.trim()) {
165
+ throw new ApiError(400, "working directory is required", "working directory is required");
166
+ }
167
+ let value = raw.trim();
168
+ if (value === "~")
169
+ value = os.homedir();
170
+ else if (/^~[\\/]/.test(value))
171
+ value = path.join(os.homedir(), value.slice(2));
172
+ if (!path.isAbsolute(value)) {
173
+ throw new ApiError(400, "working directory must be an absolute path", "working directory must be an absolute path");
174
+ }
175
+ return path.resolve(value);
176
+ }
177
+ function existingDirectory(raw) {
178
+ const dir = absolutePath(raw);
179
+ let stat;
180
+ try {
181
+ stat = fs.statSync(dir);
182
+ }
183
+ catch {
184
+ throw new ApiError(400, "working directory does not exist", "working directory does not exist");
185
+ }
186
+ if (!stat.isDirectory()) {
187
+ throw new ApiError(400, "working directory is not a folder", "working directory is not a folder");
188
+ }
189
+ return dir;
190
+ }
191
+ let cachedRoots = null;
192
+ /** Filesystem roots worth offering as shortcuts (every existing drive on Windows). */
193
+ function filesystemRoots() {
194
+ if (cachedRoots)
195
+ return cachedRoots;
196
+ if (process.platform !== "win32") {
197
+ cachedRoots = ["/"];
198
+ return cachedRoots;
199
+ }
200
+ const roots = [];
201
+ for (let code = 65; code <= 90; code += 1) {
202
+ const root = `${String.fromCharCode(code)}:\\`;
203
+ try {
204
+ if (fs.statSync(root).isDirectory())
205
+ roots.push(root);
206
+ }
207
+ catch {
208
+ // A drive letter that does not exist is simply not offered.
209
+ }
210
+ }
211
+ cachedRoots = roots.length ? roots : [path.parse(process.cwd()).root];
212
+ return cachedRoots;
213
+ }
214
+ /** One directory listing for the folder picker. Files are hidden: the picker chooses directories. */
215
+ function readFolder(raw) {
216
+ const dir = existingDirectory(raw);
217
+ let entries;
218
+ try {
219
+ entries = fs.readdirSync(dir, { withFileTypes: true });
220
+ }
221
+ catch (err) {
222
+ throw new ApiError(403, "cannot read this folder", err instanceof Error ? err.message : "cannot read this folder");
223
+ }
224
+ const folders = entries
225
+ .filter((entry) => entry.isDirectory())
226
+ .map((entry) => ({
227
+ name: entry.name,
228
+ path: path.join(dir, entry.name),
229
+ hidden: entry.name.startsWith("."),
230
+ }))
231
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
232
+ const parent = path.dirname(dir);
233
+ return {
234
+ path: dir,
235
+ name: path.basename(dir) || dir,
236
+ parent: parent === dir ? null : parent,
237
+ separator: path.sep,
238
+ home: os.homedir(),
239
+ cwd: process.cwd(),
240
+ roots: filesystemRoots(),
241
+ entries: folders,
242
+ };
243
+ }
244
+ /** Recent working directories: explicitly opened ones first, then saved-session folders. */
245
+ function recentWorkspacePaths() {
246
+ const config = loadConfig();
247
+ const stored = Array.isArray(config.web_workspaces)
248
+ ? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
249
+ : [];
250
+ const saved = listSessions().map((session) => session.cwd).filter(Boolean);
251
+ const seen = new Set();
252
+ const out = [];
253
+ for (const dir of [...stored, ...saved, process.cwd()]) {
254
+ const key = normalizeCwd(dir);
255
+ if (!key || seen.has(key))
256
+ continue;
257
+ seen.add(key);
258
+ out.push(dir);
259
+ if (out.length >= MAX_RECENT_WORKSPACES)
260
+ break;
261
+ }
262
+ return out;
263
+ }
264
+ function workspaceList() {
265
+ const sessions = listSessions();
266
+ return recentWorkspacePaths().map((dir) => {
267
+ let exists = false;
268
+ try {
269
+ exists = fs.statSync(dir).isDirectory();
270
+ }
271
+ catch {
272
+ // A folder that disappeared stays listed, greyed out, so it can be removed by choosing another.
273
+ }
274
+ return {
275
+ path: dir,
276
+ name: path.basename(dir) || dir,
277
+ sessions: sessions.filter((session) => sameCwd(session.cwd, dir)).length,
278
+ exists,
279
+ };
280
+ });
281
+ }
282
+ /** Best-effort persistence: failing to remember a folder must not fail the conversation. */
283
+ function recordWorkspace(dir) {
284
+ try {
285
+ const config = loadConfig();
286
+ const stored = Array.isArray(config.web_workspaces)
287
+ ? config.web_workspaces.filter((entry) => typeof entry === "string")
288
+ : [];
289
+ config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
290
+ .slice(0, MAX_RECENT_WORKSPACES);
291
+ saveConfig(config);
292
+ }
293
+ catch {
294
+ // The picked folder still works for this conversation.
295
+ }
296
+ }
297
+ /** Create one folder inside an existing parent. */
298
+ function createFolder(raw) {
299
+ const target = absolutePath(raw);
300
+ if (target === path.parse(target).root) {
301
+ throw new ApiError(400, "cannot create a folder here", "cannot create a folder here");
302
+ }
303
+ const parent = path.dirname(target);
304
+ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) {
305
+ throw new ApiError(400, "parent folder does not exist", "parent folder does not exist");
306
+ }
307
+ if (fs.existsSync(target)) {
308
+ throw new ApiError(409, "folder already exists", "folder already exists");
309
+ }
310
+ try {
311
+ fs.mkdirSync(target);
312
+ }
313
+ catch (err) {
314
+ throw new ApiError(400, "cannot create a folder here", err instanceof Error ? err.message : "cannot create a folder here");
315
+ }
316
+ return target;
317
+ }
318
+ function isAgentId(value) {
319
+ return AGENT_IDS.has(value);
320
+ }
321
+ function safeAgentSessionId(id) {
322
+ return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
323
+ }
135
324
  /** Build the sidebar payload: every session grouped by working directory. */
136
325
  function buildOverview() {
137
326
  const sessions = listSessions();
@@ -178,7 +367,9 @@ function buildOverview() {
178
367
  provider: live.provider,
179
368
  intensity: live.intensity,
180
369
  cwd: live.cwd,
181
- title: live.title || "New conversation",
370
+ // An unnamed conversation keeps an empty title: the browser translates
371
+ // its own fallback, and the terminal/server never impose one language.
372
+ title: live.title || "",
182
373
  message_count: 0,
183
374
  live: true,
184
375
  liveKey: live.key,
@@ -424,6 +615,155 @@ async function handleApi(req, res, url, auth) {
424
615
  sendJson(res, 200, { sessions: listSessions() });
425
616
  return true;
426
617
  }
618
+ if (pathname === "/api/workspaces") {
619
+ if (req.method === "GET") {
620
+ sendJson(res, 200, {
621
+ cwd: process.cwd(),
622
+ home: os.homedir(),
623
+ workspaces: workspaceList(),
624
+ });
625
+ return true;
626
+ }
627
+ if (req.method === "POST") {
628
+ if (!isSameOrigin(req)) {
629
+ sendJson(res, 403, { error: "cross-origin request rejected" });
630
+ return true;
631
+ }
632
+ try {
633
+ const body = await readJsonBody(req);
634
+ const dir = existingDirectory(body?.path);
635
+ recordWorkspace(dir);
636
+ sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList() });
637
+ }
638
+ catch (err) {
639
+ sendApiError(res, err);
640
+ }
641
+ return true;
642
+ }
643
+ sendJson(res, 405, { error: "method not allowed" });
644
+ return true;
645
+ }
646
+ if (pathname === "/api/fs") {
647
+ if (req.method !== "GET") {
648
+ sendJson(res, 405, { error: "method not allowed" });
649
+ return true;
650
+ }
651
+ try {
652
+ sendJson(res, 200, readFolder(url.searchParams.get("path") ?? process.cwd()));
653
+ }
654
+ catch (err) {
655
+ sendApiError(res, err);
656
+ }
657
+ return true;
658
+ }
659
+ if (pathname === "/api/fs/mkdir") {
660
+ if (req.method !== "POST") {
661
+ sendJson(res, 405, { error: "method not allowed" });
662
+ return true;
663
+ }
664
+ if (!isSameOrigin(req)) {
665
+ sendJson(res, 403, { error: "cross-origin request rejected" });
666
+ return true;
667
+ }
668
+ try {
669
+ const body = await readJsonBody(req);
670
+ const created = createFolder(body?.path);
671
+ sendJson(res, 200, { ok: true, path: created });
672
+ }
673
+ catch (err) {
674
+ sendApiError(res, err);
675
+ }
676
+ return true;
677
+ }
678
+ // Start a brand-new conversation in a working directory chosen by the
679
+ // browser. Unlike `/continue`, there is no transcript to load first.
680
+ if (pathname === "/api/conversations") {
681
+ if (req.method !== "POST") {
682
+ sendJson(res, 405, { error: "method not allowed" });
683
+ return true;
684
+ }
685
+ if (!isSameOrigin(req)) {
686
+ sendJson(res, 403, { error: "cross-origin request rejected" });
687
+ return true;
688
+ }
689
+ try {
690
+ const body = await readJsonBody(req);
691
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
692
+ throw new ApiError(400, "invalid request", "invalid request");
693
+ }
694
+ const input = body;
695
+ const cwd = existingDirectory(input.cwd ?? process.cwd());
696
+ const requested = input.selection === undefined
697
+ ? getWebSettings().selection
698
+ : input.selection;
699
+ const selection = resolveModelSelection(requested, false);
700
+ let approvalMode;
701
+ if (input.approval_mode !== undefined) {
702
+ if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
703
+ throw new ApiError(400, "invalid approval mode", "invalid approval mode");
704
+ }
705
+ approvalMode = normalizeApprovalMode(input.approval_mode);
706
+ }
707
+ const session = createWebSession(cwd, selection, approvalMode);
708
+ const key = session.start();
709
+ webSessions.set(key, session);
710
+ recordWorkspace(cwd);
711
+ sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
712
+ }
713
+ catch (err) {
714
+ sendApiError(res, err);
715
+ }
716
+ return true;
717
+ }
718
+ const deleteMatch = /^\/api\/session\/([^/]+)\/delete$/.exec(pathname);
719
+ if (req.method === "POST" && deleteMatch) {
720
+ if (!isSameOrigin(req)) {
721
+ sendJson(res, 403, { error: "cross-origin request rejected" });
722
+ return true;
723
+ }
724
+ const id = decodeURIComponent(deleteMatch[1]);
725
+ if (!isSafeSessionId(id)) {
726
+ sendJson(res, 400, { error: "invalid session id" });
727
+ return true;
728
+ }
729
+ if (hub.list().some((entry) => entry.sessionId === id)) {
730
+ sendJson(res, 409, { error: "cannot delete a running conversation" });
731
+ return true;
732
+ }
733
+ if (!deleteSession(id)) {
734
+ sendJson(res, 404, { error: "session not found" });
735
+ return true;
736
+ }
737
+ sendJson(res, 200, { ok: true, deleted: true });
738
+ return true;
739
+ }
740
+ // Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
741
+ const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
742
+ if (req.method === "GET" && agentSessionsMatch) {
743
+ const agent = decodeURIComponent(agentSessionsMatch[1]);
744
+ if (!isAgentId(agent)) {
745
+ sendJson(res, 400, { error: "unknown agent" });
746
+ return true;
747
+ }
748
+ const cwd = url.searchParams.get("cwd") || process.cwd();
749
+ sendJson(res, 200, { agent, cwd, sessions: listAgentSessions(agent, cwd) });
750
+ return true;
751
+ }
752
+ const agentSessionMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)$/.exec(pathname);
753
+ if (req.method === "GET" && agentSessionMatch) {
754
+ const agent = decodeURIComponent(agentSessionMatch[1]);
755
+ const id = decodeURIComponent(agentSessionMatch[2]);
756
+ if (!isAgentId(agent)) {
757
+ sendJson(res, 400, { error: "unknown agent" });
758
+ return true;
759
+ }
760
+ if (!safeAgentSessionId(id)) {
761
+ sendJson(res, 400, { error: "invalid session id" });
762
+ return true;
763
+ }
764
+ sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
765
+ return true;
766
+ }
427
767
  const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
428
768
  if (req.method === "GET" && sessionMatch) {
429
769
  const id = decodeURIComponent(sessionMatch[1]);
@@ -499,7 +839,7 @@ async function handleApi(req, res, url, auth) {
499
839
  streamLive(decodeURIComponent(streamMatch[1]), req, res);
500
840
  return true;
501
841
  }
502
- const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode))?$/.exec(pathname);
842
+ const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode|clear))?$/.exec(pathname);
503
843
  if (liveMatch) {
504
844
  const key = decodeURIComponent(liveMatch[1]);
505
845
  const action = liveMatch[2];
@@ -593,6 +933,14 @@ async function handleApi(req, res, url, auth) {
593
933
  sendJson(res, 200, { ok: true, closed: true });
594
934
  return true;
595
935
  }
936
+ if (action === "clear") {
937
+ if (!hub.clear(key)) {
938
+ sendJson(res, 409, { error: "this session cannot be cleared" });
939
+ return true;
940
+ }
941
+ sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
942
+ return true;
943
+ }
596
944
  // action === "message"
597
945
  let body;
598
946
  try {
@@ -10,10 +10,11 @@
10
10
  * registers itself in the live hub, so the regular `/api/live/*` endpoints
11
11
  * (snapshot, stream, pause, resume, message) work unchanged.
12
12
  */
13
+ import { randomUUID } from "node:crypto";
13
14
  import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
14
15
  import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
15
16
  import { LLMClient, describeError } from "../llm/client.js";
16
- import { advancePlan, buildPlannerInput, lastRequestText, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
17
+ import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
17
18
  import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
18
19
  import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
19
20
  import { assessToolCall } from "../llm/risk.js";
@@ -31,6 +32,35 @@ const TOOL_PENDING = "…";
31
32
  const SUB_AGENT_TOOLS = getSubAgentToolDefinitions();
32
33
  /** Marker for the model-only summary, separate from the original transcript. */
33
34
  const SUMMARY_PREFIX = "Context was compressed. Summary of the earlier part:";
35
+ /**
36
+ * A fresh session id in the same shape `saveSession` generates, so a new
37
+ * browser conversation has a stable identity before its first message exists.
38
+ */
39
+ export function newSessionId(now = new Date()) {
40
+ return `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
41
+ }
42
+ /**
43
+ * Start an empty conversation in a caller-chosen working directory. Nothing is
44
+ * written to disk until the first message, so an abandoned new-chat dialog
45
+ * cannot litter the history with empty transcripts.
46
+ */
47
+ export function createWebSession(cwd, selection, approvalMode) {
48
+ const now = new Date();
49
+ const source = {
50
+ id: newSessionId(now),
51
+ created_at: now.toISOString(),
52
+ updated_at: now.toISOString(),
53
+ model: selection.model.name,
54
+ provider: selection.provider,
55
+ intensity: selection.intensity,
56
+ cwd,
57
+ title: "",
58
+ messages: [],
59
+ tokens: { prompt: 0, completion: 0 },
60
+ approval_mode: normalizeApprovalMode(approvalMode ?? loadConfig().approval_mode),
61
+ };
62
+ return new WebSession(source, selection.model);
63
+ }
34
64
  function parseArguments(raw) {
35
65
  const text = raw?.trim() ?? "";
36
66
  if (!text)
@@ -267,6 +297,40 @@ export class WebSession {
267
297
  this.save();
268
298
  this.publishLive();
269
299
  }
300
+ /**
301
+ * Start a fresh chat in the same working directory, mirroring the terminal's
302
+ * clear shortcut. The finished transcript is checkpointed first and stays on
303
+ * disk; this session simply moves on under a new id.
304
+ */
305
+ liveClear() {
306
+ if (this.disposed)
307
+ return;
308
+ if (!this.checkpoint.flush())
309
+ return;
310
+ this.turnToken++;
311
+ if (this.pendingApproval) {
312
+ const pending = this.pendingApproval;
313
+ this.pendingApproval = null;
314
+ pending.resolve(false);
315
+ }
316
+ this.busy = false;
317
+ this.paused = false;
318
+ this.queued = null;
319
+ this.compressing = false;
320
+ this.retrying = null;
321
+ this.subagent = null;
322
+ this.reviewing = null;
323
+ this.streamingContent = null;
324
+ this.plan = null;
325
+ this.planning = false;
326
+ this.messages = [];
327
+ this.context = null;
328
+ this.lastPromptTokens = 0;
329
+ this.saveError = null;
330
+ this.sessionId = newSessionId();
331
+ this.publishLive();
332
+ this.touch();
333
+ }
270
334
  // ---- turn driving ----------------------------------------------------
271
335
  send(text) {
272
336
  const trimmed = text.trim();
@@ -371,6 +435,9 @@ export class WebSession {
371
435
  const request = lastRequestText(this.messages);
372
436
  if (!request.trim())
373
437
  return null;
438
+ // One action is not a plan: run the turn in one pass, as the terminal does.
439
+ if (!needsPlan(request))
440
+ return null;
374
441
  this.planning = true;
375
442
  this.publishLive();
376
443
  try {
@@ -472,13 +539,32 @@ export class WebSession {
472
539
  this.tokens.completion += client.usage.completion;
473
540
  this.lastPromptTokens = client.usage.prompt;
474
541
  }
542
+ const toolCalls = client.toolCalls;
475
543
  if (text.trim()) {
476
544
  this.messages.push({ role: "assistant", content: text });
477
- apiMessages.push({ role: "assistant", content: text });
545
+ }
546
+ if (text.trim() || toolCalls.length > 0) {
547
+ // The assistant message and the tool results it produced must stay
548
+ // paired: a `tool` message without its `tool_calls` parent is
549
+ // rejected by every OpenAI-compatible endpoint. The reasoning is the
550
+ // thinking that produced the calls, which DeepSeek requires back.
551
+ apiMessages.push({
552
+ role: "assistant",
553
+ content: text,
554
+ reasoning_content: client.reasoningContent ?? "",
555
+ ...(toolCalls.length > 0
556
+ ? {
557
+ tool_calls: toolCalls.map((call) => ({
558
+ id: call.id,
559
+ type: "function",
560
+ function: { name: call.name, arguments: call.arguments },
561
+ })),
562
+ }
563
+ : {}),
564
+ });
478
565
  }
479
566
  this.save();
480
567
  this.publishLive();
481
- const toolCalls = client.toolCalls;
482
568
  if (toolCalls.length === 0)
483
569
  break;
484
570
  for (const call of toolCalls) {
@@ -703,7 +789,7 @@ export class WebSession {
703
789
  return line.length > 80 ? `${line.slice(0, 77)}…` : line;
704
790
  }
705
791
  }
706
- return "Resumed chat";
792
+ return "";
707
793
  }
708
794
  retainStreamingContent() {
709
795
  if (this.streamingContent) {
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CONFIG, INTENSITY_LEVELS, contextWindow, isContextWindow, loadConfig, loadModels, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
1
+ import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, isContextWindow, loadConfig, loadModels, normalizeApprovalMode, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
2
2
  import { hashWebPassword, validateWebPassword } from "./auth.js";
3
3
  /** Public settings use a whitelist: API keys and cached credentials stay on disk. */
4
4
  export function getWebSettings() {
@@ -15,6 +15,7 @@ export function getWebSettings() {
15
15
  return {
16
16
  providers,
17
17
  intensities: INTENSITY_LEVELS,
18
+ languages: WEB_LANGUAGES.map((value) => ({ value, label: value === "zh" ? "简体中文" : "English" })),
18
19
  selection: {
19
20
  provider: provider?.name ?? "",
20
21
  model: model?.name ?? "",
@@ -26,6 +27,10 @@ export function getWebSettings() {
26
27
  approval_model: String(config.approval_model ?? ""),
27
28
  auto_approve_extreme_risk: config.auto_approve_extreme_risk === true,
28
29
  sound_alerts: config.sound_alerts !== false,
30
+ language: normalizeWebLanguage(config.language),
31
+ plan_first: config.plan_first !== false,
32
+ approval_mode: normalizeApprovalMode(config.approval_mode),
33
+ web_console: config.web_console !== false,
29
34
  },
30
35
  };
31
36
  }
@@ -67,6 +72,27 @@ export function updateWebSettings(value) {
67
72
  throw new Error("auto-approval must be a boolean");
68
73
  config[key] = val;
69
74
  }
75
+ else if (key === "language") {
76
+ if (typeof val !== "string" || !WEB_LANGUAGES.includes(val)) {
77
+ throw new Error("invalid language");
78
+ }
79
+ config.language = val;
80
+ }
81
+ else if (key === "plan_first") {
82
+ if (typeof val !== "boolean")
83
+ throw new Error("invalid plan setting");
84
+ config.plan_first = val;
85
+ }
86
+ else if (key === "approval_mode") {
87
+ if (val !== "request" && val !== "auto")
88
+ throw new Error("invalid approval mode");
89
+ config.approval_mode = val;
90
+ }
91
+ else if (key === "web_console") {
92
+ if (typeof val !== "boolean")
93
+ throw new Error("web console must be a boolean");
94
+ config.web_console = val;
95
+ }
70
96
  else if (key === "web_password") {
71
97
  validateWebPassword(val);
72
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.2.0",
3
+ "version": "0.3.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-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-auth.mjs && node scripts/test-web-auth-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-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-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-auth.mjs && node scripts/test-web-auth-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
  },