@pentoshi/clai 2.0.14 → 2.0.16

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 (63) hide show
  1. package/dist/agent/context-manager.js +40 -3
  2. package/dist/agent/context-manager.js.map +1 -1
  3. package/dist/agent/runner.d.ts +4 -0
  4. package/dist/agent/runner.js +362 -395
  5. package/dist/agent/runner.js.map +1 -1
  6. package/dist/commands/providers.js +10 -11
  7. package/dist/commands/providers.js.map +1 -1
  8. package/dist/commands/search-providers.js +10 -11
  9. package/dist/commands/search-providers.js.map +1 -1
  10. package/dist/commands/update.js +1 -1
  11. package/dist/llm/groq.d.ts +1 -0
  12. package/dist/llm/groq.js +45 -1
  13. package/dist/llm/groq.js.map +1 -1
  14. package/dist/llm/http.js +9 -2
  15. package/dist/llm/http.js.map +1 -1
  16. package/dist/llm/provider.js +4 -7
  17. package/dist/llm/provider.js.map +1 -1
  18. package/dist/llm/router.js +8 -2
  19. package/dist/llm/router.js.map +1 -1
  20. package/dist/modes/ask.js +5 -3
  21. package/dist/modes/ask.js.map +1 -1
  22. package/dist/prompts/index.d.ts +2 -2
  23. package/dist/prompts/index.js +6 -3
  24. package/dist/prompts/index.js.map +1 -1
  25. package/dist/safety/classifier.js +10 -50
  26. package/dist/safety/classifier.js.map +1 -1
  27. package/dist/store/keys.js +50 -18
  28. package/dist/store/keys.js.map +1 -1
  29. package/dist/tools/http.d.ts +1 -0
  30. package/dist/tools/http.js +109 -9
  31. package/dist/tools/http.js.map +1 -1
  32. package/dist/tools/jobs.d.ts +4 -0
  33. package/dist/tools/jobs.js +29 -1
  34. package/dist/tools/jobs.js.map +1 -1
  35. package/dist/tools/registry.js +5 -8
  36. package/dist/tools/registry.js.map +1 -1
  37. package/dist/tools/web/fetch-core.js +6 -9
  38. package/dist/tools/web/fetch-core.js.map +1 -1
  39. package/dist/tools/web/fetch.js +26 -0
  40. package/dist/tools/web/fetch.js.map +1 -1
  41. package/dist/tools/web/readable.d.ts +7 -3
  42. package/dist/tools/web/readable.js +235 -11
  43. package/dist/tools/web/readable.js.map +1 -1
  44. package/dist/tools/web/types.d.ts +7 -8
  45. package/dist/tools/web/types.js +3 -3
  46. package/dist/tools/web/types.js.map +1 -1
  47. package/dist/tui/App.js +114 -27
  48. package/dist/tui/App.js.map +1 -1
  49. package/dist/tui/components/SecretInputPanel.d.ts +1 -0
  50. package/dist/tui/components/SecretInputPanel.js +15 -2
  51. package/dist/tui/components/SecretInputPanel.js.map +1 -1
  52. package/dist/tui/format-keys.js +16 -15
  53. package/dist/tui/format-keys.js.map +1 -1
  54. package/dist/tui/hooks/useAgentRunner.d.ts +1 -2
  55. package/dist/tui/hooks/useAgentRunner.js +5 -2
  56. package/dist/tui/hooks/useAgentRunner.js.map +1 -1
  57. package/dist/tui/render-lines.js +187 -30
  58. package/dist/tui/render-lines.js.map +1 -1
  59. package/dist/tui/state.js +10 -19
  60. package/dist/tui/state.js.map +1 -1
  61. package/dist/ui/thinking.js +14 -2
  62. package/dist/ui/thinking.js.map +1 -1
  63. package/package.json +1 -1
@@ -4,6 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { streamWithProvider } from "../llm/router.js";
7
+ import { randomUUID } from "node:crypto";
8
+ import { jobManager } from "../tools/jobs.js";
7
9
  import { currentDateTimeContext, renderAgentSystemPrompt, } from "../prompts/index.js";
8
10
  import { getConfig } from "../store/config.js";
9
11
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
@@ -270,23 +272,17 @@ function stripLoneFence(text) {
270
272
  return (fenced?.[1] ?? text).trim();
271
273
  }
272
274
  /**
273
- * When a model means to call a tool but emits ONLY a bare JSON object
274
- * either a proper {"name","args"} that the strict matchers missed, or a bare
275
- * args object like {"path":"file.pdf"} with the wrapper/fence dropped — this
276
- * recognizes it. Returns:
277
- * - { call } when the object is a complete {name, args} tool call, or
278
- * - { argsOnly: true } when it looks like a bare args object (so the caller
279
- * can nudge the model to re-emit a properly named, fenced tool call).
280
- * Returns undefined for anything that is plainly a normal prose/JSON answer.
275
+ * Try to recover a bare-args tool call from a single candidate text snippet.
276
+ * Returns the recognized result or undefined if the text isn't a recoverable
277
+ * tool call. Used by both the whole-text path and the embedded-fence path.
281
278
  */
