@xingjisoft/neutrino 0.5.2 → 0.6.2

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.
@@ -1,16 +1,20 @@
1
+ import path from "node:path";
1
2
  import { Screen } from "../terminal/screen.js";
2
3
  import { C } from "../colors.js";
3
- import { findModel, findConfiguredModel, intensityForModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
4
+ import { findModel, findConfiguredModel, intensityForModel, contextCompressionThreshold, modelContextWindow, sessionDir, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadAgentPrompt, normalizeAgentMode, saveSession, } from "../config.js";
4
5
  import { drawHeader } from "../header.js";
5
6
  import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText, approvalToggle, drawApprovalToggle, inRect } from "../ui.js";
6
7
  import { drawSpans, renderMarkdownText } from "../markdown.js";
7
8
  import { ellipsize, displayWidth } from "../width.js";
8
9
  import { sanitizeText } from "../sanitize.js";
10
+ import { TaskRuntime, TaskControlError, currentTask, linkSignals, taskLimits, taskBudgetScope, resumeTaskSnapshot } from "../llm/runtime.js";
11
+ import { prepareContext, outputAllowance, finalTaskReport } from "../llm/context.js";
12
+ import { settlePlan } from "../llm/plan.js";
9
13
  import { LLMClient, describeError } from "../llm/client.js";
10
14
  import { formatDelay } from "../llm/retry.js";
11
15
  import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planCardRows, planMessage, planRowsWidth, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
12
16
  import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
13
- import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
17
+ import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, subAgentRounds, } from "../llm/subagent.js";
14
18
  import { assessToolCall } from "../llm/risk.js";
15
19
  import { ApprovalGate, captureApprovalContext, describeApprovalIntent, statedReason } from "../llm/approval.js";
