@xingjisoft/neutrino 0.5.2 → 0.6.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.
@@ -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, currentTask, linkSignals, taskLimits, 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";
@@ -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,58 @@ 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
+ journalDir: path.join(sessionDir("standard"), "tasks", path.basename(this.sessionId || "unsaved")),
797
+ });
798
+ this.taskRuntime = runtime;
799
+ this.taskOutcome = "completed";
800
+ let failed = false;
801
+ try {
802
+ await runtime.run(() => this.runTaskTurn(token, turnMessages, approvalContext));
803
+ }
804
+ catch (error) {
805
+ failed = true;
806
+ throw error;
807
+ }
808
+ finally {
809
+ await runtime.drain();
810
+ if (runtime.signal.aborted)
811
+ settlePlan(runtime.plan ?? null, true);
812
+ runtime.finish(runtime.signal.aborted ? "cancelled" : failed ? "failed" :
813
+ runtime.plan?.steps.some(step => step.status === "failed") ? "failed" :
814
+ runtime.plan?.steps.some(step => !["done", "skipped"].includes(step.status)) ? "partial" : this.taskOutcome);
815
+ if (this.taskRuntime === runtime) {
816
+ if (runtime.signal.aborted && token === this.turnToken && this.busy && !this.paused) {
817
+ if (this.taskOutcome !== "cancelled")
818
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(runtime.signal.reason)}]\n${runtime.partialReport()}` });
819
+ this.streamingContent = null;
820
+ this.onTurnFinished(false);
821
+ }
822
+ if (this.taskRuntime === runtime) {
823
+ this.lastTask = runtime.snapshot();
824
+ this.save();
825
+ this.publishLive();
826
+ }
827
+ }
828
+ }
829
+ }
830
+ async runTaskTurn(token, turnMessages, approvalContext) {
757
831
  // Shrink the history before building the request when it has outgrown the
758
832
  // configured budget.
759
833
  await this.autoCompress(token);
760
834
  if (token !== this.turnToken)
761
835
  return;
762
- const plan = await this.makePlan(token);
836
+ if (this.activeAgentMode !== "standard")
837
+ this.plan = null;
838
+ const plan = this.activeAgentMode === "standard" ? await this.makePlan(token) : null;
763
839
  if (token !== this.turnToken)
764
840
  return;
765
841
  if (plan) {
766
842
  this.plan = plan;
843
+ if (currentTask())
844
+ currentTask().runtime.plan = plan;
767
845
  // The plan is an ordinary assistant message: it is saved, folded, replayed
768
846
  // and sent back to the model exactly like any other answer, so later turns
769
847
  // (and the user) still know what was agreed.
@@ -783,7 +861,7 @@ export class ChatScreen extends Screen {
783
861
  const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
784
862
  if (token !== this.turnToken)
785
863
  return;
786
- this.setStep(i, finished ? "done" : "failed");
864
+ this.setStep(i, currentTask()?.signal.aborted ? "cancelled" : finished ? "done" : "failed");
787
865
  // A step that failed stops the plan: the remaining steps assumed this one
788
866
  // succeeded, and the user has to see the failure before anything runs on
789
867
  // top of it.
@@ -833,6 +911,19 @@ export class ChatScreen extends Screen {
833
911
  this.startTurn();
834
912
  }
835
913
  startTurn() {
914
+ if (this.taskRuntime?.settling) {
915
+ const previous = this.taskRuntime;
916
+ this.queued = lastRequestText(this.messages);
917
+ this.paused = false;
918
+ void previous.drain().then(() => {
919
+ if (this.taskRuntime === previous && this.queued && !this.paused) {
920
+ this.queued = null;
921
+ this.startTurn();
922
+ }
923
+ });
924
+ this.publishLive();
925
+ return;
926
+ }
836
927
  // Sending closes every earlier turn, including one left unfinished by
837
928
  // ESC ESC: it is folded from here on, but never while it is still paused.
838
929
  const starts = this.userMessageIndexes();
@@ -846,6 +937,9 @@ export class ChatScreen extends Screen {
846
937
  this.busy = true;
847
938
  this.paused = false;
848
939
  this.app.state.isThinking = true;
940
+ this.activeAgentMode = this.agentMode;
941
+ this.turnAbort?.abort();
942
+ this.turnAbort = new AbortController();
849
943
  const token = ++this.turnToken;
850
944
  this.streamingContent = "";
851
945
  this.app.render();
@@ -890,7 +984,9 @@ export class ChatScreen extends Screen {
890
984
  buildApiMessages(messages = this.messages, directive = "") {
891
985
  // A step directive extends the system prompt rather than adding a user
892
986
  // message: the plan is an instruction, not something the user just said.
893
- const system = directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt();
987
+ const prompt = loadAgentPrompt(this.activeAgentMode);
988
+ const system = (directive ? `${prompt}\n\n${directive}` : prompt) +
989
+ (currentTask() ? `\n\n${currentTask().runtime.instructions()}` : "");
894
990
  const api = [{ role: "system", content: system }];
895
991
  for (const m of modelHistory(messages, this.context)) {
896
992
  if (m.role === "user" || m.role === "assistant") {
@@ -912,6 +1008,9 @@ export class ChatScreen extends Screen {
912
1008
  * because a planned turn runs this once per step.
913
1009
  */
914
1010
  async agentLoop(apiMessages, token, approvalContext) {
1011
+ const tools = this.activeAgentMode === "ptc"
1012
+ ? [...TOOLS, (await import("../llm/codemode.js")).getCodeToolDefinition()] : TOOLS;
1013
+ let emptyReplies = 0;
915
1014
  try {
916
1015
  while (true) {
917
1016
  if (token !== this.turnToken)
@@ -919,11 +1018,19 @@ export class ChatScreen extends Screen {
919
1018
  // Keep the payload inside budget during a long single turn: once the
920
1019
  // context is too large, drop the body of older tool results.
921
1020
  apiMessages = this.trimApiMessages(apiMessages);
1021
+ if (currentTask())
1022
+ await prepareContext({
1023
+ messages: apiMessages, tools, capacity: modelContextWindow(this.modelProvider, this.model, "standard"),
1024
+ createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1025
+ stream: { signal: currentTask().runtime.signal, cancelled: () => token !== this.turnToken || this.paused },
1026
+ onUsage: usage => { this.tokens.prompt += usage.prompt; this.tokens.completion += usage.completion; },
1027
+ });
922
1028
  const client = new LLMClient(this.model, this.modelProvider, this.intensity);
923
1029
  let content = "";
924
1030
  let hasContent = false;
925
1031
  let aborted = false;
926
- for await (const chunk of client.streamTurn(apiMessages, TOOLS, {
1032
+ for await (const chunk of client.streamTurn(apiMessages, tools, {
1033
+ maxOutputTokens: outputAllowance(modelContextWindow(this.modelProvider, this.model)),
927
1034
  // A retried request can go quiet for up to 160s, so the status line
928
1035
  // says why instead of looking hung. The moment the user pauses or a
929
1036
  // newer turn takes over, the backoff is dropped instead of waited out.
@@ -968,8 +1075,42 @@ export class ChatScreen extends Screen {
968
1075
  this.app.render();
969
1076
  return false;
970
1077
  }
1078
+ if (currentTask()?.signal.aborted || client.outcome?.status === "cancelled") {
1079
+ this.taskOutcome = "cancelled";
1080
+ this.streamingContent = null;
1081
+ this.messages.push({ role: "assistant", content: `[Task stopped: ${String(currentTask()?.signal.reason ?? "Task cancelled")}]\n${currentTask()?.runtime.partialReport() ?? ""}` });
1082
+ return false;
1083
+ }
1084
+ 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; });
1086
+ if (token !== this.turnToken || this.paused)
1087
+ return false;
1088
+ this.taskOutcome = "partial";
1089
+ this.streamingContent = null;
1090
+ this.messages.push({ role: "assistant", content: report });
1091
+ return false;
1092
+ }
1093
+ if (client.outcome?.status === "failed") {
1094
+ const recorded = currentTask()?.runtime.evidence.length ? currentTask().runtime.partialReport() : "";
1095
+ const message = client.outcome.error?.message ?? "Model request failed";
1096
+ this.taskOutcome = recorded ? "partial" : "failed";
1097
+ this.streamingContent = null;
1098
+ this.messages.push({ role: "assistant", content: content +
1099
+ (content.includes(message) ? "" : `\n[Error: ${message}]`) + (recorded ? `\n\n${recorded}` : "") });
1100
+ return false;
1101
+ }
971
1102
  const toolCalls = client.toolCalls;
972
1103
  if (toolCalls.length === 0) {
1104
+ if (!content.trim() && emptyReplies++ === 0)
1105
+ continue;
1106
+ if (!content.trim()) {
1107
+ this.taskOutcome = "failed";
1108
+ if (!content.trim())
1109
+ this.messages.push({ role: "assistant", content: "[Error: model returned an empty completion.]" });
1110
+ else
1111
+ this.messages.push({ role: "assistant", content });
1112
+ return false;
1113
+ }
973
1114
  if (hasContent)
974
1115
  this.messages.push({ role: "assistant", content });
975
1116
  this.streamingContent = null;
@@ -1044,6 +1185,7 @@ export class ChatScreen extends Screen {
1044
1185
  catch (e) {
1045
1186
  if (token !== this.turnToken)
1046
1187
  return false;
1188
+ this.taskOutcome = "failed";
1047
1189
  const err = describeError(e);
1048
1190
  this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
1049
1191
  this.streamingContent = null;
@@ -1075,18 +1217,34 @@ export class ChatScreen extends Screen {
1075
1217
  }
1076
1218
  // ---- approval gate ---------------------------------------------------
1077
1219
  /** Classify a tool call and, when it is risky, run it past the gate. */
1078
- async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
1220
+ async requestApproval(tool, args, context = captureApprovalContext(this.messages), signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1221
+ const linked = linkSignals(signal, currentTask()?.signal);
1222
+ signal = linked.signal;
1223
+ try {
1224
+ currentTask()?.runtime.assertTool(tool, args);
1225
+ }
1226
+ catch (error) {
1227
+ linked.dispose();
1228
+ return { approved: false, source: "input", reason: error instanceof Error ? error.message : String(error) };
1229
+ }
1079
1230
  const risk = assessToolCall(tool, args, this.liveDir);
1080
- if (!risk)
1231
+ if (!risk) {
1232
+ linked.dispose();
1081
1233
  return { approved: true, source: "prompt", reason: "routine call" };
1234
+ }
1235
+ const approvalToken = this.turnToken;
1236
+ const cancelled = () => approvalToken !== this.turnToken || this.paused || signal?.aborted === true;
1082
1237
  const gate = new ApprovalGate({
1083
- askHuman: () => this.askHuman(tool, args, risk),
1238
+ cancelled,
1239
+ askHuman: () => cancelled() ? Promise.resolve(false) : this.askHuman(tool, args, risk),
1084
1240
  reviewer: () => this.reviewerModel(),
1085
1241
  autoApprovalEnabled: () => this.approvalMode === "auto",
1086
1242
  extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
1087
1243
  // Reviews happen before any card is shown, so without this the app looks
1088
1244
  // frozen while the independent assessments run.
1089
1245
  onReview: (info) => {
1246
+ if (cancelled())
1247
+ return;
1090
1248
  this.reviewing = info ? {
1091
1249
  total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
1092
1250
  request: liveApprovalDetails(tool, args, risk, this.liveDir),
@@ -1103,7 +1261,18 @@ export class ChatScreen extends Screen {
1103
1261
  this.app.showToast(`independent review running (${which}${info.total} reviewer${info.total > 1 ? "s" : ""})...`);
1104
1262
  },
1105
1263
  });
1106
- return gate.decide({ tool, args, cwd: this.liveDir, ...context, risk });
1264
+ const abortApproval = () => {
1265
+ if (this.pendingApproval?.args === args)
1266
+ this.settleApproval(false);
1267
+ };
1268
+ signal.addEventListener("abort", abortApproval, { once: true });
1269
+ try {
1270
+ return await gate.decide({ tool, args, cwd: this.liveDir, ...context, risk });
1271
+ }
1272
+ finally {
1273
+ signal.removeEventListener("abort", abortApproval);
1274
+ linked.dispose();
1275
+ }
1107
1276
  }
1108
1277
  /** Park the turn until the user answers; the header turns orange meanwhile. */
1109
1278
  askHuman(tool, args, risk) {
@@ -1193,15 +1362,97 @@ export class ChatScreen extends Screen {
1193
1362
  };
1194
1363
  }
1195
1364
  // ---- sub-agents ------------------------------------------------------
1365
+ async runProgram(args, token, apiMessages, approvalContext) {
1366
+ if (this.activeAgentMode !== "ptc")
1367
+ return "[Error: execute_ts requires PTC mode]";
1368
+ const { executeCodeProgram, ProgramToolDenied } = await import("../llm/codemode.js");
1369
+ const signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal;
1370
+ const cancelled = () => token !== this.turnToken || this.paused || signal?.aborted;
1371
+ const result = await executeCodeProgram(args, {
1372
+ signal,
1373
+ onPlan: plan => {
1374
+ if (cancelled())
1375
+ return;
1376
+ this.plan = plan;
1377
+ if (currentTask())
1378
+ currentTask().runtime.plan = plan;
1379
+ this.save();
1380
+ this.publishLive();
1381
+ this.app.render();
1382
+ },
1383
+ onStep: (index, status) => { if (!cancelled())
1384
+ this.setStep(index, status); },
1385
+ call: async (name, callArgs, programSignal) => {
1386
+ if (cancelled() || programSignal?.aborted)
1387
+ throw new Error("Execution cancelled");
1388
+ const message = { role: "tool", name, arguments: callArgs, result: "…" };
1389
+ this.messages.push(message);
1390
+ this.save();
1391
+ this.publishLive();
1392
+ this.app.render();
1393
+ const stopApproval = () => {
1394
+ if (this.pendingApproval?.args !== callArgs)
1395
+ return;
1396
+ this.settleApproval(false);
1397
+ };
1398
+ programSignal?.addEventListener("abort", stopApproval, { once: true });
1399
+ try {
1400
+ const decision = await this.requestApproval(name, callArgs, {
1401
+ ...approvalContext, context: captureApprovalContext(apiMessages).context,
1402
+ }, programSignal);
1403
+ if (cancelled() || programSignal?.aborted)
1404
+ throw new Error("Execution cancelled");
1405
+ if (!decision.approved) {
1406
+ message.result = `[Not executed: ${decision.reason}]`;
1407
+ throw new ProgramToolDenied(`Approval denied: ${decision.reason}. Do not retry or work around this denial.`);
1408
+ }
1409
+ const value = await this.runTool(name, callArgs, message, token, apiMessages, approvalContext, programSignal);
1410
+ message.result = value;
1411
+ if (cancelled() || programSignal?.aborted)
1412
+ throw new Error("Execution cancelled");
1413
+ return value;
1414
+ }
1415
+ catch (error) {
1416
+ if (message.result === "…")
1417
+ message.result = `[Error: ${error instanceof Error ? error.message : String(error)}]`;
1418
+ throw error;
1419
+ }
1420
+ finally {
1421
+ programSignal?.removeEventListener("abort", stopApproval);
1422
+ if (this.messages.includes(message)) {
1423
+ this.save();
1424
+ this.publishLive();
1425
+ this.app.render();
1426
+ }
1427
+ }
1428
+ },
1429
+ });
1430
+ if (!cancelled()) {
1431
+ this.save();
1432
+ this.publishLive();
1433
+ this.app.render();
1434
+ }
1435
+ return result;
1436
+ }
1196
1437
  /**
1197
1438
  * Run one approved tool call. `delegate` is handled here rather than inside
1198
1439
  * tools.ts: a sub-agent has to share this turn's model, approval gate, working
1199
1440
  * directory and abort signal, and only the session knows about those.
1200
1441
  */
1201
- async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
1442
+ async runTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
1443
+ const runtime = currentTask()?.runtime;
1444
+ const execute = () => this.dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, executionSignal);
1445
+ if (!runtime)
1446
+ return execute();
1447
+ const result = await runtime.tool(name, args, execute);
1448
+ return result;
1449
+ }
1450
+ async dispatchTool(name, args, toolMsg, token, apiMessages, approvalContext, signal = currentTask()?.runtime.signal ?? this.turnAbort?.signal) {
1451
+ if (name === "execute_ts")
1452
+ return this.runProgram(args, token, apiMessages, approvalContext);
1202
1453
  if (name !== DELEGATE_TOOL)
1203
- return executeTool(name, args, this.liveDir);
1204
- return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
1454
+ return executeTool(name, args, this.liveDir, signal);
1455
+ return this.delegate(args, toolMsg, token, apiMessages, approvalContext, signal);
1205
1456
  }
1206
1457
  /**
1207
1458
  * Run a sub-agent for one `delegate` call and return its report.
@@ -1211,22 +1462,33 @@ export class ChatScreen extends Screen {
1211
1462
  * part of this transcript. While it runs, its current phase is shown in the
1212
1463
  * status line and in the tool block, so a quiet log still has an explanation.
1213
1464
  */
1214
- async delegate(args, toolMsg, token, apiMessages, approvalContext) {
1465
+ async delegate(args, toolMsg, token, apiMessages, approvalContext, executionSignal) {
1215
1466
  const task = String(args.task ?? "").trim();
1216
1467
  if (!task)
1217
1468
  return "[Error: delegate needs a task: what should the sub-agent do?]";
1218
1469
  const context = typeof args.context === "string" ? args.context.trim() : "";
1219
1470
  const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
1471
+ const maxRounds = subAgentRounds(loadConfig().subagent_max_rounds);
1472
+ const runtime = currentTask()?.runtime;
1473
+ const signal = executionSignal ?? runtime?.signal ?? this.turnAbort?.signal;
1474
+ const stopped = () => token !== this.turnToken || this.paused || signal?.aborted === true;
1220
1475
  const run = await runSubAgentTask({
1476
+ runtime, signal,
1477
+ readOnly: args.read_only === true,
1478
+ writePaths: Array.isArray(args.write_paths) ? args.write_paths.filter((p) => typeof p === "string") : undefined,
1479
+ contextWindow: modelContextWindow(this.modelProvider, this.model, "standard"),
1221
1480
  createClient: () => new LLMClient(this.model, this.modelProvider, this.intensity),
1222
1481
  system: loadSubAgentPrompt(),
1223
- task: buildSubAgentTask({ task, context, cwd: this.liveDir }),
1482
+ task: buildSubAgentTask({ task, context, cwd: this.liveDir, rounds: maxRounds }),
1224
1483
  tools: SUB_AGENT_TOOLS,
1225
- approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
1484
+ maxRounds,
1485
+ approve: (name, callArgs) => this.requestApproval(name, callArgs, approval, signal),
1226
1486
  execute: (name, callArgs) => name === DELEGATE_TOOL
1227
1487
  ? Promise.resolve("[Error: sub-agents cannot delegate further]")
1228
- : executeTool(name, callArgs, this.liveDir),
1488
+ : executeTool(name, callArgs, this.liveDir, signal),
1229
1489
  onProgress: (label) => {
1490
+ if (stopped())
1491
+ return;
1230
1492
  this.subagent = label;
1231
1493
  // The tool block is what a reader of the transcript sees while the child
1232
1494
  // works; the status line says the same thing at the top of the screen.
@@ -1235,24 +1497,30 @@ export class ChatScreen extends Screen {
1235
1497
  this.publishLive();
1236
1498
  },
1237
1499
  onUsage: (usage) => {
1500
+ if (stopped())
1501
+ return;
1238
1502
  this.tokens.prompt += usage.prompt;
1239
1503
  this.tokens.completion += usage.completion;
1240
1504
  },
1241
1505
  onRetry: (notice) => {
1506
+ if (stopped())
1507
+ return;
1242
1508
  this.retryNotice =
1243
1509
  `Sub-agent — network hiccup, retrying in ${formatDelay(notice.delayMs)} ` +
1244
1510
  `(attempt ${notice.attempt}/${notice.attempts})`;
1245
1511
  this.app.render();
1246
1512
  },
1247
- cancelled: () => token !== this.turnToken || this.paused,
1513
+ cancelled: () => token !== this.turnToken || this.paused || signal?.aborted === true,
1248
1514
  });
1249
1515
  // The final report replaces the live marker, and the status line goes back
1250
1516
  // to the parent's own state.
1251
- this.subagent = null;
1252
- this.retryNotice = null;
1253
- toolMsg.result = "";
1254
- this.app.render();
1255
- this.publishLive();
1517
+ if (!stopped()) {
1518
+ this.subagent = null;
1519
+ this.retryNotice = null;
1520
+ toolMsg.result = "";
1521
+ this.app.render();
1522
+ this.publishLive();
1523
+ }
1256
1524
  return formatSubAgentResult(run);
1257
1525
  }
1258
1526
  // ---- turn folding ----------------------------------------------------
@@ -1525,7 +1793,7 @@ export class ChatScreen extends Screen {
1525
1793
  if (chatMessages.length === 0)
1526
1794
  return true;
1527
1795
  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);
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);
1529
1797
  this.saveError = null;
1530
1798
  return true;
1531
1799
  }
@@ -1621,7 +1889,7 @@ export class ChatScreen extends Screen {
1621
1889
  add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
1622
1890
  // The line under the tool name is the call's own material: a command, a
1623
1891
  // 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 });
1892
+ const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? m.arguments.query ?? m.arguments.task ?? "", { singleLine: true });
1625
1893
  if (argText)
1626
1894
  add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
1627
1895
  const decision = this.approvals.get(m);
@@ -1674,6 +1942,10 @@ export class ChatScreen extends Screen {
1674
1942
  status = `History save failed: ${sanitizeText(this.saveError, { singleLine: true })}`;
1675
1943
  statusStyle = { fg: C.orange, bold: true };
1676
1944
  }
1945
+ else if (this.taskRuntime?.snapshot().state === "cancelling") {
1946
+ status = "Stopping tasks…";
1947
+ statusStyle = { fg: C.amber, bold: true };
1948
+ }
1677
1949
  else if (this.compressing) {
1678
1950
  status = "Compressing context…";
1679
1951
  statusStyle = { fg: C.amber, bold: true };
package/dist/web/hub.js CHANGED
@@ -189,6 +189,15 @@ class LiveHub {
189
189
  this.notify(key);
190
190
  return result;
191
191
  }
192
+ setAgentMode(key, mode) {
193
+ const entry = this.entries.get(key);
194
+ if (!entry?.agent.liveSetAgentMode)
195
+ return { ok: false, error: "this session cannot change agent mode" };
196
+ const result = entry.agent.liveSetAgentMode(mode);
197
+ if (result.ok)
198
+ this.notify(key);
199
+ return result;
200
+ }
192
201
  setApprovalMode(key, mode) {
193
202
  const entry = this.entries.get(key);
194
203
  if (!entry?.agent.liveSetApprovalMode)