@pentoshi/clai 2.0.26 → 2.0.28

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 (55) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/context-manager.d.ts +15 -2
  3. package/dist/agent/context-manager.js +41 -70
  4. package/dist/agent/context-manager.js.map +1 -1
  5. package/dist/agent/events.d.ts +1 -1
  6. package/dist/agent/runner.d.ts +38 -1
  7. package/dist/agent/runner.js +719 -135
  8. package/dist/agent/runner.js.map +1 -1
  9. package/dist/agent/session-title.d.ts +27 -0
  10. package/dist/agent/session-title.js +109 -0
  11. package/dist/agent/session-title.js.map +1 -0
  12. package/dist/commands/update.js +1 -1
  13. package/dist/index.js +5 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/llm/http.js +20 -6
  16. package/dist/llm/http.js.map +1 -1
  17. package/dist/modes/agent.d.ts +1 -0
  18. package/dist/modes/agent.js.map +1 -1
  19. package/dist/modes/ask.d.ts +29 -0
  20. package/dist/modes/ask.js +188 -23
  21. package/dist/modes/ask.js.map +1 -1
  22. package/dist/prompts/index.d.ts +2 -2
  23. package/dist/prompts/index.js +32 -9
  24. package/dist/prompts/index.js.map +1 -1
  25. package/dist/repl.js +104 -3
  26. package/dist/repl.js.map +1 -1
  27. package/dist/safety/classifier.js +66 -7
  28. package/dist/safety/classifier.js.map +1 -1
  29. package/dist/store/history.js +68 -48
  30. package/dist/store/history.js.map +1 -1
  31. package/dist/tools/fs.d.ts +4 -0
  32. package/dist/tools/fs.js +40 -2
  33. package/dist/tools/fs.js.map +1 -1
  34. package/dist/tools/registry.d.ts +8 -0
  35. package/dist/tools/registry.js +3 -1
  36. package/dist/tools/registry.js.map +1 -1
  37. package/dist/tui/App.js +242 -33
  38. package/dist/tui/App.js.map +1 -1
  39. package/dist/tui/components/ConfirmModal.js +14 -2
  40. package/dist/tui/components/ConfirmModal.js.map +1 -1
  41. package/dist/tui/components/PickerPanel.d.ts +2 -1
  42. package/dist/tui/components/PickerPanel.js +73 -23
  43. package/dist/tui/components/PickerPanel.js.map +1 -1
  44. package/dist/tui/confirm.d.ts +1 -1
  45. package/dist/tui/confirm.js +8 -0
  46. package/dist/tui/confirm.js.map +1 -1
  47. package/dist/tui/hooks/useAgentRunner.d.ts +27 -2
  48. package/dist/tui/hooks/useAgentRunner.js +149 -18
  49. package/dist/tui/hooks/useAgentRunner.js.map +1 -1
  50. package/dist/tui/render-lines.js +28 -1
  51. package/dist/tui/render-lines.js.map +1 -1
  52. package/dist/tui/state.d.ts +1 -1
  53. package/dist/ui/markdown.js +63 -30
  54. package/dist/ui/markdown.js.map +1 -1
  55. package/package.json +3 -2
@@ -4,17 +4,17 @@ import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { fixOwner, handlePermissionError } from "../os/permissions.js";
6
6
  import { join, resolve } from "node:path";
7
- import { streamWithProvider } from "../llm/router.js";
7
+ import { streamWithProvider, completeWithProvider } from "../llm/router.js";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { jobManager } from "../tools/jobs.js";
10
10
  import { currentDateTimeContext, renderAgentSystemPrompt, } from "../prompts/index.js";
11
11
  import { getConfig } from "../store/config.js";
12
12
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
13
- import { availableToolNames, normalizeToolCall, runToolCall } from "../tools/registry.js";
13
+ import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
14
14
  import { looksInteractiveStdin } from "../tools/shell.js";
15
15
  import { reduceToolOutput } from "../tools/policies/output-policy.js";
16
16
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
17
- import { compactMessages, estimateMessagesTokens } from "./context-manager.js";
17
+ import { compactMessagesWithSummary, estimateMessagesTokens, isCompactionMemoryMessage, } from "./context-manager.js";
18
18
  import { auditLog } from "../store/logs.js";
19
19
  import { loadProjectContext } from "../store/project.js";
20
20
  import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
@@ -36,11 +36,12 @@ function renderPlanForTerminal(plan) {
36
36
  : undefined;
37
37
  return side ?? renderPlanChecklist(plan);
38
38
  }
