@xingjisoft/neutrino 0.6.0 → 0.7.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.
Files changed (169) hide show
  1. package/README.md +6 -1
  2. package/assets/fast-planner.txt +57 -0
  3. package/assets/fast-replanner.txt +28 -0
  4. package/dist/agents.d.ts +25 -0
  5. package/dist/app.d.ts +206 -0
  6. package/dist/app.js +15 -0
  7. package/dist/background.d.ts +96 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +40 -3
  10. package/dist/browser.d.ts +18 -0
  11. package/dist/clipboard.d.ts +16 -0
  12. package/dist/colors.d.ts +29 -0
  13. package/dist/compact.d.ts +52 -0
  14. package/dist/config.d.ts +221 -0
  15. package/dist/config.js +22 -7
  16. package/dist/cwd.d.ts +19 -0
  17. package/dist/fast/artifacts.d.ts +26 -0
  18. package/dist/fast/artifacts.js +109 -0
  19. package/dist/fast/engine.d.ts +78 -0
  20. package/dist/fast/engine.js +657 -0
  21. package/dist/fast/evidence.d.ts +21 -0
  22. package/dist/fast/evidence.js +68 -0
  23. package/dist/fast/planner.d.ts +10 -0
  24. package/dist/fast/planner.js +51 -0
  25. package/dist/fast/pool.d.ts +17 -0
  26. package/dist/fast/pool.js +78 -0
  27. package/dist/fast/scheduler.d.ts +54 -0
  28. package/dist/fast/scheduler.js +383 -0
  29. package/dist/fast/types.d.ts +100 -0
  30. package/dist/fast/types.js +21 -0
  31. package/dist/fast/worker.d.ts +23 -0
  32. package/dist/fast/worker.js +185 -0
  33. package/dist/header.d.ts +3 -0
  34. package/dist/index.d.ts +5 -0
  35. package/dist/llm/approval.d.ts +204 -0
  36. package/dist/llm/approval.js +14 -3
  37. package/dist/llm/client.d.ts +118 -0
  38. package/dist/llm/client.js +57 -8
  39. package/dist/llm/codeExecutor.d.ts +14 -0
  40. package/dist/llm/codemode.d.ts +15 -0
  41. package/dist/llm/context.d.ts +29 -0
  42. package/dist/llm/context.js +17 -4
  43. package/dist/llm/diagnostics.d.ts +91 -0
  44. package/dist/llm/diagnostics.js +242 -0
  45. package/dist/llm/plan.d.ts +148 -0
  46. package/dist/llm/retry.d.ts +80 -0
  47. package/dist/llm/risk.d.ts +21 -0
  48. package/dist/llm/risk.js +7 -0
  49. package/dist/llm/runtime.d.ts +189 -0
  50. package/dist/llm/runtime.js +178 -24
  51. package/dist/llm/search.d.ts +2 -0
  52. package/dist/llm/searchNetwork.d.ts +8 -0
  53. package/dist/llm/subagent.d.ts +112 -0
  54. package/dist/llm/subagent.js +25 -12
  55. package/dist/llm/tools.d.ts +20 -0
  56. package/dist/llm/tools.js +4 -1
  57. package/dist/llm/workspace.d.ts +6 -0
  58. package/dist/llm/workspace.js +271 -0
  59. package/dist/markdown.d.ts +58 -0
  60. package/dist/migration.d.ts +8 -0
  61. package/dist/persistence.d.ts +14 -0
  62. package/dist/plugins/catalog.d.ts +20 -0
  63. package/dist/plugins/catalog.js +45 -0
  64. package/dist/plugins/cli.d.ts +1 -0
  65. package/dist/plugins/cli.js +106 -0
  66. package/dist/plugins/host.d.ts +121 -0
  67. package/dist/plugins/host.js +450 -0
  68. package/dist/plugins/manager.d.ts +24 -0
  69. package/dist/plugins/manager.js +288 -0
  70. package/dist/plugins/package.d.ts +5 -0
  71. package/dist/plugins/package.js +53 -0
  72. package/dist/plugins/runner.d.ts +1 -0
  73. package/dist/plugins/runner.js +227 -0
  74. package/dist/plugins/session.d.ts +12 -0
  75. package/dist/plugins/session.js +97 -0
  76. package/dist/plugins/terminal.d.ts +22 -0
  77. package/dist/plugins/terminal.js +33 -0
  78. package/dist/plugins/types.d.ts +178 -0
  79. package/dist/plugins/types.js +6 -0
  80. package/dist/plugins/validation.d.ts +14 -0
  81. package/dist/plugins/validation.js +174 -0
  82. package/dist/plugins/web-sdk.d.ts +58 -0
  83. package/dist/plugins/web-sdk.js +1 -0
  84. package/dist/pro/agents.d.ts +34 -0
  85. package/dist/pro/agents.js +1 -1
  86. package/dist/pro/config.d.ts +75 -0
  87. package/dist/pro/config.js +3 -2
  88. package/dist/pro/preflight.d.ts +21 -0
  89. package/dist/sanitize.d.ts +34 -0
  90. package/dist/screens/agentConversation.d.ts +28 -0
  91. package/dist/screens/agentHistory.d.ts +25 -0
  92. package/dist/screens/agentSelect.d.ts +16 -0
  93. package/dist/screens/chat.d.ts +292 -0
  94. package/dist/screens/chat.js +179 -65
  95. package/dist/screens/history.d.ts +23 -0
  96. package/dist/screens/modelSelect.d.ts +29 -0
  97. package/dist/screens/plugins.d.ts +29 -0
  98. package/dist/screens/plugins.js +196 -0
  99. package/dist/screens/settings.d.ts +42 -0
  100. package/dist/screens/start.d.ts +43 -0
  101. package/dist/screens/start.js +25 -17
  102. package/dist/screens/updatePrompt.d.ts +41 -0
  103. package/dist/selection.d.ts +75 -0
  104. package/dist/state.d.ts +26 -0
  105. package/dist/terminal/buffer.d.ts +47 -0
  106. package/dist/terminal/input.d.ts +70 -0
  107. package/dist/terminal/renderer.d.ts +43 -0
  108. package/dist/terminal/restore.d.ts +41 -0
  109. package/dist/terminal/screen.d.ts +57 -0
  110. package/dist/tray.d.ts +14 -0
  111. package/dist/ui.d.ts +123 -0
  112. package/dist/update.d.ts +71 -0
  113. package/dist/updateWatch.d.ts +43 -0
  114. package/dist/web/approvalQueue.d.ts +8 -0
  115. package/dist/web/approvalQueue.js +59 -0
  116. package/dist/web/auth.d.ts +14 -0
  117. package/dist/web/clientState.d.ts +16 -0
  118. package/dist/web/folderPicker.d.ts +34 -0
  119. package/dist/web/folderPicker.js +544 -0
  120. package/dist/web/hub.d.ts +213 -0
  121. package/dist/web/hub.js +14 -2
  122. package/dist/web/localApi.d.ts +11 -0
  123. package/dist/web/localApi.js +260 -0
  124. package/dist/web/plugins.d.ts +8 -0
  125. package/dist/web/plugins.js +64 -0
  126. package/dist/web/server.d.ts +81 -0
  127. package/dist/web/server.js +127 -19
  128. package/dist/web/service.d.ts +7 -0
  129. package/dist/web/service.js +1 -0
  130. package/dist/web/session.d.ts +176 -0
  131. package/dist/web/session.js +170 -21
  132. package/dist/web/settings.d.ts +65 -0
  133. package/dist/web/terminalBridge.d.ts +15 -0
  134. package/dist/web/terminalBridge.js +8 -2
  135. package/dist/width.d.ts +6 -0
  136. package/docs/plugins/README.md +126 -0
  137. package/docs/plugins/agent-sdk.md +92 -0
  138. package/docs/plugins/architecture.md +64 -0
  139. package/docs/plugins/compatibility.md +34 -0
  140. package/docs/plugins/development.md +55 -0
  141. package/docs/plugins/manifest.md +59 -0
  142. package/docs/plugins/manifest.schema.json +286 -0
  143. package/docs/plugins/security.md +53 -0
  144. package/docs/plugins/skills-prompts.md +69 -0
  145. package/docs/plugins/ui-sdk.md +109 -0
  146. package/examples/local-api.py +105 -0
  147. package/examples/plugins/cards/card.html +6 -0
  148. package/examples/plugins/cards/neutrino.plugin.json +42 -0
  149. package/examples/plugins/composer/editor.html +10 -0
  150. package/examples/plugins/composer/neutrino.plugin.json +10 -0
  151. package/examples/plugins/function-tools/agent.js +32 -0
  152. package/examples/plugins/function-tools/neutrino.plugin.json +15 -0
  153. package/examples/plugins/menus-tabs/neutrino.plugin.json +15 -0
  154. package/examples/plugins/menus-tabs/panel.html +6 -0
  155. package/examples/plugins/skills-prompts/neutrino.plugin.json +13 -0
  156. package/examples/plugins/skills-prompts/prompts/evidence.md +1 -0
  157. package/examples/plugins/skills-prompts/prompts/reviewer.md +1 -0
  158. package/examples/plugins/skills-prompts/skills/review/SKILL.md +8 -0
  159. package/examples/plugins/theme/neutrino.plugin.json +12 -0
  160. package/package.json +66 -42
  161. package/web/LOCAL-API.md +65 -0
  162. package/web/README.md +30 -8
  163. package/web/app.js +256 -67
  164. package/web/i18n.js +18 -3
  165. package/web/index.html +3 -1
  166. package/web/plugins.css +10 -0
  167. package/web/plugins.js +230 -0
  168. package/web/style.css +12 -3
  169. package/web/turns.js +7 -8
