@xingjisoft/neutrino 0.1.7 → 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.
@@ -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, 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.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
+ 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
- const api = [{ role: "system", content: loadPrompt() }];
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 executeTool(call.name, args, this.dir);
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
- this.onTurnFinished();
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
- this.onTurnFinished();
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.1.7",
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,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