39
- export function createSessionPolicy() {
39
+ export function createSessionPolicy(sessionId) {
40
40
  return {
41
41
  allow: new Set(),
42
42
  pentestAuthorized: { value: false },
43
- sessionId: `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
43
+ sessionId: sessionId ??
44
+ `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
44
45
  planApproved: { value: false },
45
46
  };
46
47
  }
@@ -55,21 +56,21 @@ export function preprocessJson(raw) {
55
56
  result += char;
56
57
  }
57
58
  else if (inString) {
58
- if (char === '\n') {
59
- result += '\\n';
59
+ if (char === "\n") {
60
+ result += "\\n";
60
61
  }
61
- else if (char === '\r') {
62
- result += '\\r';
62
+ else if (char === "\r") {
63
+ result += "\\r";
63
64
  }
64
- else if (char === '\t') {
65
- result += '\\t';
65
+ else if (char === "\t") {
66
+ result += "\\t";
66
67
  }
67
68
  else {
68
69
  result += char;
69
70
  }
70
71
  }
71
72
  else {
72
- if (char === ',' && i + 1 < raw.length) {
73
+ if (char === "," && i + 1 < raw.length) {
73
74
  let nextNonWs = "";
74
75
  for (let j = i + 1; j < raw.length; j++) {
75
76
  if (!/\s/.test(raw[j])) {
@@ -77,13 +78,13 @@ export function preprocessJson(raw) {
77
78
  break;
78
79
  }
79
80
  }
80
- if (nextNonWs === '}' || nextNonWs === ']') {
81
+ if (nextNonWs === "}" || nextNonWs === "]") {
81
82
  continue;
82
83
  }
83
84
  }
84
85
  result += char;
85
86
  }
86
- if (char === '\\' && inString) {
87
+ if (char === "\\" && inString) {
87
88
  escaped = !escaped;
88
89
  }
89
90
  else {
@@ -92,24 +93,154 @@ export function preprocessJson(raw) {
92
93
  }
93
94
  return result;
94
95
  }
96
+ /**
97
+ * Last-resort lenient repair for tool-call JSON that strict JSON.parse
98
+ * rejected. Models frequently emit "almost JSON": smart/curly quotes from a
99
+ * copy-paste, Python-style True/False/None literals, or an object that is
100
+ * wholly single-quoted. We only apply these transforms when a strict parse
101
+ * has already failed, so well-formed JSON is never touched.
102
+ */
103
+ function lenientJsonParse(text) {
104
+ const candidates = [];
105
+ // 1. Normalize unicode/smart quotes to ASCII quotes.
106
+ const deSmart = text
107
+ .replace(/[\u201C\u201D\u201E\u2033]/g, '"')
108
+ .replace(/[\u2018\u2019\u201A\u2032]/g, "'");
109
+ candidates.push(deSmart);
110
+ // 2. Python/JS literals → JSON literals (outside of double-quoted strings).
111
+ candidates.push(replaceOutsideStrings(deSmart));
112
+ // 3. Single-quoted object → double-quoted (only when there are no double
113
+ // quotes already, so we don't corrupt strings that contain apostrophes).
114
+ if (!deSmart.includes('"') && deSmart.includes("'")) {
115
+ candidates.push(deSmart.replace(/'/g, '"'));
116
+ }
117
+ for (const candidate of candidates) {
118
+ try {
119
+ return JSON.parse(preprocessJson(candidate).trim());
120
+ }
121
+ catch {
122
+ // try the next repair
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+ /** Replace bare Python/JS literals (True/False/None/NaN) with JSON equivalents,
128
+ * skipping anything inside a double-quoted string. */
129
+ function replaceOutsideStrings(text) {
130
+ let inString = false;
131
+ let escaped = false;
132
+ let out = "";
133
+ let i = 0;
134
+ while (i < text.length) {
135
+ const ch = text[i];
136
+ if (inString) {
137
+ out += ch;
138
+ if (escaped)
139
+ escaped = false;
140
+ else if (ch === "\\")
141
+ escaped = true;
142
+ else if (ch === '"')
143
+ inString = false;
144
+ i += 1;
145
+ continue;
146
+ }
147
+ if (ch === '"') {
148
+ inString = true;
149
+ out += ch;
150
+ i += 1;
151
+ continue;
152
+ }
153
+ const rest = text.slice(i);
154
+ const m = /^(True|False|None|NaN|undefined)\b/.exec(rest);
155
+ if (m) {
156
+ const word = m[1];
157
+ out +=
158
+ word === "True"
159
+ ? "true"
160
+ : word === "False"
161
+ ? "false"
162
+ : word === "NaN"
163
+ ? "0"
164
+ : "null"; // None / undefined
165
+ i += word.length;
166
+ continue;
167
+ }
168
+ out += ch;
169
+ i += 1;
170
+ }
171
+ return out;
172
+ }
95
173
  function tryParseCall(raw) {
174
+ let parsed;
96
175
  try {
97
- const preprocessed = preprocessJson(raw);
98
- const parsed = JSON.parse(preprocessed.trim());
99
- if (typeof parsed.name === "string" &&
100
- parsed.args &&
101
- typeof parsed.args === "object") {
102
- return {
103
- name: parsed.name,
104
- args: parsed.args,
105
- };
106
- }
176
+ parsed = JSON.parse(preprocessJson(raw).trim());
107
177
  }
108
178
  catch {
109
- // not valid JSON
179
+ // Strict parse failed — try lenient repairs before giving up so a model
180
+ // that emits smart quotes / single quotes / Python literals still works.
181
+ const repaired = lenientJsonParse(raw.trim());
182
+ if (repaired && typeof repaired === "object" && !Array.isArray(repaired)) {
183
+ parsed = repaired;
184
+ }
185
+ }
186
+ if (!parsed)
187
+ return undefined;
188
+ const anyParsed = parsed;
189
+ // Accept name under several keys models commonly use.
190
+ const nameRaw = typeof parsed.name === "string"
191
+ ? parsed.name
192
+ : typeof anyParsed.tool_name === "string"
193
+ ? anyParsed.tool_name
194
+ : undefined;
195
+ if (typeof nameRaw === "string" && nameRaw.length > 0) {
196
+ // Strip a leading "functions." namespace some models add.
197
+ const name = nameRaw.replace(/^functions\./, "");
198
+ // Many OpenAI/Hermes/Qwen-trained models emit {"name","arguments"}
199
+ // (or "parameters"/"input") instead of {"name","args"} — accept any.
200
+ const argsSrc = pickObject(parsed.args) ??
201
+ pickObject(parsed.arguments) ??
202
+ pickObject(anyParsed.parameters) ??
203
+ pickObject(anyParsed.input);
204
+ if (argsSrc) {
205
+ return { name, args: argsSrc };
206
+ }
207
+ // Allow an empty args object explicitly written as {} or null (common for
208
+ // sysinfo), but do NOT invent args for objects that merely happen to
209
+ // contain a "name" key (e.g. {"name":"shell.exec"} with no command).
210
+ if (parsed.args === null || parsed.arguments === null) {
211
+ return { name, args: {} };
212
+ }
213
+ // Flattened form: the args are emitted as SIBLINGS of `name` rather than
214
+ // nested (e.g. {"name":"web.fetch","url":"…","responseMode":"raw"}). Treat
215
+ // the non-reserved keys as args, but only when at least one is a known
216
+ // tool-arg key so plain data objects carrying a `name` are not misread.
217
+ const flat = {};
218
+ for (const key of Object.keys(anyParsed)) {
219
+ if (!FLATTENED_RESERVED_KEYS.has(key))
220
+ flat[key] = anyParsed[key];
221
+ }
222
+ const flatKeys = Object.keys(flat);
223
+ if (flatKeys.length > 0 && flatKeys.some((k) => TOOL_ARG_KEYS.has(k))) {
224
+ return { name, args: flat };
225
+ }
110
226
  }
111
227
  return undefined;
112
228
  }
229
+ // Keys that name the tool or wrap its arguments — excluded when recovering a
230
+ // flattened tool call whose args sit alongside `name`.
231
+ const FLATTENED_RESERVED_KEYS = new Set([
232
+ "name",
233
+ "tool_name",
234
+ "args",
235
+ "arguments",
236
+ "parameters",
237
+ "input",
238
+ ]);
239
+ function pickObject(value) {
240
+ return value && typeof value === "object" && !Array.isArray(value)
241
+ ? value
242
+ : undefined;
243
+ }
113
244
  // Kimi K2 / Moonshot models on NVIDIA NIM emit tool calls using a
114
245
  // sentinel-token format that looks like:
115
246
  // <|tool_calls_section_begin|>
@@ -127,13 +258,56 @@ function parseKimiToolCall(text) {
127
258
  const name = match[1];
128
259
  return tryParseCall(JSON.stringify({ name, args: tryJson(match[2]) ?? {} }));
129
260
  }
261
+ // Recognized XML-ish tool-call wrappers some models emit (with or without a
262
+ // matching close tag). Used so we can recover a call even when the model
263
+ // forgot the closing tag, while plain prose never matches.
264
+ const XML_BLOCK_OPENERS = /<tool_call>|<function_calls>|<invoke>|<ant:invoke>/i;
265
+ /**
266
+ * Scan `text` starting at the index of the first `{`, returning the balanced
267
+ * JSON substring (respecting strings) or undefined if braces never balance.
268
+ * Lets us recover a tool-call JSON object the model emitted without a closing
269
+ * wrapper tag, where a non-greedy regex would stop at the first inner brace.
270
+ */
271
+ function extractBalancedJson(text) {
272
+ const start = text.indexOf("{");
273
+ if (start < 0)
274
+ return undefined;
275
+ let depth = 0;
276
+ let inString = false;
277
+ let escaped = false;
278
+ for (let i = start; i < text.length; i += 1) {
279
+ const ch = text[i];
280
+ if (escaped) {
281
+ escaped = false;
282
+ continue;
283
+ }
284
+ if (ch === "\\") {
285
+ escaped = true;
286
+ continue;
287
+ }
288
+ if (ch === '"') {
289
+ inString = !inString;
290
+ continue;
291
+ }
292
+ if (inString)
293
+ continue;
294
+ if (ch === "{")
295
+ depth += 1;
296
+ else if (ch === "}") {
297
+ depth -= 1;
298
+ if (depth === 0)
299
+ return text.slice(start, i + 1);
300
+ }
301
+ }
302
+ return undefined;
303
+ }
130
304
  function parseXmlToolCall(text) {
131
- // Pattern 1:
132
- // <tool_call>
305
+ // Pattern 1 (name + args/arguments/parameters JSON):
306
+ // <tool_call>
133
307
  // <name>tool.name</name>
134
- // <args>{...}</args>
135
- // </tool_call>
136
- const xmlNameArgs = text.match(/<tool_call>[\s\S]*?<name>\s*([\w.]+?)\s*<\/name>\s*<args>\s*(\{[\s\S]*?\})\s*<\/args>[\s\S]*?<\/tool_call>/i);
308
+ // <args>{...}</args> (or <arguments> / <parameters>)
309
+ // <tool_call>
310
+ const xmlNameArgs = text.match(/<tool_call>[\s\S]*?<name>\s*([\w.]+?)\s*<\/name>\s*<(?:args|arguments|parameters)>\s*(\{[\s\S]*?\})\s*<\/(?:args|arguments|parameters)>[\s\S]*?<\/tool_call>/i);
137
311
  if (xmlNameArgs?.[1] && xmlNameArgs?.[2]) {
138
312
  try {
139
313
  const args = JSON.parse(preprocessJson(xmlNameArgs[2]));
@@ -145,11 +319,11 @@ function parseXmlToolCall(text) {
145
319
  catch { }
146
320
  }
147
321
  // Pattern 1b (MiMo alternative):
148
- // <tool_call>
322
+ // <tool_call>
149
323
  // <tool_name>tool.name</tool_name>
150
- // <parameters>{...}</parameters>
151
- // </tool_call>
152
- const xmlToolNameParams = text.match(/<tool_call>[\s\S]*?<tool_name>\s*([\w.]+?)\s*<\/tool_name>\s*<parameters>\s*(\{[\s\S]*?\})\s*<\/parameters>[\s\S]*?<\/tool_call>/i);
324
+ // <parameters>{...}</parameters> (or <arguments> / <args>)
325
+ // <tool_call>
326
+ const xmlToolNameParams = text.match(/<tool_call>[\s\S]*?<tool_name>\s*([\w.]+?)\s*<\/tool_name>\s*<(?:parameters|arguments|args)>\s*(\{[\s\S]*?\})\s*<\/(?:parameters|arguments|args)>[\s\S]*?<\/tool_call>/i);
153
327
  if (xmlToolNameParams?.[1] && xmlToolNameParams?.[2]) {
154
328
  try {
155
329
  const args = JSON.parse(preprocessJson(xmlToolNameParams[2]));
@@ -160,13 +334,12 @@ function parseXmlToolCall(text) {
160
334
  }
161
335
  catch { }
162
336
  }
163
- // Pattern 1c (MiMo function/parameter format):
164
- // <tool_call>
337
+ // Pattern 1c (MiMo function/parameter format), with or WITHOUT a
338
+ // surrounding <tool_call> wrapper (some models emit the bare function block):
165
339
  // <function=tool.name>
166
340
  // <parameter=name>value</parameter>
167
341
  // </function>
168
- // </tool_call>
169
- const xmlFunctionBlock = text.match(/<tool_call>[\s\S]*?<function=([\w.]+?)>([\s\S]*?)<\/function>[\s\S]*?<\/tool_call>/i);
342
+ const xmlFunctionBlock = text.match(/<function=([\w.]+?)>([\s\S]*?)<\/function>/i);
170
343
  if (xmlFunctionBlock?.[1] && xmlFunctionBlock?.[2]) {
171
344
  const name = xmlFunctionBlock[1];
172
345
  const inner = xmlFunctionBlock[2];
@@ -187,17 +360,55 @@ function parseXmlToolCall(text) {
187
360
  }
188
361
  return { name, args };
189
362
  }
190
- // Pattern 2:
191
- // <tool_call>
192
- // <tool>
193
- // {"name": "...", "args": {...}}
194
- // </tool_call>
195
- // or simply <tool_call> {"name": "...", "args": {...}} </tool_call>
196
- const xmlJson = text.match(/<tool_call>[\s\S]*?(?:<tool>)?\s*(\{[\s\S]*?\})\s*<\/tool_call>/i);
197
- if (xmlJson?.[1]) {
198
- const call = tryParseCall(xmlJson[1]);
199
- if (call)
200
- return call;
363
+ // Pattern 2: JSON object inside a recognized wrapper (closed). The wrapper
364
+ // may be the tool_call sentinel, <function_calls>, or <invoke>/<ant:invoke>.
365
+ // Backtracking off the closing tag handles nested {} in the args.
366
+ const wrappers = [
367
+ "<tool_call>",
368
+ "<function_calls>",
369
+ "<invoke>",
370
+ "<ant:invoke>",
371
+ ];
372
+ for (const open of wrappers) {
373
+ const close = open === "<function_calls>"
374
+ ? "</function_calls>"
375
+ : open === "<invoke>"
376
+ ? "</invoke>"
377
+ : open === "<ant:invoke>"
378
+ ? "</ant:invoke>"
379
+ : "</tool_call>";
380
+ const re = new RegExp(open.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
381
+ "[\\s\\S]*?(?:<tool>)?\\s*(\\{[\\s\\S]*?\\})\\s*" +
382
+ close.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
383
+ const match = text.match(re);
384
+ if (match?.[1]) {
385
+ const call = tryParseCall(match[1]);
386
+ if (call)
387
+ return call;
388
+ }
389
+ }
390
+ // Pattern 3: a recognized opener is present but there is no matching close
391
+ // (model forgot it, or the stream was cut). Use a balanced brace scan from
392
+ // the first `{` after the opener so nested args survive, then try to parse.
393
+ const openerMatch = XML_BLOCK_OPENERS.exec(text);
394
+ if (openerMatch) {
395
+ const after = text.slice(openerMatch.index + openerMatch[0].length);
396
+ const json = extractBalancedJson(after);
397
+ if (json) {
398
+ const call = tryParseCall(json);
399
+ if (call)
400
+ return call;
401
+ }
402
+ // Also try the <name>...</name><arguments>{...}</arguments> tag shape
403
+ // without a closing wrapper (Hermes/Qwen often omit it).
404
+ const tagShape = after.match(/<name>\s*([\w.]+?)\s*<\/name>\s*<(?:args|arguments|parameters)>\s*(\{[\s\S]*?\})\s*<\/(?:args|arguments|parameters)>/i);
405
+ if (tagShape?.[1] && tagShape?.[2]) {
406
+ try {
407
+ const args = JSON.parse(preprocessJson(tagShape[2]));
408
+ return { name: tagShape[1], args: args };
409
+ }
410
+ catch { }
411
+ }
201
412
  }
202
413
  return undefined;
203
414
  }
@@ -568,8 +779,32 @@ export function parseAllToolCalls(text) {
568
779
  if (call)
569
780
  found.push({ index: m.index, call });
570
781
  }
782
+ // Bare <function=name>…</function> blocks (no <tool_call> wrapper) — some
783
+ // models emit one or several of these. Route each through parseXmlToolCall
784
+ // so the <parameter=…> args are decoded. Skip any that overlap a
785
+ // <tool_call> block already captured above (avoid double-counting).
786
+ const fnRe = /<function=[\w.]+?>[\s\S]*?<\/function>/gi;
787
+ while ((m = fnRe.exec(text)) !== null) {
788
+ const alreadyCaptured = found.some((f) => m.index >= f.index && m.index < f.index + 12);
789
+ const overlapsToolCall = /<tool_call>/i.test(text.slice(Math.max(0, m.index - 24), m.index));
790
+ if (alreadyCaptured || overlapsToolCall)
791
+ continue;
792
+ const call = parseXmlToolCall(m[0]);
793
+ if (call)
794
+ found.push({ index: m.index, call });
795
+ }
571
796
  found.sort((a, b) => a.index - b.index);
572
- return found.map((f) => f.call);
797
+ // De-duplicate calls that two different matchers picked up at (nearly) the
798
+ // same spot so a single XML call isn't executed twice.
799
+ const deduped = [];
800
+ for (const entry of found) {
801
+ if (deduped.some((d) => sameToolCall(d.call, entry.call) &&
802
+ Math.abs(d.index - entry.index) < 64)) {
803
+ continue;
804
+ }
805
+ deduped.push(entry);
806
+ }
807
+ return deduped.map((f) => f.call);
573
808
  }
574
809
  /** Structural equality for two tool calls (name + canonical args JSON). */
575
810
  export function sameToolCall(a, b) {
@@ -582,6 +817,54 @@ export function sameToolCall(a, b) {
582
817
  return false;
583
818
  }
584
819
  }
820
+ /**
821
+ * Partition a batch of tool calls (in document order) into execution groups.
822
+ * A run of consecutive parallel-safe calls forms one group to be run
823
+ * concurrently (bounded by maxGroupSize); every non-parallel-safe call is its
824
+ * own single-element group, i.e. a sequential barrier. Because plan updates
825
+ * and side-effecting tools are never parallel-safe, they always split the
826
+ * batch — which keeps parallelism scoped within a single task and prevents
827
+ * plan-state races and overlapping writes.
828
+ */
829
+ export function groupToolCallsForExecution(calls, isParallelSafe, maxGroupSize = 4) {
830
+ const groups = [];
831
+ let cursor = 0;
832
+ while (cursor < calls.length) {
833
+ const group = [calls[cursor]];
834
+ if (isParallelSafe(calls[cursor])) {
835
+ let j = cursor + 1;
836
+ while (j < calls.length &&
837
+ group.length < maxGroupSize &&
838
+ isParallelSafe(calls[j])) {
839
+ group.push(calls[j]);
840
+ j += 1;
841
+ }
842
+ }
843
+ groups.push(group);
844
+ cursor += group.length;
845
+ }
846
+ return groups;
847
+ }
848
+ /**
849
+ * Build the conversation to hand back to the caller at turn end. Strips system
850
+ * prompts (they're re-added each turn) but keeps the user turn plus every
851
+ * assistant tool-call and tool result, then appends the final answer if it
852
+ * isn't already the last message. Persisting this is what lets a resumed
853
+ * session give the model back what it actually did — commands, outputs, and
854
+ * results — instead of only its prose answers.
855
+ */
856
+ export function buildTurnHistory(messages, answer) {
857
+ // Drop system messages (the main prompt, plan context, and reflections are
858
+ // all re-injected each turn) EXCEPT compacted session memory, which is the
859
+ // only record of summarized older turns and must survive a resume.
860
+ const convo = messages.filter((m) => m.role !== "system" || isCompactionMemoryMessage(m));
861
+ const last = convo[convo.length - 1];
862
+ if (answer &&
863
+ !(last && last.role === "assistant" && last.content === answer)) {
864
+ convo.push({ role: "assistant", content: answer });
865
+ }
866
+ return convo;
867
+ }
585
868
  /**
586
869
  * Collapse pathological repetition before a message is stored in history.
587
870
  * Some models degenerate into emitting the same short phrase hundreds of
@@ -621,7 +904,7 @@ function textBeforeToolCall(text) {
621
904
  }
622
905
  return text.trim();
623
906
  }
624
- function formatToolArgs(call) {
907
+ export function formatToolArgs(call) {
625
908
  if (call.name === "shell.exec")
626
909
  return String(call.args.command ?? "");
627
910
  if (call.name === "net.scan")
@@ -844,17 +1127,17 @@ function buildWorkflowDirective() {
844
1127
  "1. EXPLORE: fs.list the working directory (and key subdirs) to see what already exists. Use tool.batch to parallelize reads.",
845
1128
  "2. UNDERSTAND: fs.read the files that matter (like package.json for js related and same for other languages too, config, entry points, existing components). Detect the existing stack/tooling and MATCH it. If the dir is empty or only has a stub, start fresh with a sensible modern default and say so.",
846
1129
  "3. PLAN: call plan.create with a COMPREHENSIVE plan — a detailed `detail` (stack chosen and WHY, architecture, how you'll verify) and 4-8 SEPARATE, ordered, high-quality tasks. The FIRST task initializes the project (scaffolder); the MIDDLE tasks MUST implement the ACTUAL FEATURE the user asked for by REPLACING the scaffolder's boilerplate (e.g. rewrite src/App.jsx into the real todo/blog/etc. UI, add components, state, styles); the LAST task verifies with a build. Scaffolding + install + run ALONE is NOT acceptable — that just leaves the Vite starter page. Each task is one distinct, verifiable action. Then STOP and wait for the user to /implement.",
847
- "4. IMPLEMENT: once approved, work task by task in STRICT ORDER. For each task: call task.update {taskId, state:'in_progress'} → do the real work → VERIFY it actually succeeded (read a file you wrote, check the command's exit/output) → call task.update {taskId, state:'done'}, then move to the NEXT task. You MAY emit several tool calls in one message and they run in order, top to bottom (the batch STOPS if one fails). A clean rhythm is: task.update in_progress + the work + task.update done together. Keep going until EVERY task is done. Do NOT claim work you didn't actually run.",
1130
+ "4. IMPLEMENT: once approved, work task by task in STRICT ORDER. For each task: call task.update {taskId, state:'in_progress'} → do the real work → VERIFY it actually succeeded (read a file you wrote, check the command's exit/output) → call task.update {taskId, state:'done'}, then move to the NEXT task. You MAY emit several tool calls in one message; they run in document order (independent read-only lookups run in parallel, while task.update and any write/command runs one at a time), and the batch STOPS if one fails. Keep every batch scoped to ONE task. A clean rhythm is: task.update in_progress + the work + task.update done together. Keep going until EVERY task is done. Do NOT claim work you didn't actually run.",
848
1131
  "",
849
1132
  "INITIALIZE WITH THE OFFICIAL SCAFFOLDER FIRST (do NOT hand-write build configs):",
850
- "- React/Vue/Svelte/vanilla → `npm create vite@latest <appname> -- --template react` (templates: react, react-ts, vue, vue-ts, svelte, vanilla). Next.js → `npx --yes create-next-app@latest <appname> --yes --eslint --no-tailwind --app --src-dir --import-alias \"@/*\"`. Node API → `npm init -y`.",
1133
+ '- React/Vue/Svelte/vanilla → `npm create vite@latest <appname> -- --template react` (templates: react, react-ts, vue, vue-ts, svelte, vanilla). Next.js → `npx --yes create-next-app@latest <appname> --yes --eslint --no-tailwind --app --src-dir --import-alias "@/*"`. Node API → `npm init -y`.',
851
1134
  "- GET THE TEMPLATE FLAG RIGHT. With `npm create vite@latest NAME -- --template react` the `--` IS required (it forwards --template to create-vite). With `npx create-vite@latest NAME --template react` do NOT add `--` (npx passes args straight through, so `-- --template react` makes npx DROP the flag and you silently get the WRONG, vanilla template). Pick ONE form and keep the template flag attached. After scaffolding, fs.read the generated index.html / src entry to CONFIRM you got React (a src/main.jsx + App.jsx, not a vanilla main.js/counter.js). If it's the wrong template, delete the folder and re-run with the correct command.",
852
1135
  "- RUN SCAFFOLDERS NON-INTERACTIVELY and into a NEW SUBFOLDER (`<appname>`). Scaffolders REFUSE to run in a non-empty directory and then print 'Operation cancelled' — and the current dir frequently already has a file like .DS_Store. So scaffold into a subfolder (always works). `--yes` does NOT fix the non-empty-dir cancel; a subfolder does. NEVER background a scaffolder with `&` or pipe `yes |` into it.",
853
- "- If a scaffolder cannot be driven non-interactively or keeps failing, FALL BACK to hand-writing a minimal Vite setup (package.json with \"type\":\"module\", @vitejs/plugin-react, index.html that loads /src/main.jsx, src/main.jsx, src/App.jsx) then `npm install`. That never prompts and you control every file.",
1136
+ '- If a scaffolder cannot be driven non-interactively or keeps failing, FALL BACK to hand-writing a minimal Vite setup (package.json with "type":"module", @vitejs/plugin-react, index.html that loads /src/main.jsx, src/main.jsx, src/App.jsx) then `npm install`. That never prompts and you control every file.',
854
1137
  "- VERIFY the init actually worked before marking the task done: fs.read package.json (it must now exist AND list react + react-dom) and fs.read index.html (it must reference your jsx entry). 'Operation cancelled' / non-zero exit means the task FAILED — do not proceed as if it succeeded.",
855
1138
  "",
856
1139
  "CRITICAL RULES during IMPLEMENTATION:",
857
- "- You may batch tool calls: emit one or several ```tool blocks in a message and they run in order, top to bottom. If any call fails, the rest of that batch is cancelled so you can react — so order dependent steps correctly and keep batches focused. A good batch is task.update(in_progress) + the work + task.update(done) for ONE task.",
1140
+ "- You may batch tool calls: emit one or several ```tool blocks in a message. They run in document order — independent READ-ONLY lookups (fs.read/list/search, dns/whois, http.fetch GET, web.search/fetch, sysinfo) run in parallel, while task.update and any write/command (fs.write*, shell.exec, pkg.install, net.scan) run one at a time. If any call fails, the rest of that batch is cancelled so you can react — so order dependent steps correctly and keep every batch scoped to ONE task. A good batch is task.update(in_progress) + the work + task.update(done) for ONE task.",
858
1141
  "- Do NOT re-explore. Step 1 (EXPLORE) was already completed during planning. Start executing the first pending task immediately.",
859
1142
  "- ONE task at a time, in ORDER. Do NOT skip ahead to task 3 before task 2 is done.",
860
1143
  "- KEEP EACH FILE SMALL ENOUGH TO WRITE IN ONE CALL. If a fs.write is reported as 'cut off (output too long)', the file was NOT fully written and is likely broken/invalid — re-write it, splitting a large component into smaller files if needed. NEVER leave a half-written file and move on.",
@@ -1033,15 +1316,51 @@ function summarizeOutput(output, maxChars = 8_000) {
1033
1316
  truncated: true,
1034
1317
  };
1035
1318
  }
1319
+ // Tools whose output is the actual content the model needs verbatim (file
1320
+ // bodies, listings, search hits). Running these through the security-signal
1321
+ // `genericReducer` was wrong: it ranks lines by pentest keywords and drops
1322
+ // the rest, so source code came back as a fragmentary head+tail — the model
1323
+ // saw a "truncated" file and kept re-reading it in wasted retries. For these
1324
+ // we pass the raw content through (up to a generous cap) and point the model
1325
+ // at the saved artifact when it exceeds the cap.
1326
+ const PASSTHROUGH_TOOLS = new Set([
1327
+ "fs.read",
1328
+ "fs.list",
1329
+ "fs.search",
1330
+ "fs.edit",
1331
+ "pdf.read",
1332
+ ]);
1333
+ const PASSTHROUGH_CAP_CHARS = 400_000;
1334
+ // web.fetch/http.fetch pull in arbitrary third-party pages/API responses that
1335
+ // can be hundreds of KB (e.g. a large OpenAPI spec). Unlike local files the
1336
+ // model asked to read, this content is never bounded by the user's own
1337
+ // project, so it must be capped like every other tool's context output —
1338
+ // otherwise a single fetch can single-handedly blow the context budget and
1339
+ // starve the model of room to actually respond (observed as empty/garbled
1340
+ // completions on smaller-context-window models after a big fetch).
1341
+ const WEB_FETCH_CAP_CHARS = 20_000;
1036
1342
  function formatToolContext(call, result) {
1037
1343
  const output = result.output.trim();
1038
1344
  if (!output)
1039
1345
  return "";
1040
1346
  if (call.name === "web.fetch" || call.name === "http.fetch") {
1347
+ const { text, truncated } = summarizeOutput(output, WEB_FETCH_CAP_CHARS);
1348
+ if (!truncated)
1349
+ return text;
1041
1350
  const saved = result.outputPath
1042
- ? `\nFull output saved to: ${result.outputPath}`
1043
- : "";
1044
- return `${output}${saved}`.trim();
1351
+ ? `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; showing the head and tail. The FULL response is saved at: ${result.outputPath} — re-fetch a narrower URL or read the saved file if you need the middle.]`
1352
+ : `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; only head and tail shown.]`;
1353
+ return `${text}${saved}`.trim();
1354
+ }
1355
+ // Pass-through tools: never reduce — the model needs the real content.
1356
+ if (PASSTHROUGH_TOOLS.has(call.name)) {
1357
+ const { text, truncated } = summarizeOutput(output, PASSTHROUGH_CAP_CHARS);
1358
+ if (!truncated)
1359
+ return text;
1360
+ const saved = result.outputPath
1361
+ ? `\n\n[File content exceeds ${PASSTHROUGH_CAP_CHARS.toLocaleString()} chars; showing the head and tail. The FULL content is saved at: ${result.outputPath} — re-read specific line ranges with fs.read if you need the middle.]`
1362
+ : `\n\n[File content exceeds ${PASSTHROUGH_CAP_CHARS.toLocaleString()} chars; only head and tail shown. Read it in smaller chunks if the middle is needed.]`;
1363
+ return `${text}${saved}`.trim();
1045
1364
  }
1046
1365
  let reduced;
1047
1366
  try {
@@ -1083,6 +1402,13 @@ const inquirerConfirmPort = {
1083
1402
  default: true,
1084
1403
  });
1085
1404
  },
1405
+ async confirmAgentSwitch(info) {
1406
+ const tools = info.tools.length > 0 ? ` (${info.tools.join(", ")})` : "";
1407
+ return confirm({
1408
+ message: chalk.yellow(` this needs agent mode${tools} — switch and run it?`),
1409
+ default: true,
1410
+ });
1411
+ },
1086
1412
  };
1087
1413
  async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
1088
1414
  if (!isPentestToolCall(call))
@@ -1247,6 +1573,24 @@ async function handlePlanTool(call, session, ctx) {
1247
1573
  modelNote: `task.update failed: state must be one of ${validStates.join(", ")}.`,
1248
1574
  };
1249
1575
  }
1576
+ // Only one task may be in_progress at a time. This forces genuine
1577
+ // task-by-task execution: the model must close (done/failed/skipped) the
1578
+ // current task before opening the next one, instead of leaving a task
1579
+ // "in_progress" as an umbrella while it quietly works through the rest
1580
+ // of the plan underneath it.
1581
+ if (stateRaw === "in_progress") {
1582
+ const otherInProgress = plan.tasks.find((t) => t.id !== taskId && t.state === "in_progress");
1583
+ if (otherInProgress) {
1584
+ return {
1585
+ handled: true,
1586
+ ok: false,
1587
+ display: chalk.red(` \u2717 task.update: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress\n`),
1588
+ modelNote: `task.update failed: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress. ` +
1589
+ "Finish it first \u2014 call task.update with state 'done' (or 'failed'/'skipped' with a note) " +
1590
+ `for [${otherInProgress.id}] before starting [${taskId}].`,
1591
+ };
1592
+ }
1593
+ }
1250
1594
  const ok = markTask(plan, taskId, stateRaw, note);
1251
1595
  if (!ok) {
1252
1596
  const ids = plan.tasks.map((t) => t.id).join(", ");
@@ -1351,7 +1695,19 @@ export async function runAgentLoop(prompt, options = {}) {
1351
1695
  }
1352
1696
  emit(event);
1353
1697
  };
1698
+ // Points at the live message array so finishTurn can hand the full
1699
+ // conversation back to the caller. Assigned once `messages` is built below;
1700
+ // all later mutations are in-place so this reference stays current.
1701
+ let liveMessages = [];
1354
1702
  const finishTurn = (answer, steps) => {
1703
+ if (options.onMessages) {
1704
+ try {
1705
+ options.onMessages(buildTurnHistory(liveMessages, answer));
1706
+ }
1707
+ catch {
1708
+ // Persisting history must never break the turn.
1709
+ }
1710
+ }
1355
1711
  emit({ type: "turn-end", finalAnswer: answer, steps });
1356
1712
  return answer;
1357
1713
  };
@@ -1417,6 +1773,7 @@ export async function runAgentLoop(prompt, options = {}) {
1417
1773
  ...(options.history ?? []),
1418
1774
  userMessage,
1419
1775
  ];
1776
+ liveMessages = messages;
1420
1777
  const recoveryUserMessage = (content) => {
1421
1778
  const message = { role: "user", content };
1422
1779
  if (options.images && options.images.length > 0) {
@@ -1513,12 +1870,14 @@ export async function runAgentLoop(prompt, options = {}) {
1513
1870
  promise: Promise.resolve(),
1514
1871
  async acquire() {
1515
1872
  let release = () => { };
1516
- const next = new Promise((r) => { release = r; });
1873
+ const next = new Promise((r) => {
1874
+ release = r;
1875
+ });
1517
1876
  const current = this.promise;
1518
1877
  this.promise = current.then(() => next);
1519
1878
  await current;
1520
1879
  return release;
1521
- }
1880
+ },
1522
1881
  };
1523
1882
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
1524
1883
  let call = normalizeToolCall(rawCall);
@@ -1537,20 +1896,34 @@ export async function runAgentLoop(prompt, options = {}) {
1537
1896
  const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
1538
1897
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1539
1898
  const result = { ok: false, output: reason, exitCode: 1 };
1540
- return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
1899
+ return {
1900
+ ok: false,
1901
+ call,
1902
+ result,
1903
+ contextOutput: reason,
1904
+ blockOrCancel: true,
1905
+ };
1541
1906
  }
1542
1907
  if (loopCheck.reason) {
1543
1908
  writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1544
1909
  }
1545
1910
  if (call.name === "plan.create" || call.name === "task.update") {
1546
- const planResult = await handlePlanTool(call, session, { loopGuard, step });
1911
+ const planResult = await handlePlanTool(call, session, {
1912
+ loopGuard,
1913
+ step,
1914
+ });
1547
1915
  if (planResult.handled) {
1548
1916
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1549
1917
  if (planResult.plan) {
1550
1918
  writePlanUpdate(planResult.plan, planResult.display);
1551
1919
  }
1552
1920
  const result = { ok: planResult.ok, output: planResult.modelNote };
1553
- return { ok: planResult.ok, call, result, contextOutput: planResult.modelNote };
1921
+ return {
1922
+ ok: planResult.ok,
1923
+ call,
1924
+ result,
1925
+ contextOutput: planResult.modelNote,
1926
+ };
1554
1927
  }
1555
1928
  }
1556
1929
  const scope = await loadScope();
@@ -1566,7 +1939,45 @@ export async function runAgentLoop(prompt, options = {}) {
1566
1939
  const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1567
1940
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1568
1941
  const result = { ok: false, output: reason, exitCode: 1 };
1569
- return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
1942
+ return {
1943
+ ok: false,
1944
+ call,
1945
+ result,
1946
+ contextOutput: reason,
1947
+ blockOrCancel: true,
1948
+ };
1949
+ }
1950
+ // ── Task-scoped execution gate ───────────────────────────────────
1951
+ // Once a plan is approved, every non-plan tool call must run while
1952
+ // exactly one task is "in_progress". This stops a model from batching
1953
+ // tool calls for many/all tasks in one turn and only touching task
1954
+ // state at the very end (or never) — the failure mode where a model
1955
+ // claimed most tasks "done" in prose without ever recording it in the
1956
+ // plan. Multiple tool calls per task are still fine; they just must be
1957
+ // bracketed by task.update in_progress → (work) → task.update done.
1958
+ if (session.planApproved.value) {
1959
+ const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
1960
+ if (livePlanForGate) {
1961
+ const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
1962
+ const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
1963
+ if (unfinished && !inProgress) {
1964
+ const nextPending = livePlanForGate.tasks.find((t) => t.state === "pending");
1965
+ const reason = nextPending
1966
+ ? `${call.name} blocked — no task is in_progress. Call task.update {taskId:"${nextPending.id}", state:"in_progress"} before doing any work for it.`
1967
+ : `${call.name} blocked — no task is in_progress.`;
1968
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1969
+ const result = { ok: false, output: reason, exitCode: 1 };
1970
+ // Recoverable ordering mistake (NOT a user/session control gate):
1971
+ // feed the reason back so the model marks the task in_progress and
1972
+ // retries within the same turn, rather than ending the turn.
1973
+ return {
1974
+ ok: false,
1975
+ call,
1976
+ result,
1977
+ contextOutput: `${reason}\nThis tool did NOT run. Emit task.update {state:"in_progress"} for the task first, then the work.`,
1978
+ };
1979
+ }
1980
+ }
1570
1981
  }
1571
1982
  if (call.name === "web.search") {
1572
1983
  sawFreshWebSearch = true;
@@ -1580,9 +1991,20 @@ export async function runAgentLoop(prompt, options = {}) {
1580
1991
  }
1581
1992
  if (decision.level === "block") {
1582
1993
  writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1583
- const lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
1584
- const result = { ok: false, output: lastAnswer, exitCode: 1 };
1585
- return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
1994
+ const message = `Blocked: ${call.name} — ${decision.reason}`;
1995
+ const result = { ok: false, output: message, exitCode: 1 };
1996
+ // Safety classifier blocks are recoverable model mistakes: feed the
1997
+ // failed result back to the model and let it choose a safer next step
1998
+ // instead of ending the entire agent turn. Hard workflow gates
1999
+ // (plan not approved, task not in_progress, auth declined, aborts)
2000
+ // still use blockOrCancel above/below because continuing would violate
2001
+ // user/session control rather than merely correcting a bad command.
2002
+ return {
2003
+ ok: false,
2004
+ call,
2005
+ result,
2006
+ contextOutput: `${message}\nThis tool call did not run. Continue the task using a safer allowed method; do not retry the same blocked command unchanged.`,
2007
+ };
1586
2008
  }
1587
2009
  let authorized = true;
1588
2010
  let pentestJustConfirmed = false;
@@ -1598,7 +2020,14 @@ export async function runAgentLoop(prompt, options = {}) {
1598
2020
  const lastAnswer = "Pentest authorization not confirmed.";
1599
2021
  writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1600
2022
  const result = { ok: false, output: lastAnswer, exitCode: 1 };
1601
- return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
2023
+ return {
2024
+ ok: false,
2025
+ call,
2026
+ result,
2027
+ contextOutput: lastAnswer,
2028
+ lastAnswer,
2029
+ blockOrCancel: true,
2030
+ };
1602
2031
  }
1603
2032
  if (needsPentestAuth) {
1604
2033
  pentestJustConfirmed = true;
@@ -1608,9 +2037,17 @@ export async function runAgentLoop(prompt, options = {}) {
1608
2037
  !isPreApprovalAllowedTool(call.name)) {
1609
2038
  const pathArg = typeof call.args.path === "string" ? call.args.path : undefined;
1610
2039
  if (pathArg) {
1611
- const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\") ? join(homedir(), p.slice(2)) : p === "~" ? homedir() : p;
2040
+ const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\")
2041
+ ? join(homedir(), p.slice(2))
2042
+ : p === "~"
2043
+ ? homedir()
2044
+ : p;
1612
2045
  const resolved = resolve(expandHomeLocal(pathArg));
1613
- const mode = (call.name === "fs.read" || call.name === "fs.list" || call.name === "fs.search") ? "read" : "write";
2046
+ const mode = call.name === "fs.read" ||
2047
+ call.name === "fs.list" ||
2048
+ call.name === "fs.search"
2049
+ ? "read"
2050
+ : "write";
1614
2051
  if (!pathInsideSandbox(resolved, mode)) {
1615
2052
  forceManualConfirm = true;
1616
2053
  }
@@ -1623,7 +2060,14 @@ export async function runAgentLoop(prompt, options = {}) {
1623
2060
  const lastAnswer = "Cancelled.";
1624
2061
  writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1625
2062
  const result = { ok: false, output: lastAnswer, exitCode: 1 };
1626
- return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
2063
+ return {
2064
+ ok: false,
2065
+ call,
2066
+ result,
2067
+ contextOutput: lastAnswer,
2068
+ lastAnswer,
2069
+ blockOrCancel: true,
2070
+ };
1627
2071
  }
1628
2072
  }
1629
2073
  }
@@ -1715,7 +2159,13 @@ export async function runAgentLoop(prompt, options = {}) {
1715
2159
  jobManager.updateJobStatus(jobId, "failed", 1);
1716
2160
  if (isAbortError(toolError, toolAc.signal)) {
1717
2161
  writeAbort();
1718
- return { ok: false, call, result: { ok: false, output: "Aborted." }, contextOutput: "Aborted.", lastAnswer: "Aborted." };
2162
+ return {
2163
+ ok: false,
2164
+ call,
2165
+ result: { ok: false, output: "Aborted." },
2166
+ contextOutput: "Aborted.",
2167
+ lastAnswer: "Aborted.",
2168
+ };
1719
2169
  }
1720
2170
  const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
1721
2171
  result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
@@ -1794,6 +2244,81 @@ export async function runAgentLoop(prompt, options = {}) {
1794
2244
  }
1795
2245
  return { ok: result.ok, call, result, contextOutput };
1796
2246
  }
2247
+ // ── Automatic context compaction ───────────────────────────────────────
2248
+ // As a long turn accumulates tool outputs and reasoning, the context can
2249
+ // grow past what the model can hold. We proactively summarize the older
2250
+ // turns into a single continuation memory (the SAME model-written summary
2251
+ // the /compact command uses — never a mechanical transcript dump) and then
2252
+ // re-inject the ACTIVE PLAN so the agent never loses track of the plan,
2253
+ // what is done, and what remains. The estimate is chars/4; the budget is
2254
+ // deliberately conservative so we compact a little early rather than hit a
2255
+ // provider context-window error mid-task.
2256
+ const AUTO_COMPACT_TOKEN_BUDGET = 60_000;
2257
+ const AUTO_COMPACT_KEEP_RECENT = 12;
2258
+ let lastCompactionMsgCount = 0;
2259
+ const summarizeForCompaction = async (summaryPrompt) => {
2260
+ const response = await completeWithProvider({
2261
+ provider,
2262
+ model,
2263
+ messages: [
2264
+ {
2265
+ role: "system",
2266
+ content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
2267
+ },
2268
+ { role: "user", content: summaryPrompt },
2269
+ ],
2270
+ temperature: 0.1,
2271
+ maxTokens: 2_048,
2272
+ signal: options.signal,
2273
+ });
2274
+ return response.text;
2275
+ };
2276
+ async function maybeAutoCompact(reason, force = false) {
2277
+ const beforeTokens = estimateMessagesTokens(messages);
2278
+ if (!force && beforeTokens < AUTO_COMPACT_TOKEN_BUDGET)
2279
+ return;
2280
+ if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
2281
+ return;
2282
+ // Avoid compaction loops: don't re-compact until enough new messages have
2283
+ // accumulated since the last compaction.
2284
+ if (messages.length <= lastCompactionMsgCount + 4)
2285
+ return;
2286
+ try {
2287
+ const result = await compactMessagesWithSummary(messages, summarizeForCompaction, { budgetTokens: 0, keepRecent: AUTO_COMPACT_KEEP_RECENT });
2288
+ if (!result.summarized || result.afterTokens >= beforeTokens)
2289
+ return;
2290
+ messages.splice(0, messages.length, ...result.messages);
2291
+ loopGuard.resetReadOnly();
2292
+ // Re-inject the live plan so the model keeps full plan awareness even
2293
+ // after older turns (which carried the plan context) were summarized.
2294
+ const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2295
+ if (livePlan) {
2296
+ messages.push({
2297
+ role: "system",
2298
+ content: planContextMessage(livePlan, session.planApproved.value),
2299
+ });
2300
+ }
2301
+ lastCompactionMsgCount = messages.length;
2302
+ const afterTokens = estimateMessagesTokens(messages);
2303
+ await auditLog("agent.compact", {
2304
+ newLength: messages.length,
2305
+ estimatedTokens: afterTokens,
2306
+ reason,
2307
+ });
2308
+ writeNotice("info", `context auto-compacted to fit the window (~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens)`, chalk.dim(` ℹ context auto-compacted (~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens)\n`));
2309
+ }
2310
+ catch (error) {
2311
+ if (error instanceof Error &&
2312
+ (error.name === "AbortError" || error.message.includes("aborted"))) {
2313
+ throw error;
2314
+ }
2315
+ // Summarization failed — DO NOT fall back to a mechanical dump. Keep the
2316
+ // current context and continue; we'll try again as it keeps growing.
2317
+ await auditLog("agent.compact.failed", {
2318
+ reason: error instanceof Error ? error.message : String(error),
2319
+ });
2320
+ }
2321
+ }
1797
2322
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1798
2323
  // `step` is the productive-step index (used for display + audit). It only
1799
2324
  // advances when the previous iteration actually executed a tool.
@@ -1815,25 +2340,19 @@ export async function runAgentLoop(prompt, options = {}) {
1815
2340
  const extension = Math.max(40, maxSteps);
1816
2341
  stepBudget += extension;
1817
2342
  maxIterations = stepBudget * 3;
1818
- // Compact older messages to free context space.
1819
- const compacted = compactMessages(messages, { budgetTokens: 0, keepRecent: 12 });
1820
- if (compacted.length < messages.length) {
1821
- messages.splice(0, messages.length, ...compacted);
1822
- loopGuard.resetReadOnly();
1823
- await auditLog("agent.compact", {
1824
- newLength: messages.length,
1825
- estimatedTokens: estimateMessagesTokens(messages),
1826
- reason: "step-budget-continue",
1827
- });
1828
- }
2343
+ // Compact older messages (model-written summary, no mechanical dump)
2344
+ // to free context space for the next chunk of work.
2345
+ await maybeAutoCompact("step-budget-continue", true);
1829
2346
  // Inject a progress summary so the model stays focused.
1830
2347
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1831
2348
  let progressNote = "The step limit was reached and the user chose to continue. ";
1832
- progressNote += "Review what you have accomplished so far and continue with the NEXT unfinished step. ";
1833
- progressNote += "Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
2349
+ progressNote +=
2350
+ "Review what you have accomplished so far and continue with the NEXT unfinished step. ";
2351
+ progressNote +=
2352
+ "Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
1834
2353
  if (livePlan) {
1835
- const doneTasks = livePlan.tasks.filter(t => t.state === "done");
1836
- const pendingTasks = livePlan.tasks.filter(t => t.state === "pending" || t.state === "in_progress");
2354
+ const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
2355
+ const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1837
2356
  progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
1838
2357
  if (pendingTasks.length > 0) {
1839
2358
  progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
@@ -1867,6 +2386,10 @@ export async function runAgentLoop(prompt, options = {}) {
1867
2386
  writeStatus(batchStatus, chalk.dim(batchStatus));
1868
2387
  }
1869
2388
  else {
2389
+ // Before a fresh model round-trip, proactively compact if the context has
2390
+ // grown too large, so we never hit a provider context-window error and the
2391
+ // model keeps a clean, plan-aware memory.
2392
+ await maybeAutoCompact("auto-token-budget");
1870
2393
  // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1871
2394
  // Status messages (rate-limit retries, fallback hints) still surface live.
1872
2395
  // A spinner gives the user feedback during long thinking phases on
@@ -1950,6 +2473,16 @@ export async function runAgentLoop(prompt, options = {}) {
1950
2473
  deltaParser?.finish();
1951
2474
  const assistantTextResult = rememberThinkingFromText(completion.text);
1952
2475
  assistantText = assistantTextResult;
2476
+ // Commit thinking to the transcript IMMEDIATELY, before any of the
2477
+ // branches below decide to `continue` (retry a malformed tool call,
2478
+ // nudge for narration, guard premature completion, etc). Previously
2479
+ // writeThinkingBlock was only called from a few terminal branches, so
2480
+ // any retry path silently dropped the model's reasoning — the user
2481
+ // would see the live "thinking…" preview during streaming and then
2482
+ // watch it vanish with nothing committed once the turn moved on.
2483
+ if (assistantText.hasThinking) {
2484
+ writeThinkingBlock(assistantText.thinkContent);
2485
+ }
1953
2486
  // Try visible text first, then thinking content — some models (e.g. glm-5.1)
1954
2487
  // wrap tool calls inside considering tags, so stripThinking removes them
1955
2488
  // into thinkContent and visible becomes empty. Recovering from thinkContent
@@ -1977,7 +2510,6 @@ export async function runAgentLoop(prompt, options = {}) {
1977
2510
  emptyVisibleRetries += 1;
1978
2511
  if (emptyVisibleRetries <= 3) {
1979
2512
  if (assistantText.hasThinking) {
1980
- writeThinkingBlock(assistantText.thinkContent);
1981
2513
  writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
1982
2514
  }
1983
2515
  else {
@@ -2041,7 +2573,10 @@ export async function runAgentLoop(prompt, options = {}) {
2041
2573
  bareToolJsonRetries += 1;
2042
2574
  if (bareToolJsonRetries <= 3) {
2043
2575
  writeNotice("warn", "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
2044
- messages.push({ role: "assistant", content: assistantText.visible });
2576
+ messages.push({
2577
+ role: "assistant",
2578
+ content: assistantText.visible,
2579
+ });
2045
2580
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
2046
2581
  ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
2047
2582
  "This is a BUILD/SCAFFOLD task with NO plan yet. " +
@@ -2063,7 +2598,10 @@ export async function runAgentLoop(prompt, options = {}) {
2063
2598
  // model to retry the tool call in a clean JSON format.
2064
2599
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
2065
2600
  writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
2066
- messages.push({ role: "assistant", content: assistantText.visible });
2601
+ messages.push({
2602
+ role: "assistant",
2603
+ content: assistantText.visible,
2604
+ });
2067
2605
  messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
2068
2606
  "Reply with ONLY a fenced ```tool block containing valid JSON " +
2069
2607
  'of the form `{"name": "<tool>", "args": { ... }}`. ' +
@@ -2078,7 +2616,10 @@ export async function runAgentLoop(prompt, options = {}) {
2078
2616
  truncatedToolRetries += 1;
2079
2617
  if (truncatedToolRetries <= 3) {
2080
2618
  writeNotice("warn", "tool call was cut off (output too long) — asking the model to retry in smaller pieces", chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry in smaller pieces\n"));
2081
- messages.push({ role: "assistant", content: assistantText.visible });
2619
+ messages.push({
2620
+ role: "assistant",
2621
+ content: assistantText.visible,
2622
+ });
2082
2623
  messages.push({
2083
2624
  role: "user",
2084
2625
  content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
@@ -2105,7 +2646,10 @@ export async function runAgentLoop(prompt, options = {}) {
2105
2646
  malformedFenceRetries += 1;
2106
2647
  if (malformedFenceRetries <= 3) {
2107
2648
  writeNotice("warn", "tool block present but its JSON didn't parse — asking the model to re-emit valid JSON", chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
2108
- messages.push({ role: "assistant", content: assistantText.visible });
2649
+ messages.push({
2650
+ role: "assistant",
2651
+ content: assistantText.visible,
2652
+ });
2109
2653
  messages.push({
2110
2654
  role: "user",
2111
2655
  content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
@@ -2138,9 +2682,7 @@ export async function runAgentLoop(prompt, options = {}) {
2138
2682
  if (wantsAction &&
2139
2683
  cleaned.trim().length > 0 &&
2140
2684
  actionIntentRetries < 3 &&
2141
- (productiveSteps === 0 ||
2142
- planNarrated ||
2143
- narratedAction)) {
2685
+ (productiveSteps === 0 || planNarrated || narratedAction)) {
2144
2686
  actionIntentRetries += 1;
2145
2687
  let nudge;
2146
2688
  if (activePlan && session.planApproved.value) {
@@ -2179,14 +2721,22 @@ export async function runAgentLoop(prompt, options = {}) {
2179
2721
  "Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
2180
2722
  writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
2181
2723
  }
2182
- messages.push({ role: "assistant", content: assistantText.visible });
2724
+ messages.push({
2725
+ role: "assistant",
2726
+ content: assistantText.visible,
2727
+ });
2183
2728
  messages.push(recoveryUserMessage(nudge));
2184
2729
  continue;
2185
2730
  }
2186
- if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
2731
+ if (freshWebSearchRequired &&
2732
+ !sawFreshWebSearch &&
2733
+ !freshnessRetryUsed) {
2187
2734
  freshnessRetryUsed = true;
2188
2735
  writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
2189
- messages.push({ role: "assistant", content: assistantText.visible });
2736
+ messages.push({
2737
+ role: "assistant",
2738
+ content: assistantText.visible,
2739
+ });
2190
2740
  messages.push({
2191
2741
  role: "user",
2192
2742
  content: freshnessGuardMessage() +
@@ -2207,7 +2757,10 @@ export async function runAgentLoop(prompt, options = {}) {
2207
2757
  prematureCompletionRetries += 1;
2208
2758
  const next = unfinished[0];
2209
2759
  writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
2210
- messages.push({ role: "assistant", content: assistantText.visible });
2760
+ messages.push({
2761
+ role: "assistant",
2762
+ content: assistantText.visible,
2763
+ });
2211
2764
  messages.push({
2212
2765
  role: "user",
2213
2766
  content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
@@ -2248,9 +2801,6 @@ export async function runAgentLoop(prompt, options = {}) {
2248
2801
  if (completionWarning) {
2249
2802
  writeNotice("warn", completionWarningText, completionWarning);
2250
2803
  }
2251
- if (assistantText.hasThinking) {
2252
- writeThinkingBlock(assistantText.thinkContent);
2253
- }
2254
2804
  await auditLog("agent.final", { provider, model, steps: step + 1 });
2255
2805
  lastAnswer = cleaned;
2256
2806
  return finishTurn(lastAnswer, step + 1);
@@ -2263,44 +2813,86 @@ export async function runAgentLoop(prompt, options = {}) {
2263
2813
  if (beforeTool) {
2264
2814
  writeAssistantMessage(beforeTool);
2265
2815
  }
2266
- if (assistantText.hasThinking) {
2267
- writeThinkingBlock(assistantText.thinkContent);
2268
- }
2269
2816
  let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
2270
2817
  if (allCalls.length === 0 && call) {
2271
2818
  allCalls = [call];
2272
2819
  }
2273
2820
  const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
2274
- allCalls.map(c => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``).join("\n\n");
2821
+ allCalls
2822
+ .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
2823
+ .join("\n\n");
2275
2824
  messages.push({
2276
2825
  role: "assistant",
2277
2826
  content: standardizedContent,
2278
2827
  });
2279
2828
  if (allCalls.length > 1) {
2280
- writeNotice("info", `${allCalls.length} tool calls in this message — running them in parallel`, chalk.dim(` ℹ ${allCalls.length} tool calls in this message running them in parallel\n`));
2829
+ writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls read-only lookups in parallel, the rest in order\n`));
2281
2830
  }
2282
- // Execute all calls in parallel
2283
- const toolPromises = allCalls.map((c) => {
2284
- const id = `tool-${++nextToolEventId}`;
2285
- return executeSingleTool(c, id, options.signal || new AbortController().signal);
2286
- });
2287
- const results = await Promise.all(toolPromises);
2831
+ // ── Scoped-parallel batch execution ────────────────────────────────
2832
+ // The model may emit several calls in one message. We partition them,
2833
+ // IN DOCUMENT ORDER, into segments:
2834
+ // • A run of consecutive READ-ONLY, safe-classified calls (the same
2835
+ // allowlist tool.batch uses) executes CONCURRENTLY — this is where
2836
+ // independent lookups within a single task fan out (e.g. whois +
2837
+ // dns + http.fetch during recon).
2838
+ // • Every other call (plan.create/task.update, and any mutating or
2839
+ // confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
2840
+ // runs ALONE as a sequential barrier.
2841
+ // Because task.update is never parallel-safe, it always acts as a
2842
+ // barrier: it commits before the work it gates and after the work it
2843
+ // closes. That keeps execution strictly task-by-task and eliminates the
2844
+ // plan-state races / overlapping writes that a blanket Promise.all
2845
+ // caused, while still letting one task's independent lookups run in
2846
+ // parallel. The whole batch stops on the first abort, block, or failure
2847
+ // so the model always sees and reacts to an error.
2848
+ const scopeForBatch = await loadScope().catch(() => undefined);
2849
+ const isParallelSafe = (c) => {
2850
+ if (!BATCH_SAFE_TOOLS.has(c.name))
2851
+ return false;
2852
+ try {
2853
+ return (classifyToolCall(c, { scope: scopeForBatch }).level === "safe");
2854
+ }
2855
+ catch {
2856
+ return false;
2857
+ }
2858
+ };
2859
+ const PARALLEL_LIMIT = 4;
2288
2860
  let aborted = false;
2289
2861
  let blocked = false;
2290
2862
  let blockedResult = null;
2291
- for (const res of results) {
2292
- if (res.lastAnswer === "Aborted.") {
2293
- aborted = true;
2294
- }
2295
- if (res.blockOrCancel) {
2296
- blocked = true;
2297
- blockedResult = res;
2298
- }
2863
+ let failed = false;
2864
+ const recordResult = (res) => {
2299
2865
  messages.push({
2300
2866
  role: "tool",
2301
2867
  content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
2302
2868
  });
2303
2869
  productiveSteps += 1;
2870
+ if (res.lastAnswer === "Aborted.")
2871
+ aborted = true;
2872
+ else if (res.blockOrCancel) {
2873
+ blocked = true;
2874
+ blockedResult = res;
2875
+ }
2876
+ else if (!res.ok)
2877
+ failed = true;
2878
+ };
2879
+ const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
2880
+ for (const group of groups) {
2881
+ if (aborted || blocked || failed)
2882
+ break;
2883
+ if (group.length === 1) {
2884
+ const id = `tool-${++nextToolEventId}`;
2885
+ const res = await executeSingleTool(group[0], id, options.signal || new AbortController().signal);
2886
+ recordResult(res);
2887
+ }
2888
+ else {
2889
+ // Concurrent group — assign ids in document order, then push their
2890
+ // results in document order for a stable transcript.
2891
+ const ids = group.map(() => `tool-${++nextToolEventId}`);
2892
+ const results = await Promise.all(group.map((c, k) => executeSingleTool(c, ids[k], options.signal || new AbortController().signal)));
2893
+ for (const res of results)
2894
+ recordResult(res);
2895
+ }
2304
2896
  }
2305
2897
  if (aborted) {
2306
2898
  lastAnswer = "Aborted.";
@@ -2311,20 +2903,12 @@ export async function runAgentLoop(prompt, options = {}) {
2311
2903
  lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
2312
2904
  return finishTurn(lastAnswer, productiveSteps);
2313
2905
  }
2314
- // Compact older messages when the running estimate exceeds budget
2315
- if (estimateMessagesTokens(messages) > 32_000) {
2316
- const compacted = compactMessages(messages, { keepRecent: 12 });
2317
- if (compacted.length < messages.length) {
2318
- messages.splice(0, messages.length, ...compacted);
2319
- // Reset read-only tool counters so the model can re-fetch data
2320
- // whose results were compacted away.
2321
- loopGuard.resetReadOnly();
2322
- await auditLog("agent.compact", {
2323
- newLength: messages.length,
2324
- estimatedTokens: estimateMessagesTokens(messages),
2325
- });
2326
- }
2327
- }
2906
+ // A plain failure just stops the remaining calls; we fall through so
2907
+ // the model sees the failed tool's output and decides what to do next.
2908
+ // Compact older messages when the running estimate exceeds budget. Uses
2909
+ // the model-written summary path (with plan re-injection) — never a
2910
+ // mechanical transcript dump.
2911
+ await maybeAutoCompact("post-tool-token-budget");
2328
2912
  }
2329
2913
  }
2330
2914
  // maxIterations ceiling reached (safety net — normally the step budget