@pentoshi/clai 2.0.26 → 2.0.27

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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/context-manager.d.ts +5 -2
  3. package/dist/agent/context-manager.js +26 -69
  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 +21 -1
  7. package/dist/agent/runner.js +686 -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/ask.d.ts +29 -0
  18. package/dist/modes/ask.js +188 -23
  19. package/dist/modes/ask.js.map +1 -1
  20. package/dist/prompts/index.d.ts +2 -2
  21. package/dist/prompts/index.js +32 -9
  22. package/dist/prompts/index.js.map +1 -1
  23. package/dist/repl.js +104 -3
  24. package/dist/repl.js.map +1 -1
  25. package/dist/safety/classifier.js +66 -7
  26. package/dist/safety/classifier.js.map +1 -1
  27. package/dist/store/history.js +68 -48
  28. package/dist/store/history.js.map +1 -1
  29. package/dist/tools/fs.d.ts +4 -0
  30. package/dist/tools/fs.js +40 -2
  31. package/dist/tools/fs.js.map +1 -1
  32. package/dist/tools/registry.d.ts +8 -0
  33. package/dist/tools/registry.js +3 -1
  34. package/dist/tools/registry.js.map +1 -1
  35. package/dist/tui/App.js +242 -33
  36. package/dist/tui/App.js.map +1 -1
  37. package/dist/tui/components/ConfirmModal.js +14 -2
  38. package/dist/tui/components/ConfirmModal.js.map +1 -1
  39. package/dist/tui/components/PickerPanel.d.ts +2 -1
  40. package/dist/tui/components/PickerPanel.js +73 -23
  41. package/dist/tui/components/PickerPanel.js.map +1 -1
  42. package/dist/tui/confirm.d.ts +1 -1
  43. package/dist/tui/confirm.js +8 -0
  44. package/dist/tui/confirm.js.map +1 -1
  45. package/dist/tui/hooks/useAgentRunner.d.ts +27 -2
  46. package/dist/tui/hooks/useAgentRunner.js +132 -17
  47. package/dist/tui/hooks/useAgentRunner.js.map +1 -1
  48. package/dist/tui/render-lines.js +28 -1
  49. package/dist/tui/render-lines.js.map +1 -1
  50. package/dist/tui/state.d.ts +1 -1
  51. package/dist/ui/markdown.js +63 -30
  52. package/dist/ui/markdown.js.map +1 -1
  53. 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, } 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,34 @@ 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