282
- export function recognizeBareToolJson(text) {
283
- const inner = stripLoneFence(text);
284
- // Must be a single JSON object spanning the whole (de-fenced) output.
285
- if (!inner.startsWith("{") || !inner.endsWith("}"))
279
+ function tryRecognizeBareArgs(inner) {
280
+ const trimmed = inner.trim();
281
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
286
282
  return undefined;
287
283
  let parsed;
288
284
  try {
289
- parsed = JSON.parse(inner);
285
+ parsed = JSON.parse(trimmed);
290
286
  }
291
287
  catch {
292
288
  return undefined;
@@ -295,28 +291,64 @@ export function recognizeBareToolJson(text) {
295
291
  return undefined;
296
292
  }
297
293
  const obj = parsed;
298
- // Complete {name, args} call the earlier matchers didn't catch (e.g. not
299
- // anchored to end-of-string). Recover it directly.
300
- const direct = tryParseCall(inner);
294
+ // Complete {name, args} call the earlier matchers didn't catch.
295
+ const direct = tryParseCall(trimmed);
301
296
  if (direct)
302
297
  return { call: direct };
303
- // Bare args object: every key is a known tool-arg key, and it carries at
304
- // least one identifying arg. Don't treat huge/odd objects as tool args.
298
+ // Bare args object: every key is a known tool-arg key.
305
299
  const keys = Object.keys(obj);
306
300
  if (keys.length === 0 || keys.length > 6)
307
301
  return undefined;
308
302
  const allKnown = keys.every((key) => TOOL_ARG_KEYS.has(key));
309
303
  if (!allKnown)
310
304
  return undefined;
311
- // Try to infer the tool from the arg shape so an unambiguous bare object
312
- // (e.g. {"command":"…"}) runs immediately instead of forcing the user to
313
- // type "run". Ambiguous shapes fall back to a re-emit nudge.
314
305
  const inferred = inferToolFromArgs(obj);
315
306
  if (inferred) {
316
307
  return { call: { name: inferred, args: obj } };
317
308
  }
318
309
  return { argsOnly: true };
319
310
  }
311
+ /**
312
+ * When a model means to call a tool but emits ONLY a bare JSON object —
313
+ * either a proper {"name","args"} that the strict matchers missed, or a bare
314
+ * args object like {"path":"file.pdf"} with the wrapper/fence dropped — this
315
+ * recognizes it. Returns:
316
+ * - { call } when the object is a complete {name, args} tool call, or
317
+ * - { argsOnly: true } when it looks like a bare args object (so the caller
318
+ * can nudge the model to re-emit a properly named, fenced tool call).
319
+ * Returns undefined for anything that is plainly a normal prose/JSON answer.
320
+ *
321
+ * Also handles the case where a model emits prose followed by a non-`tool`
322
+ * fenced code block (e.g. ```web\n{"url":"..."}\n```) that contains a bare
323
+ * args object — the fence is scanned even when it's not the sole content.
324
+ */
325
+ export function recognizeBareToolJson(text) {
326
+ // ── Primary path: the whole (de-fenced) text is a bare JSON object ──────
327
+ const inner = stripLoneFence(text);
328
+ const primary = tryRecognizeBareArgs(inner);
329
+ if (primary)
330
+ return primary;
331
+ // ── Secondary path: scan for any fenced block embedded in the text ──────
332
+ // This catches models that prepend prose before emitting a bare-args fence,
333
+ // e.g. "Let me fetch it.\n\n```web\n{\"url\":\"https://...\"}\n```"
334
+ // We skip ```tool fences — those are handled by parseToolCall already.
335
+ const embeddedFenceRe = /```([a-zA-Z]*)\s*\n?([\s\S]*?)```/g;
336
+ let m;
337
+ while ((m = embeddedFenceRe.exec(text)) !== null) {
338
+ const lang = m[1] ?? "";
339
+ const body = (m[2] ?? "").trim();
340
+ // Skip ```tool blocks — parseToolCall owns those.
341
+ if (lang.toLowerCase() === "tool")
342
+ continue;
343
+ // Skip empty or multi-line JSON that spans more than a simple object.
344
+ if (!body.startsWith("{") || !body.endsWith("}"))
345
+ continue;
346
+ const result = tryRecognizeBareArgs(body);
347
+ if (result)
348
+ return result;
349
+ }
350
+ return undefined;
351
+ }
320
352
  /**
321
353
  * Detect an opened-but-unparseable tool call. This happens when the model's
322
354
  * output is truncated by the token limit mid-JSON: we see the ```tool fence
@@ -585,7 +617,7 @@ export function looksLikeBuildTask(prompt, history) {
585
617
  }
586
618
  // Matrix of action-verb narration: the model says it is *about to* do
587
619
  // something but hasn't. Used to detect "narrate, don't act" stalls.
588
- const ACTION_NARRATION_RE = /\b(?:let me|let's|i'?ll|i will|i'?m going to|i am going to|i need to|i should|i'?m about to|going to|now i'?ll|first[,]?\s*i'?ll)\s+(?:now\s+|first\s+|quickly\s+|just\s+|go\s+ahead\s+and\s+)?(?:explore|list|read|check|inspect|examine|look|create|run|start|write|build|add|scaffold|set\s*up|setup|install|initialize|init|generate|make|review|open|find|search|verify|update|edit|modify|fix|implement)\b/i;
620
+ const ACTION_NARRATION_RE = /\b(?:let me|let's|i'?ll|i will|i'?m going to|i am going to|i need to|i should|i'?m about to|going to|now i'?ll|first[,]?\s*i'?ll)\s+(?:now\s+|first\s+|quickly\s+|just\s+|go\s+ahead\s+and\s+)?(?:explore|list|read|fetch|browse|check|inspect|examine|look|create|run|start|write|build|add|scaffold|set\s*up|setup|install|initialize|init|generate|make|review|open|find|search|verify|update|edit|modify|fix|implement)\b/i;
589
621
  /**
590
622
  * Detect a message that narrates an *upcoming* action ("let me explore the
591
623
  * directory", "I'll create the components") rather than an actual answer or
@@ -804,6 +836,12 @@ function formatToolContext(call, result) {
804
836
  const output = result.output.trim();
805
837
  if (!output)
806
838
  return "";
839
+ if (call.name === "web.fetch" || call.name === "http.fetch") {
840
+ const saved = result.outputPath
841
+ ? `\nFull output saved to: ${result.outputPath}`
842
+ : "";
843
+ return `${output}${saved}`.trim();
844
+ }
807
845
  let reduced;
808
846
  try {
809
847
  const command = call.name === "shell.exec" ? String(call.args.command ?? "") : call.name;
@@ -1261,6 +1299,258 @@ export async function runAgentLoop(prompt, options = {}) {
1261
1299
  let productiveSteps = 0;
1262
1300
  let step = -1;
1263
1301
  let nextToolEventId = 0;
1302
+ const promptMutex = {
1303
+ promise: Promise.resolve(),
1304
+ async acquire() {
1305
+ let release = () => { };
1306
+ const next = new Promise((r) => { release = r; });
1307
+ const current = this.promise;
1308
+ this.promise = current.then(() => next);
1309
+ await current;
1310
+ return release;
1311
+ }
1312
+ };
1313
+ async function executeSingleTool(rawCall, toolEventId, parentSignal) {
1314
+ let call = normalizeToolCall(rawCall);
1315
+ if (call.name === "image.ocr" && !imageOcrEnabled) {
1316
+ writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
1317
+ const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
1318
+ "Do not call image.ocr or infer text from OCR. Answer the user's question from the actual image pixels now.";
1319
+ const result = { ok: true, output: recoveryText };
1320
+ return { ok: true, call, result, contextOutput: recoveryText };
1321
+ }
1322
+ const loopCheck = loopGuard.shouldBlock(call.name, call.args);
1323
+ if (loopCheck.block) {
1324
+ const isWrite = call.name === "fs.write" ||
1325
+ call.name === "fs.writeMany" ||
1326
+ call.name === "fs.edit";
1327
+ const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
1328
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1329
+ const result = { ok: false, output: reason, exitCode: 1 };
1330
+ return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
1331
+ }
1332
+ if (loopCheck.reason) {
1333
+ writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1334
+ }
1335
+ if (call.name === "plan.create" || call.name === "task.update") {
1336
+ const planResult = await handlePlanTool(call, session, { loopGuard, step });
1337
+ if (planResult.handled) {
1338
+ loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1339
+ if (planResult.plan) {
1340
+ writePlanUpdate(planResult.plan, planResult.display);
1341
+ }
1342
+ const result = { ok: planResult.ok, output: planResult.modelNote };
1343
+ return { ok: planResult.ok, call, result, contextOutput: planResult.modelNote };
1344
+ }
1345
+ }
1346
+ const scope = await loadScope();
1347
+ const decision = classifyToolCall(call, { scope });
1348
+ await auditLog("tool.classified", {
1349
+ call,
1350
+ decision,
1351
+ scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
1352
+ });
1353
+ if (activePlan &&
1354
+ !session.planApproved.value &&
1355
+ !isPreApprovalAllowedTool(call.name)) {
1356
+ const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1357
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1358
+ const result = { ok: false, output: reason, exitCode: 1 };
1359
+ return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
1360
+ }
1361
+ if (call.name === "web.search") {
1362
+ sawFreshWebSearch = true;
1363
+ }
1364
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1365
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1366
+ const scopeTarget = scopeTargetForToolCall(call);
1367
+ if (scopeTarget &&
1368
+ (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
1369
+ writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1370
+ }
1371
+ if (decision.level === "block") {
1372
+ writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1373
+ const lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
1374
+ const result = { ok: false, output: lastAnswer, exitCode: 1 };
1375
+ return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
1376
+ }
1377
+ let authorized = true;
1378
+ let pentestJustConfirmed = false;
1379
+ const releasePrompt = await promptMutex.acquire();
1380
+ try {
1381
+ parentSignal.throwIfAborted();
1382
+ const needsPentestAuth = isPentestToolCall(call) &&
1383
+ !getConfig().pentestAuthorized &&
1384
+ !session.pentestAuthorized.value;
1385
+ authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
1386
+ restoreInteractiveStdin();
1387
+ if (!authorized) {
1388
+ const lastAnswer = "Pentest authorization not confirmed.";
1389
+ writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1390
+ const result = { ok: false, output: lastAnswer, exitCode: 1 };
1391
+ return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
1392
+ }
1393
+ if (needsPentestAuth) {
1394
+ pentestJustConfirmed = true;
1395
+ }
1396
+ const forceManualConfirm = call.name === "fs.delete";
1397
+ if (decision.level === "confirm" && !pentestJustConfirmed) {
1398
+ const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
1399
+ restoreInteractiveStdin();
1400
+ if (!ok) {
1401
+ const lastAnswer = "Cancelled.";
1402
+ writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1403
+ const result = { ok: false, output: lastAnswer, exitCode: 1 };
1404
+ return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
1405
+ }
1406
+ }
1407
+ }
1408
+ finally {
1409
+ releasePrompt();
1410
+ }
1411
+ parentSignal.throwIfAborted();
1412
+ options.onToolStart?.(call);
1413
+ const interactiveCommand = (call.name === "shell.exec" &&
1414
+ typeof call.args.command === "string" &&
1415
+ looksInteractiveStdin(call.args.command)) ||
1416
+ call.name === "net.scan" ||
1417
+ call.name === "pentest.recon";
1418
+ if (interactiveCommand && process.stdin.isTTY) {
1419
+ writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
1420
+ }
1421
+ const toolAc = new AbortController();
1422
+ const onParentAbort = () => toolAc.abort();
1423
+ parentSignal.addEventListener("abort", onParentAbort);
1424
+ let result;
1425
+ let liveBytes = 0;
1426
+ const liveCap = 16_000;
1427
+ let liveTruncatedNotified = false;
1428
+ let lastProgressAt = 0;
1429
+ const shouldDimLive = !interactiveCommand;
1430
+ const writeToolInfo = (text) => {
1431
+ writeToolOutput(toolEventId, `${text}\n`, chalk.dim(` ${text}\n`));
1432
+ };
1433
+ const printLive = (chunk) => {
1434
+ if (call.name === "fs.read" ||
1435
+ call.name === "fs.list" ||
1436
+ call.name === "fs.search")
1437
+ return;
1438
+ if (liveBytes >= liveCap) {
1439
+ if (!liveTruncatedNotified) {
1440
+ liveTruncatedNotified = true;
1441
+ writeToolInfo("... live preview truncated, full output saved");
1442
+ writeToolInfo("(tool still running — ESC or Ctrl+C to abort)");
1443
+ lastProgressAt = Date.now();
1444
+ }
1445
+ const now = Date.now();
1446
+ if (now - lastProgressAt > 5_000) {
1447
+ lastProgressAt = now;
1448
+ writeToolOutput(toolEventId, ".", chalk.dim("."));
1449
+ }
1450
+ return;
1451
+ }
1452
+ const remaining = liveCap - liveBytes;
1453
+ const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
1454
+ liveBytes += slice.length;
1455
+ const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
1456
+ const body = indented.startsWith("\n") ? indented : ` ${indented}`;
1457
+ writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
1458
+ };
1459
+ const jobId = randomUUID().slice(0, 8);
1460
+ const backgroundJob = {
1461
+ id: jobId,
1462
+ command: `${call.name} ${formatToolArgs(call)}`,
1463
+ cwd: safeCwd(),
1464
+ status: "running",
1465
+ startedAt: new Date().toISOString(),
1466
+ artifactPath: "",
1467
+ };
1468
+ jobManager.registerJob(jobId, backgroundJob, toolAc);
1469
+ try {
1470
+ result = await runToolCall(call, {
1471
+ signal: toolAc.signal,
1472
+ requestSecret: options.requestSecret,
1473
+ onOutput: (chunk) => {
1474
+ if (toolAc.signal.aborted)
1475
+ return;
1476
+ printLive(chunk);
1477
+ },
1478
+ });
1479
+ if (liveBytes > 0 || liveTruncatedNotified) {
1480
+ writeToolOutput(toolEventId, "\n", "\n");
1481
+ }
1482
+ jobManager.updateJobStatus(jobId, result.ok ? "exited" : "failed", result.exitCode);
1483
+ }
1484
+ catch (toolError) {
1485
+ jobManager.updateJobStatus(jobId, "failed", 1);
1486
+ if (isAbortError(toolError, toolAc.signal)) {
1487
+ writeAbort();
1488
+ return { ok: false, call, result: { ok: false, output: "Aborted." }, contextOutput: "Aborted.", lastAnswer: "Aborted." };
1489
+ }
1490
+ const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
1491
+ result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
1492
+ }
1493
+ finally {
1494
+ parentSignal.removeEventListener("abort", onParentAbort);
1495
+ }
1496
+ const output = result.output.trim();
1497
+ const displayMax = 6_000;
1498
+ const savedOutputPath = result.outputPath ??
1499
+ (output.length > displayMax
1500
+ ? await saveToolOutput(call, output)
1501
+ : undefined);
1502
+ const resultWithArtifact = {
1503
+ ...result,
1504
+ outputPath: savedOutputPath,
1505
+ truncated: result.truncated ?? Boolean(savedOutputPath),
1506
+ };
1507
+ if (savedOutputPath) {
1508
+ const storedJob = jobManager.getJob(jobId);
1509
+ if (storedJob) {
1510
+ storedJob.artifactPath = savedOutputPath;
1511
+ }
1512
+ }
1513
+ const contextOutput = formatToolContext(call, resultWithArtifact);
1514
+ emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
1515
+ options.onToolResult?.(call, resultWithArtifact);
1516
+ await auditLog("tool.result", {
1517
+ call,
1518
+ ok: result.ok,
1519
+ exitCode: result.exitCode,
1520
+ output: result.output.slice(0, 4_000),
1521
+ });
1522
+ loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1523
+ const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
1524
+ writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
1525
+ if (output) {
1526
+ const displaySummary = summarizeOutput(output, displayMax);
1527
+ const displayText = displaySummary.truncated
1528
+ ? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
1529
+ : displaySummary.text;
1530
+ if (liveBytes > 0) {
1531
+ if (savedOutputPath) {
1532
+ writeToolInfo(`full output saved to ${savedOutputPath}`);
1533
+ }
1534
+ }
1535
+ else {
1536
+ const renderedOutput = indentAndWrapText(displayText);
1537
+ writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
1538
+ }
1539
+ }
1540
+ if (output) {
1541
+ const viewport = registerViewport({
1542
+ toolName: call.name,
1543
+ argsDisplay: formatToolArgs(call),
1544
+ artifactPath: savedOutputPath,
1545
+ summary: contextOutput,
1546
+ });
1547
+ if (savedOutputPath) {
1548
+ const viewportHint = `${formatViewportHint(viewport)}\n`;
1549
+ writeStatus(viewportHint, viewportHint);
1550
+ }
1551
+ }
1552
+ return { ok: result.ok, call, result, contextOutput };
1553
+ }
1264
1554
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1265
1555
  // `step` is the productive-step index (used for display + audit). It only
1266
1556
  // advances when the previous iteration actually executed a tool.
@@ -1539,14 +1829,18 @@ export async function runAgentLoop(prompt, options = {}) {
1539
1829
  // … please approve") instead of calling a tool — accepting it as a
1540
1830
  // final answer ends the turn with nothing done and no real plan saved.
1541
1831
  // Nudge it to emit a real tool call, with a concrete example.
1542
- const wantsAction = buildLikeTurn || (activePlan && session.planApproved.value);
1832
+ const narratedAction = looksLikeActionNarration(cleaned);
1833
+ const wantsAction = buildLikeTurn ||
1834
+ (activePlan && session.planApproved.value) ||
1835
+ freshWebSearchRequired ||
1836
+ narratedAction;
1543
1837
  const planNarrated = buildLikeTurn && !activePlan && looksLikePlanNarration(cleaned);
1544
1838
  if (wantsAction &&
1545
1839
  cleaned.trim().length > 0 &&
1546
1840
  actionIntentRetries < 3 &&
1547
1841
  (productiveSteps === 0 ||
1548
1842
  planNarrated ||
1549
- looksLikeActionNarration(cleaned))) {
1843
+ narratedAction)) {
1550
1844
  actionIntentRetries += 1;
1551
1845
  let nudge;
1552
1846
  if (activePlan && session.planApproved.value) {
@@ -1554,6 +1848,13 @@ export async function runAgentLoop(prompt, options = {}) {
1554
1848
  "You wrote a message but emitted NO ```tool block, so NOTHING ran. Do NOT narrate what you will do — DO it. Emit the next tool call now (task.update / fs.writeMany / shell.exec) in a single ```tool block.";
1555
1849
  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"));
1556
1850
  }
1851
+ else if (!buildLikeTurn) {
1852
+ nudge =
1853
+ "You wrote that you would fetch/search/read something but emitted NO ```tool block, so NOTHING ran. Do NOT narrate the next browsing step — DO it. Emit exactly one valid ```tool block now. If you know the exact page, use:\n" +
1854
+ '```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
1855
+ "If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
1856
+ writeNotice("warn", "described a web action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a web action but emitted no tool call — nudging it to run one\n"));
1857
+ }
1557
1858
  else if (planNarrated || productiveSteps > 0) {
1558
1859
  // It explored and/or wrote a plan as prose but never called
1559
1860
  // plan.create — emit the plan as a real tool call so the user gets
@@ -1648,9 +1949,7 @@ export async function runAgentLoop(prompt, options = {}) {
1648
1949
  return finishTurn(lastAnswer, step + 1);
1649
1950
  }
1650
1951
  // A valid primary tool call exists for this fresh model turn. Show any
1651
- // prose / thinking that preceded it, record the assistant message ONCE,
1652
- // then queue any additional tool calls from the same message so they
1653
- // run in order on the next iterations (no extra round-trip).
1952
+ // prose / thinking that preceded it, record the assistant message ONCE.
1654
1953
  const beforeTool = recoveredFromBareJson
1655
1954
  ? ""
1656
1955
  : textBeforeToolCall(assistantText.visible);
@@ -1664,388 +1963,56 @@ export async function runAgentLoop(prompt, options = {}) {
1664
1963
  role: "assistant",
1665
1964
  content: collapseRepeatedText(assistantText.visible),
1666
1965
  });
1667
- if (!recoveredFromBareJson && call) {
1668
- const allCalls = parseAllToolCalls(assistantText.visible);
1669
- if (allCalls.length > 1 &&
1670
- allCalls[0] &&
1671
- sameToolCall(allCalls[0], call)) {
1672
- pendingCalls = allCalls.slice(1);
1673
- writeNotice("info", `${allCalls.length} tool calls in this message — running them in order`, chalk.dim(` ℹ ${allCalls.length} tool calls in this message — running them in order\n`));
1674
- }
1966
+ let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
1967
+ if (allCalls.length === 0 && call) {
1968
+ allCalls = [call];
1675
1969
  }
1676
- }
1677
- // Type guard: every path above either set `call` or returned/continued.
1678
- if (!call)
1679
- continue;
1680
- // Models often emit a bare CLI name as the tool (e.g. {"name":"sed",...})
1681
- // instead of wrapping it in shell.exec. Rewrite such unknown, un-namespaced
1682
- // names into a shell.exec call BEFORE classification so the command both
1683
- // runs and is safety-classified as the shell command it really is —
1684
- // instead of dead-ending on "Unknown tool: sed".
1685
- call = normalizeToolCall(call);
1686
- if (call.name === "image.ocr" && !imageOcrEnabled) {
1687
- pendingCalls = pendingCalls.filter((queued) => queued.name !== "image.ocr");
1688
- writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
1689
- messages.push(recoveryUserMessage("The original image is attached to this message and you can inspect it directly. " +
1690
- "Do not call image.ocr or infer text from OCR. Answer the user's question from the actual image pixels now."));
1691
- continue;
1692
- }
1693
- const toolEventId = `tool-${++nextToolEventId}`;
1694
- // ── Duplicate-call detection ──────────────────────────────────────────
1695
- // If the model calls the exact same tool with the exact same args
1696
- // repeatedly, it's stuck in a loop. Inject a corrective message
1697
- // telling it to summarize the results it already has.
1698
- const loopCheck = loopGuard.shouldBlock(call.name, call.args);
1699
- if (loopCheck.block) {
1700
- const isWrite = call.name === "fs.write" ||
1701
- call.name === "fs.writeMany" ||
1702
- call.name === "fs.edit";
1703
- const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
1704
- writeNotice("warn", reason, chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
1705
- // A repeat means this batch went off the rails — drop any queued calls
1706
- // and let the model react. The assistant message was already recorded.
1707
- pendingCalls = [];
1708
- messages.push({
1709
- role: "user",
1710
- content: isWrite
1711
- ? `You already wrote that exact file with ${call.name}. It is saved. ` +
1712
- "Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
1713
- "verify the project (list the tree, run the build/install command) and give your final answer."
1714
- : `You already called ${call.name} with the same arguments and received results. ` +
1715
- "Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
1716
- });
1717
- continue;
1718
- }
1719
- if (loopCheck.reason) {
1720
- writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1721
- }
1722
- // ── Plan / task tools (session-scoped, handled inline) ─────────────
1723
- // These don't go through the generic registry because they need the
1724
- // session id and mutate the live plan that the user can view (Ctrl+P).
1725
- if (call.name === "plan.create" || call.name === "task.update") {
1726
- const planResult = await handlePlanTool(call, session, {
1727
- loopGuard,
1728
- step,
1970
+ if (allCalls.length > 1) {
1971
+ 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`));
1972
+ }
1973
+ // Execute all calls in parallel
1974
+ const toolPromises = allCalls.map((c) => {
1975
+ const id = `tool-${++nextToolEventId}`;
1976
+ return executeSingleTool(c, id, options.signal || new AbortController().signal);
1729
1977
  });
1730
- if (planResult.handled) {
1731
- productiveSteps += 1;
1732
- loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1733
- if (planResult.plan) {
1734
- writePlanUpdate(planResult.plan, planResult.display);
1978
+ const results = await Promise.all(toolPromises);
1979
+ let aborted = false;
1980
+ let blocked = false;
1981
+ let blockedResult = null;
1982
+ for (const res of results) {
1983
+ if (res.lastAnswer === "Aborted.") {
1984
+ aborted = true;
1985
+ }
1986
+ if (res.blockOrCancel) {
1987
+ blocked = true;
1988
+ blockedResult = res;
1735
1989
  }
1736
- // plan.create means "STOP and wait for /implement" — abandon any
1737
- // other calls the model batched alongside it.
1738
- if (call.name === "plan.create")
1739
- pendingCalls = [];
1740
1990
  messages.push({
1741
1991
  role: "tool",
1742
- content: `Tool ${call.name} result (ok=${planResult.ok}):\n${planResult.modelNote}`,
1992
+ content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
1743
1993
  });
1744
- continue;
1745
- }
1746
- }
1747
- const scope = await loadScope();
1748
- const decision = classifyToolCall(call, { scope });
1749
- await auditLog("tool.classified", {
1750
- call,
1751
- decision,
1752
- scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
1753
- });
1754
- // ── Plan-awaiting-approval gate ────────────────────────────────────
1755
- // When an active plan exists but the user has NOT approved it with
1756
- // /implement, the agent must NOT execute the plan. Any free-text the
1757
- // user typed after the plan was shown is a PLAN REVISION, not a "go"
1758
- // signal — the agent should re-plan (plan.create) and wait again. We
1759
- // hard-block execution tools here so a model that ignores the prompt
1760
- // directive (or recovers a stray tool call) can't start running the
1761
- // plan. Read-only exploration is still allowed so it can refine the
1762
- // plan intelligently.
1763
- if (activePlan &&
1764
- !session.planApproved.value &&
1765
- !isPreApprovalAllowedTool(call.name)) {
1766
- const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1767
- writeNotice("warn", reason, chalk.yellow(` ⚠ plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
1768
- pendingCalls = [];
1769
- messages.push({
1770
- role: "user",
1771
- content: `There is an ACTIVE PLAN that has NOT been approved yet, so you must NOT execute it — ` +
1772
- `you tried to call ${call.name}, which is blocked. The user's latest message is a PLAN REVISION, ` +
1773
- `not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
1774
- `revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
1775
- `Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
1776
- });
1777
- continue;
1778
- }
1779
- if (call.name === "web.search") {
1780
- sawFreshWebSearch = true;
1781
- }
1782
- // Show tool call
1783
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1784
- writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1785
- const scopeTarget = scopeTargetForToolCall(call);
1786
- if (scopeTarget &&
1787
- (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
1788
- writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1789
- }
1790
- if (decision.level === "block") {
1791
- writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1792
- lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
1793
- return finishTurn(lastAnswer, productiveSteps);
1794
- }
1795
- // Pentest authorization — if user confirms this, skip the per-tool confirm
1796
- let pentestJustConfirmed = false;
1797
- const needsPentestAuth = isPentestToolCall(call) &&
1798
- !getConfig().pentestAuthorized &&
1799
- !session.pentestAuthorized.value;
1800
- const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
1801
- // inquirer's confirm() creates its own readline interface which resets
1802
- // raw mode AND pauses stdin when it finishes. Re-assert raw mode and
1803
- // resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
1804
- // output pane) keeps working during the next streaming/tool phase.
1805
- restoreInteractiveStdin();
1806
- if (!authorized) {
1807
- lastAnswer = "Pentest authorization not confirmed.";
1808
- writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1809
- return finishTurn(lastAnswer, productiveSteps);
1810
- }
1811
- if (needsPentestAuth) {
1812
- pentestJustConfirmed = true;
1813
- }
1814
- // Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
1815
- // fs.delete and shell deletions NEVER auto-confirm even with -y flag.
1816
- const forceManualConfirm = call.name === "fs.delete";
1817
- if (decision.level === "confirm" && !pentestJustConfirmed) {
1818
- const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
1819
- // Re-assert raw mode and resume stdin after inquirer's confirm()
1820
- // (see restoreInteractiveStdin / the comment above).
1821
- restoreInteractiveStdin();
1822
- if (!ok) {
1823
- lastAnswer = "Cancelled.";
1824
- writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1825
- return finishTurn(lastAnswer, productiveSteps);
1826
- }
1827
- }
1828
- // Execute tool
1829
- options.signal?.throwIfAborted();
1830
- options.onToolStart?.(call);
1831
- // Heads-up when the command is about to run something that may pause
1832
- // for a password prompt (sudo, ssh, gpg, ...). The shell tool already
1833
- // routes such commands through inherited stdin so the user can type
1834
- // directly into the controlling TTY; we just warn them to expect it.
1835
- const interactiveCommand = (call.name === "shell.exec" &&
1836
- typeof call.args.command === "string" &&
1837
- looksInteractiveStdin(call.args.command)) ||
1838
- call.name === "net.scan" ||
1839
- call.name === "pentest.recon";
1840
- if (interactiveCommand && process.stdin.isTTY) {
1841
- writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
1842
- }
1843
- let result;
1844
- let liveBytes = 0;
1845
- const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
1846
- let liveTruncatedNotified = false;
1847
- let lastProgressAt = 0;
1848
- // When the underlying command may pause for a password prompt
1849
- // (sudo / ssh / etc.) we stream the live preview *without* the dim
1850
- // attribute so the prompt is fully readable. Otherwise we keep the
1851
- // dim styling that makes ordinary tool chatter visually distinct
1852
- // from the model's prose.
1853
- const shouldDimLive = !interactiveCommand;
1854
- const printLive = (chunk) => {
1855
- // Suppress live preview for fs.read / fs.list — those are read-only
1856
- // and the final summary is already concise. Stream shell-style tools
1857
- // (shell.exec, net.scan, pentest.recon, pkg.install).
1858
- if (call.name === "fs.read" ||
1859
- call.name === "fs.list" ||
1860
- call.name === "fs.search")
1861
- return;
1862
- if (liveBytes >= liveCap) {
1863
- if (!liveTruncatedNotified) {
1864
- liveTruncatedNotified = true;
1865
- writeNotice("info", "live preview truncated, full output saved", chalk.dim("\n … live preview truncated, full output saved\n"));
1866
- writeNotice("info", "tool still running — ESC or Ctrl+C to abort", chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
1867
- lastProgressAt = Date.now();
1868
- }
1869
- // After truncation, show a dot every 5 seconds so the user knows
1870
- // the tool is still running and the terminal isn't frozen.
1871
- const now = Date.now();
1872
- if (now - lastProgressAt > 5_000) {
1873
- lastProgressAt = now;
1874
- writeToolOutput(toolEventId, ".", chalk.dim("."));
1875
- }
1876
- return;
1877
- }
1878
- const remaining = liveCap - liveBytes;
1879
- const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
1880
- liveBytes += slice.length;
1881
- // Indent each line so live output lines up under the tool call.
1882
- const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
1883
- const body = indented.startsWith("\n") ? indented : ` ${indented}`;
1884
- // Skip the dim wrapper for interactive commands so a sudo password
1885
- // prompt is rendered at full brightness; everything else stays dim
1886
- // so tool chatter is visually distinct from the model's prose.
1887
- writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
1888
- };
1889
- try {
1890
- result = await runToolCall(call, {
1891
- signal: options.signal,
1892
- requestSecret: options.requestSecret,
1893
- onOutput: (chunk) => {
1894
- if (options.signal?.aborted)
1895
- return;
1896
- printLive(chunk);
1897
- },
1898
- });
1899
- // Newline separator if live output or progress dots didn't already end with one.
1900
- if (liveBytes > 0 || liveTruncatedNotified) {
1901
- writeToolOutput(toolEventId, "\n", "\n");
1994
+ productiveSteps += 1;
1902
1995
  }
1903
- }
1904
- catch (toolError) {
1905
- if (isAbortError(toolError, options.signal)) {
1996
+ if (aborted) {
1906
1997
  lastAnswer = "Aborted.";
1907
1998
  writeAbort();
1908
1999
  return lastAnswer;
1909
2000
  }
1910
- const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
1911
- result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
1912
- }
1913
- // Stop-on-error: if this call failed, abandon any remaining queued calls
1914
- // from the same message so the model sees the failure and decides what to
1915
- // do next instead of blindly running steps that depended on it.
1916
- if (!result.ok && pendingCalls.length > 0) {
1917
- const cancelledQueuedStatus = ` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`;
1918
- writeStatus(cancelledQueuedStatus, chalk.dim(cancelledQueuedStatus));
1919
- pendingCalls = [];
1920
- }
1921
- const output = result.output.trim();
1922
- const displayMax = 6_000;
1923
- // If the tool already produced an artifact (shell.exec now streams to one
1924
- // as it runs), respect that path. Otherwise, fall back to the post-hoc
1925
- // save for tools that return their full output in memory.
1926
- const savedOutputPath = result.outputPath ??
1927
- (output.length > displayMax
1928
- ? await saveToolOutput(call, output)
1929
- : undefined);
1930
- const resultWithArtifact = {
1931
- ...result,
1932
- outputPath: savedOutputPath,
1933
- truncated: result.truncated ?? Boolean(savedOutputPath),
1934
- };
1935
- const contextOutput = formatToolContext(call, resultWithArtifact);
1936
- emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
1937
- options.onToolResult?.(call, resultWithArtifact);
1938
- await auditLog("tool.result", {
1939
- call,
1940
- ok: result.ok,
1941
- exitCode: result.exitCode,
1942
- output: result.output.slice(0, 4_000),
1943
- });
1944
- // Record the attempt in the loop guard for dedup tracking.
1945
- loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1946
- // A tool actually executed this iteration — count it against the
1947
- // productive-step budget. Recovery iterations (thinking-only nudges,
1948
- // malformed-call retries, freshness/loop-guard prompts) reach `continue`
1949
- // before this point and therefore never consume the budget.
1950
- productiveSteps += 1;
1951
- // ── Auto-retry on "command not found" ──────────────────────────
1952
- // Detect missing tools and instruct the model to install + retry.
1953
- const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
1954
- if (!result.ok && NOT_FOUND_RE.test(output)) {
1955
- const cmdName = call.name === "shell.exec"
1956
- ? String(call.args.command ?? "").split(/\s+/)[0]
1957
- : call.name === "net.scan"
1958
- ? "nmap"
1959
- : call.name === "image.ocr"
1960
- ? "tesseract"
1961
- : undefined;
1962
- if (cmdName) {
1963
- writeNotice("warn", `${cmdName} not found — asking model to install and retry`, chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
1964
- messages.push({
1965
- role: "tool",
1966
- content: `Tool failed: "${cmdName}" is not installed.\n` +
1967
- `You MUST: 1) use pkg.install to install "${cmdName}", ` +
1968
- `2) then RETRY the original command. Do NOT stop or give up.`,
1969
- });
1970
- continue;
2001
+ if (blocked && blockedResult) {
2002
+ lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
2003
+ return finishTurn(lastAnswer, productiveSteps);
1971
2004
  }
1972
- }
1973
- // ── Auto-retry on "a terminal is required" sudo error ──────────────
1974
- // Older Node versions and some non-TTY contexts can still surface the
1975
- // canonical sudo "a terminal is required" or "no askpass program"
1976
- // failure. Tell the model to retry through plain `sudo …` (which the
1977
- // shell tool now inherits stdin for) instead of getting clever with
1978
- // -S / askpass / piping a password.
1979
- const SUDO_NEEDS_TTY_RE = /sudo:\s+a terminal is required to read the password|sudo:\s+a password is required|no askpass program|sudo: \d+ incorrect password attempts|sudo:\s+(?:no tty present|sorry, you must have a tty)/i;
1980
- if (!result.ok && SUDO_NEEDS_TTY_RE.test(output)) {
1981
- writeNotice("warn", "sudo needs an interactive terminal — asking the model to retry without -S/askpass", chalk.yellow(" ⚠ sudo needs an interactive terminal — asking the model to retry without -S/askpass\n"));
1982
- messages.push({
1983
- role: "tool",
1984
- content: "Tool failed: sudo could not read a password.\n" +
1985
- "On the next attempt: call shell.exec with `sudo <command>` directly. " +
1986
- "clai inherits stdin from the user's terminal, so the user can type the password live. " +
1987
- 'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
1988
- "Just run `sudo <command>` and the password prompt will be visible.",
1989
- });
1990
- continue;
1991
- }
1992
- // Print tool result
1993
- const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
1994
- writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
1995
- if (output) {
1996
- const displaySummary = summarizeOutput(output, displayMax);
1997
- const displayText = displaySummary.truncated
1998
- ? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
1999
- : displaySummary.text;
2000
- // If we already streamed live output for this call, skip re-printing
2001
- // the same bytes. Just note where the full output lives if it was saved.
2002
- if (liveBytes > 0) {
2003
- if (savedOutputPath) {
2004
- writeNotice("info", `full output saved to ${savedOutputPath}`, chalk.dim(` full output saved to ${savedOutputPath}\n`));
2005
+ // Compact older messages when the running estimate exceeds budget
2006
+ if (estimateMessagesTokens(messages) > 24_000) {
2007
+ const compacted = compactMessages(messages);
2008
+ if (compacted.length < messages.length) {
2009
+ messages.splice(0, messages.length, ...compacted);
2010
+ await auditLog("agent.compact", {
2011
+ newLength: messages.length,
2012
+ estimatedTokens: estimateMessagesTokens(messages),
2013
+ });
2005
2014
  }
2006
2015
  }
2007
- else {
2008
- const renderedOutput = indentAndWrapText(displayText);
2009
- writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
2010
- }
2011
- }
2012
- if (isAbortError(undefined, options.signal)) {
2013
- lastAnswer = "Aborted.";
2014
- writeAbort();
2015
- return lastAnswer;
2016
- }
2017
- // Register a collapse/expand viewport so the user can pull the full raw
2018
- // output back with Ctrl+O or `/output last` after the AI summary lands.
2019
- if (output) {
2020
- const viewport = registerViewport({
2021
- toolName: call.name,
2022
- argsDisplay: formatToolArgs(call),
2023
- artifactPath: savedOutputPath,
2024
- summary: contextOutput,
2025
- });
2026
- // Only print the Ctrl+O hint when there's a real artifact file
2027
- // (large output saved to disk). Avoid spamming the hint for
2028
- // every tiny tool call — the user can always use /output last.
2029
- if (savedOutputPath) {
2030
- const viewportHint = `${formatViewportHint(viewport)}\n`;
2031
- writeStatus(viewportHint, viewportHint);
2032
- }
2033
- }
2034
- messages.push({
2035
- role: "tool",
2036
- content: `Tool ${call.name} result (exit=${result.exitCode ?? 0}, ok=${result.ok}):\n${contextOutput}`,
2037
- });
2038
- // Compact older messages when the running estimate exceeds budget so
2039
- // free-tier context windows are not blown by long pentest sessions.
2040
- if (estimateMessagesTokens(messages) > 24_000) {
2041
- const compacted = compactMessages(messages);
2042
- if (compacted.length < messages.length) {
2043
- messages.splice(0, messages.length, ...compacted);
2044
- await auditLog("agent.compact", {
2045
- newLength: messages.length,
2046
- estimatedTokens: estimateMessagesTokens(messages),
2047
- });
2048
- }
2049
2016
  }
2050
2017
  }
2051
2018
  lastAnswer = `Stopped after ${productiveSteps} steps.`;