@xingjisoft/neutrino 0.2.1 → 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.
@@ -10,6 +10,7 @@
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";
@@ -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();
@@ -725,7 +789,7 @@ export class WebSession {
725
789
  return line.length > 80 ? `${line.slice(0, 77)}…` : line;
726
790
  }
727
791
  }
728
- return "Resumed chat";
792
+ return "";
729
793
  }
730
794
  retainStreamingContent() {
731
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.1",
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
  },
package/web/README.md CHANGED
@@ -8,8 +8,17 @@ A small, dependency-free single page app that is served by the Neutrino CLI at
8
8
  - watch a running terminal conversation stream in real time: the work behind every
9
9
  finished turn folds into one line that opens on a click, while the reply itself
10
10
  stays on screen (the turn being answered is open, and a pause is not a fold);
11
+ - read replies as rendered markdown: headings, nested/task lists, tables, block
12
+ quotes, code fences, emphasis, safe links and images; raw HTML is escaped;
13
+ - start a new conversation in a working directory you choose, including a folder
14
+ created from the built-in folder picker;
15
+ - switch the workspace between every known folder and **All directories**;
11
16
  - switch the provider, model and intensity for the next turn;
12
- - edit defaults, context compression and approval settings in a Settings dialog;
17
+ - edit every terminal setting — defaults, interface language, plan-first turns,
18
+ default approval mode, context compression, reviewer and Web password;
19
+ - switch the interface between English and 简体中文, instantly and persistently;
20
+ - open the same plan / token card as the terminal's `ⓘ`, clear a conversation,
21
+ delete a saved transcript, and review other agents' read-only history;
13
22
  - drive the running conversation: pause / resume the current turn and send new
14
23
  messages (they queue while the agent is busy);
15
24
  - pick up a saved conversation in the browser and keep chatting, even when no
@@ -112,13 +121,42 @@ selection. Changing the selectors and then using **Continue here** or sending a
112
121
  message also uses the draft selection, so a transcript whose old model was
113
122
  removed can still be continued.
114
123
 
115
- The **Settings** (gear) button in the sidebar opens defaults for new conversations,
116
- the context compression threshold, the approval reviewer model, and the
117
- extreme-risk automatic approval option. Save settings explicitly; cancelling
118
- leaves the saved values intact. Conversation model changes are separate from
119
- these defaults. Models come from the same catalog as the terminal, and API keys
120
- stay on the server. Configure missing keys in the terminal model screen first.
121
- Intensity uses the same Low–Ultra sampling settings as the terminal.
124
+ The **Settings** (gear) button in the sidebar opens the interface language,
125
+ plan-first turns, the default approval mode, defaults for new conversations, the
126
+ context compression threshold, the approval reviewer model, sound alerts, the
127
+ extreme-risk automatic approval option, and the Web password. Save settings
128
+ explicitly; cancelling leaves the saved values intact. Conversation model changes
129
+ are separate from these defaults. Models come from the same catalog as the
130
+ terminal, and API keys stay on the server. Configure missing keys in the terminal
131
+ model screen first. Intensity uses the same Low–Ultra sampling settings as the
132
+ terminal.
133
+
134
+ ## New conversations and working directories
135
+
136
+ **New conversation** starts a chat that has no transcript yet. The dialog takes a
137
+ working directory plus provider, model, thinking level and approval mode. The
138
+ conversation is browser-owned from that moment: it appears in the sidebar under
139
+ its folder and is written to its own session file once the first message exists.
140
+
141
+ **Browse…** opens the server-side folder picker. It lists folders only, offers
142
+ the existing drive roots (on Windows) or `/`, moves up through the directory
143
+ tree, and can create a folder in place. **Use this folder** fills the working
144
+ directory of the new conversation.
145
+
146
+ **Working directories** (the folder button under New conversation) shows every
147
+ folder the console has opened or that already holds conversations. Choosing one
148
+ scopes the sidebar to that folder and shows a landing page where typing starts a
149
+ conversation there. **All directories** returns to the grouped view. Opened
150
+ folders are stored in `config.json` under `web_workspaces`, so an empty folder
151
+ that has no conversations yet is still listed after a restart.
152
+
153
+ ## Interface language
154
+
155
+ The sidebar footer and **Settings → Interface** both offer English and 简体中文.
156
+ The change is applied to the already-open page (labels, messages, status line,
157
+ plan card and dialogs) without a reload, is stored in `config.json`, and is
158
+ shared by every browser. The terminal UI stays in English. New conversation
159
+ content, of course, remains in the language the model writes.
122
160
 
123
161
  ## History vs. live sessions
124
162
 
@@ -144,6 +182,22 @@ Browser-owned sessions are dropped after 30 idle minutes, or immediately through
144
182
  `POST /api/live/:key/close`. Sessions owned by the terminal cannot be closed from
145
183
  the web.
146
184
 
185
+ **Clear** empties the running conversation in place (the previous transcript is
186
+ checkpointed first and stays on disk), mirroring `Ctrl+L` in the terminal. A
187
+ read-only transcript gets **Delete** instead, which removes the saved JSON file
188
+ after confirmation; a conversation that is currently live cannot be deleted.
189
+ Sessions from the terminal's `Ctrl+L` also appear as fresh live conversations in
190
+ the sidebar.
191
+
192
+ ## Other agents' history
193
+
194
+ **Other agents** in the sidebar lists read-only sessions recorded by Claude Code,
195
+ Codex and OpenCode for the current working directory (the scoped workspace, the
196
+ open conversation's folder, or the server's launch directory). Clicking one shows
197
+ the complete transcript in the conversation pane with a `read-only` badge. The
198
+ sources are the same as the terminal's history browser; OpenCode requires Node.js
199
+ 22.13+ because it reads its SQLite database through `node:sqlite`.
200
+
147
201
  ## Plan-first turns