+ }
585
848
  /**
586
849
  * Collapse pathological repetition before a message is stored in history.
587
850
  * Some models degenerate into emitting the same short phrase hundreds of
@@ -621,7 +884,7 @@ function textBeforeToolCall(text) {
621
884
  }
622
885
  return text.trim();
623
886
  }
624
- function formatToolArgs(call) {
887
+ export function formatToolArgs(call) {
625
888
  if (call.name === "shell.exec")
626
889
  return String(call.args.command ?? "");
627
890
  if (call.name === "net.scan")
@@ -844,17 +1107,17 @@ function buildWorkflowDirective() {
844
1107
  "1. EXPLORE: fs.list the working directory (and key subdirs) to see what already exists. Use tool.batch to parallelize reads.",
845
1108
  "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
1109
  "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.",
1110
+ "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
1111
  "",
849
1112
  "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`.",
1113
+ '- 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
1114
  "- 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
1115
  "- 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.",
1116
+ '- 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
1117
  "- 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
1118
  "",
856
1119
  "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.",
1120
+ "- 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
1121
  "- Do NOT re-explore. Step 1 (EXPLORE) was already completed during planning. Start executing the first pending task immediately.",
859
1122
  "- ONE task at a time, in ORDER. Do NOT skip ahead to task 3 before task 2 is done.",
860
1123
  "- 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 +1296,51 @@ function summarizeOutput(output, maxChars = 8_000) {
1033
1296
  truncated: true,
1034
1297
  };
1035
1298
  }
1299
+ // Tools whose output is the actual content the model needs verbatim (file
1300
+ // bodies, listings, search hits). Running these through the security-signal
1301
+ // `genericReducer` was wrong: it ranks lines by pentest keywords and drops
1302
+ // the rest, so source code came back as a fragmentary head+tail — the model
1303
+ // saw a "truncated" file and kept re-reading it in wasted retries. For these
1304
+ // we pass the raw content through (up to a generous cap) and point the model
1305
+ // at the saved artifact when it exceeds the cap.
1306
+ const PASSTHROUGH_TOOLS = new Set([
1307
+ "fs.read",
1308
+ "fs.list",
1309
+ "fs.search",
1310
+ "fs.edit",
1311
+ "pdf.read",
1312
+ ]);
1313
+ const PASSTHROUGH_CAP_CHARS = 400_000;
1314
+ // web.fetch/http.fetch pull in arbitrary third-party pages/API responses that
1315
+ // can be hundreds of KB (e.g. a large OpenAPI spec). Unlike local files the
1316
+ // model asked to read, this content is never bounded by the user's own
1317
+ // project, so it must be capped like every other tool's context output —
1318
+ // otherwise a single fetch can single-handedly blow the context budget and
1319
+ // starve the model of room to actually respond (observed as empty/garbled
1320
+ // completions on smaller-context-window models after a big fetch).
1321
+ const WEB_FETCH_CAP_CHARS = 20_000;
1036
1322
  function formatToolContext(call, result) {
1037
1323
  const output = result.output.trim();
1038
1324
  if (!output)
1039
1325
  return "";
1040
1326
  if (call.name === "web.fetch" || call.name === "http.fetch") {
1327
+ const { text, truncated } = summarizeOutput(output, WEB_FETCH_CAP_CHARS);
1328
+ if (!truncated)
1329
+ return text;
1330
+ const saved = result.outputPath
1331
+ ? `\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.]`
1332
+ : `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; only head and tail shown.]`;
1333
+ return `${text}${saved}`.trim();
1334
+ }
1335
+ // Pass-through tools: never reduce — the model needs the real content.
1336
+ if (PASSTHROUGH_TOOLS.has(call.name)) {
1337
+ const { text, truncated } = summarizeOutput(output, PASSTHROUGH_CAP_CHARS);
1338
+ if (!truncated)
1339
+ return text;
1041
1340
  const saved = result.outputPath
1042
- ? `\nFull output saved to: ${result.outputPath}`
1043
- : "";
1044
- return `${output}${saved}`.trim();
1341
+ ? `\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.]`
1342
+ : `\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.]`;
1343
+ return `${text}${saved}`.trim();
1045
1344
  }
1046
1345
  let reduced;
1047
1346
  try {
@@ -1083,6 +1382,13 @@ const inquirerConfirmPort = {
1083
1382
  default: true,
1084
1383
  });
1085
1384
  },
1385
+ async confirmAgentSwitch(info) {
1386
+ const tools = info.tools.length > 0 ? ` (${info.tools.join(", ")})` : "";
1387
+ return confirm({
1388
+ message: chalk.yellow(` this needs agent mode${tools} — switch and run it?`),
1389
+ default: true,
1390
+ });
1391
+ },
1086
1392
  };
1087
1393
  async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
1088
1394
  if (!isPentestToolCall(call))
@@ -1247,6 +1553,24 @@ async function handlePlanTool(call, session, ctx) {
1247
1553
  modelNote: `task.update failed: state must be one of ${validStates.join(", ")}.`,
1248
1554
  };
1249
1555
  }
1556
+ // Only one task may be in_progress at a time. This forces genuine
1557
+ // task-by-task execution: the model must close (done/failed/skipped) the
1558
+ // current task before opening the next one, instead of leaving a task
1559
+ // "in_progress" as an umbrella while it quietly works through the rest
1560
+ // of the plan underneath it.
1561
+ if (stateRaw === "in_progress") {
1562
+ const otherInProgress = plan.tasks.find((t) => t.id !== taskId && t.state === "in_progress");
1563
+ if (otherInProgress) {
1564
+ return {
1565
+ handled: true,
1566
+ ok: false,
1567
+ display: chalk.red(` \u2717 task.update: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress\n`),
1568
+ modelNote: `task.update failed: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress. ` +
1569
+ "Finish it first \u2014 call task.update with state 'done' (or 'failed'/'skipped' with a note) " +
1570
+ `for [${otherInProgress.id}] before starting [${taskId}].`,
1571
+ };
1572
+ }
1573
+ }
1250
1574
  const ok = markTask(plan, taskId, stateRaw, note);
1251
1575
  if (!ok) {
1252
1576
  const ids = plan.tasks.map((t) => t.id).join(", ");
@@ -1513,12 +1837,14 @@ export async function runAgentLoop(prompt, options = {}) {
1513
1837
  promise: Promise.resolve(),
1514
1838
  async acquire() {
1515
1839
  let release = () => { };
1516
- const next = new Promise((r) => { release = r; });
1840
+ const next = new Promise((r) => {
1841
+ release = r;
1842
+ });
1517
1843
  const current = this.promise;
1518
1844
  this.promise = current.then(() => next);
1519
1845
  await current;
1520
1846
  return release;
1521
- }
1847
+ },
1522
1848
  };
1523
1849
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
1524
1850
  let call = normalizeToolCall(rawCall);
@@ -1537,20 +1863,34 @@ export async function runAgentLoop(prompt, options = {}) {
1537
1863
  const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
1538
1864
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1539
1865
  const result = { ok: false, output: reason, exitCode: 1 };
1540
- return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
1866
+ return {
1867
+ ok: false,
1868
+ call,
1869
+ result,
1870
+ contextOutput: reason,
1871
+ blockOrCancel: true,
1872
+ };
1541
1873
  }
1542
1874
  if (loopCheck.reason) {
1543
1875
  writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1544
1876
  }
1545
1877
  if (call.name === "plan.create" || call.name === "task.update") {
1546
- const planResult = await handlePlanTool(call, session, { loopGuard, step });
1878
+ const planResult = await handlePlanTool(call, session, {
1879
+ loopGuard,
1880
+ step,
1881
+ });
1547
1882
  if (planResult.handled) {
1548
1883
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1549
1884
  if (planResult.plan) {
1550
1885
  writePlanUpdate(planResult.plan, planResult.display);
1551
1886
  }
1552
1887
  const result = { ok: planResult.ok, output: planResult.modelNote };
1553
- return { ok: planResult.ok, call, result, contextOutput: planResult.modelNote };
1888
+ return {
1889
+ ok: planResult.ok,
1890
+ call,
1891
+ result,
1892
+ contextOutput: planResult.modelNote,
1893
+ };
1554
1894
  }
1555
1895
  }
1556
1896
  const scope = await loadScope();
@@ -1566,7 +1906,45 @@ export async function runAgentLoop(prompt, options = {}) {
1566
1906
  const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1567
1907
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1568
1908
  const result = { ok: false, output: reason, exitCode: 1 };
1569
- return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
1909
+ return {
1910
+ ok: false,
1911
+ call,
1912
+ result,
1913
+ contextOutput: reason,
1914
+ blockOrCancel: true,
1915
+ };
1916
+ }
1917
+ // ── Task-scoped execution gate ───────────────────────────────────
1918
+ // Once a plan is approved, every non-plan tool call must run while
1919
+ // exactly one task is "in_progress". This stops a model from batching
1920
+ // tool calls for many/all tasks in one turn and only touching task
1921
+ // state at the very end (or never) — the failure mode where a model
1922
+ // claimed most tasks "done" in prose without ever recording it in the
1923
+ // plan. Multiple tool calls per task are still fine; they just must be
1924
+ // bracketed by task.update in_progress → (work) → task.update done.
1925
+ if (session.planApproved.value) {
1926
+ const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
1927
+ if (livePlanForGate) {
1928
+ const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
1929
+ const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
1930
+ if (unfinished && !inProgress) {
1931
+ const nextPending = livePlanForGate.tasks.find((t) => t.state === "pending");
1932
+ const reason = nextPending
1933
+ ? `${call.name} blocked — no task is in_progress. Call task.update {taskId:"${nextPending.id}", state:"in_progress"} before doing any work for it.`
1934
+ : `${call.name} blocked — no task is in_progress.`;
1935
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1936
+ const result = { ok: false, output: reason, exitCode: 1 };
1937
+ // Recoverable ordering mistake (NOT a user/session control gate):
1938
+ // feed the reason back so the model marks the task in_progress and
1939
+ // retries within the same turn, rather than ending the turn.
1940
+ return {
1941
+ ok: false,
1942
+ call,
1943
+ result,
1944
+ contextOutput: `${reason}\nThis tool did NOT run. Emit task.update {state:"in_progress"} for the task first, then the work.`,
1945
+ };
1946
+ }
1947
+ }
1570
1948
  }
1571
1949
  if (call.name === "web.search") {
1572
1950
  sawFreshWebSearch = true;
@@ -1580,9 +1958,20 @@ export async function runAgentLoop(prompt, options = {}) {
1580
1958
  }
1581
1959
  if (decision.level === "block") {
1582
1960
  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 };
1961
+ const message = `Blocked: ${call.name} — ${decision.reason}`;
1962
+ const result = { ok: false, output: message, exitCode: 1 };
1963
+ // Safety classifier blocks are recoverable model mistakes: feed the
1964
+ // failed result back to the model and let it choose a safer next step
1965
+ // instead of ending the entire agent turn. Hard workflow gates
1966
+ // (plan not approved, task not in_progress, auth declined, aborts)
1967
+ // still use blockOrCancel above/below because continuing would violate
1968
+ // user/session control rather than merely correcting a bad command.
1969
+ return {
1970
+ ok: false,
1971
+ call,
1972
+ result,
1973
+ contextOutput: `${message}\nThis tool call did not run. Continue the task using a safer allowed method; do not retry the same blocked command unchanged.`,
1974
+ };
1586
1975
  }
1587
1976
  let authorized = true;
1588
1977
  let pentestJustConfirmed = false;
@@ -1598,7 +1987,14 @@ export async function runAgentLoop(prompt, options = {}) {
1598
1987
  const lastAnswer = "Pentest authorization not confirmed.";
1599
1988
  writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1600
1989
  const result = { ok: false, output: lastAnswer, exitCode: 1 };
1601
- return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
1990
+ return {
1991
+ ok: false,
1992
+ call,
1993
+ result,
1994
+ contextOutput: lastAnswer,
1995
+ lastAnswer,
1996
+ blockOrCancel: true,
1997
+ };
1602
1998
  }
1603
1999
  if (needsPentestAuth) {
1604
2000
  pentestJustConfirmed = true;
@@ -1608,9 +2004,17 @@ export async function runAgentLoop(prompt, options = {}) {
1608
2004
  !isPreApprovalAllowedTool(call.name)) {
1609
2005
  const pathArg = typeof call.args.path === "string" ? call.args.path : undefined;
1610
2006
  if (pathArg) {
1611
- const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\") ? join(homedir(), p.slice(2)) : p === "~" ? homedir() : p;
2007
+ const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\")
2008
+ ? join(homedir(), p.slice(2))
2009
+ : p === "~"
2010
+ ? homedir()
2011
+ : p;
1612
2012
  const resolved = resolve(expandHomeLocal(pathArg));
1613
- const mode = (call.name === "fs.read" || call.name === "fs.list" || call.name === "fs.search") ? "read" : "write";
2013
+ const mode = call.name === "fs.read" ||
2014
+ call.name === "fs.list" ||
2015
+ call.name === "fs.search"
2016
+ ? "read"
2017
+ : "write";
1614
2018
  if (!pathInsideSandbox(resolved, mode)) {
1615
2019
  forceManualConfirm = true;
1616
2020
  }
@@ -1623,7 +2027,14 @@ export async function runAgentLoop(prompt, options = {}) {
1623
2027
  const lastAnswer = "Cancelled.";
1624
2028
  writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1625
2029
  const result = { ok: false, output: lastAnswer, exitCode: 1 };
1626
- return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
2030
+ return {
2031
+ ok: false,
2032
+ call,
2033
+ result,
2034
+ contextOutput: lastAnswer,
2035
+ lastAnswer,
2036
+ blockOrCancel: true,
2037
+ };
1627
2038
  }
1628
2039
  }
1629
2040
  }
@@ -1715,7 +2126,13 @@ export async function runAgentLoop(prompt, options = {}) {
1715
2126
  jobManager.updateJobStatus(jobId, "failed", 1);
1716
2127
  if (isAbortError(toolError, toolAc.signal)) {
1717
2128
  writeAbort();
1718
- return { ok: false, call, result: { ok: false, output: "Aborted." }, contextOutput: "Aborted.", lastAnswer: "Aborted." };
2129
+ return {
2130
+ ok: false,
2131
+ call,
2132
+ result: { ok: false, output: "Aborted." },
2133
+ contextOutput: "Aborted.",
2134
+ lastAnswer: "Aborted.",
2135
+ };
1719
2136
  }
1720
2137
  const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
1721
2138
  result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
@@ -1794,6 +2211,81 @@ export async function runAgentLoop(prompt, options = {}) {
1794
2211
  }
1795
2212
  return { ok: result.ok, call, result, contextOutput };
1796
2213
  }
2214
+ // ── Automatic context compaction ───────────────────────────────────────
2215
+ // As a long turn accumulates tool outputs and reasoning, the context can
2216
+ // grow past what the model can hold. We proactively summarize the older
2217
+ // turns into a single continuation memory (the SAME model-written summary
2218
+ // the /compact command uses — never a mechanical transcript dump) and then
2219
+ // re-inject the ACTIVE PLAN so the agent never loses track of the plan,
2220
+ // what is done, and what remains. The estimate is chars/4; the budget is
2221
+ // deliberately conservative so we compact a little early rather than hit a
2222
+ // provider context-window error mid-task.
2223
+ const AUTO_COMPACT_TOKEN_BUDGET = 60_000;
2224
+ const AUTO_COMPACT_KEEP_RECENT = 12;
2225
+ let lastCompactionMsgCount = 0;
2226
+ const summarizeForCompaction = async (summaryPrompt) => {
2227
+ const response = await completeWithProvider({
2228
+ provider,
2229
+ model,
2230
+ messages: [
2231
+ {
2232
+ role: "system",
2233
+ content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
2234
+ },
2235
+ { role: "user", content: summaryPrompt },
2236
+ ],
2237
+ temperature: 0.1,
2238
+ maxTokens: 2_048,
2239
+ signal: options.signal,
2240
+ });
2241
+ return response.text;
2242
+ };
2243
+ async function maybeAutoCompact(reason, force = false) {
2244
+ const beforeTokens = estimateMessagesTokens(messages);
2245
+ if (!force && beforeTokens < AUTO_COMPACT_TOKEN_BUDGET)
2246
+ return;
2247
+ if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
2248
+ return;
2249
+ // Avoid compaction loops: don't re-compact until enough new messages have
2250
+ // accumulated since the last compaction.
2251
+ if (messages.length <= lastCompactionMsgCount + 4)
2252
+ return;
2253
+ try {
2254
+ const result = await compactMessagesWithSummary(messages, summarizeForCompaction, { budgetTokens: 0, keepRecent: AUTO_COMPACT_KEEP_RECENT });
2255
+ if (!result.summarized || result.afterTokens >= beforeTokens)
2256
+ return;
2257
+ messages.splice(0, messages.length, ...result.messages);
2258
+ loopGuard.resetReadOnly();
2259
+ // Re-inject the live plan so the model keeps full plan awareness even
2260
+ // after older turns (which carried the plan context) were summarized.
2261
+ const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2262
+ if (livePlan) {
2263
+ messages.push({
2264
+ role: "system",
2265
+ content: planContextMessage(livePlan, session.planApproved.value),
2266
+ });
2267
+ }
2268
+ lastCompactionMsgCount = messages.length;
2269
+ const afterTokens = estimateMessagesTokens(messages);
2270
+ await auditLog("agent.compact", {
2271
+ newLength: messages.length,
2272
+ estimatedTokens: afterTokens,
2273
+ reason,
2274
+ });
2275
+ 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`));
2276
+ }
2277
+ catch (error) {
2278
+ if (error instanceof Error &&
2279
+ (error.name === "AbortError" || error.message.includes("aborted"))) {
2280
+ throw error;
2281
+ }
2282
+ // Summarization failed — DO NOT fall back to a mechanical dump. Keep the
2283
+ // current context and continue; we'll try again as it keeps growing.
2284
+ await auditLog("agent.compact.failed", {
2285
+ reason: error instanceof Error ? error.message : String(error),
2286
+ });
2287
+ }
2288
+ }
1797
2289
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1798
2290
  // `step` is the productive-step index (used for display + audit). It only
1799
2291
  // advances when the previous iteration actually executed a tool.
@@ -1815,25 +2307,19 @@ export async function runAgentLoop(prompt, options = {}) {
1815
2307
  const extension = Math.max(40, maxSteps);
1816
2308
  stepBudget += extension;
1817
2309
  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
- }
2310
+ // Compact older messages (model-written summary, no mechanical dump)
2311
+ // to free context space for the next chunk of work.
2312
+ await maybeAutoCompact("step-budget-continue", true);
1829
2313
  // Inject a progress summary so the model stays focused.
1830
2314
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1831
2315
  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.";
2316
+ progressNote +=
2317
+ "Review what you have accomplished so far and continue with the NEXT unfinished step. ";
2318
+ progressNote +=
2319
+ "Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
1834
2320
  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");
2321
+ const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
2322
+ const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1837
2323
  progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
1838
2324
  if (pendingTasks.length > 0) {
1839
2325
  progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
@@ -1867,6 +2353,10 @@ export async function runAgentLoop(prompt, options = {}) {
1867
2353
  writeStatus(batchStatus, chalk.dim(batchStatus));
1868
2354
  }
1869
2355
  else {
2356
+ // Before a fresh model round-trip, proactively compact if the context has
2357
+ // grown too large, so we never hit a provider context-window error and the
2358
+ // model keeps a clean, plan-aware memory.
2359
+ await maybeAutoCompact("auto-token-budget");
1870
2360
  // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1871
2361
  // Status messages (rate-limit retries, fallback hints) still surface live.
1872
2362
  // A spinner gives the user feedback during long thinking phases on
@@ -1950,6 +2440,16 @@ export async function runAgentLoop(prompt, options = {}) {
1950
2440
  deltaParser?.finish();
1951
2441
  const assistantTextResult = rememberThinkingFromText(completion.text);
1952
2442
  assistantText = assistantTextResult;
2443
+ // Commit thinking to the transcript IMMEDIATELY, before any of the
2444
+ // branches below decide to `continue` (retry a malformed tool call,
2445
+ // nudge for narration, guard premature completion, etc). Previously
2446
+ // writeThinkingBlock was only called from a few terminal branches, so
2447
+ // any retry path silently dropped the model's reasoning — the user
2448
+ // would see the live "thinking…" preview during streaming and then
2449
+ // watch it vanish with nothing committed once the turn moved on.
2450
+ if (assistantText.hasThinking) {
2451
+ writeThinkingBlock(assistantText.thinkContent);
2452
+ }
1953
2453
  // Try visible text first, then thinking content — some models (e.g. glm-5.1)
1954
2454
  // wrap tool calls inside considering tags, so stripThinking removes them
1955
2455
  // into thinkContent and visible becomes empty. Recovering from thinkContent
@@ -1977,7 +2477,6 @@ export async function runAgentLoop(prompt, options = {}) {
1977
2477
  emptyVisibleRetries += 1;
1978
2478
  if (emptyVisibleRetries <= 3) {
1979
2479
  if (assistantText.hasThinking) {
1980
- writeThinkingBlock(assistantText.thinkContent);
1981
2480
  writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
1982
2481
  }
1983
2482
  else {
@@ -2041,7 +2540,10 @@ export async function runAgentLoop(prompt, options = {}) {
2041
2540
  bareToolJsonRetries += 1;
2042
2541
  if (bareToolJsonRetries <= 3) {
2043
2542
  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 });
2543
+ messages.push({
2544
+ role: "assistant",
2545
+ content: assistantText.visible,
2546
+ });
2045
2547
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
2046
2548
  ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
2047
2549
  "This is a BUILD/SCAFFOLD task with NO plan yet. " +
@@ -2063,7 +2565,10 @@ export async function runAgentLoop(prompt, options = {}) {
2063
2565
  // model to retry the tool call in a clean JSON format.
2064
2566
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
2065
2567
  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 });
2568
+ messages.push({
2569
+ role: "assistant",
2570
+ content: assistantText.visible,
2571
+ });
2067
2572
  messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
2068
2573
  "Reply with ONLY a fenced ```tool block containing valid JSON " +
2069
2574
  'of the form `{"name": "<tool>", "args": { ... }}`. ' +
@@ -2078,7 +2583,10 @@ export async function runAgentLoop(prompt, options = {}) {
2078
2583
  truncatedToolRetries += 1;
2079
2584
  if (truncatedToolRetries <= 3) {
2080
2585
  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 });
2586
+ messages.push({
2587
+ role: "assistant",
2588
+ content: assistantText.visible,
2589
+ });
2082
2590
  messages.push({
2083
2591
  role: "user",
2084
2592
  content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
@@ -2105,7 +2613,10 @@ export async function runAgentLoop(prompt, options = {}) {
2105
2613
  malformedFenceRetries += 1;
2106
2614
  if (malformedFenceRetries <= 3) {
2107
2615
  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 });
2616
+ messages.push({
2617
+ role: "assistant",
2618
+ content: assistantText.visible,
2619
+ });
2109
2620
  messages.push({
2110
2621
  role: "user",
2111
2622
  content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
@@ -2138,9 +2649,7 @@ export async function runAgentLoop(prompt, options = {}) {
2138
2649
  if (wantsAction &&
2139
2650
  cleaned.trim().length > 0 &&
2140
2651
  actionIntentRetries < 3 &&
2141
- (productiveSteps === 0 ||
2142
- planNarrated ||
2143
- narratedAction)) {
2652
+ (productiveSteps === 0 || planNarrated || narratedAction)) {
2144
2653
  actionIntentRetries += 1;
2145
2654
  let nudge;
2146
2655
  if (activePlan && session.planApproved.value) {
@@ -2179,14 +2688,22 @@ export async function runAgentLoop(prompt, options = {}) {
2179
2688
  "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
2689
  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
2690
  }
2182
- messages.push({ role: "assistant", content: assistantText.visible });
2691
+ messages.push({
2692
+ role: "assistant",
2693
+ content: assistantText.visible,
2694
+ });
2183
2695
  messages.push(recoveryUserMessage(nudge));
2184
2696
  continue;
2185
2697
  }
2186
- if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
2698
+ if (freshWebSearchRequired &&
2699
+ !sawFreshWebSearch &&
2700
+ !freshnessRetryUsed) {
2187
2701
  freshnessRetryUsed = true;
2188
2702
  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 });
2703
+ messages.push({
2704
+ role: "assistant",
2705
+ content: assistantText.visible,
2706
+ });
2190
2707
  messages.push({
2191
2708
  role: "user",
2192
2709
  content: freshnessGuardMessage() +
@@ -2207,7 +2724,10 @@ export async function runAgentLoop(prompt, options = {}) {
2207
2724
  prematureCompletionRetries += 1;
2208
2725
  const next = unfinished[0];
2209
2726
  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 });
2727
+ messages.push({
2728
+ role: "assistant",
2729
+ content: assistantText.visible,
2730
+ });
2211
2731
  messages.push({
2212
2732
  role: "user",
2213
2733
  content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
@@ -2248,9 +2768,6 @@ export async function runAgentLoop(prompt, options = {}) {
2248
2768
  if (completionWarning) {
2249
2769
  writeNotice("warn", completionWarningText, completionWarning);
2250
2770
  }
2251
- if (assistantText.hasThinking) {
2252
- writeThinkingBlock(assistantText.thinkContent);
2253
- }
2254
2771
  await auditLog("agent.final", { provider, model, steps: step + 1 });
2255
2772
  lastAnswer = cleaned;
2256
2773
  return finishTurn(lastAnswer, step + 1);
@@ -2263,44 +2780,86 @@ export async function runAgentLoop(prompt, options = {}) {
2263
2780
  if (beforeTool) {
2264
2781
  writeAssistantMessage(beforeTool);
2265
2782
  }
2266
- if (assistantText.hasThinking) {
2267
- writeThinkingBlock(assistantText.thinkContent);
2268
- }
2269
2783
  let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
2270
2784
  if (allCalls.length === 0 && call) {
2271
2785
  allCalls = [call];
2272
2786
  }
2273
2787
  const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
2274
- allCalls.map(c => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``).join("\n\n");
2788
+ allCalls
2789
+ .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
2790
+ .join("\n\n");
2275
2791
  messages.push({
2276
2792
  role: "assistant",
2277
2793
  content: standardizedContent,
2278
2794
  });
2279
2795
  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`));
2796
+ 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
2797
  }
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);
2798
+ // ── Scoped-parallel batch execution ────────────────────────────────
2799
+ // The model may emit several calls in one message. We partition them,
2800
+ // IN DOCUMENT ORDER, into segments:
2801
+ // • A run of consecutive READ-ONLY, safe-classified calls (the same
2802
+ // allowlist tool.batch uses) executes CONCURRENTLY — this is where
2803
+ // independent lookups within a single task fan out (e.g. whois +
2804
+ // dns + http.fetch during recon).
2805
+ // • Every other call (plan.create/task.update, and any mutating or
2806
+ // confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
2807
+ // runs ALONE as a sequential barrier.
2808
+ // Because task.update is never parallel-safe, it always acts as a
2809
+ // barrier: it commits before the work it gates and after the work it
2810
+ // closes. That keeps execution strictly task-by-task and eliminates the
2811
+ // plan-state races / overlapping writes that a blanket Promise.all
2812
+ // caused, while still letting one task's independent lookups run in
2813
+ // parallel. The whole batch stops on the first abort, block, or failure
2814
+ // so the model always sees and reacts to an error.
2815
+ const scopeForBatch = await loadScope().catch(() => undefined);
2816
+ const isParallelSafe = (c) => {
2817
+ if (!BATCH_SAFE_TOOLS.has(c.name))
2818
+ return false;
2819
+ try {
2820
+ return (classifyToolCall(c, { scope: scopeForBatch }).level === "safe");
2821
+ }
2822
+ catch {
2823
+ return false;
2824
+ }
2825
+ };
2826
+ const PARALLEL_LIMIT = 4;
2288
2827
  let aborted = false;
2289
2828
  let blocked = false;
2290
2829
  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
- }
2830
+ let failed = false;
2831
+ const recordResult = (res) => {
2299
2832
  messages.push({
2300
2833
  role: "tool",
2301
2834
  content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
2302
2835
  });
2303
2836
  productiveSteps += 1;
2837
+ if (res.lastAnswer === "Aborted.")
2838
+ aborted = true;
2839
+ else if (res.blockOrCancel) {
2840
+ blocked = true;
2841
+ blockedResult = res;
2842
+ }
2843
+ else if (!res.ok)
2844
+ failed = true;
2845
+ };
2846
+ const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
2847
+ for (const group of groups) {
2848
+ if (aborted || blocked || failed)
2849
+ break;
2850
+ if (group.length === 1) {
2851
+ const id = `tool-${++nextToolEventId}`;
2852
+ const res = await executeSingleTool(group[0], id, options.signal || new AbortController().signal);
2853
+ recordResult(res);
2854
+ }
2855
+ else {
2856
+ // Concurrent group — assign ids in document order, then push their
2857
+ // results in document order for a stable transcript.
2858
+ const ids = group.map(() => `tool-${++nextToolEventId}`);
2859
+ const results = await Promise.all(group.map((c, k) => executeSingleTool(c, ids[k], options.signal || new AbortController().signal)));
2860
+ for (const res of results)
2861
+ recordResult(res);
2862
+ }
2304
2863
  }
2305
2864
  if (aborted) {
2306
2865
  lastAnswer = "Aborted.";
@@ -2311,20 +2870,12 @@ export async function runAgentLoop(prompt, options = {}) {
2311
2870
  lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
2312
2871
  return finishTurn(lastAnswer, productiveSteps);
2313
2872
  }
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
- }
2873
+ // A plain failure just stops the remaining calls; we fall through so
2874
+ // the model sees the failed tool's output and decides what to do next.
2875
+ // Compact older messages when the running estimate exceeds budget. Uses
2876
+ // the model-written summary path (with plan re-injection) — never a
2877
+ // mechanical transcript dump.
2878
+ await maybeAutoCompact("post-tool-token-budget");
2328
2879
  }
2329
2880
  }
2330
2881
  // maxIterations ceiling reached (safety net — normally the step budget