@@ -1,4 +1,10 @@
1
+ import { terminalTemplate } from "../plugins/terminal.js";
2
+ import { runPluginTurn, pluginCommand, pluginApprovalContext } from "../plugins/session.js";
1
3
  import path from "node:path";
4
+ import { ApprovalQueue } from "../web/approvalQueue.js";
5
+ import { randomUUID } from "node:crypto";
6
+ import { FastAgentEngine } from "../fast/engine.js";
7
+ import { FAST_MODE, assertFastAvailable, fastSettings } from "../fast/types.js";
2
8
  import { Screen } from "../terminal/screen.js";
3
9
  import { C } from "../colors.js";
4
10
  import { findModel, findConfiguredModel, intensityForModel, contextCompressionThreshold, modelContextWindow, sessionDir, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadAgentPrompt, normalizeAgentMode, saveSession, } from "../config.js";
@@ -7,7 +13,7 @@ import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrap
7
13
  import { drawSpans, renderMarkdownText } from "../markdown.js";
8
14
  import { ellipsize, displayWidth } from "../width.js";
9
15
  import { sanitizeText } from "../sanitize.js";
10
- import { TaskRuntime, currentTask, linkSignals, taskLimits, resumeTaskSnapshot } from "../llm/runtime.js";
16
+ import { TaskRuntime, TaskControlError, currentTask, linkSignals, taskLimits, taskBudgetScope, resumeTaskSnapshot } from "../llm/runtime.js";
11
17
  import { prepareContext, outputAllowance, finalTaskReport } from "../llm/context.js";
