@xingjisoft/neutrino 0.1.7 → 0.2.1

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.
@@ -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
@@ -10,6 +10,7 @@ export function defaultState() {
10
10
  blinkOn: false,
11
11
  cursorOn: true,
12
12
  approval: null,
13
+ webUrl: null,
13
14
  selection: null,
14
15
  };
15
16
  }
@@ -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 { executeTool, getToolDefinitions } from "../llm/tools.js";
16
+ import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, 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,121 @@ export class WebSession {
278
298
  this.streamingContent = "";
279
299
  this.publishLive();
280
300
  void (async () => {
281
- await this.autoCompress(token);
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
+ // One action is not a plan: run the turn in one pass, as the terminal does.
375
+ if (!needsPlan(request))
376
+ return null;
377
+ this.planning = true;
378
+ this.publishLive();
379
+ try {
380
+ const client = new LLMClient(this.model, this.provider, this.intensity);
381
+ const answer = await client.complete(loadPlannerPrompt(), buildPlannerInput({ request, cwd: this.dir, transcript: planTranscript(this.messages) }), {
382
+ onRetry: (notice) => {
383
+ this.retrying = notice;
384
+ this.publishLive();
385
+ },
386
+ cancelled: () => token !== this.turnToken || this.paused,
387
+ });
388
+ if (client.usage) {
389
+ this.tokens.prompt += client.usage.prompt;
390
+ this.tokens.completion += client.usage.completion;
391
+ this.lastPromptTokens = client.usage.prompt;
392
+ }
393
+ if (token !== this.turnToken)
394
+ return null;
395
+ return parsePlan(answer);
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ finally {
401
+ this.planning = false;
402
+ this.retrying = null;
403
+ if (token === this.turnToken)
404
+ this.publishLive();
405
+ }
406
+ }
290
407
  /** Ends a turn whose floating promise rejected, instead of crashing the app. */
291
408
  failTurn(err) {
292
409
  this.retainStreamingContent();
293
410
  this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
294
411
  this.streamingContent = null;
412
+ // The turn died outside the loop, so the step it was on never reported back.
413
+ const active = this.plan?.steps.findIndex((step) => step.status === "active") ?? -1;
414
+ if (active >= 0)
415
+ advancePlan(this.plan, active, "failed");
295
416
  try {
296
417
  this.onTurnFinished();
297
418
  }
@@ -300,8 +421,12 @@ export class WebSession {
300
421
  this.paused = false;
301
422
  }
302
423
  }
303
- buildApiMessages(messages = this.messages) {
304
- const api = [{ role: "system", content: loadPrompt() }];
424
+ buildApiMessages(messages = this.messages, directive = "") {
425
+ // A step directive extends the system prompt: the plan is an instruction, not
426
+ // something the user just said.
427
+ const api = [
428
+ { role: "system", content: directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt() },
429
+ ];
305
430
  for (const m of modelHistory(messages, this.context)) {
306
431
  if (m.role === "user" || m.role === "assistant") {
307
432
  api.push({ role: m.role, content: m.content });
@@ -312,44 +437,75 @@ export class WebSession {
312
437
  }
313
438
  return api;
314
439
  }
440
+ /**
441
+ * One agent conversation for a single request (or a single plan step). Returns
442
+ * true when the model finished on its own, false when the turn was cut short;
443
+ * the caller owns the turn's end because a planned turn runs this per step.
444
+ */
315
445
  async agentLoop(apiMessages, token, approvalContext) {
316
446
  const tools = getToolDefinitions();
317
447
  try {
318
448
  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
319
449
  if (token !== this.turnToken || this.paused)
320
- return;
450
+ return false;
321
451
  const client = new LLMClient(this.model, this.provider, this.intensity);
322
452
  let text = "";
323
453
  this.streamingContent = "";
324
454
  this.publishLive();
325
- for await (const chunk of client.streamTurn(apiMessages, tools)) {
455
+ for await (const chunk of client.streamTurn(apiMessages, tools, {
456
+ onRetry: (notice) => {
457
+ this.retrying = notice;
458
+ this.publishLive();
459
+ },
460
+ cancelled: () => token !== this.turnToken || this.paused,
461
+ })) {
462
+ this.retrying = null;
326
463
  if (token !== this.turnToken || this.paused)
327
- return;
464
+ return false;
328
465
  text += chunk;
329
466
  this.streamingContent = text;
330
467
  this.checkpoint.schedule();
331
468
  this.publishLive();
332
469
  }
333
470
  if (token !== this.turnToken || this.paused)
334
- return;
471
+ return false;
335
472
  this.streamingContent = null;
336
473
  if (client.usage) {
337
474
  this.tokens.prompt += client.usage.prompt;
338
475
  this.tokens.completion += client.usage.completion;
339
476
  this.lastPromptTokens = client.usage.prompt;
340
477
  }
478
+ const toolCalls = client.toolCalls;
341
479
  if (text.trim()) {
342
480
  this.messages.push({ role: "assistant", content: text });
343
- apiMessages.push({ role: "assistant", content: text });
481
+ }
482
+ if (text.trim() || toolCalls.length > 0) {
483
+ // The assistant message and the tool results it produced must stay
484
+ // paired: a `tool` message without its `tool_calls` parent is
485
+ // rejected by every OpenAI-compatible endpoint. The reasoning is the
486
+ // thinking that produced the calls, which DeepSeek requires back.
487
+ apiMessages.push({
488
+ role: "assistant",
489
+ content: text,
490
+ reasoning_content: client.reasoningContent ?? "",
491
+ ...(toolCalls.length > 0
492
+ ? {
493
+ tool_calls: toolCalls.map((call) => ({
494
+ id: call.id,
495
+ type: "function",
496
+ function: { name: call.name, arguments: call.arguments },
497
+ })),
498
+ }
499
+ : {}),
500
+ });
344
501
  }
345
502
  this.save();
346
503
  this.publishLive();
347
- const toolCalls = client.toolCalls;
348
504
  if (toolCalls.length === 0)
349
505
  break;
350
506
  for (const call of toolCalls) {
351
507
  if (token !== this.turnToken || this.paused)
352
- return;
508
+ return false;
353
509
  const args = parseArguments(call.arguments);
354
510
  const toolMsg = {
355
511
  role: "tool",
@@ -365,7 +521,7 @@ export class WebSession {
365
521
  context: captureApprovalContext(apiMessages).context,
366
522
  });
367
523
  if (token !== this.turnToken || this.paused)
368
- return;
524
+ return false;
369
525
  if (!decision.approved) {
370
526
  // A malformed request was never shown to anyone, so there is no
371
527
  // verdict to respect and the right move is a corrected retry.
@@ -382,27 +538,27 @@ export class WebSession {
382
538
  }
383
539
  let result;
384
540
  try {
385
- result = await executeTool(call.name, args, this.dir);
541
+ result = await this.runTool(call.name, args, toolMsg, token, apiMessages, approvalContext);
386
542
  }
387
543
  catch (err) {
388
544
  result = `Error: ${err instanceof Error ? err.message : String(err)}`;
389
545
  }
390
546
  if (this.disposed)
391
- return;
547
+ return false;
392
548
  toolMsg.result = result;
393
549
  this.save();
394
550
  if (token !== this.turnToken || this.paused)
395
- return;
551
+ return false;
396
552
  apiMessages.push({ role: "tool", content: result, tool_call_id: call.id });
397
553
  this.publishLive();
398
554
  }
399
555
  this.save();
400
556
  }
401
- this.onTurnFinished();
557
+ return true;
402
558
  }
403
559
  catch (err) {
404
560
  if (token !== this.turnToken)
405
- return;
561
+ return false;
406
562
  if (this.streamingContent) {
407
563
  this.messages.push({ role: "assistant", content: this.streamingContent });
408
564
  }
@@ -411,10 +567,11 @@ export class WebSession {
411
567
  role: "assistant",
412
568
  content: `[Error: ${err instanceof Error ? err.message : String(err)}]`,
413
569
  });
414
- this.onTurnFinished();
570
+ return false;
415
571
  }
416
572
  }
417
573
  onTurnFinished() {
574
+ this.retrying = null;
418
575
  this.busy = false;
419
576
  this.paused = false;
420
577
  this.streamingContent = null;
@@ -463,6 +620,58 @@ export class WebSession {
463
620
  }
464
621
  return { model: this.model, provider: this.provider, intensity: this.intensity };
465
622
  }
623
+ // ---- sub-agents ------------------------------------------------------
624
+ /**
625
+ * Run one approved tool call. `delegate` runs a child agent instead of
626
+ * touching the filesystem, so it is handled here, where the session's model,
627
+ * approval gate and abort signal live.
628
+ */
629
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
630
+ if (name !== DELEGATE_TOOL)
631
+ return executeTool(name, args, this.dir);
632
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
633
+ }
634
+ /**
635
+ * Run a sub-agent for one `delegate` call and return its report. The child
636
+ * works in its own transcript; only the report reaches this conversation, and
637
+ * its risky calls go through the same approval gate as the parent turn.
638
+ */
639
+ async delegate(args, toolMsg, token, apiMessages, approvalContext) {
640
+ const task = String(args.task ?? "").trim();
641
+ if (!task)
642
+ return "[Error: delegate needs a task: what should the sub-agent do?]";
643
+ const context = typeof args.context === "string" ? args.context.trim() : "";
644
+ const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
645
+ const run = await runSubAgentTask({
646
+ createClient: () => new LLMClient(this.model, this.provider, this.intensity),
647
+ system: loadSubAgentPrompt(),
648
+ task: buildSubAgentTask({ task, context, cwd: this.dir }),
649
+ tools: SUB_AGENT_TOOLS,
650
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
651
+ execute: (name, callArgs) => name === DELEGATE_TOOL
652
+ ? Promise.resolve("[Error: sub-agents cannot delegate further]")
653
+ : executeTool(name, callArgs, this.dir),
654
+ onProgress: (label) => {
655
+ this.subagent = label;
656
+ toolMsg.result = `… ${label}`;
657
+ this.publishLive();
658
+ },
659
+ onUsage: (usage) => {
660
+ this.tokens.prompt += usage.prompt;
661
+ this.tokens.completion += usage.completion;
662
+ },
663
+ onRetry: (notice) => {
664
+ this.retrying = notice;
665
+ this.publishLive();
666
+ },
667
+ cancelled: () => token !== this.turnToken || this.paused || this.disposed,
668
+ });
669
+ this.subagent = null;
670
+ this.retrying = null;
671
+ toolMsg.result = TOOL_PENDING;
672
+ this.publishLive();
673
+ return formatSubAgentResult(run);
674
+ }
466
675
  // ---- compression -----------------------------------------------------
467
676
  compressThreshold() {
468
677
  return contextCompressionThreshold(this.provider, this.model);
@@ -487,7 +696,9 @@ export class WebSession {
487
696
  this.publishLive();
488
697
  let summary = "";
489
698
  try {
490
- summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript);
699
+ summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript, {
700
+ cancelled: () => token !== this.turnToken || this.paused,
701
+ });
491
702
  }
492
703
  catch {
493
704
  summary = "";
@@ -538,7 +749,7 @@ export class WebSession {
538
749
  if (chat.length === 0)
539
750
  return true;
540
751
  try {
541
- this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode);
752
+ this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan);
542
753
  this.saveError = null;
543
754
  return true;
544
755
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
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,7 +1,7 @@
1
1
  # Neutrino Web UI
2
2
 
3
- A small, dependency-free single page app that is served by the Neutrino CLI on
4
- a port you choose. It lets you:
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;
@@ -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
- While using the terminal UI, mount the website on a port:
22
+ The terminal UI starts the website by default:
23
23
 
24
24
  ```sh
25
- neutrino --web-port 8765
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 web --port 8765 --lan # every interface (0.0.0.0)
45
- neutrino web --port 8765 --host 100.64.0.7 # one specific address
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 intensity**
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 **⚙ Settings** button in the sidebar opens defaults for new conversations,
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 (dashed `read-only` badge) and there is nothing to
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 **✎ Continue here** in the header, or
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