16
20
  import { COMPACT_KEEP, COMPACT_MAX_CHARS_PER_MESSAGE, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
@@ -21,8 +25,8 @@ const TOOLS = getToolDefinitions();
21
25
  /** A sub-agent gets the same tools minus delegation: one level, never a tree. */
22
26
  const SUB_AGENT_TOOLS = getSubAgentToolDefinitions();
23
27
  /**
24
- * A finished turn folds its *work* away — the prompt body, the thinking and the
25
- * tool calls — and never its final reply, which always stays on screen. Doing
28
+ * A finished turn folds its *work* away — the thinking and the tool calls —
29
+ * while the full user prompt and final reply always stay on screen. Doing
26
30
  * that is only worth a click when the work is substantial: four or more blocks,
27
31
  * or roughly six wrapped lines of text. Short exchanges stay readable without a
28
32
  * click. `web/turns.js` applies the same rule in the browser, so both UIs fold
@@ -79,6 +83,8 @@ export class ChatScreen extends Screen {
79
83
  loadedContext = null;
80
84
  loadedApprovalMode = null;
81
85
  loadedPlan = null;
86
+ loadedTask;
87
+ loadedAgentMode = null;
82
88
  approvalMode = "request";
83
89
  modelProvider = "DeepSeek";
84
90
  model = { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true };
@@ -105,7 +111,13 @@ export class ChatScreen extends Screen {
105
111
  busy = false;
106
112
  paused = false;
107
113
  queued = null;
114
+ taskRuntime = null;
115
+ lastTask;
116
+ taskOutcome = "completed";
108
117
  turnToken = 0;
118
+ agentMode = "standard";
119
+ activeAgentMode = "standard";
120
+ turnAbort = null;
109
121
  inputValue = "";
110
122
  cursor = 0;
111
123
  scrollTop = 0;
@@ -164,6 +176,7 @@ export class ChatScreen extends Screen {
164
176
  if (this.initialized)
165
177
  return;
166
178
  this.initialized = true;
179
+ this.agentMode = normalizeAgentMode(this.loadedAgentMode);
167
180
  this.approvalMode = normalizeApprovalMode(this.loadedApprovalMode ?? loadConfig().approval_mode);
168
181
  const st = this.app.state;
169
182
  this.modelProvider = this.loadedProvider ?? st.selectedProvider;
@@ -201,6 +214,7 @@ export class ChatScreen extends Screen {
201
214
  for (const start of this.userMessageIndexes())
202
215
  this.finishedTurns.add(start);
203
216
  this.plan = resumePlan(this.loadedPlan);
217
+ this.lastTask = resumeTaskSnapshot(this.loadedTask);
204
218
  this.context = normalizeContext(this.loadedContext, this.messages.filter((m) => m.role !== "system").length);
205
219
  this.liveDir = this.loadedCwd ?? process.cwd();
206
220
  this.checkpoint.register();
@@ -258,6 +272,9 @@ export class ChatScreen extends Screen {
258
272
  model: this.model.name,
259
273
  intensity: this.intensity,
260
274
  approvalMode: this.approvalMode,
275
+ agentMode: this.agentMode,
276
+ activeAgentMode: this.busy ? this.activeAgentMode : this.agentMode,
277
+ task: this.taskRuntime?.snapshot() ?? this.lastTask,
261
278
  busy: this.busy,
262
279
  paused: this.paused,
263
280
  queued: this.queued,
@@ -300,6 +317,11 @@ export class ChatScreen extends Screen {
300
317
  if (this.paused || (!this.busy && !this.queued))
301
318
  return;
302
319
  this.paused = true;
320
+ this.subagent = null;
321
+ this.retryNotice = null;
322
+ this.reviewing = null;
323
+ this.planning = false;
324
+ this.turnAbort?.abort();
303
325
  this.turnToken++;
304
326
  // A queued decision would otherwise keep the agent awaiting forever.
305
327
  if (this.pendingApproval)
@@ -323,6 +345,12 @@ export class ChatScreen extends Screen {
323
345
  this.settleApproval(approved);
324
346
  return true;
325
347
  }
348
+ liveSetAgentMode(mode) {
349
+ this.agentMode = mode;
350
+ this.save();
351
+ this.publishLive();
352
+ return { ok: true };
353
+ }
326
354
  liveSetApprovalMode(mode) {
327
355
  if (this.approvalMode === mode)
328
356
  return;
@@ -344,7 +372,10 @@ export class ChatScreen extends Screen {
344
372
  this.app.render();
345
373
  return;
346
374
  }
375
+ this.turnAbort?.abort();
347
376
  this.turnToken++;
377
+ this.taskRuntime = null;
378
+ this.lastTask = undefined;
348
379
  if (this.pendingApproval)
349
380
  this.settleApproval(false);
350
381
  this.busy = false;
@@ -658,6 +689,11 @@ export class ChatScreen extends Screen {
658
689
  return;
659
690
  this.paused = !this.paused;
660
691
  if (this.paused) {
692
+ this.reviewing = null;
693
+ this.planning = false;
694
+ this.subagent = null;
695
+ this.retryNotice = null;
696
+ this.turnAbort?.abort();
661
697
  this.turnToken++;
662
698
  this.busy = false;
663
699
  this.app.state.isThinking = false;
@@ -723,9 +759,9 @@ export class ChatScreen extends Screen {
723
759
  return null;
724
760
  }
725
761
  finally {
726
- this.planning = false;
727
- this.retryNotice = null;
728
762
  if (token === this.turnToken) {
763
+ this.planning = false;
764
+ this.retryNotice = null;
729
765
  this.app.render();
730
766
  this.publishLive();
731
767
  }
@@ -754,16 +790,59 @@ export class ChatScreen extends Screen {
754
790
  * own. Without a plan the turn runs in one pass, as before.
755
791
  */
756
792
  async runTurn(token, turnMessages, approvalContext) {
793
+ const runtime = new TaskRuntime({
794
+ request: approvalContext.prompt, priorUserRequests: approvalContext.priorUserRequests,
795
+ cwd: this.liveDir, signal: this.turnAbort?.signal, limits: taskLimits(loadConfig("standard")),
796
+ budgetScope: taskBudgetScope(loadConfig("standard")),
797
+ journalDir: path.join(sessionDir("standard"), "tasks", path.basename(this.sessionId || "unsaved")),
798
+ });
799
+ this.taskRuntime = runtime;
800
+ this.taskOutcome = "completed";
801
+ let failed = false;
802
+ try {
803
+ await runtime.run(() => this.runTaskTurn(token, turnMessages, approvalContext));
804
+ }
805
+ catch (error) {
806
+ failed = true;
807
+ throw error;
808
+ }
809
+ finally {
810
+ await runtime.drain();
811
+ if (runtime.signal.aborted)
812
+ settlePlan(runtime.plan ?? null, true);
813
+ runtime.finish(runtime.signal.aborted ? "cancelled" : failed ? "failed" :
814
+ runtime.plan?.steps.some(step => step.status === "failed") ? "failed" :
815
+ runtime.plan?.steps.some(step => !["done", "skipped"].includes(step.status)) ? "partial" : this.taskOutcome);
816
+ if (this.taskRuntime === runtime) {
817
+ if (runtime.signal.aborted && token === this.turnToken && this.busy && !this.paused) {
818
+ if (this.taskOutcome !== "cancelled")
819
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(runtime.signal.reason)}]\n${runtime.partialReport()}` });
820
+ this.streamingContent = null;
821
+ this.onTurnFinished(false);
822
+ }
823
+ if (this.taskRuntime === runtime) {
824
+ this.lastTask = runtime.snapshot();
825
+ this.save();
826
+ this.publishLive();
827
+ }
828
+ }
829
+ }
830
+ }
831
+ async runTaskTurn(token, turnMessages, approvalContext) {
757
832
  // Shrink the history before building the request when it has outgrown the
758
833
  // configured budget.
759
834
  await this.autoCompress(token);
760
835
  if (token !== this.turnToken)
761
836
  return;
762
- const plan = await this.makePlan(token);
837
+ if (this.activeAgentMode !== "standard")
838
+ this.plan = null;
839
+ const plan = this.activeAgentMode === "standard" ? await this.makePlan(token) : null;
763
840
  if (token !== this.turnToken)
764
841
  return;
765
842
  if (plan) {
766
843
  this.plan = plan;
844
+ if (currentTask())
845
+ currentTask().runtime.plan = plan;
767
846
  // The plan is an ordinary assistant message: it is saved, folded, replayed
768
847
  // and sent back to the model exactly like any other answer, so later turns
769
848
  // (and the user) still know what was agreed.
@@ -783,7 +862,7 @@ export class ChatScreen extends Screen {
783
862
  const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
784
863
  if (token !== this.turnToken)
785
864
  return;
786
- this.setStep(i, finished ? "done" : "failed");
865
+ this.setStep(i, currentTask()?.signal.aborted ? "cancelled" : finished ? "done" : "failed");
787
866
  // A step that failed stops the plan: the remaining steps assumed this one
788
867
  // succeeded, and the user has to see the failure before anything runs on
789
868
  // top of it.
@@ -833,6 +912,19 @@ export class ChatScreen extends Screen {
833
912
  this.startTurn();
834
913
  }
835
914
  startTurn() {
915
+ if (this.taskRuntime?.settling) {
916
+ const previous = this.taskRuntime;
917
+ this.queued = lastRequestText(this.messages);
918
+ this.paused = false;
919
+ void previous.drain().then(() => {
920
+ if (this.taskRuntime === previous && this.queued && !this.paused) {
921
+ this.queued = null;
922
+ this.startTurn();
923
+ }
924
+ });
925
+ this.publishLive();
926
+ return;
927
+ }
836
928
  // Sending closes every earlier turn, including one left unfinished by
837
929
  // ESC ESC: it is folded from here on, but never while it is still paused.
838
930
  const starts = this.userMessageIndexes();
@@ -846,6 +938,9 @@ export class ChatScreen extends Screen {
846
938
  this.busy = true;
847
939
  this.paused = false;
848
940
  this.app.state.isThinking = true;
941
+ this.activeAgentMode = this.agentMode;
942
+ this.turnAbort?.abort();
943
+ this.turnAbort = new AbortController();
849
944
  const token = ++this.turnToken;
850
945
  this.streamingContent = "";
851
946
  this.app.render();
@@ -890,7 +985,9 @@ export class ChatScreen extends Screen {
890
985
  buildApiMessages(messages = this.messages, directive = "") {
891
986
  // A step directive extends the system prompt rather than adding a user
892
987
  // message: the plan is an instruction, not something the user just said.
893
- const system = directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt();
988
+ const prompt = loadAgentPrompt(this.activeAgentMode);
989
+ const system = (directive ? `${prompt}\n\n${directive}` : prompt) +
990
+ (currentTask() ? `\n\n${currentTask().runtime.instructions()}` : "");
894
991
  const api = [{ role: "system", content: system }];
895
992
  for (const m of modelHistory(messages, this.context)) {
896
993
  if (m.role === "user" || m.role === "assistant") {
@@ -912,6 +1009,9 @@ export class ChatScreen extends Screen {
912
1009
  * because a planned turn runs this once per step.
913
1010
  */
914
1011
  async agentLoop(apiMessages, token, approvalContext) {
1012
+ const tools = this.activeAgentMode === "ptc"
1013
+ ? [...TOOLS, (await import("../llm/codemode.js")).getCodeToolDefinition()] : TOOLS;
1014
+ let emptyReplies = 0;
915
1015
  try {
916
1016
  while (true) {
917
1017
  if (token !== this.turnToken)
@@ -919,11 +1019,19 @@ export class ChatScreen extends Screen {
919
1019
  // Keep the payload inside budget during a long single turn: once the
920
1020
  // context is too large, drop the body of older tool results.
921
1021
  apiMessages = this.trimApiMessages(apiMessages);
1022
+ if (currentTask())
1023
+ await prepareContext({
1024
+ messages: apiMessages, tools, capacity: modelContextWindow(this.modelProvider, this.model, "standard"),
1025
+ createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1026
+ stream: { signal: currentTask().runtime.signal, cancelled: () => token !== this.turnToken || this.paused },
1027
+ onUsage: usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; },
1028
+ });
922
1029
  const client = new LLMClient(this.model, this.modelProvider, this.intensity);
923
1030
  let content = "";
924
1031
  let hasContent = false;
925
1032
  let aborted = false;
926
- for await (const chunk of client.streamTurn(apiMessages, TOOLS, {
1033
+ for await (const chunk of client.streamTurn(apiMessages, tools, {
1034
+ maxOutputTokens: outputAllowance(modelContextWindow(this.modelProvider, this.model)),
927
1035
  // A retried request can go quiet for up to 160s, so the status line
928
1036
  // says why instead of looking hung. The moment the user pauses or a
929
1037
  // newer turn takes over, the backoff is dropped instead of waited out.
@@ -968,8 +1076,42 @@ export class ChatScreen extends Screen {
968
1076
  this.app.render();
969
1077
  return false;
970
1078
  }
1079
+ if (currentTask()?.signal.aborted || client.outcome?.status === "cancelled") {
1080
+ this.taskOutcome = "cancelled";
1081
+ this.streamingContent = null;
1082
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(currentTask()?.signal.reason ?? "Task cancelled")}]\n${currentTask()?.runtime.partialReport() ?? ""}` });
1083
+ return false;
1084
+ }
1085
+ if (client.outcome?.error?.code.endsWith("_budget") && currentTask()) {
1086
+ 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);
1087
+ if (token !== this.turnToken || this.paused)
1088
+ return false;
1089
+ this.taskOutcome = "partial";
1090
+ this.streamingContent = null;
1091
+ this.messages.push({ role: "assistant", content: report });
1092
+ return false;
1093
+ }
1094
+ if (client.outcome?.status === "failed") {
1095
+ const recorded = currentTask()?.runtime.evidence.length ? currentTask().runtime.partialReport() : "";
1096
+ const message = client.outcome.error?.message ?? "Model request failed";
1097
+ this.taskOutcome = recorded ? "partial" : "failed";
1098
+ this.streamingContent = null;
1099
+ this.messages.push({ role: "assistant", content: content +
1100
+ (content.includes(message) ? "" : `\n[Error: ${message}]`) + (recorded ? `\n\n${recorded}` : "") });
1101
+ return false;
1102
+ }
971
1103
  const toolCalls = client.toolCalls;
