@xingjisoft/neutrino 0.3.0 → 0.4.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.
Files changed (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import process from "node:process";
3
+ import { backgroundLog, clearBackgroundStatus, isProcessAlive, writeBackgroundStatus, } from "../background.js";
4
+ import { loadConfig, saveConfig } from "../config.js";
5
+ import { startBackgroundTray } from "../tray.js";
6
+ import { hub } from "./hub.js";
7
+ import { startWebServer } from "./server.js";
8
+ import { TerminalBridges } from "./terminalBridge.js";
9
+ /** The detached process owns browser conversations for their entire lifetime. */
10
+ export async function runWebService(options) {
11
+ const token = process.env.NEUTRINO_BACKGROUND_TOKEN?.trim() || randomBytes(32).toString("hex");
12
+ let resident = process.env.NEUTRINO_BACKGROUND_RESIDENT !== "0";
13
+ const owner = Number(process.env.NEUTRINO_BACKGROUND_OWNER);
14
+ let owners = new Set(Number.isSafeInteger(owner) && owner > 0 ? [owner] : []);
15
+ const startedAt = new Date().toISOString();
16
+ let handle = null;
17
+ let tray = null;
18
+ let monitor = null;
19
+ let stopping = false;
20
+ const bridges = new TerminalBridges();
21
+ const status = () => {
22
+ if (!handle)
23
+ throw new Error("the Web console is starting");
24
+ return {
25
+ pid: process.pid, port: handle.port, host: handle.host, url: handle.url,
26
+ auth_required: options.auth, token, started_at: startedAt,
27
+ resident, owner_pids: [...owners],
28
+ };
29
+ };
30
+ const syncTray = () => {
31
+ if (!resident) {
32
+ tray?.close();
33
+ tray = null;
34
+ }
35
+ else if (!tray && options.tray && handle) {
36
+ tray = startBackgroundTray({ url: handle.url, stopUrl: `${handle.url}/api/background/stop`, token, pid: process.pid });
37
+ }
38
+ };
39
+ const shutdown = () => {
40
+ if (stopping)
41
+ return;
42
+ stopping = true;
43
+ if (monitor)
44
+ clearInterval(monitor);
45
+ tray?.close();
46
+ bridges.close();
47
+ backgroundLog(`stopping pid=${process.pid}`);
48
+ clearBackgroundStatus(process.pid);
49
+ const finish = () => process.exit(0);
50
+ if (handle)
51
+ handle.close().finally(finish).catch(finish);
52
+ else
53
+ finish();
54
+ setTimeout(finish, 3000).unref();
55
+ };
56
+ const configure = (update) => {
57
+ if (stopping)
58
+ throw new Error("the Web console is shutting down");
59
+ if (update.ownerPid !== undefined && !isProcessAlive(update.ownerPid)) {
60
+ throw new Error("the terminal that owns this console has already exited");
61
+ }
62
+ const previousResident = resident;
63
+ const previousOwners = owners;
64
+ const config = loadConfig();
65
+ const changed = update.resident !== undefined;
66
+ if (changed)
67
+ saveConfig({ ...config, background: update.resident });
68
+ resident = update.resident ?? resident;
69
+ owners = new Set(owners);
70
+ if (update.ownerPid !== undefined)
71
+ owners.add(update.ownerPid);
72
+ try {
73
+ writeBackgroundStatus(status());
74
+ }
75
+ catch (error) {
76
+ resident = previousResident;
77
+ owners = previousOwners;
78
+ if (changed) {
79
+ try {
80
+ saveConfig(config);
81
+ }
82
+ catch { /* Report the original failure. */ }
83
+ }
84
+ throw error;
85
+ }
86
+ if (update.bridge && update.ownerPid)
87
+ bridges.add(update.ownerPid, update.bridge);
88
+ syncTray();
89
+ return status();
90
+ };
91
+ try {
92
+ handle = await startWebServer({
93
+ port: options.port, host: options.host, auth: options.auth,
94
+ backgroundPrompt: !resident && process.env.NEUTRINO_BACKGROUND_PROMPT === "1",
95
+ backgroundService: { token, onStop: shutdown, status, configure },
96
+ onAddressChange: () => { if (handle)
97
+ writeBackgroundStatus(status()); },
98
+ });
99
+ writeBackgroundStatus(status());
100
+ }
101
+ catch (error) {
102
+ backgroundLog(`failed to start pid=${process.pid}: ${error instanceof Error ? error.message : String(error)}`);
103
+ if (handle)
104
+ await handle.close();
105
+ process.exit(1);
106
+ }
107
+ backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
108
+ syncTray();
109
+ // No helper processes: polling pids must remain invisible on Windows. An
110
+ // orphaned, non-resident console lets an in-flight browser turn finish before
111
+ // exiting, so disabling residency also cannot throw away running work.
112
+ monitor = setInterval(() => {
113
+ try {
114
+ const live = new Set([...owners].filter(isProcessAlive));
115
+ if (live.size !== owners.size) {
116
+ for (const owner of owners)
117
+ if (!live.has(owner))
118
+ bridges.remove(owner);
119
+ owners = live;
120
+ writeBackgroundStatus(status());
121
+ }
122
+ if (!resident && owners.size === 0 && !hub.list().some(entry => entry.busy || entry.paused || entry.queued || entry.compressing || entry.approval || entry.reviewing)) {
123
+ shutdown();
124
+ }
125
+ }
126
+ catch (error) {
127
+ backgroundLog(`lifetime check failed: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }, 500);
130
+ monitor.unref();
131
+ process.on("SIGINT", shutdown);
132
+ process.on("SIGTERM", shutdown);
133
+ if (process.platform !== "win32")
134
+ process.on("SIGHUP", () => { });
135
+ }
@@ -11,9 +11,12 @@
11
11
  * (snapshot, stream, pause, resume, message) work unchanged.
12
12
  */
13
13
  import { randomUUID } from "node:crypto";
14
- import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
14
+ import { findModel, contextCompressionThreshold, intensityForModel, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
15
15
  import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
16
16
  import { LLMClient, describeError } from "../llm/client.js";
17
+ import { defaultProPreset, loadProModels, loadProPrompt, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
18
+ import { resolveProTeam, ProPreflightError } from "../pro/preflight.js";
19
+ import { ProAgentTeam, getProToolDefinitions } from "../pro/agents.js";
17
20
  import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
18
21
  import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
19
22
  import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
@@ -24,8 +27,6 @@ import { writeCrashLog } from "../terminal/restore.js";
24
27
  import { hub, liveApprovalDetails, } from "./hub.js";
25
28
  /** Drop a resumed conversation nobody touched for this long. */
26
29
  const IDLE_DISPOSE_MS = 30 * 60 * 1000;
27
- /** Upper bound on tool round-trips inside a single turn. */
28
- const MAX_TOOL_ROUNDS = 24;
29
30
  /** Placeholder shown while a tool is still running. */
30
31
  const TOOL_PENDING = "…";
31
32
  /** A sub-agent gets the same tools minus delegation: one level, never a tree. */
@@ -44,7 +45,7 @@ export function newSessionId(now = new Date()) {
44
45
  * written to disk until the first message, so an abandoned new-chat dialog
45
46
  * cannot litter the history with empty transcripts.
46
47
  */
47
- export function createWebSession(cwd, selection, approvalMode) {
48
+ export function createWebSession(cwd, selection, approvalMode, mode = "standard", proPreset) {
48
49
  const now = new Date();
49
50
  const source = {
50
51
  id: newSessionId(now),
@@ -57,9 +58,23 @@ export function createWebSession(cwd, selection, approvalMode) {
57
58
  title: "",
58
59
  messages: [],
59
60
  tokens: { prompt: 0, completion: 0 },
60
- approval_mode: normalizeApprovalMode(approvalMode ?? loadConfig().approval_mode),
61
+ approval_mode: normalizeApprovalMode(approvalMode ?? loadConfig(mode).approval_mode),
62
+ ...(mode === "pro" ? { pro_preset: requireProPreset(proPreset ?? defaultProPreset()) } : {}),
63
+ };
64
+ return new WebSession(source, selection.model, mode);
65
+ }
66
+ /** A preset may need fallback; defer model credentials/availability to preflight. */
67
+ export function proInitialSelection(preset = defaultProPreset()) {
68
+ const tier = loadProModels().tier.find((entry) => entry.name === requireProPreset(preset));
69
+ const setting = tier["model-setting"].find((entry) => entry.type === "main_model");
70
+ const provider = setting?.provider ?? "";
71
+ const name = setting?.model ?? "";
72
+ const model = loadModels().providers.find((entry) => entry.name === provider)?.models.find((entry) => entry.name === name);
73
+ return {
74
+ provider,
75
+ model: model ? { ...model } : { name, url: "", api_key: "", supports_streaming: true },
76
+ intensity: setting?.intensity || "high",
61
77
  };
62
- return new WebSession(source, selection.model);
63
78
  }
64
79
  function parseArguments(raw) {
65
80
  const text = raw?.trim() ?? "";
@@ -93,15 +108,27 @@ export function resolveModel(modelName, providerName) {
93
108
  return { ok: true, model };
94
109
  }
95
110
  /** Build a web session from a saved transcript, or explain why we cannot. */
96
- export function resumeSession(source, selection) {
111
+ export function resumeSession(source, selection, mode = "standard") {
112
+ if (mode === "pro") {
113
+ try {
114
+ const initial = proInitialSelection(source.pro_preset ?? defaultProPreset());
115
+ return { ok: true, session: new WebSession({ ...source, provider: initial.provider,
116
+ model: initial.model.name, intensity: initial.intensity }, initial.model, mode) };
117
+ }
118
+ catch (error) {
119
+ return { ok: false, error: describeError(error) };
120
+ }
121
+ }
97
122
  const updated = selection ? { ...source, ...selection } : source;
98
123
  const resolved = resolveModel(updated.model, updated.provider);
99
124
  if (!resolved.ok)
100
125
  return resolved;
101
- return { ok: true, session: new WebSession(updated, resolved.model) };
126
+ return { ok: true, session: new WebSession(updated, resolved.model, mode) };
102
127
  }
103
128
  export class WebSession {
104
129
  key = null;
130
+ /** Workspace this conversation belongs to; keeps Pro and standard separate. */
131
+ mode;
105
132
  model;
106
133
  provider;
107
134
  intensity;
@@ -120,6 +147,10 @@ export class WebSession {
120
147
  plan = null;
121
148
  /** True while the planner agent is writing the steps. */
122
149
  planning = false;
150
+ proPreset = null;
151
+ proTeam = null;
152
+ proState;
153
+ proAbort = null;
123
154
  /** What the sub-agent is doing, or null when none is running. */
124
155
  subagent = null;
125
156
  saveError = null;
@@ -138,11 +169,16 @@ export class WebSession {
138
169
  pendingApproval = null;
139
170
  /** Reviewer count while an automated review runs, null otherwise. */
140
171
  reviewing = null;
141
- constructor(source, model) {
172
+ constructor(source, model, mode = "standard") {
173
+ this.mode = mode;
174
+ if (mode === "pro")
175
+ this.proPreset = requireProPreset(source.pro_preset ?? defaultProPreset());
142
176
  this.model = model;
143
- this.approvalMode = normalizeApprovalMode(source.approval_mode ?? loadConfig().approval_mode);
177
+ this.approvalMode = normalizeApprovalMode(source.approval_mode ?? loadConfig(this.mode).approval_mode);
144
178
  this.provider = source.provider;
145
- this.intensity = source.intensity;
179
+ // A transcript saved before the model's limits existed (or before the model
180
+ // itself was narrowed) must not replay an unsupported sampling level.
181
+ this.intensity = mode === "pro" ? source.intensity : intensityForModel(model, source.intensity, loadConfig(this.mode).default_intensity);
146
182
  this.dir = source.cwd;
147
183
  this.sessionId = source.id;
148
184
  this.messages = source.messages.slice();
@@ -167,6 +203,7 @@ export class WebSession {
167
203
  if (this.disposed)
168
204
  return;
169
205
  this.disposed = true;
206
+ this.proAbort?.abort();
170
207
  this.turnToken++;
171
208
  this.busy = false;
172
209
  if (this.pendingApproval) {
@@ -183,6 +220,8 @@ export class WebSession {
183
220
  return this.dir;
184
221
  }
185
222
  liveConfigure(selection) {
223
+ if (this.mode === "pro")
224
+ return { ok: false, error: "choose a Pro preset to change professional models" };
186
225
  if (this.disposed)
187
226
  return { ok: false, error: "this session has ended" };
188
227
  if (this.busy || this.pendingApproval) {
@@ -190,13 +229,37 @@ export class WebSession {
190
229
  }
191
230
  this.model = { ...selection.model };
192
231
  this.provider = selection.provider;
193
- this.intensity = selection.intensity;
232
+ this.intensity = intensityForModel(this.model, selection.intensity, loadConfig(this.mode).default_intensity);
194
233
  this.save();
195
234
  this.publishLive();
196
235
  return { ok: true };
197
236
  }
237
+ liveSetProPreset(preset) {
238
+ if (this.mode !== "pro" || this.disposed)
239
+ return { ok: false, error: "this is not an active Pro session" };
240
+ if (this.busy || this.pendingApproval)
241
+ return { ok: false, error: "pause the current turn before changing presets" };
242
+ try {
243
+ this.proPreset = requireProPreset(preset);
244
+ const initial = proInitialSelection(this.proPreset);
245
+ this.model = initial.model;
246
+ this.provider = initial.provider;
247
+ this.intensity = initial.intensity;
248
+ this.proTeam = null;
249
+ this.proState = undefined;
250
+ this.save();
251
+ this.publishLive();
252
+ return { ok: true };
253
+ }
254
+ catch (error) {
255
+ return { ok: false, error: describeError(error) };
256
+ }
257
+ }
198
258
  liveMeta() {
199
259
  return {
260
+ ...(this.mode === "pro" ? { pro: this.proState ?? {
261
+ preset: this.proPreset, checking: false, checkId: "", models: [], fallbacks: [],
262
+ } } : {}),
200
263
  sessionId: this.sessionId,
201
264
  title: this.title(),
202
265
  provider: this.provider,
@@ -251,6 +314,7 @@ export class WebSession {
251
314
  if (this.paused || (!this.busy && !this.queued))
252
315
  return;
253
316
  this.paused = true;
317
+ this.proAbort?.abort();
254
318
  this.turnToken++;
255
319
  // A queued decision would otherwise keep the promise (and the gate) alive.
256
320
  if (this.pendingApproval) {
@@ -261,6 +325,13 @@ export class WebSession {
261
325
  this.busy = false;
262
326
  this.retainStreamingContent();
263
327
  this.subagent = null;
328
+ if (this.proState)
329
+ this.proState.checking = false;
330
+ if (this.mode === "pro") {
331
+ this.reviewing = null;
332
+ this.retrying = null;
333
+ this.planning = false;
334
+ }
264
335
  stopPlan(this.plan);
265
336
  this.save();
266
337
  this.publishLive();
@@ -307,6 +378,9 @@ export class WebSession {
307
378
  return;
308
379
  if (!this.checkpoint.flush())
309
380
  return;
381
+ this.proAbort?.abort();
382
+ this.proTeam = null;
383
+ this.proState = undefined;
310
384
  this.turnToken++;
311
385
  if (this.pendingApproval) {
312
386
  const pending = this.pendingApproval;
@@ -374,6 +448,8 @@ export class WebSession {
374
448
  * pass, exactly as it did before planning existed.
375
449
  */
376
450
  async runTurn(token, turnMessages, approvalContext) {
451
+ if (this.mode === "pro")
452
+ return this.runProTurn(token, turnMessages, approvalContext);
377
453
  await this.autoCompress(token);
378
454
  if (token !== this.turnToken || this.paused)
379
455
  return;
@@ -405,6 +481,127 @@ export class WebSession {
405
481
  }
406
482
  this.onTurnFinished();
407
483
  }
484
+ /** Freeze one resolved team per task, plan first, then give the whole brief to main. */
485
+ async runProTurn(token, turnMessages, approvalContext) {
486
+ const cancelled = () => token !== this.turnToken || this.paused || this.disposed;
487
+ const controller = new AbortController();
488
+ this.proAbort = controller;
489
+ this.proTeam = null;
490
+ this.plan = null;
491
+ this.proState = { preset: this.proPreset, checking: true, checkId: randomUUID(), models: [], fallbacks: [] };
492
+ this.publishLive();
493
+ try {
494
+ const resolved = await resolveProTeam(this.proPreset, {
495
+ signal: controller.signal,
496
+ onUsage: (usage) => {
497
+ if (!cancelled()) {
498
+ this.tokens.prompt += usage.prompt;
499
+ this.tokens.completion += usage.completion;
500
+ }
501
+ },
502
+ });
503
+ if (cancelled())
504
+ return;
505
+ this.proState.models = Object.values(resolved.models).map((entry) => ({
506
+ role: entry.role, provider: entry.provider, model: entry.model.name, intensity: entry.intensity, tier: entry.tier,
507
+ }));
508
+ this.proState.fallbacks = resolved.fallbacks;
509
+ this.proState.checking = false;
510
+ const team = new ProAgentTeam({
511
+ models: resolved.models, maxDepth: proMaxRecursionDepth(), cwd: this.dir, cancelled,
512
+ approve: (name, args) => this.requestApproval(name, args, {
513
+ ...approvalContext, context: captureApprovalContext(this.turnView(turnMessages)).context,
514
+ }),
515
+ execute: (name, args) => executeTool(name, args, this.dir),
516
+ onProgress: (label) => { if (!cancelled()) {
517
+ this.subagent = label;
518
+ this.publishLive();
519
+ } },
520
+ onUsage: (usage) => {
521
+ if (!cancelled()) {
522
+ this.tokens.prompt += usage.prompt;
523
+ this.tokens.completion += usage.completion;
524
+ }
525
+ },
526
+ onRetry: (notice) => { if (!cancelled()) {
527
+ this.retrying = notice;
528
+ this.publishLive();
529
+ } },
530
+ });
531
+ this.proTeam = team;
532
+ const main = resolved.models.main_model;
533
+ this.model = main.model;
534
+ this.provider = main.provider;
535
+ this.intensity = main.intensity;
536
+ this.publishLive();
537
+ this.planning = true;
538
+ this.publishLive();
539
+ const planning = await team.call("plan_model", buildPlannerInput({
540
+ request: approvalContext.prompt, cwd: this.dir, transcript: planTranscript(turnMessages),
541
+ }), "Plan the task before execution. Do not modify files or execute commands.", 0, true);
542
+ if (cancelled())
543
+ return;
544
+ this.planning = false;
545
+ this.subagent = null;
546
+ this.retrying = null;
547
+ if (!planning.trim() || /\[Error:|\[Sub-agent .*without.*report/.test(planning)) {
548
+ throw new Error(`Pro planning failed: ${planning}`);
549
+ }
550
+ this.plan = parsePlan(planning);
551
+ if (!this.plan) {
552
+ // Standard planning skips single actions; Pro plans every task.
553
+ try {
554
+ const single = JSON.parse(planning.trim().replace(/^```(?:json)?\s*|\s*```$/g, ""));
555
+ if (single.steps?.length === 1 && typeof single.steps[0].title === "string" && single.steps[0].title.trim()) {
556
+ this.plan = { goal: String(single.goal ?? "").slice(0, 240), steps: [{
557
+ title: single.steps[0].title.slice(0, 160), detail: String(single.steps[0].detail ?? "").slice(0, 400), status: "pending",
558
+ }] };
559
+ }
560
+ }
561
+ catch { /* A useful prose plan can still guide the main agent. */ }
562
+ }
563
+ await this.autoCompress(token);
564
+ if (cancelled())
565
+ return;
566
+ this.messages.push({ role: "assistant", content: this.plan ? planMessage(this.plan) : planning });
567
+ this.save();
568
+ this.publishLive();
569
+ const directive = `The planning agent produced this plan for the current user request:\n${planning}\n\n` +
570
+ "Execute the complete task, using the appropriate professional agents for reasoning, frontend, backend and modeling work. Verify the result and report the outcome.";
571
+ if (this.plan?.steps.length)
572
+ this.setStep(0, "active");
573
+ const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
574
+ if (cancelled())
575
+ return;
576
+ if (this.plan) {
577
+ if (finished)
578
+ for (let i = 0; i < this.plan.steps.length; i++)
579
+ this.setStep(i, "done");
580
+ else
581
+ this.setStep(0, "failed");
582
+ }
583
+ }
584
+ catch (error) {
585
+ if (cancelled())
586
+ return;
587
+ this.proState.checking = false;
588
+ this.proState.error = describeError(error);
589
+ if (error instanceof ProPreflightError)
590
+ this.proState.fallbacks = error.fallbacks;
591
+ throw error;
592
+ }
593
+ finally {
594
+ if (!cancelled()) {
595
+ this.planning = false;
596
+ this.subagent = null;
597
+ this.retrying = null;
598
+ this.proState.checking = false;
599
+ this.publishLive();
600
+ }
601
+ }
602
+ if (!cancelled())
603
+ this.onTurnFinished();
604
+ }
408
605
  /**
409
606
  * The transcript this turn may see: the messages it started with plus what it
410
607
  * added since. A user message queued while the turn runs belongs to the next
@@ -430,7 +627,7 @@ export class WebSession {
430
627
  * a plan must improve a turn, never be a new way for one to fail.
431
628
  */
432
629
  async makePlan(token) {
433
- if (!planFirstEnabled())
630
+ if (!planFirstEnabled(this.mode))
434
631
  return null;
435
632
  const request = lastRequestText(this.messages);
436
633
  if (!request.trim())
@@ -488,8 +685,9 @@ export class WebSession {
488
685
  buildApiMessages(messages = this.messages, directive = "") {
489
686
  // A step directive extends the system prompt: the plan is an instruction, not
490
687
  // something the user just said.
688
+ const prompt = this.mode === "pro" ? loadProPrompt("main_model") : loadPrompt();
491
689
  const api = [
492
- { role: "system", content: directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt() },
690
+ { role: "system", content: directive ? `${prompt}\n\n${directive}` : prompt },
493
691
  ];
494
692
  for (const m of modelHistory(messages, this.context)) {
495
693
  if (m.role === "user" || m.role === "assistant") {
@@ -507,12 +705,16 @@ export class WebSession {
507
705
  * the caller owns the turn's end because a planned turn runs this per step.
508
706
  */
509
707
  async agentLoop(apiMessages, token, approvalContext) {
510
- const tools = getToolDefinitions();
708
+ const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
709
+ const team = this.proTeam;
511
710
  try {
512
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
711
+ while (true) {
513
712
  if (token !== this.turnToken || this.paused)
514
713
  return false;
515
- const client = new LLMClient(this.model, this.provider, this.intensity);
714
+ // Match the terminal: long tasks can keep using tools until the model
715
+ // finishes, while older tool results are trimmed near the context limit.
716
+ apiMessages = this.trimApiMessages(apiMessages);
717
+ const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
516
718
  let text = "";
517
719
  this.streamingContent = "";
518
720
  this.publishLive();
@@ -552,6 +754,7 @@ export class WebSession {
552
754
  role: "assistant",
553
755
  content: text,
554
756
  reasoning_content: client.reasoningContent ?? "",
757
+ ...(client.anthropicContent.length ? { anthropic_content: client.anthropicContent } : {}),
555
758
  ...(toolCalls.length > 0
556
759
  ? {
557
760
  tool_calls: toolCalls.map((call) => ({
@@ -565,8 +768,13 @@ export class WebSession {
565
768
  }
566
769
  this.save();
567
770
  this.publishLive();
568
- if (toolCalls.length === 0)
569
- break;
771
+ if (toolCalls.length === 0) {
772
+ if (this.mode === "pro" && !text.trim()) {
773
+ this.messages.push({ role: "assistant", content: "[Error: Pro main model returned an empty completion.]" });
774
+ return false;
775
+ }
776
+ return !/\[Error:/.test(text);
777
+ }
570
778
  for (const call of toolCalls) {
571
779
  if (token !== this.turnToken || this.paused)
572
780
  return false;
@@ -618,7 +826,6 @@ export class WebSession {
618
826
  }
619
827
  this.save();
620
828
  }
621
- return true;
622
829
  }
623
830
  catch (err) {
624
831
  if (token !== this.turnToken)
@@ -652,15 +859,28 @@ export class WebSession {
652
859
  const risk = assessToolCall(tool, args, this.dir);
653
860
  if (!risk)
654
861
  return { approved: true, source: "prompt", reason: "routine call" };
862
+ const approvalToken = this.turnToken;
863
+ const team = this.proTeam;
655
864
  const gate = new ApprovalGate({
656
865
  askHuman: () => new Promise((resolve) => {
866
+ if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed)) {
867
+ resolve(false);
868
+ return;
869
+ }
657
870
  this.pendingApproval = { tool, args, risk, resolve };
658
871
  this.publishLive();
659
872
  }),
660
873
  reviewer: () => this.reviewerModel(),
874
+ ...(this.mode === "pro" && team ? {
875
+ createReviewerClient: () => team.client("confirm_model"),
876
+ reviewerSystemPrompt: () => loadProPrompt("confirm_model"),
877
+ cancelled: () => approvalToken !== this.turnToken || this.paused || this.disposed,
878
+ } : {}),
661
879
  autoApprovalEnabled: () => this.approvalMode === "auto",
662
- extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
880
+ extremeAutoApprovalEnabled: () => loadConfig(this.mode).auto_approve_extreme_risk === true,
663
881
  onReview: (info) => {
882
+ if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed))
883
+ return;
664
884
  this.reviewing = info
665
885
  ? {
666
886
  total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
@@ -674,7 +894,7 @@ export class WebSession {
674
894
  }
675
895
  /** Reviewer model: `approval_model` when set, otherwise the chat model. */
676
896
  reviewerModel() {
677
- const name = String(loadConfig().approval_model ?? "").trim();
897
+ const name = String(loadConfig(this.mode).approval_model ?? "").trim();
678
898
  const found = name ? findModel(name) : null;
679
899
  if (found) {
680
900
  const usable = resolveModel(found.model.name, found.provider);
@@ -691,6 +911,13 @@ export class WebSession {
691
911
  * approval gate and abort signal live.
692
912
  */
693
913
  async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
914
+ if (this.mode === "pro" && (name === "call_pro_agent" || name === DELEGATE_TOOL)) {
915
+ const team = this.proTeam;
916
+ if (!team)
917
+ return "[Error: Pro models have not passed preflight]";
918
+ return team.call(String(args.role ?? "backend_output_model"), String(args.task ?? ""), `${typeof args.context === "string" ? args.context : ""}\n\nCurrent user request: ${approvalContext.prompt}\n` +
919
+ captureApprovalContext(apiMessages).context, 1);
920
+ }
694
921
  if (name !== DELEGATE_TOOL)
695
922
  return executeTool(name, args, this.dir);
696
923
  return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
@@ -738,7 +965,33 @@ export class WebSession {
738
965
  }
739
966
  // ---- compression -----------------------------------------------------
740
967
  compressThreshold() {
741
- return contextCompressionThreshold(this.provider, this.model);
968
+ if (this.mode === "pro") {
969
+ const setting = this.proState?.models.find((entry) => entry.role === "main_model");
970
+ const tier = loadProModels().tier.find((entry) => entry.name === setting?.tier);
971
+ const capacity = tier?.["model-setting"].find((entry) => entry.type === "main_model")?.context_window;
972
+ if (capacity)
973
+ return Math.max(1, Math.floor(capacity * 0.8));
974
+ }
975
+ return contextCompressionThreshold(this.provider, this.model, this.mode);
976
+ }
977
+ /** Trim old tool results in a long turn without changing the saved history. */
978
+ trimApiMessages(apiMessages) {
979
+ const threshold = this.compressThreshold();
980
+ if (threshold <= 0 || this.lastPromptTokens < threshold)
981
+ return apiMessages;
982
+ const toolIdx = [];
983
+ for (let i = 0; i < apiMessages.length; i++) {
984
+ if (apiMessages[i].role === "tool")
985
+ toolIdx.push(i);
986
+ }
987
+ const dropCount = toolIdx.length - COMPACT_KEEP;
988
+ if (dropCount <= 0)
989
+ return apiMessages;
990
+ const copy = apiMessages.slice();
991
+ for (let k = 0; k < dropCount; k++) {
992
+ copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
993
+ }
994
+ return copy;
742
995
  }
743
996
  async autoCompress(token) {
744
997
  const threshold = this.compressThreshold();
@@ -760,7 +1013,7 @@ export class WebSession {
760
1013
  this.publishLive();
761
1014
  let summary = "";
762
1015
  try {
763
- summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript, {
1016
+ summary = await (this.proTeam?.client("main_model") ?? new LLMClient(this.model, this.provider, this.intensity)).summarize(transcript, {
764
1017
  cancelled: () => token !== this.turnToken || this.paused,
765
1018
  });
766
1019
  }
@@ -813,7 +1066,7 @@ export class WebSession {
813
1066
  if (chat.length === 0)
814
1067
  return true;
815
1068
  try {
816
- this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan);
1069
+ this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan, this.mode, this.proPreset ?? undefined);
817
1070
  this.saveError = null;
818
1071
  return true;
819
1072
  }