148
202
 
149
203
  A browser-owned turn is planned before it runs, exactly like a turn in the
@@ -169,8 +223,10 @@ needed by a client:
169
223
 
170
224
  `status` is `pending`, `active`, `done` or `failed`; `stopped: true` marks a plan
171
225
  whose turn was paused before the last step. The terminal draws this in its `ⓘ`
172
- card; a browser client can render the same timeline from the snapshot. The page
173
- itself currently shows the plan as the message it already is.
226
+ card, and the web console renders the same timeline in its `ⓘ` plan card: the
227
+ active step is highlighted, finished steps are ticked, failed steps are red, and
228
+ the footer counts the steps completed. The card also shows token usage when no
229
+ plan is running.
174
230
 
175
231
  Planning is the default for both loops. It can be turned off with
176
232
  `"plan_first": false` in `~/.neutrino/config.json` or `NEUTRINO_PLAN=off`; a
@@ -193,21 +249,31 @@ to know whether a conversation is driven by the terminal or by the server.
193
249
  | --- | --- | --- |
194
250
  | GET | `/api/overview` | Sessions grouped by cwd + live sessions |
195
251
  | GET | `/api/sessions` | Flat list of saved sessions |
196
- | GET | `/api/settings` | Model catalog and public settings; no credentials |
197
- | POST | `/api/settings` | Save validated `{ selection?, settings? }` defaults |
252
+ | GET | `/api/workspaces` | Recent working directories, launch cwd and home |
253
+ | POST | `/api/workspaces` | Validate and remember `{ "path": "…" }` |
254
+ | GET | `/api/fs?path=…` | Folder listing for the picker (folders only, roots, parent) |
255
+ | POST | `/api/fs/mkdir` | Create `{ "path": "…" }` and return the new path |
256
+ | POST | `/api/conversations` | Start `{ cwd, selection?, approval_mode? }`, returns `{ key, snapshot }` |
257
+ | GET | `/api/settings` | Model catalog, languages and public settings; no credentials |
258
+ | POST | `/api/settings` | Save validated `{ selection?, settings? }`, including `language`, `plan_first`, `approval_mode` and `web_password` |
198
259
  | GET | `/api/session/:id` | One saved transcript |
199
260
  | POST | `/api/session/:id/continue` | Resume a transcript with optional `{ selection }`, returns `{ key }` |
261
+ | POST | `/api/session/:id/delete` | Delete a saved, non-running transcript |
262
+ | GET | `/api/agents/:agent/sessions?cwd=…` | Read-only sessions from Claude Code / Codex / OpenCode |
263
+ | GET | `/api/agents/:agent/sessions/:id` | Read-only messages of one other-agent session |
200
264
  | GET | `/api/live/:key` | Snapshot of a live session |
201
265
  | GET | `/api/live/:key/stream` | SSE stream of a live session |
202
266
  | POST | `/api/live/:key/pause` | Pause the running turn |
203
267
  | POST | `/api/live/:key/resume` | Resume a paused conversation |
204
268
  | POST | `/api/live/:key/message` | Send `{ "text": "…" }` |
205
269
  | POST | `/api/live/:key/model` | Apply `{ provider, model, intensity }` while idle or paused |
270
+ | POST | `/api/live/:key/clear` | Empty the conversation in place; keeps the old transcript |
271
+ | POST | `/api/live/:key/approve` | Answer `{ "approved": true|false }` |
272
+ | POST | `/api/live/:key/approval-mode` | Set `{ "mode": "request"|"auto" }` |
206
273
  | POST | `/api/live/:key/close` | Dispose a browser-owned session |
207
274
  | GET | `/api/health` | Liveness probe |
208
275
  | POST | `/api/auth/login` | Sign in with `{ "password": "…" }` |
209
276
  | POST | `/api/auth/logout` | Clear the login cookie |
210
- | GET / POST | `/api/settings` | Read settings or update them, including `settings.web_password` |
211
277
 
212
278
  Conversation and settings routes require the login cookie; see
213
279
  [Authentication](#authentication). `OPTIONS` is answered unauthenticated so
@@ -219,8 +285,10 @@ When a running agent wants to do something risky (a delete-class command, an
219
285
  install, a path outside the working directory) the browser shows an orange
220
286
  **Request Approval** banner above the composer, showing the full command (or
221
287
  formatted tool arguments), working directory and risk reasons, plus two buttons.
222
- Long proposals remain complete and scroll inside the card. Automatic reviews
223
- show the same proposal details while they run.
288
+ The banner only appears when a human decision is actually waiting. Long proposals
289
+ remain complete and scroll inside the card.
290
+ Automatic reviews are reported in the conversation's status badge instead
291
+ (`reviewing round 2/3`), so auto approval never looks like a request for approval.
224
292
  `Approve` runs the call, `Deny` returns a note to the agent.
225
293
  The reviewer uses the current turn's captured user request and earlier user
226
294
  messages; queued input belongs to the next turn. Expand a denied tool result