972
1104
  if (toolCalls.length === 0) {
1105
+ if (!content.trim() && emptyReplies++ === 0)
1106
+ continue;
1107
+ if (!content.trim()) {
1108
+ this.taskOutcome = "failed";
1109
+ if (!content.trim())
1110
+ this.messages.push({ role: "assistant", content: "[Error: model returned an empty completion.]" });
1111
+ else
1112
+ this.messages.push({ role: "assistant", content });
1113
+ return false;
1114
+ }
973
1115
  if (hasContent)
974
1116
  this.messages.push({ role: "assistant", content });
975
1117
  this.streamingContent = null;
@@ -1026,7 +1168,15 @@ export class ChatScreen extends Screen {
1026
1168
  this.app.render();
1027
1169
  continue;
1028
1170
  }
1029
- const result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
1171
+ let result;
1172
+ try {
1173
+ result = await this.runTool(tc.name, args, toolMsg, token, apiMessages, approvalContext);
1174
+ }
1175
+ catch (error) {
1176
+ toolMsg.result = `[Not executed: ${describeError(error)}]`;
1177
+ this.save();
1178
+ throw error;
1179
+ }
1030
1180
  // A clear starts a different transcript while an old tool may finish.
1031
1181
  if (!this.messages.includes(toolMsg))
1032
1182
  return false;
@@ -1044,6 +1194,16 @@ export class ChatScreen extends Screen {
1044
1194
  catch (e) {
1045
1195
  if (token !== this.turnToken)
1046
1196
  return false;
1197
+ if (e instanceof TaskControlError && e.code.endsWith("_budget") && currentTask()) {
1198
+ 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);
1199
+ if (token !== this.turnToken || this.paused)
1200
+ return false;
1201
+ this.taskOutcome = "partial";
1202
+ this.streamingContent = null;
1203
+ this.messages.push({ role: "assistant", content: report });
1204
+ return false;
1205
+ }
1206
+ this.taskOutcome = "failed";
1047
1207
  const err = describeError(e);
1048
1208
  this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
1049
1209
  this.streamingContent = null;
@@ -1075,18 +1235,34 @@ export class ChatScreen extends Screen {
1075
1235
  }
1076
1236
  // ---- approval gate ---------------------------------------------------
1077
1237
  /** Classify a tool call and, when it is risky, run it past the gate. */
1078
- async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
1238
+ async requestApproval(tool, args, context = captureApprovalContext(this.messages), signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1239
+ const linked = linkSignals(signal, currentTask()?.signal);
1240
+ signal = linked.signal;
1241
+ try {
1242
+ currentTask()?.runtime.assertTool(tool, args);
1243
+ }
1244
+ catch (error) {
1245
+ linked.dispose();
1246
+ return { approved: false, source: "input", reason: error instanceof Error ? error.message : String(error) };
1247
+ }
1079
1248
  const risk = assessToolCall(tool, args, this.liveDir);
1080
- if (!risk)
1249
+ if (!risk) {
1250
+ linked.dispose();
1081
1251
  return { approved: true, source: "prompt", reason: "routine call" };
1252
+ }
1253
+ const approvalToken = this.turnToken;
1254
+ const cancelled = () => approvalToken !== this.turnToken || this.paused || signal?.aborted === true;
1082
1255
  const gate = new ApprovalGate({
1083
- askHuman: () => this.askHuman(tool, args, risk),
1256
+ cancelled,
1257
+ askHuman: () => cancelled() ? Promise.resolve(false) : this.askHuman(tool, args, risk),
1084
1258
  reviewer: () => this.reviewerModel(),
1085
1259
  autoApprovalEnabled: () => this.approvalMode === "auto",
1086
1260
  extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
1087
1261
  // Reviews happen before any card is shown, so without this the app looks
1088
1262
  // frozen while the independent assessments run.
1089
1263
  onReview: (info) => {
1264
+ if (cancelled())
1265
+ return;
1090
1266
  this.reviewing = info ? {
1091
1267
  total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
1092
1268
  request: liveApprovalDetails(tool, args, risk, this.liveDir),
@@ -1103,7 +1279,18 @@ export class ChatScreen extends Screen {
1103
1279
  this.app.showToast(`independent review running (${which}${info.total} reviewer${info.total > 1 ? "s" : ""})...`);
1104
1280
  },
1105
1281
  });
1106
- return gate.decide({ tool, args, cwd: this.liveDir, ...context, risk });
1282
+ const abortApproval = () => {
1283
+ if (this.pendingApproval?.args === args)
1284
+ this.settleApproval(false);
1285
+ };
1286
+ signal.addEventListener("abort", abortApproval, { once: true });
1287
+ try {
1288
+ return await gate.decide({ tool, args, cwd: this.liveDir, ...context, risk });
1289
+ }
1290
+ finally {
1291
+ signal.removeEventListener("abort", abortApproval);
1292
+ linked.dispose();
1293
+ }
1107
1294
  }
1108
1295
  /** Park the turn until the user answers; the header turns orange meanwhile. */
1109
1296
  askHuman(tool, args, risk) {
@@ -1193,15 +1380,97 @@ export class ChatScreen extends Screen {
1193
1380
  };
1194
1381
  }
1195
1382
  // ---- sub-agents ------------------------------------------------------
1383
+ async runProgram(args, token, apiMessages, approvalContext) {
1384
+ if (this.activeAgentMode !== "ptc")
1385
+ return "[Error: execute_ts requires PTC mode]";
1386
+ const { executeCodeProgram, ProgramToolDenied } = await import("../llm/codemode.js");
1387
+ const signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal;
1388
+ const cancelled = () => token !== this.turnToken || this.paused || signal?.aborted;
1389
+ const result = await executeCodeProgram(args, {
1390
+ signal,
1391
+ onPlan: plan => {
1392
+ if (cancelled())
1393
+ return;
1394
+ this.plan = plan;
1395
+ if (currentTask())
1396
+ currentTask().runtime.plan = plan;
1397
+ this.save();
1398
+ this.publishLive();
1399
+ this.app.render();
1400
+ },
1401
+ onStep: (index, status) => { if (!cancelled())
1402
+ this.setStep(index, status); },
1403
+ call: async (name, callArgs, programSignal) => {
1404
+ if (cancelled() || programSignal?.aborted)
1405
+ throw new Error("Execution cancelled");
1406
+ const message = { role: "tool", name, arguments: callArgs, result: "…" };
1407
+ this.messages.push(message);
1408
+ this.save();
1409
+ this.publishLive();
1410
+ this.app.render();
1411
+ const stopApproval = () => {
1412
+ if (this.pendingApproval?.args !== callArgs)
1413
+ return;
1414
+ this.settleApproval(false);
1415
+ };
1416
+ programSignal?.addEventListener("abort", stopApproval, { once: true });
1417
+ try {
1418
+ const decision = await this.requestApproval(name, callArgs, {
1419
+ ...approvalContext, context: captureApprovalContext(apiMessages).context,
1420
+ }, programSignal);
1421
+ if (cancelled() || programSignal?.aborted)
1422
+ throw new Error("Execution cancelled");
1423
+ if (!decision.approved) {
1424
+ message.result = `[Not executed: ${decision.reason}]`;
1425
+ throw new ProgramToolDenied(`Approval denied: ${decision.reason}. Do not retry or work around this denial.`);
1426
+ }
1427
+ const value = await this.runTool(name, callArgs, message, token, apiMessages, approvalContext, programSignal);
1428
+ message.result = value;
1429
+ if (cancelled() || programSignal?.aborted)
1430
+ throw new Error("Execution cancelled");
1431
+ return value;
1432
+ }
1433
+ catch (error) {
1434
+ if (message.result === "…")
1435
+ message.result = `[Error: ${error instanceof Error ? error.message : String(error)}]`;
1436
+ throw error;
1437
+ }
1438
+ finally {
1439
+ programSignal?.removeEventListener("abort", stopApproval);
1440
+ if (this.messages.includes(message)) {
1441
+ this.save();
1442
+ this.publishLive();
1443
+ this.app.render();
1444
+ }
1445
+ }
1446
+ },
1447
+ });
1448
+ if (!cancelled()) {
1449
+ this.save();
1450
+ this.publishLive();
1451
+ this.app.render();
1452
+ }
1453
+ return result;
1454
+ }
1196
1455
  /**
1197
1456
  * Run one approved tool call. `delegate` is handled here rather than inside
1198
1457
  * tools.ts: a sub-agent has to share this turn's model, approval gate, working
1199
1458
  * directory and abort signal, and only the session knows about those.
1200
1459
  */
1201
- async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
1460
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
1461
+ const runtime = currentTask()?.runtime;
1462
+ const execute = () => this.dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal);
1463
+ if (!runtime)
1464
+ return execute();
1465
+ const result = await runtime.tool(name, args, execute);
1466
+ return result;
1467
+ }
1468
+ async dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1469
+ if (name === "execute_ts")
1470
+ return this.runProgram(args, token, apiMessages, approvalContext);
1202
1471
  if (name !== DELEGATE_TOOL)
1203
- return executeTool(name, args, this.liveDir);
1204
- return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
1472
+ return executeTool(name, args, this.liveDir, signal);
1473
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext, signal);
1205
1474
  }
