@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/README.md +124 -78
- package/assets/prompt.txt +3 -2
- package/dist/config.js +23 -1
- package/dist/llm/client.js +37 -2
- package/dist/llm/plan.js +49 -0
- package/dist/llm/subagent.js +3 -0
- package/dist/screens/chat.js +57 -32
- package/dist/web/hub.js +9 -0
- package/dist/web/server.js +353 -5
- package/dist/web/session.js +90 -4
- package/dist/web/settings.js +27 -1
- package/package.json +2 -2
- package/web/README.md +83 -15
- package/web/app.js +1309 -124
- package/web/i18n.js +345 -0
- package/web/index.html +210 -34
- package/web/markdown.js +710 -0
- package/web/settings.js +73 -19
- package/web/style.css +1124 -85
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();
|
package/dist/web/server.js
CHANGED
|
@@ -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:
|
|
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 {
|
package/dist/web/session.js
CHANGED
|
@@ -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
|
-
|
|
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 "
|
|
792
|
+
return "";
|
|
707
793
|
}
|
|
708
794
|
retainStreamingContent() {
|
|
709
795
|
if (this.streamingContent) {
|
package/dist/web/settings.js
CHANGED
|
@@ -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.
|
|
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
|
},
|