12
18
  import { settlePlan } from "../llm/plan.js";
13
19
  import { LLMClient, describeError } from "../llm/client.js";
@@ -21,12 +27,10 @@ import { COMPACT_KEEP, COMPACT_MAX_CHARS_PER_MESSAGE, planCompaction, transcript
21
27
  import { hub, liveApprovalDetails } from "../web/hub.js";
22
28
  import { SessionCheckpoint } from "../persistence.js";
23
29
  import { writeCrashLog } from "../terminal/restore.js";
24
- const TOOLS = getToolDefinitions();
25
30
  /** A sub-agent gets the same tools minus delegation: one level, never a tree. */
26
- const SUB_AGENT_TOOLS = getSubAgentToolDefinitions();
27
31
  /**
28
- * A finished turn folds its *work* away — the prompt body, the thinking and the
29
- * tool calls — and never its final reply, which always stays on screen. Doing
32
+ * A finished turn folds its *work* away — the thinking and the tool calls —
33
+ * while the full user prompt and final reply always stay on screen. Doing
30
34
  * that is only worth a click when the work is substantial: four or more blocks,
31
35
  * or roughly six wrapped lines of text. Short exchanges stay readable without a
32
36
  * click. `web/turns.js` applies the same rule in the browser, so both UIs fold
@@ -84,6 +88,7 @@ export class ChatScreen extends Screen {
84
88
  loadedApprovalMode = null;
85
89
  loadedPlan = null;
86
90
  loadedTask;
91
+ loadedFast;
87
92
  loadedAgentMode = null;
88
93
  approvalMode = "request";
89
94
  modelProvider = "DeepSeek";
@@ -117,6 +122,8 @@ export class ChatScreen extends Screen {
117
122
  turnToken = 0;
118
123
  agentMode = "standard";
119
124
  activeAgentMode = "standard";
125
+ fastState;
126
+ queuedAgentMode = null;
120
127
  turnAbort = null;
121
128
  inputValue = "";
122
129
  cursor = 0;
@@ -161,6 +168,7 @@ export class ChatScreen extends Screen {
161
168
  liveDir = process.cwd();
162
169
  // ---- approval gate ---------------------------------------------------
163
170
  /** Non-null while a tool call is waiting for a human decision. */
171
+ approvalQueue = new ApprovalQueue();
164
172
  pendingApproval = null;
165
173
  reviewing = null;
166
174
  /** Decisions taken by the gate, keyed by the tool message (survives
@@ -215,6 +223,11 @@ export class ChatScreen extends Screen {
215
223
  this.finishedTurns.add(start);
216
224
  this.plan = resumePlan(this.loadedPlan);
217
225
  this.lastTask = resumeTaskSnapshot(this.loadedTask);
226
+ this.fastState = this.loadedFast ? structuredClone(this.loadedFast) : undefined;
227
+ if (this.fastState && !["completed", "failed", "cancelled"].includes(this.fastState.phase)) {
228
+ this.fastState.phase = "failed";
229
+ this.fastState.error = "Process restarted; review saved artifacts before resuming. Tools are never replayed automatically.";
230
+ }
218
231
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
219
232
  this.liveDir = this.loadedCwd ?? process.cwd();
220
233
  this.checkpoint.register();
@@ -228,6 +241,7 @@ export class ChatScreen extends Screen {
228
241
  }
229
242
  }
230
243
  onDeactivate() {
244
+ this.approvalQueue.cancelWaiting();
231
245
  // Never leave the agent awaiting a decision nobody can give.
232
246
  if (this.pendingApproval)
233
247
  this.settleApproval(false);
@@ -274,6 +288,7 @@ export class ChatScreen extends Screen {
274
288
  approvalMode: this.approvalMode,
275
289
  agentMode: this.agentMode,
276
290
  activeAgentMode: this.busy ? this.activeAgentMode : this.agentMode,
291
+ ...(this.fastState ? { fast: this.fastState } : {}),
277
292
  task: this.taskRuntime?.snapshot() ?? this.lastTask,
278
293
  busy: this.busy,
279
294
  paused: this.paused,
@@ -283,7 +298,7 @@ export class ChatScreen extends Screen {
283
298
  tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
284
299
  subagent: this.subagent,
285
300
  approval: this.pendingApproval
286
- ? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir)
301
+ ? { ...liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.liveDir), id: this.pendingApproval.id }
287
302
  : null,
288
303
  reviewing: this.reviewing,
289
304
  };
@@ -316,6 +331,8 @@ export class ChatScreen extends Screen {
316
331
  livePause() {
317
332
  if (this.paused || (!this.busy && !this.queued))
318
333
  return;
334
+ if (this.fastState && this.activeAgentMode === FAST_MODE && this.busy)
335
+ this.fastState.phase = "cancelling";
319
336
  this.paused = true;
320
337
  this.subagent = null;
321
338
  this.retryNotice = null;
@@ -339,13 +356,21 @@ export class ChatScreen extends Screen {
339
356
  this.togglePause();
340
357
  }
341
358
  /** Answer the pending call from the web UI. */
342
- liveApprove(approved) {
343
- if (!this.pendingApproval)
359
+ liveApprove(approved, approvalId) {
360
+ if (!this.pendingApproval || (approvalId !== undefined && this.pendingApproval.id !== approvalId))
344
361
  return false;
345
362
  this.settleApproval(approved);
346
363
  return true;
347
364
  }
348
365
  liveSetAgentMode(mode) {
366
+ if (mode === FAST_MODE) {
367
+ try {
368
+ assertFastAvailable(loadConfig());
369
+ }
370
+ catch (error) {
371
+ return { ok: false, error: describeError(error) };
372
+ }
373
+ }
349
374
  this.agentMode = mode;
350
375
  this.save();
351
376
  this.publishLive();
@@ -376,6 +401,8 @@ export class ChatScreen extends Screen {
376
401
  this.turnToken++;
377
402
  this.taskRuntime = null;
378
403
  this.lastTask = undefined;
404
+ this.fastState = undefined;
405
+ this.queuedAgentMode = null;
379
406
  if (this.pendingApproval)
380
407
  this.settleApproval(false);
381
408
  this.busy = false;
@@ -738,6 +765,7 @@ export class ChatScreen extends Screen {
738
765
  try {
739
766
  const client = new LLMClient(this.model, this.modelProvider, this.intensity);
740
767
  const answer = await client.complete(loadPlannerPrompt(), buildPlannerInput({ request, cwd: this.liveDir, transcript: planTranscript(this.messages) }), {
768
+ ...(currentTask()?.runtime.diagnostics ? { diagnostic: { role: "planning" } } : {}),
741
769
  onRetry: (notice) => {
742
770
  this.retryNotice =
743
771
  `Planning — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
@@ -791,15 +819,26 @@ export class ChatScreen extends Screen {
791
819
  */
792
820
  async runTurn(token, turnMessages, approvalContext) {
793
821
  const runtime = new TaskRuntime({
822
+ executionMode: this.activeAgentMode,
794
823
  request: approvalContext.prompt, priorUserRequests: approvalContext.priorUserRequests,
795
824
  cwd: this.liveDir, signal: this.turnAbort?.signal, limits: taskLimits(loadConfig("standard")),
825
+ budgetScope: taskBudgetScope(loadConfig("standard")),
796
826
  journalDir: path.join(sessionDir("standard"), "tasks", path.basename(this.sessionId || "unsaved")),
797
827
  });
798
828
  this.taskRuntime = runtime;
799
829
  this.taskOutcome = "completed";
800
830
  let failed = false;
801
831
  try {
802
- await runtime.run(() => this.runTaskTurn(token, turnMessages, approvalContext));
832
+ await runPluginTurn(runtime, { profile: "standard", cwd: this.liveDir, sessionId: this.sessionId ?? undefined, role: "main", request: approvalContext.prompt }, (name, args, signal) => this.requestApproval(name, args, approvalContext, signal), async () => {
833
+ const command = pluginCommand(approvalContext.prompt);
834
+ if (command) {
835
+ const result = await runtime.plugins.runCommand(command.name, command.args);
836
+ this.messages.push({ role: "assistant", content: typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2) });
837
+ this.onTurnFinished();
838
+ return;
839
+ }
840
+ await this.runTaskTurn(token, turnMessages, approvalContext);
841
+ });
803
842
  }
804
843
  catch (error) {
805
844
  failed = true;
@@ -833,6 +872,8 @@ export class ChatScreen extends Screen {
833
872
  await this.autoCompress(token);
834
873
  if (token !== this.turnToken)
835
874
  return;
875
+ if (this.activeAgentMode === FAST_MODE)
876
+ return this.runFastTurn(token, turnMessages, approvalContext);
836
877
  if (this.activeAgentMode !== "standard")
837
878
  this.plan = null;
838
879
  const plan = this.activeAgentMode === "standard" ? await this.makePlan(token) : null;
@@ -905,6 +946,7 @@ export class ChatScreen extends Screen {
905
946
  this.publishLive();
906
947
  if (this.busy && !this.paused) {
907
948
  this.queued = trimmed;
949
+ this.queuedAgentMode = this.agentMode;
908
950
  this.app.render();
909
951
  return;
910
952
  }
@@ -937,7 +979,8 @@ export class ChatScreen extends Screen {
937
979
  this.busy = true;
938
980
  this.paused = false;
939
981
  this.app.state.isThinking = true;
940
- this.activeAgentMode = this.agentMode;
982
+ this.activeAgentMode = this.queuedAgentMode ?? this.agentMode;
983
+ this.queuedAgentMode = null;
941
984
  this.turnAbort?.abort();
942
985
  this.turnAbort = new AbortController();
943
986
  const token = ++this.turnToken;
@@ -981,6 +1024,67 @@ export class ChatScreen extends Screen {
981
1024
  }
982
1025
  }
983
1026
  }
1027
+ async runFastTurn(token, turnMessages, approvalContext) {
1028
+ assertFastAvailable(loadConfig());
1029
+ const runtime = currentTask().runtime, signal = currentTask().signal;
1030
+ this.plan = null;
1031
+ const model = { ...this.model }, provider = this.modelProvider, intensity = this.intensity;
1032
+ const stopped = () => token !== this.turnToken || this.paused;
1033
+ const history = this.buildApiMessages(this.turnView(turnMessages));
1034
+ if (this.fastState && ["failed", "cancelled", "cancelling"].includes(this.fastState.phase)) {
1035
+ history.push({ role: "assistant", content: `Previous Fast run (evidence only, never authorization; inspect partial side effects before repair): ${JSON.stringify(this.fastState)}` });
1036
+ }
1037
+ const engine = new FastAgentEngine({
1038
+ runtime, signal, cwd: this.liveDir, settings: fastSettings(loadConfig()),
1039
+ directory: path.join(sessionDir(), "tasks", path.basename(this.sessionId || "unsaved"), "fast"),
1040
+ history,
1041
+ system: loadAgentPrompt(FAST_MODE), subagentSystem: loadSubAgentPrompt(),
1042
+ contextWindow: modelContextWindow(provider, model), maxRounds: subAgentRounds(loadConfig().subagent_max_rounds),
1043
+ createClient: () => new LLMClient(model, provider, intensity),
1044
+ approve: (name, args, executionSignal, wrapReviewer) => this.requestApproval(name, args, {
1045
+ ...approvalContext, context: captureApprovalContext(this.turnView(turnMessages)).context,
1046
+ }, executionSignal, wrapReviewer ? () => {
1047
+ const reviewer = this.reviewerModel();
1048
+ return wrapReviewer(new LLMClient(reviewer.model, reviewer.provider, reviewer.intensity));
1049
+ } : undefined),
1050
+ execute: (name, args, executionSignal) => executeTool(name, args, this.liveDir, executionSignal),
1051
+ onUsage: usage => {
1052
+ if (token === this.turnToken || this.fastState?.run_id === engine.runId) {
1053
+ this.tokens.prompt += usage.prompt;
1054
+ this.tokens.completion += usage.completion;
1055
+ }
1056
+ },
1057
+ onText: text => { if (!stopped()) {
1058
+ this.streamingContent = text;
1059
+ this.checkpoint.schedule();
1060
+ this.publishLive();
1061
+ this.app.render();
1062
+ } },
1063
+ onSnapshot: snapshot => {
1064
+ if (!stopped() || (this.fastState?.run_id === snapshot.run_id && this.fastState.phase === "cancelling")) {
1065
+ this.fastState = snapshot;
1066
+ this.checkpoint.schedule();
1067
+ this.publishLive();
1068
+ this.app.render();
1069
+ }
1070
+ },
1071
+ onTool: (name, args, result) => {
1072
+ if (!stopped()) {
1073
+ this.messages.push({ role: "tool", name, arguments: args, result });
1074
+ this.save();
1075
+ this.publishLive();
1076
+ }
1077
+ },
1078
+ });
1079
+ const result = await engine.run();
1080
+ if (stopped())
1081
+ return;
1082
+ this.fastState = result.snapshot;
1083
+ this.taskOutcome = result.status;
1084
+ this.messages.push({ role: "assistant", content: result.report });
1085
+ this.streamingContent = null;
1086
+ this.onTurnFinished();
1087
+ }
984
1088
  buildApiMessages(messages = this.messages, directive = "") {
985
1089
  // A step directive extends the system prompt rather than adding a user
986
1090
  // message: the plan is an instruction, not something the user just said.
@@ -1009,7 +1113,7 @@ export class ChatScreen extends Screen {
1009
1113
  */
1010
1114
  async agentLoop(apiMessages, token, approvalContext) {
1011
1115
  const tools = this.activeAgentMode === "ptc"
1012
- ? [...TOOLS, (await import("../llm/codemode.js")).getCodeToolDefinition()] : TOOLS;
1116
+ ? [...getToolDefinitions(), (await import("../llm/codemode.js")).getCodeToolDefinition()] : getToolDefinitions();
1013
1117
  let emptyReplies = 0;
1014
1118
  try {
1015
1119
  while (true) {
@@ -1082,7 +1186,7 @@ export class ChatScreen extends Screen {
1082
1186
  return false;
1083
1187
  }
1084
1188
  if (client.outcome?.error?.code.endsWith("_budget") && currentTask()) {
1085
- const report = await finalTaskReport(currentTask().runtime, () => new LLMClient(this.model, this.modelProvider, this.intensity), usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; });
1189
+ const report = await finalTaskReport(currentTask().runtime, () => new LLMClient(this.model, this.modelProvider, this.intensity), usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; }, apiMessages);
1086
1190
  if (token !== this.turnToken || this.paused)
1087
1191
  return false;
1088
1192
  this.taskOutcome = "partial";
@@ -1167,7 +1271,15 @@ export class ChatScreen extends Screen {
1167
1271
  this.app.render();
1168
1272
  continue;
1169
1273
  }
1170
- const result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
1274
+ let result;
1275
+ try {
1276
+ result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
1277
+ }
1278
+ catch (error) {
1279
+ toolMsg.result = `[Not executed: ${describeError(error)}]`;
1280
+ this.save();
1281
+ throw error;
1282
+ }
1171
1283
  // A clear starts a different transcript while an old tool may finish.
1172
1284
  if (!this.messages.includes(toolMsg))
1173
1285
  return false;
@@ -1185,6 +1297,15 @@ export class ChatScreen extends Screen {
1185
1297
  catch (e) {
1186
1298
  if (token !== this.turnToken)
1187
1299
  return false;
1300
+ if (e instanceof TaskControlError && e.code.endsWith("_budget") && currentTask()) {
1301
+ const report = await finalTaskReport(currentTask().runtime, () => new LLMClient(this.model, this.modelProvider, this.intensity), usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; }, apiMessages);
1302
+ if (token !== this.turnToken || this.paused)
1303
+ return false;
1304
+ this.taskOutcome = "partial";
1305
+ this.streamingContent = null;
1306
+ this.messages.push({ role: "assistant", content: report });
1307
+ return false;
1308
+ }
1188
1309
  this.taskOutcome = "failed";
1189
1310
  const err = describeError(e);
1190
1311
  this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
@@ -1217,11 +1338,15 @@ export class ChatScreen extends Screen {
1217
1338
  }
1218
1339
  // ---- approval gate ---------------------------------------------------
1219
1340
  /** Classify a tool call and, when it is risky, run it past the gate. */
1220
- async requestApproval(tool, args, context = captureApprovalContext(this.messages), signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1341
+ async requestApproval(tool, args, context = captureApprovalContext(this.messages), signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal, createReviewerClient) {
1342
+ context = pluginApprovalContext(context);
1221
1343
  const linked = linkSignals(signal, currentTask()?.signal);
1222
1344
  signal = linked.signal;
1223
1345
  try {
1224
1346
  currentTask()?.runtime.assertTool(tool, args);
1347
+ const plugins = currentTask()?.runtime.plugins;
1348
+ if (plugins)
1349
+ await plugins.guardTool(tool, args);
1225
1350
  }
1226
1351
  catch (error) {
1227
1352
  linked.dispose();
@@ -1236,8 +1361,9 @@ export class ChatScreen extends Screen {
1236
1361
  const cancelled = () => approvalToken !== this.turnToken || this.paused || signal?.aborted === true;
1237
1362
  const gate = new ApprovalGate({
1238
1363
  cancelled,
1239
- askHuman: () => cancelled() ? Promise.resolve(false) : this.askHuman(tool, args, risk),
1364
+ askHuman: () => this.askHuman(tool, args, risk, cancelled, signal),
1240
1365
  reviewer: () => this.reviewerModel(),
1366
+ ...(createReviewerClient ? { createReviewerClient } : {}),
1241
1367
  autoApprovalEnabled: () => this.approvalMode === "auto",
1242
1368
  extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
1243
1369
  // Reviews happen before any card is shown, so without this the app looks
@@ -1275,9 +1401,18 @@ export class ChatScreen extends Screen {
1275
1401
  }
1276
1402
  }
1277
1403
  /** Park the turn until the user answers; the header turns orange meanwhile. */
1278
- askHuman(tool, args, risk) {
1279
- return new Promise((resolve) => {
1280
- this.pendingApproval = { tool, args, risk, resolve };
1404
+ askHuman(tool, args, risk, cancelled, signal) {
1405
+ const requestedMode = this.approvalMode;
1406
+ return this.approvalQueue.run(() => new Promise((resolve) => {
1407
+ if (cancelled()) {
1408
+ resolve(false);
1409
+ return;
1410
+ }
1411
+ if (requestedMode !== this.approvalMode) {
1412
+ resolve(null);
1413
+ return;
1414
+ }
1415
+ this.pendingApproval = { id: randomUUID(), tool, args, risk, resolve };
1281
1416
  this.app.state.approval = { tool, level: risk.level };
1282
1417
  // The card floats over the same corner the approval card needs on a short
1283
1418
  // terminal, and a decision the user cannot see is not a decision. The plan
@@ -1286,7 +1421,7 @@ export class ChatScreen extends Screen {
1286
1421
  this.app.bell?.("approval");
1287
1422
  this.app.render();
1288
1423
  this.publishLive();
1289
- });
1424
+ }), signal);
1290
1425
  }
1291
1426
  /** Answer the pending request (keyboard, mouse, or the web UI). */
1292
1427
  settleApproval(approved) {
@@ -1480,7 +1615,7 @@ export class ChatScreen extends Screen {
1480
1615
  createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1481
1616
  system: loadSubAgentPrompt(),
1482
1617
  task: buildSubAgentTask({ task, context, cwd: this.liveDir, rounds: maxRounds }),
1483
- tools: SUB_AGENT_TOOLS,
1618
+ tools: getSubAgentToolDefinitions(),
1484
1619
  maxRounds,
1485
1620
  approve: (name, callArgs) => this.requestApproval(name, callArgs, approval, signal),
1486
1621
  execute: (name, callArgs) => name === DELEGATE_TOOL
@@ -1544,7 +1679,7 @@ export class ChatScreen extends Screen {
1544
1679
  /**
1545
1680
  * A finished turn is only worth folding when the work it hides is substantial.
1546
1681
  * Whether it is folded right now is a separate question (`expandedTurns`), and
1547
- * its reply is never part of what it hides.
1682
+ * its user input and reply are never part of what it hides.
1548
1683
  */
1549
1684
  turnHides(start, end) {
1550
1685
  if (!this.finishedTurns.has(start))
@@ -1553,12 +1688,12 @@ export class ChatScreen extends Screen {
1553
1688
  let blocks = 0;
1554
1689
  let chars = 0;
1555
1690
  for (let i = start; i < end; i++) {
1556
- // The reply stays on screen, so it is neither hidden content nor a reason
1557
- // to fold: a long answer alone must never collapse its own turn.
1691
+ // User input and the reply stay on screen, so neither counts toward the
1692
+ // folding threshold, however long either one is.
1558
1693
  if (i === answer)
1559
1694
  continue;
1560
1695
  const m = this.messages[i];
1561
- if (m.role === "user" || m.role === "assistant") {
1696
+ if (m.role === "assistant") {
1562
1697
  blocks++;
1563
1698
  chars += m.content.length;
1564
1699
  }
@@ -1613,23 +1748,6 @@ export class ChatScreen extends Screen {
1613
1748
  }
1614
1749
  return map;
1615
1750
  }
1616
- /**
1617
- * The first line of the first message in `role`. Only one line: the header is
1618
- * a signpost, and the full text is one click away.
1619
- */
1620
- turnPreview(start, end, role) {
1621
- for (const m of this.messages.slice(start, end)) {
1622
- if (m.role !== role)
1623
- continue;
1624
- const line = sanitizeText(m.content)
1625
- .split("\n")
1626
- .map((part) => part.trim())
1627
- .find((part) => part.length > 0);
1628
- if (line)
1629
- return line;
1630
- }
1631
- return "";
1632
- }
1633
1751
  /** The header standing in for a folded turn, or the way back into one. */
1634
1752
  pushTurnHeader(start, end, width, add) {
1635
1753
  const count = end - start;
@@ -1646,13 +1764,10 @@ export class ChatScreen extends Screen {
1646
1764
  }, -1, start);
1647
1765
  return;
1648
1766
  }
1649
- const you = `You: ${this.turnPreview(start, end, "user") || "(no text)"}`;
1650
- add({ spans: [{ text: ellipsize(`▸ ${you}`, width), style: { fg: C.amber, bold: true } }] }, -1, start);
1651
- // The prompt and the reply sit either side of the work this fold hides, so
1652
- // the control line goes between them and the answer stays on screen below.
1653
- const hidden = this.turnAnswer(start, end) < 0 ? count : count - 1;
1767
+ const answer = this.turnAnswer(start, end);
1768
+ const hidden = this.messages.slice(start, end).filter((message, offset) => message.role !== "user" && message.role !== "system" && start + offset !== answer).length;
1654
1769
  const meta = `${hidden} of ${count} message${count === 1 ? "" : "s"} folded`;
1655
- add({ spans: [{ text: ellipsize(` ${meta} — click to expand`, width), style: { fg: C.dim } }] }, -1, start);
1770
+ add({ spans: [{ text: ellipsize(`▸ ${meta} — click to expand`, width), style: { fg: C.dim } }] }, -1, start);
1656
1771
  }
1657
1772
  /** Fold or unfold one turn. */
1658
1773
  toggleTurn(start) {
@@ -1793,7 +1908,7 @@ export class ChatScreen extends Screen {
1793
1908
  if (chatMessages.length === 0)
1794
1909
  return true;
1795
1910
  try {
1796
- this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode, this.plan, "standard", undefined, this.agentMode, this.taskRuntime?.snapshot() ?? this.lastTask);
1911
+ this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode, this.plan, "standard", undefined, this.agentMode, this.taskRuntime?.snapshot() ?? this.lastTask, this.fastState);
1797
1912
  this.saveError = null;
1798
1913
  return true;
1799
1914
  }
@@ -1845,30 +1960,28 @@ export class ChatScreen extends Screen {
1845
1960
  archived = context !== null && recordIndex < context.end;
1846
1961
  recordIndex++;
1847
1962
  }
1848
- // A foldable turn always shows its control lines first; its work then
1849
- // renders when the reader has opened it, and its reply always does.
1850
- if (m.role !== "system" && turn === mi && !(archived && !this.expandedContext)) {
1851
- this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1852
- }
1853
1963
  const folded = turn >= 0 && !this.expandedTurns.has(turn);
1854
1964
  const reply = folded && answers[mi] === mi;
1855
1965
  if (m.role === "system") {
1856
1966
  pushText(sanitizeText(m.content), { fg: C.dim });
1857
1967
  }
1858
- else if ((archived && !this.expandedContext) || (folded && !reply)) {
1859
- // Hidden: by the collapsed "Earlier conversation" fold, or by the turn's
1860
- // own fold, which hides the prompt body and the work but never the
1861
- // reply. Either way the message stays in the transcript, on disk and in
1862
- // the request; the header above says how to get it back on screen.
1863
- }
1864
1968
  else if (m.role === "user") {
1865
1969
  add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
1866
- pushMarkdown(sanitizeText(m.content), { fg: C.user });
1970
+ pushMarkdown(sanitizeText(terminalTemplate("chat.message.renderer", { content: m.content, role: "user" }) ?? m.content), { fg: C.user });
1867
1971
  add({ spans: [] });
1972
+ // The fold control follows the complete prompt, which stays visible
1973
+ // even when the turn or the earlier conversation is collapsed.
1974
+ if (turn === mi && !(archived && !this.expandedContext)) {
1975
+ this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1976
+ }
1977
+ }
1978
+ else if ((archived && !this.expandedContext) || (folded && !reply)) {
1979
+ // Only AI output is hidden. Every message remains in the transcript,
1980
+ // on disk and in the request; the controls reveal it again.
1868
1981
  }
1869
1982
  else if (m.role === "assistant") {
1870
1983
  add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
1871
- pushMarkdown(sanitizeText(m.content), { fg: C.assistant });
1984
+ pushMarkdown(sanitizeText(terminalTemplate("chat.message.renderer", { content: m.content, role: "assistant" }) ?? m.content), { fg: C.assistant });
1872
1985
  add({ spans: [] });
1873
1986
  }
1874
1987
  else if (m.role === "summary") {
@@ -1906,7 +2019,7 @@ export class ChatScreen extends Screen {
1906
2019
  }
1907
2020
  // Command output routinely carries ANSI colours and tabs; clean it once
1908
2021
  // so the collapsed line count and the expanded body agree.
1909
- const body = sanitizeText(m.result || "");
2022
+ const body = sanitizeText(terminalTemplate("chat.card.tool", { name: m.name, result: m.result, role: "tool", arguments: JSON.stringify(m.arguments) }) ?? m.result);
1910
2023
  if (this.expandedTools.has(mi)) {
1911
2024
  pushText(body || "(no output)", { fg: C.dim }, mi);
1912
2025
  add({ spans: [{ text: " ▾ click to collapse", style: { fg: C.toolHeader } }] }, mi);
@@ -1918,7 +2031,7 @@ export class ChatScreen extends Screen {
1918
2031
  }
1919
2032
  add({ spans: [] });
1920
2033
  }
1921
- if (context && archived && recordIndex === context.end) {
2034
+ if (context && archived && recordIndex === context.end && this.expandedContext) {
1922
2035
  this.contextLines.add(lines.length);
1923
2036
  add({ spans: [{ text: "▾ Collapse earlier conversation", style: { fg: C.amber } }] });
1924
2037
  add({ spans: [] });
@@ -2028,7 +2141,7 @@ export class ChatScreen extends Screen {
2028
2141
  active: true,
2029
2142
  dimmed: this.busy,
2030
2143
  cursorOn: this.app.state.cursorOn,
2031
- placeholder: "Type a message...",
2144
+ placeholder: sanitizeText(terminalTemplate("chat.composer", { text: this.inputValue }) ?? "Type a message...", { singleLine: true }),
2032
2145
  });
2033
2146
  const hintY = h - 1;
2034
2147
  const hints = "Enter send · Ctrl+P plan · Ctrl+C pause · ESC ESC pause · Ctrl+A approval · Ctrl+B menu · Ctrl+L clear · drag to copy";
@@ -2090,7 +2203,8 @@ export class ChatScreen extends Screen {
2090
2203
  { kind: "line", text: `Total: ${total}` },
2091
2204
  ];
2092
2205
  }
2093
- const rows = planCardRows(plan, { width, maxRows: Math.max(1, maxRows - 1) });
2206
+ const custom = terminalTemplate("chat.card.plan", { plan: JSON.stringify(plan), role: "main" });
2207
+ const rows = custom ? wrapText(sanitizeText(custom), width).slice(0, Math.max(1, maxRows - 1)).map(text => ({ kind: "line", text })) : planCardRows(plan, { width, maxRows: Math.max(1, maxRows - 1) });
2094
2208
  rows.push({ kind: "line", text: `Tokens ${this.tokens.prompt} → ${this.tokens.completion}` });
2095
2209
  return rows;
2096
2210
  }
@@ -0,0 +1,23 @@
1
+ import type { Buffer } from "../terminal/buffer.js";
2
+ import { Screen } from "../terminal/screen.js";
3
+ import type { Key, MouseEvent } from "../terminal/input.js";
4
+ export declare class HistoryScreen extends Screen {
5
+ private sessions;
6
+ private selectedIdx;
7
+ private scrollTop;
8
+ private lastHoverX;
9
+ private lastHoverY;
10
+ private rows;
11
+ private itemBounds;
12
+ private cwd;
13
+ onActivate(): void;
14
+ private move;
15
+ onKey(key: Key): void;
16
+ onMouse(ev: MouseEvent): void;
17
+ /** Map a terminal row to the history item index (0 = "Open other Agent's history"). */
18
+ private itemIndexAtRow;
19
+ private loadSelected;
20
+ private deleteSelected;
21
+ private buildRows;
22
+ render(buf: Buffer): void;
23
+ }
@@ -0,0 +1,29 @@
1
+ import type { Buffer } from "../terminal/buffer.js";
2
+ import { Screen } from "../terminal/screen.js";
3
+ import type { Key, MouseEvent } from "../terminal/input.js";
4
+ export declare class ModelSelectScreen extends Screen {
5
+ private level;
6
+ private providers;
7
+ private modelMap;
8
+ private selectedProviderIdx;
9
+ private selectedModelIdx;
10
+ private selectedIntensityIdx;
11
+ private scrollBtn;
12
+ private lastHoverX;
13
+ private lastHoverY;
14
+ onActivate(): void;
15
+ private currentList;
16
+ /** Only the intensity names this exact model accepts. */
17
+ private currentIntensities;
18
+ private currentIdx;
19
+ private setIdx;
20
+ private move;
21
+ private confirm;
22
+ private goBack;
23
+ /** Map a terminal row to the list index rendered there, if any. */
24
+ private indexAtRow;
25
+ onMouse(ev: MouseEvent): void;
26
+ onKey(key: Key): void;
27
+ render(buf: Buffer): void;
28
+ private currentModelName;
29
+ }
@@ -0,0 +1,29 @@
1
+ import { Screen } from "../terminal/screen.js";
2
+ import type { Buffer } from "../terminal/buffer.js";
3
+ import type { Key, MouseEvent } from "../terminal/input.js";
4
+ import type { OwnedUI } from "../plugins/host.js";
5
+ export declare class PluginViewScreen extends Screen {
6
+ private item;
7
+ private offset;
8
+ constructor(item: OwnedUI);
9
+ onKey(key: Key): void;
10
+ onMouse(ev: MouseEvent): void;
11
+ render(buf: Buffer): void;
12
+ }
13
+ export declare class PluginsScreen extends Screen {
14
+ private profile;
15
+ private plugins;
16
+ private index;
17
+ private detail;
18
+ private editing;
19
+ private draft;
20
+ private message;
21
+ private trustPending;
22
+ onActivate(): void;
23
+ private refresh;
24
+ private action;
25
+ onKey(key: Key): void;
26
+ onPaste(text: string): void;
27
+ onMouse(ev: MouseEvent): void;
28
+ render(buf: Buffer): void;
29
+ }