1206
1475
  /**
1207
1476
  * Run a sub-agent for one `delegate` call and return its report.
@@ -1211,22 +1480,33 @@ export class ChatScreen extends Screen {
1211
1480
  * part of this transcript. While it runs, its current phase is shown in the
1212
1481
  * status line and in the tool block, so a quiet log still has an explanation.
1213
1482
  */
1214
- async delegate(args, toolMsg, token, apiMessages, approvalContext) {
1483
+ async delegate(args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
1215
1484
  const task = String(args.task ?? "").trim();
1216
1485
  if (!task)
1217
1486
  return "[Error: delegate needs a task: what should the sub-agent do?]";
1218
1487
  const context = typeof args.context === "string" ? args.context.trim() : "";
1219
1488
  const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
1489
+ const maxRounds = subAgentRounds(loadConfig().subagent_max_rounds);
1490
+ const runtime = currentTask()?.runtime;
1491
+ const signal = executionSignal ?? runtime?.signal ?? this.turnAbort?.signal;
1492
+ const stopped = () => token !== this.turnToken || this.paused || signal?.aborted === true;
1220
1493
  const run = await runSubAgentTask({
1494
+ runtime, signal,
1495
+ readOnly: args.read_only === true,
1496
+ writePaths: Array.isArray(args.write_paths) ? args.write_paths.filter((p) => typeof p === "string") : undefined,
1497
+ contextWindow: modelContextWindow(this.modelProvider, this.model, "standard"),
1221
1498
  createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1222
1499
  system: loadSubAgentPrompt(),
1223
- task: buildSubAgentTask({ task, context, cwd: this.liveDir }),
1500
+ task: buildSubAgentTask({ task, context, cwd: this.liveDir, rounds: maxRounds }),
1224
1501
  tools: SUB_AGENT_TOOLS,
1225
- approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
1502
+ maxRounds,
1503
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval, signal),
1226
1504
  execute: (name, callArgs) => name === DELEGATE_TOOL
1227
1505
  ? Promise.resolve("[Error: sub-agents cannot delegate further]")
1228
- : executeTool(name, callArgs, this.liveDir),
1506
+ : executeTool(name, callArgs, this.liveDir, signal),
1229
1507
  onProgress: (label) => {
1508
+ if (stopped())
1509
+ return;
1230
1510
  this.subagent = label;
1231
1511
  // The tool block is what a reader of the transcript sees while the child
1232
1512
  // works; the status line says the same thing at the top of the screen.
@@ -1235,24 +1515,30 @@ export class ChatScreen extends Screen {
1235
1515
  this.publishLive();
1236
1516
  },
1237
1517
  onUsage: (usage) => {
1518
+ if (stopped())
1519
+ return;
1238
1520
  this.tokens.prompt += usage.prompt;
1239
1521
  this.tokens.completion += usage.completion;
1240
1522
  },
1241
1523
  onRetry: (notice) => {
1524
+ if (stopped())
1525
+ return;
1242
1526
  this.retryNotice =
1243
1527
  `Sub-agent — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
1244
1528
  `(attempt ${notice.attempt}/${notice.attempts})`;
1245
1529
  this.app.render();
1246
1530
  },
1247
- cancelled: () => token !== this.turnToken || this.paused,
1531
+ cancelled: () => token !== this.turnToken || this.paused || signal?.aborted === true,
1248
1532
  });
1249
1533
  // The final report replaces the live marker, and the status line goes back
1250
1534
  // to the parent's own state.
1251
- this.subagent = null;
1252
- this.retryNotice = null;
1253
- toolMsg.result = "";
1254
- this.app.render();
1255
- this.publishLive();
1535
+ if (!stopped()) {
1536
+ this.subagent = null;
1537
+ this.retryNotice = null;
1538
+ toolMsg.result = "";
1539
+ this.app.render();
1540
+ this.publishLive();
1541
+ }
1256
1542
  return formatSubAgentResult(run);
1257
1543
  }
1258
1544
  // ---- turn folding ----------------------------------------------------
@@ -1276,7 +1562,7 @@ export class ChatScreen extends Screen {
1276
1562
  /**
1277
1563
  * A finished turn is only worth folding when the work it hides is substantial.
1278
1564
  * Whether it is folded right now is a separate question (`expandedTurns`), and
1279
- * its reply is never part of what it hides.
1565
+ * its user input and reply are never part of what it hides.
1280
1566
  */
1281
1567
  turnHides(start, end) {
1282
1568
  if (!this.finishedTurns.has(start))
@@ -1285,12 +1571,12 @@ export class ChatScreen extends Screen {
1285
1571
  let blocks = 0;
1286
1572
  let chars = 0;
1287
1573
  for (let i = start; i < end; i++) {
1288
- // The reply stays on screen, so it is neither hidden content nor a reason
1289
- // to fold: a long answer alone must never collapse its own turn.
1574
+ // User input and the reply stay on screen, so neither counts toward the
1575
+ // folding threshold, however long either one is.
1290
1576
  if (i === answer)
1291
1577
  continue;
1292
1578
  const m = this.messages[i];
1293
- if (m.role === "user" || m.role === "assistant") {
1579
+ if (m.role === "assistant") {
1294
1580
  blocks++;
1295
1581
  chars += m.content.length;
1296
1582
  }
@@ -1345,23 +1631,6 @@ export class ChatScreen extends Screen {
1345
1631
  }
1346
1632
  return map;
1347
1633
  }
1348
- /**
1349
- * The first line of the first message in `role`. Only one line: the header is
1350
- * a signpost, and the full text is one click away.
1351
- */
1352
- turnPreview(start, end, role) {
1353
- for (const m of this.messages.slice(start, end)) {
1354
- if (m.role !== role)
1355
- continue;
1356
- const line = sanitizeText(m.content)
1357
- .split("\n")
1358
- .map((part) => part.trim())
1359
- .find((part) => part.length > 0);
1360
- if (line)
1361
- return line;
1362
- }
1363
- return "";
1364
- }
1365
1634
  /** The header standing in for a folded turn, or the way back into one. */
1366
1635
  pushTurnHeader(start, end, width, add) {
1367
1636
  const count = end - start;
@@ -1378,13 +1647,10 @@ export class ChatScreen extends Screen {
1378
1647
  }, -1, start);
1379
1648
  return;
1380
1649
  }
1381
- const you = `You: ${this.turnPreview(start, end, "user") || "(no text)"}`;
1382
- add({ spans: [{ text: ellipsize(`▸ ${you}`, width), style: { fg: C.amber, bold: true } }] }, -1, start);
1383
- // The prompt and the reply sit either side of the work this fold hides, so
1384
- // the control line goes between them and the answer stays on screen below.
1385
- const hidden = this.turnAnswer(start, end) < 0 ? count : count - 1;
1650
+ const answer = this.turnAnswer(start, end);
1651
+ const hidden = this.messages.slice(start, end).filter((message, offset) => message.role !== "user" && message.role !== "system" && start + offset !== answer).length;
1386
1652
  const meta = `${hidden} of ${count} message${count === 1 ? "" : "s"} folded`;
1387
- add({ spans: [{ text: ellipsize(` ${meta} — click to expand`, width), style: { fg: C.dim } }] }, -1, start);
1653
+ add({ spans: [{ text: ellipsize(`▸ ${meta} — click to expand`, width), style: { fg: C.dim } }] }, -1, start);
1388
1654
  }
1389
1655
  /** Fold or unfold one turn. */
1390
1656
  toggleTurn(start) {
@@ -1525,7 +1791,7 @@ export class ChatScreen extends Screen {
1525
1791
  if (chatMessages.length === 0)
1526
1792
  return true;
1527
1793
  try {
1528
- this.sessionId = saveSession(chatMessages, this.model.name, this.modelProvider, this.intensity, this.liveDir, this.sessionId ?? undefined, this.tokens, this.context, this.approvalMode, this.plan);
1794
+ 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);
1529
1795
  this.saveError = null;
1530
1796
  return true;
1531
1797
  }
@@ -1577,26 +1843,24 @@ export class ChatScreen extends Screen {
1577
1843
  archived = context !== null && recordIndex < context.end;
1578
1844
  recordIndex++;
1579
1845
  }
1580
- // A foldable turn always shows its control lines first; its work then
1581
- // renders when the reader has opened it, and its reply always does.
1582
- if (m.role !== "system" && turn === mi && !(archived && !this.expandedContext)) {
1583
- this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1584
- }
1585
1846
  const folded = turn >= 0 && !this.expandedTurns.has(turn);
1586
1847
  const reply = folded && answers[mi] === mi;
1587
1848
  if (m.role === "system") {
1588
1849
  pushText(sanitizeText(m.content), { fg: C.dim });
1589
1850
  }
1590
- else if ((archived && !this.expandedContext) || (folded && !reply)) {
1591
- // Hidden: by the collapsed "Earlier conversation" fold, or by the turn's
1592
- // own fold, which hides the prompt body and the work but never the
1593
- // reply. Either way the message stays in the transcript, on disk and in
1594
- // the request; the header above says how to get it back on screen.
1595
- }
1596
1851
  else if (m.role === "user") {
1597
1852
  add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
1598
1853
  pushMarkdown(sanitizeText(m.content), { fg: C.user });
1599
1854
  add({ spans: [] });
1855
+ // The fold control follows the complete prompt, which stays visible
1856
+ // even when the turn or the earlier conversation is collapsed.
1857
+ if (turn === mi && !(archived && !this.expandedContext)) {
1858
+ this.pushTurnHeader(mi, this.turnEnd(mi), width, add);
1859
+ }
1860
+ }
1861
+ else if ((archived && !this.expandedContext) || (folded && !reply)) {
1862
+ // Only AI output is hidden. Every message remains in the transcript,
1863
+ // on disk and in the request; the controls reveal it again.
1600
1864
  }
1601
1865
  else if (m.role === "assistant") {
1602
1866
  add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
@@ -1621,7 +1885,7 @@ export class ChatScreen extends Screen {
1621
1885
  add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
1622
1886
  // The line under the tool name is the call's own material: a command, a
1623
1887
  // path, or — for `delegate` — the task handed to the sub-agent.
1624
- const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? m.arguments.task ?? "", { singleLine: true });
1888
+ const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? m.arguments.query ?? m.arguments.task ?? "", { singleLine: true });
1625
1889
  if (argText)
1626
1890
  add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
1627
1891
  const decision = this.approvals.get(m);
@@ -1650,7 +1914,7 @@ export class ChatScreen extends Screen {
1650
1914
  }
1651
1915
  add({ spans: [] });
1652
1916
  }
1653
- if (context && archived && recordIndex === context.end) {
1917
+ if (context && archived && recordIndex === context.end && this.expandedContext) {
1654
1918
  this.contextLines.add(lines.length);
1655
1919
  add({ spans: [{ text: "▾ Collapse earlier conversation", style: { fg: C.amber } }] });
1656
1920
  add({ spans: [] });
@@ -1674,6 +1938,10 @@ export class ChatScreen extends Screen {
1674
1938
  status = `History save failed: ${sanitizeText(this.saveError, { singleLine: true })}`;
1675
1939
  statusStyle = { fg: C.orange, bold: true };
1676
1940
  }
1941
+ else if (this.taskRuntime?.snapshot().state === "cancelling") {
1942
+ status = "Stopping tasks…";
1943
+ statusStyle = { fg: C.amber, bold: true };
1944
+ }
1677
1945
  else if (this.compressing) {
1678
1946
  status = "Compressing context…";
1679
1947
  statusStyle = { fg: C.amber, bold: true };