@xingjisoft/neutrino 0.1.6 → 0.2.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 +137 -28
- package/assets/planner.txt +35 -0
- package/assets/prompt.txt +56 -21
- package/assets/step.txt +14 -0
- package/assets/subagent.txt +20 -0
- package/dist/app.js +44 -2
- package/dist/bin.js +38 -13
- package/dist/config.js +45 -1
- package/dist/header.js +5 -0
- package/dist/llm/client.js +64 -140
- package/dist/llm/plan.js +403 -0
- package/dist/llm/retry.js +209 -0
- package/dist/llm/subagent.js +134 -0
- package/dist/llm/tools.js +43 -1
- package/dist/screens/chat.js +439 -78
- package/dist/screens/settings.js +30 -1
- package/dist/state.js +1 -0
- package/dist/web/session.js +211 -22
- package/package.json +2 -2
- package/web/README.md +49 -13
- package/web/app.js +79 -31
- package/web/index.html +55 -17
- package/web/style.css +2070 -1088
- package/web/turns.js +25 -11
package/dist/screens/settings.js
CHANGED
|
@@ -5,7 +5,7 @@ import { drawHeader } from "../header.js";
|
|
|
5
5
|
import { centerVertically, drawHint } from "../ui.js";
|
|
6
6
|
import { hashWebPassword } from "../web/auth.js";
|
|
7
7
|
/** The settings that can be changed here, top to bottom. */
|
|
8
|
-
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_password"];
|
|
8
|
+
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "web_password"];
|
|
9
9
|
export class SettingsScreen extends Screen {
|
|
10
10
|
intensityIdx = 2;
|
|
11
11
|
contextTokens = DEFAULT_CONTEXT_WINDOW;
|
|
@@ -16,6 +16,10 @@ export class SettingsScreen extends Screen {
|
|
|
16
16
|
extremeIdx = 0;
|
|
17
17
|
/** 0 = silent, 1 = audible cue on turn end and on approval requests. */
|
|
18
18
|
soundIdx = 1;
|
|
19
|
+
/** 0 = web UI off, 1 = mounted alongside the terminal. */
|
|
20
|
+
webIdx = 1;
|
|
21
|
+
/** Only a row the user actually touched rewrites the saved default. */
|
|
22
|
+
webChanged = false;
|
|
19
23
|
models = [];
|
|
20
24
|
rowIdx = 0;
|
|
21
25
|
lastHoverX = -1;
|
|
@@ -38,6 +42,10 @@ export class SettingsScreen extends Screen {
|
|
|
38
42
|
this.approvalModelIdx = approvalAt >= 0 ? approvalAt + 1 : 0;
|
|
39
43
|
this.extremeIdx = config.auto_approve_extreme_risk === true ? 1 : 0;
|
|
40
44
|
this.soundIdx = config.sound_alerts === false ? 0 : 1;
|
|
45
|
+
// A console started by an explicit flag is on even if the saved default is
|
|
46
|
+
// off, so reflect what is actually running first.
|
|
47
|
+
this.webIdx = (this.app.state.webUrl || config.web_console !== false) ? 1 : 0;
|
|
48
|
+
this.webChanged = false;
|
|
41
49
|
}
|
|
42
50
|
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
43
51
|
cycle(row, delta) {
|
|
@@ -62,6 +70,15 @@ export class SettingsScreen extends Screen {
|
|
|
62
70
|
else if (row === "sound") {
|
|
63
71
|
this.soundIdx = this.soundIdx === 1 ? 0 : 1;
|
|
64
72
|
}
|
|
73
|
+
else if (row === "web_console") {
|
|
74
|
+
this.webIdx = this.webIdx === 1 ? 0 : 1;
|
|
75
|
+
this.webChanged = true;
|
|
76
|
+
this.save();
|
|
77
|
+
// Mount or unmount immediately; a failed bind is shown as a toast and
|
|
78
|
+
// the saved preference stays on so the next launch tries again.
|
|
79
|
+
void this.app.setWebConsole(this.webIdx === 1);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
65
82
|
else {
|
|
66
83
|
const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
|
|
67
84
|
const i = options.indexOf(this.contextTokens);
|
|
@@ -209,6 +226,14 @@ export class SettingsScreen extends Screen {
|
|
|
209
226
|
label: "Sound alerts",
|
|
210
227
|
value: this.soundIdx === 1 ? "On (turn end, approvals)" : "Off",
|
|
211
228
|
},
|
|
229
|
+
{
|
|
230
|
+
label: "Web console",
|
|
231
|
+
value: this.webIdx === 0
|
|
232
|
+
? "Off"
|
|
233
|
+
: this.app.state.webUrl
|
|
234
|
+
? `On · ${this.app.state.webUrl}`
|
|
235
|
+
: "On (not running)",
|
|
236
|
+
},
|
|
212
237
|
{
|
|
213
238
|
label: "Web password",
|
|
214
239
|
value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
|
|
@@ -244,6 +269,10 @@ export class SettingsScreen extends Screen {
|
|
|
244
269
|
config.approval_model = this.approvalModelIdx === 0 ? "" : this.models[this.approvalModelIdx - 1] ?? "";
|
|
245
270
|
config.auto_approve_extreme_risk = this.extremeIdx === 1;
|
|
246
271
|
config.sound_alerts = this.soundIdx === 1;
|
|
272
|
+
if (this.webChanged) {
|
|
273
|
+
config.web_console = this.webIdx === 1;
|
|
274
|
+
this.webChanged = false;
|
|
275
|
+
}
|
|
247
276
|
saveConfig(config);
|
|
248
277
|
}
|
|
249
278
|
render(buf) {
|
package/dist/state.js
CHANGED
package/dist/web/session.js
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
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 { findModel, contextCompressionThreshold, loadConfig, normalizeApprovalMode, loadModels, loadPrompt, saveSession, } from "../config.js";
|
|
13
|
+
import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
|
|
14
14
|
import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
|
|
15
15
|
import { LLMClient, describeError } from "../llm/client.js";
|
|
16
|
-
import {
|
|
16
|
+
import { advancePlan, buildPlannerInput, lastRequestText, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
|
|
17
|
+
import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
|
|
18
|
+
import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
|
|
17
19
|
import { assessToolCall } from "../llm/risk.js";
|
|
18
20
|
import { ApprovalGate, captureApprovalContext, describeApprovalIntent } from "../llm/approval.js";
|
|
19
21
|
import { SessionCheckpoint } from "../persistence.js";
|
|
@@ -25,6 +27,8 @@ const IDLE_DISPOSE_MS = 30 * 60 * 1000;
|
|
|
25
27
|
const MAX_TOOL_ROUNDS = 24;
|
|
26
28
|
/** Placeholder shown while a tool is still running. */
|
|
27
29
|
const TOOL_PENDING = "…";
|
|
30
|
+
/** A sub-agent gets the same tools minus delegation: one level, never a tree. */
|
|
31
|
+
const SUB_AGENT_TOOLS = getSubAgentToolDefinitions();
|
|
28
32
|
/** Marker for the model-only summary, separate from the original transcript. */
|
|
29
33
|
const SUMMARY_PREFIX = "Context was compressed. Summary of the earlier part:";
|
|
30
34
|
function parseArguments(raw) {
|
|
@@ -82,7 +86,15 @@ export class WebSession {
|
|
|
82
86
|
compressing = false;
|
|
83
87
|
context = null;
|
|
84
88
|
streamingContent = null;
|
|
89
|
+
/** The plan of the current turn, if this turn was planned. */
|
|
90
|
+
plan = null;
|
|
91
|
+
/** True while the planner agent is writing the steps. */
|
|
92
|
+
planning = false;
|
|
93
|
+
/** What the sub-agent is doing, or null when none is running. */
|
|
94
|
+
subagent = null;
|
|
85
95
|
saveError = null;
|
|
96
|
+
/** A retried request, so the browser can explain why the stream is quiet. */
|
|
97
|
+
retrying = null;
|
|
86
98
|
checkpoint = new SessionCheckpoint(() => {
|
|
87
99
|
const saved = this.writeSession();
|
|
88
100
|
this.publishLive();
|
|
@@ -105,6 +117,7 @@ export class WebSession {
|
|
|
105
117
|
this.sessionId = source.id;
|
|
106
118
|
this.messages = source.messages.slice();
|
|
107
119
|
this.context = normalizeContext(source.context, this.messages.filter((m) => m.role !== "system").length);
|
|
120
|
+
this.plan = resumePlan(source.plan ?? null);
|
|
108
121
|
this.tokens = {
|
|
109
122
|
prompt: source.tokens?.prompt ?? 0,
|
|
110
123
|
completion: source.tokens?.completion ?? 0,
|
|
@@ -165,7 +178,11 @@ export class WebSession {
|
|
|
165
178
|
queued: this.queued,
|
|
166
179
|
compressing: this.compressing,
|
|
167
180
|
saveError: this.saveError,
|
|
181
|
+
retrying: this.retrying
|
|
182
|
+
? { attempt: this.retrying.attempt, attempts: this.retrying.attempts, delayMs: this.retrying.delayMs }
|
|
183
|
+
: null,
|
|
168
184
|
tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
|
|
185
|
+
subagent: this.subagent,
|
|
169
186
|
approval: this.pendingApproval
|
|
170
187
|
? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.dir)
|
|
171
188
|
: null,
|
|
@@ -197,6 +214,7 @@ export class WebSession {
|
|
|
197
214
|
messages,
|
|
198
215
|
streamingContent: this.streamingContent,
|
|
199
216
|
context: this.context,
|
|
217
|
+
plan: this.plan,
|
|
200
218
|
};
|
|
201
219
|
}
|
|
202
220
|
livePause() {
|
|
@@ -212,6 +230,8 @@ export class WebSession {
|
|
|
212
230
|
}
|
|
213
231
|
this.busy = false;
|
|
214
232
|
this.retainStreamingContent();
|
|
233
|
+
this.subagent = null;
|
|
234
|
+
stopPlan(this.plan);
|
|
215
235
|
this.save();
|
|
216
236
|
this.publishLive();
|
|
217
237
|
}
|
|
@@ -278,20 +298,118 @@ export class WebSession {
|
|
|
278
298
|
this.streamingContent = "";
|
|
279
299
|
this.publishLive();
|
|
280
300
|
void (async () => {
|
|
281
|
-
await this.
|
|
282
|
-
if (token !== this.turnToken || this.paused)
|
|
283
|
-
return;
|
|
284
|
-
await this.agentLoop(this.buildApiMessages(turnMessages), token, approvalContext);
|
|
301
|
+
await this.runTurn(token, turnMessages, approvalContext);
|
|
285
302
|
})().catch((err) => {
|
|
286
303
|
if (token === this.turnToken)
|
|
287
304
|
this.failTurn(err);
|
|
288
305
|
});
|
|
289
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* One user turn, mirroring the terminal: plan first, then execute the steps in
|
|
309
|
+
* order, one agent loop per step. Without a usable plan the turn runs in one
|
|
310
|
+
* pass, exactly as it did before planning existed.
|
|
311
|
+
*/
|
|
312
|
+
async runTurn(token, turnMessages, approvalContext) {
|
|
313
|
+
await this.autoCompress(token);
|
|
314
|
+
if (token !== this.turnToken || this.paused)
|
|
315
|
+
return;
|
|
316
|
+
const plan = await this.makePlan(token);
|
|
317
|
+
if (token !== this.turnToken || this.paused)
|
|
318
|
+
return;
|
|
319
|
+
if (plan) {
|
|
320
|
+
this.plan = plan;
|
|
321
|
+
this.messages.push({ role: "assistant", content: planMessage(plan) });
|
|
322
|
+
this.save();
|
|
323
|
+
this.publishLive();
|
|
324
|
+
const template = loadStepPrompt();
|
|
325
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
326
|
+
if (token !== this.turnToken || this.paused)
|
|
327
|
+
return;
|
|
328
|
+
this.setStep(i, "active");
|
|
329
|
+
const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), renderStepDirective(template, plan, i)), token, approvalContext);
|
|
330
|
+
if (token !== this.turnToken || this.paused)
|
|
331
|
+
return;
|
|
332
|
+
this.setStep(i, finished ? "done" : "failed");
|
|
333
|
+
if (!finished)
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages)), token, approvalContext);
|
|
339
|
+
if (token !== this.turnToken || this.paused)
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.onTurnFinished();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The transcript this turn may see: the messages it started with plus what it
|
|
346
|
+
* added since. A user message queued while the turn runs belongs to the next
|
|
347
|
+
* turn, so the model never sees it early.
|
|
348
|
+
*/
|
|
349
|
+
turnView(snapshot) {
|
|
350
|
+
const view = snapshot.slice();
|
|
351
|
+
for (let i = snapshot.length; i < this.messages.length; i++) {
|
|
352
|
+
if (this.messages[i].role !== "user")
|
|
353
|
+
view.push(this.messages[i]);
|
|
354
|
+
}
|
|
355
|
+
return view;
|
|
356
|
+
}
|
|
357
|
+
/** Move the plan to one step and publish it. */
|
|
358
|
+
setStep(index, status) {
|
|
359
|
+
if (!advancePlan(this.plan, index, status))
|
|
360
|
+
return;
|
|
361
|
+
this.save();
|
|
362
|
+
this.publishLive();
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Run the planner agent over the current request. Null on every failure path:
|
|
366
|
+
* a plan must improve a turn, never be a new way for one to fail.
|
|
367
|
+
*/
|
|
368
|
+
async makePlan(token) {
|
|
369
|
+
if (!planFirstEnabled())
|
|
370
|
+
return null;
|
|
371
|
+
const request = lastRequestText(this.messages);
|
|
372
|
+
if (!request.trim())
|
|
373
|
+
return null;
|
|
374
|
+
this.planning = true;
|
|
375
|
+
this.publishLive();
|
|
376
|
+
try {
|
|
377
|
+
const client = new LLMClient(this.model, this.provider, this.intensity);
|
|
378
|
+
const answer = await client.complete(loadPlannerPrompt(), buildPlannerInput({ request, cwd: this.dir, transcript: planTranscript(this.messages) }), {
|
|
379
|
+
onRetry: (notice) => {
|
|
380
|
+
this.retrying = notice;
|
|
381
|
+
this.publishLive();
|
|
382
|
+
},
|
|
383
|
+
cancelled: () => token !== this.turnToken || this.paused,
|
|
384
|
+
});
|
|
385
|
+
if (client.usage) {
|
|
386
|
+
this.tokens.prompt += client.usage.prompt;
|
|
387
|
+
this.tokens.completion += client.usage.completion;
|
|
388
|
+
this.lastPromptTokens = client.usage.prompt;
|
|
389
|
+
}
|
|
390
|
+
if (token !== this.turnToken)
|
|
391
|
+
return null;
|
|
392
|
+
return parsePlan(answer);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
this.planning = false;
|
|
399
|
+
this.retrying = null;
|
|
400
|
+
if (token === this.turnToken)
|
|
401
|
+
this.publishLive();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
290
404
|
/** Ends a turn whose floating promise rejected, instead of crashing the app. */
|
|
291
405
|
failTurn(err) {
|
|
292
406
|
this.retainStreamingContent();
|
|
293
407
|
this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
|
|
294
408
|
this.streamingContent = null;
|
|
409
|
+
// The turn died outside the loop, so the step it was on never reported back.
|
|
410
|
+
const active = this.plan?.steps.findIndex((step) => step.status === "active") ?? -1;
|
|
411
|
+
if (active >= 0)
|
|
412
|
+
advancePlan(this.plan, active, "failed");
|
|
295
413
|
try {
|
|
296
414
|
this.onTurnFinished();
|
|
297
415
|
}
|
|
@@ -300,8 +418,12 @@ export class WebSession {
|
|
|
300
418
|
this.paused = false;
|
|
301
419
|
}
|
|
302
420
|
}
|
|
303
|
-
buildApiMessages(messages = this.messages) {
|
|
304
|
-
|
|
421
|
+
buildApiMessages(messages = this.messages, directive = "") {
|
|
422
|
+
// A step directive extends the system prompt: the plan is an instruction, not
|
|
423
|
+
// something the user just said.
|
|
424
|
+
const api = [
|
|
425
|
+
{ role: "system", content: directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt() },
|
|
426
|
+
];
|
|
305
427
|
for (const m of modelHistory(messages, this.context)) {
|
|
306
428
|
if (m.role === "user" || m.role === "assistant") {
|
|
307
429
|
api.push({ role: m.role, content: m.content });
|
|
@@ -312,26 +434,38 @@ export class WebSession {
|
|
|
312
434
|
}
|
|
313
435
|
return api;
|
|
314
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* One agent conversation for a single request (or a single plan step). Returns
|
|
439
|
+
* true when the model finished on its own, false when the turn was cut short;
|
|
440
|
+
* the caller owns the turn's end because a planned turn runs this per step.
|
|
441
|
+
*/
|
|
315
442
|
async agentLoop(apiMessages, token, approvalContext) {
|
|
316
443
|
const tools = getToolDefinitions();
|
|
317
444
|
try {
|
|
318
445
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
319
446
|
if (token !== this.turnToken || this.paused)
|
|
320
|
-
return;
|
|
447
|
+
return false;
|
|
321
448
|
const client = new LLMClient(this.model, this.provider, this.intensity);
|
|
322
449
|
let text = "";
|
|
323
450
|
this.streamingContent = "";
|
|
324
451
|
this.publishLive();
|
|
325
|
-
for await (const chunk of client.streamTurn(apiMessages, tools
|
|
452
|
+
for await (const chunk of client.streamTurn(apiMessages, tools, {
|
|
453
|
+
onRetry: (notice) => {
|
|
454
|
+
this.retrying = notice;
|
|
455
|
+
this.publishLive();
|
|
456
|
+
},
|
|
457
|
+
cancelled: () => token !== this.turnToken || this.paused,
|
|
458
|
+
})) {
|
|
459
|
+
this.retrying = null;
|
|
326
460
|
if (token !== this.turnToken || this.paused)
|
|
327
|
-
return;
|
|
461
|
+
return false;
|
|
328
462
|
text += chunk;
|
|
329
463
|
this.streamingContent = text;
|
|
330
464
|
this.checkpoint.schedule();
|
|
331
465
|
this.publishLive();
|
|
332
466
|
}
|
|
333
467
|
if (token !== this.turnToken || this.paused)
|
|
334
|
-
return;
|
|
468
|
+
return false;
|
|
335
469
|
this.streamingContent = null;
|
|
336
470
|
if (client.usage) {
|
|
337
471
|
this.tokens.prompt += client.usage.prompt;
|
|
@@ -349,7 +483,7 @@ export class WebSession {
|
|
|
349
483
|
break;
|
|
350
484
|
for (const call of toolCalls) {
|
|
351
485
|
if (token !== this.turnToken || this.paused)
|
|
352
|
-
return;
|
|
486
|
+
return false;
|
|
353
487
|
const args = parseArguments(call.arguments);
|
|
354
488
|
const toolMsg = {
|
|
355
489
|
role: "tool",
|
|
@@ -365,7 +499,7 @@ export class WebSession {
|
|
|
365
499
|
context: captureApprovalContext(apiMessages).context,
|
|
366
500
|
});
|
|
367
501
|
if (token !== this.turnToken || this.paused)
|
|
368
|
-
return;
|
|
502
|
+
return false;
|
|
369
503
|
if (!decision.approved) {
|
|
370
504
|
// A malformed request was never shown to anyone, so there is no
|
|
371
505
|
// verdict to respect and the right move is a corrected retry.
|
|
@@ -382,27 +516,27 @@ export class WebSession {
|
|
|
382
516
|
}
|
|
383
517
|
let result;
|
|
384
518
|
try {
|
|
385
|
-
result = await
|
|
519
|
+
result = await this.runTool(call.name, args, toolMsg, token, apiMessages, approvalContext);
|
|
386
520
|
}
|
|
387
521
|
catch (err) {
|
|
388
522
|
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
389
523
|
}
|
|
390
524
|
if (this.disposed)
|
|
391
|
-
return;
|
|
525
|
+
return false;
|
|
392
526
|
toolMsg.result = result;
|
|
393
527
|
this.save();
|
|
394
528
|
if (token !== this.turnToken || this.paused)
|
|
395
|
-
return;
|
|
529
|
+
return false;
|
|
396
530
|
apiMessages.push({ role: "tool", content: result, tool_call_id: call.id });
|
|
397
531
|
this.publishLive();
|
|
398
532
|
}
|
|
399
533
|
this.save();
|
|
400
534
|
}
|
|
401
|
-
|
|
535
|
+
return true;
|
|
402
536
|
}
|
|
403
537
|
catch (err) {
|
|
404
538
|
if (token !== this.turnToken)
|
|
405
|
-
return;
|
|
539
|
+
return false;
|
|
406
540
|
if (this.streamingContent) {
|
|
407
541
|
this.messages.push({ role: "assistant", content: this.streamingContent });
|
|
408
542
|
}
|
|
@@ -411,10 +545,11 @@ export class WebSession {
|
|
|
411
545
|
role: "assistant",
|
|
412
546
|
content: `[Error: ${err instanceof Error ? err.message : String(err)}]`,
|
|
413
547
|
});
|
|
414
|
-
|
|
548
|
+
return false;
|
|
415
549
|
}
|
|
416
550
|
}
|
|
417
551
|
onTurnFinished() {
|
|
552
|
+
this.retrying = null;
|
|
418
553
|
this.busy = false;
|
|
419
554
|
this.paused = false;
|
|
420
555
|
this.streamingContent = null;
|
|
@@ -463,6 +598,58 @@ export class WebSession {
|
|
|
463
598
|
}
|
|
464
599
|
return { model: this.model, provider: this.provider, intensity: this.intensity };
|
|
465
600
|
}
|
|
601
|
+
// ---- sub-agents ------------------------------------------------------
|
|
602
|
+
/**
|
|
603
|
+
* Run one approved tool call. `delegate` runs a child agent instead of
|
|
604
|
+
* touching the filesystem, so it is handled here, where the session's model,
|
|
605
|
+
* approval gate and abort signal live.
|
|
606
|
+
*/
|
|
607
|
+
async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
|
|
608
|
+
if (name !== DELEGATE_TOOL)
|
|
609
|
+
return executeTool(name, args, this.dir);
|
|
610
|
+
return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Run a sub-agent for one `delegate` call and return its report. The child
|
|
614
|
+
* works in its own transcript; only the report reaches this conversation, and
|
|
615
|
+
* its risky calls go through the same approval gate as the parent turn.
|
|
616
|
+
*/
|
|
617
|
+
async delegate(args, toolMsg, token, apiMessages, approvalContext) {
|
|
618
|
+
const task = String(args.task ?? "").trim();
|
|
619
|
+
if (!task)
|
|
620
|
+
return "[Error: delegate needs a task: what should the sub-agent do?]";
|
|
621
|
+
const context = typeof args.context === "string" ? args.context.trim() : "";
|
|
622
|
+
const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
|
|
623
|
+
const run = await runSubAgentTask({
|
|
624
|
+
createClient: () => new LLMClient(this.model, this.provider, this.intensity),
|
|
625
|
+
system: loadSubAgentPrompt(),
|
|
626
|
+
task: buildSubAgentTask({ task, context, cwd: this.dir }),
|
|
627
|
+
tools: SUB_AGENT_TOOLS,
|
|
628
|
+
approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
|
|
629
|
+
execute: (name, callArgs) => name === DELEGATE_TOOL
|
|
630
|
+
? Promise.resolve("[Error: sub-agents cannot delegate further]")
|
|
631
|
+
: executeTool(name, callArgs, this.dir),
|
|
632
|
+
onProgress: (label) => {
|
|
633
|
+
this.subagent = label;
|
|
634
|
+
toolMsg.result = `… ${label}`;
|
|
635
|
+
this.publishLive();
|
|
636
|
+
},
|
|
637
|
+
onUsage: (usage) => {
|
|
638
|
+
this.tokens.prompt += usage.prompt;
|
|
639
|
+
this.tokens.completion += usage.completion;
|
|
640
|
+
},
|
|
641
|
+
onRetry: (notice) => {
|
|
642
|
+
this.retrying = notice;
|
|
643
|
+
this.publishLive();
|
|
644
|
+
},
|
|
645
|
+
cancelled: () => token !== this.turnToken || this.paused || this.disposed,
|
|
646
|
+
});
|
|
647
|
+
this.subagent = null;
|
|
648
|
+
this.retrying = null;
|
|
649
|
+
toolMsg.result = TOOL_PENDING;
|
|
650
|
+
this.publishLive();
|
|
651
|
+
return formatSubAgentResult(run);
|
|
652
|
+
}
|
|
466
653
|
// ---- compression -----------------------------------------------------
|
|
467
654
|
compressThreshold() {
|
|
468
655
|
return contextCompressionThreshold(this.provider, this.model);
|
|
@@ -487,7 +674,9 @@ export class WebSession {
|
|
|
487
674
|
this.publishLive();
|
|
488
675
|
let summary = "";
|
|
489
676
|
try {
|
|
490
|
-
summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript
|
|
677
|
+
summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript, {
|
|
678
|
+
cancelled: () => token !== this.turnToken || this.paused,
|
|
679
|
+
});
|
|
491
680
|
}
|
|
492
681
|
catch {
|
|
493
682
|
summary = "";
|
|
@@ -538,7 +727,7 @@ export class WebSession {
|
|
|
538
727
|
if (chat.length === 0)
|
|
539
728
|
return true;
|
|
540
729
|
try {
|
|
541
|
-
this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode);
|
|
730
|
+
this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan);
|
|
542
731
|
this.saveError = null;
|
|
543
732
|
return true;
|
|
544
733
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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-turn-fold.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-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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Neutrino Web UI
|
|
2
2
|
|
|
3
|
-
A small, dependency-free single page app that is served by the Neutrino CLI
|
|
4
|
-
|
|
3
|
+
A small, dependency-free single page app that is served by the Neutrino CLI at
|
|
4
|
+
<http://127.0.0.1:8765> whenever the terminal UI runs. It lets you:
|
|
5
5
|
|
|
6
6
|
- browse every saved conversation grouped by working directory (like Codex),
|
|
7
7
|
with only the five latest transcripts per path shown and the rest collapsed;
|
|
8
|
-
- watch a running terminal conversation stream in real time
|
|
9
|
-
finished turn
|
|
10
|
-
being answered
|
|
8
|
+
- watch a running terminal conversation stream in real time: the work behind every
|
|
9
|
+
finished turn folds into one line that opens on a click, while the reply itself
|
|
10
|
+
stays on screen (the turn being answered is open, and a pause is not a fold);
|
|
11
11
|
- switch the provider, model and intensity for the next turn;
|
|
12
12
|
- edit defaults, context compression and approval settings in a Settings dialog;
|
|
13
13
|
- drive the running conversation: pause / resume the current turn and send new
|
|
@@ -19,12 +19,15 @@ Nothing needs to be built — these are plain `.html`, `.css` and `.js` files.
|
|
|
19
19
|
|
|
20
20
|
## Running it
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
The terminal UI starts the website by default:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
neutrino
|
|
25
|
+
neutrino
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Use `--port` to move it, `--no-web` to skip it for that run, or turn it off
|
|
29
|
+
permanently in the terminal's **Settings → Web console**.
|
|
30
|
+
|
|
28
31
|
Or serve only the saved history, without a terminal:
|
|
29
32
|
|
|
30
33
|
```sh
|
|
@@ -41,8 +44,9 @@ The server binds to loopback by default. To open it from your phone or another
|
|
|
41
44
|
machine, bind an interface others can reach:
|
|
42
45
|
|
|
43
46
|
```sh
|
|
44
|
-
neutrino
|
|
45
|
-
neutrino
|
|
47
|
+
neutrino --lan # TUI + web UI, every interface (0.0.0.0)
|
|
48
|
+
neutrino --host 100.64.0.7 # TUI + web UI, one specific address
|
|
49
|
+
neutrino web --port 8765 --lan # headless web UI, every interface
|
|
46
50
|
```
|
|
47
51
|
|
|
48
52
|
Startup prints the addresses to open:
|
|
@@ -97,7 +101,7 @@ disables password login.
|
|
|
97
101
|
|
|
98
102
|
## Models and settings
|
|
99
103
|
|
|
100
|
-
Open a conversation to see the **Provider**, **Model** and **Thinking
|
|
104
|
+
Open a conversation to see the **Provider**, **Model** and **Thinking**
|
|
101
105
|
selectors. Choose a configured model and press **Apply to conversation**. The
|
|
102
106
|
next turn uses that selection, and it is saved with the transcript. The same
|
|
103
107
|
controls also update a conversation running in the terminal. Pause an active
|
|
@@ -108,7 +112,7 @@ selection. Changing the selectors and then using **Continue here** or sending a
|
|
|
108
112
|
message also uses the draft selection, so a transcript whose old model was
|
|
109
113
|
removed can still be continued.
|
|
110
114
|
|
|
111
|
-
The
|
|
115
|
+
The **Settings** (gear) button in the sidebar opens defaults for new conversations,
|
|
112
116
|
the context compression threshold, the approval reviewer model, and the
|
|
113
117
|
extreme-risk automatic approval option. Save settings explicitly; cancelling
|
|
114
118
|
leaves the saved values intact. Conversation model changes are separate from
|
|
@@ -119,12 +123,12 @@ Intensity uses the same Low–Ultra sampling settings as the terminal.
|
|
|
119
123
|
## History vs. live sessions
|
|
120
124
|
|
|
121
125
|
A saved transcript on disk is a finished record: nothing is running, so the
|
|
122
|
-
page shows it read-only (
|
|
126
|
+
page shows it read-only (a neutral `read-only` badge) and there is nothing to
|
|
123
127
|
pause.
|
|
124
128
|
|
|
125
129
|
To continue one, either
|
|
126
130
|
|
|
127
|
-
- press
|
|
131
|
+
- press **Continue here** in the header, or
|
|
128
132
|
- just start typing in the composer and press Enter.
|
|
129
133
|
|
|
130
134
|
Both create a *browser-owned session*: the server resumes the transcript from
|
|
@@ -140,6 +144,38 @@ Browser-owned sessions are dropped after 30 idle minutes, or immediately through
|
|
|
140
144
|
`POST /api/live/:key/close`. Sessions owned by the terminal cannot be closed from
|
|
141
145
|
the web.
|
|
142
146
|
|
|
147
|
+
## Plan-first turns
|
|
148
|
+
|
|
149
|
+
A browser-owned turn is planned before it runs, exactly like a turn in the
|
|
150
|
+
terminal: the planner agent writes the ordered steps, then each step is executed
|
|
151
|
+
one at a time. The plan is written into the transcript as an assistant message,
|
|
152
|
+
so it shows up in the conversation with everything else, and it is saved with the
|
|
153
|
+
session (`plan` in the session file) so a resumed transcript still knows its
|
|
154
|
+
steps.
|
|
155
|
+
|
|
156
|
+
The plan travels in every live snapshot as a structured field — no text parsing
|
|
157
|
+
needed by a client:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
"plan": {
|
|
161
|
+
"goal": "build and animate the scene",
|
|
162
|
+
"steps": [
|
|
163
|
+
{ "title": "Model the case from box.png", "detail": "match the reference", "status": "done" },
|
|
164
|
+
{ "title": "Model the monitor from vision.jpg", "status": "active" },
|
|
165
|
+
{ "title": "Animate the camera", "status": "pending" }
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`status` is `pending`, `active`, `done` or `failed`; `stopped: true` marks a plan
|
|
171
|
+
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.
|
|
174
|
+
|
|
175
|
+
Planning is the default for both loops. It can be turned off with
|
|
176
|
+
`"plan_first": false` in `~/.neutrino/config.json` or `NEUTRINO_PLAN=off`; a
|
|
177
|
+
request that produces no usable plan then runs in one pass.
|
|
178
|
+
|
|
143
179
|
## How real-time works
|
|
144
180
|
|
|
145
181
|
A `ChatScreen` registers itself with the in-process hub when it is opened. Every
|