@pentoshi/clai 1.2.6 → 1.2.7

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 (69) hide show
  1. package/dist/agent/classic-renderer.d.ts +5 -0
  2. package/dist/agent/classic-renderer.js +124 -0
  3. package/dist/agent/classic-renderer.js.map +1 -0
  4. package/dist/agent/events.d.ts +62 -0
  5. package/dist/agent/events.js +2 -0
  6. package/dist/agent/events.js.map +1 -0
  7. package/dist/agent/runner.d.ts +8 -0
  8. package/dist/agent/runner.js +195 -71
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/commands/update.js +1 -1
  11. package/dist/index.js +32 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/llm/capabilities.js +1 -0
  14. package/dist/llm/capabilities.js.map +1 -1
  15. package/dist/llm/http.js +19 -11
  16. package/dist/llm/http.js.map +1 -1
  17. package/dist/llm/openai.js +10 -1
  18. package/dist/llm/openai.js.map +1 -1
  19. package/dist/modes/agent.d.ts +4 -1
  20. package/dist/modes/agent.js.map +1 -1
  21. package/dist/repl.js +57 -15
  22. package/dist/repl.js.map +1 -1
  23. package/dist/tui/App.d.ts +8 -0
  24. package/dist/tui/App.js +273 -0
  25. package/dist/tui/App.js.map +1 -0
  26. package/dist/tui/can-use-tui.d.ts +20 -0
  27. package/dist/tui/can-use-tui.js +28 -0
  28. package/dist/tui/can-use-tui.js.map +1 -0
  29. package/dist/tui/components/Composer.d.ts +6 -0
  30. package/dist/tui/components/Composer.js +122 -0
  31. package/dist/tui/components/Composer.js.map +1 -0
  32. package/dist/tui/components/ConfirmModal.d.ts +6 -0
  33. package/dist/tui/components/ConfirmModal.js +16 -0
  34. package/dist/tui/components/ConfirmModal.js.map +1 -0
  35. package/dist/tui/components/Header.d.ts +7 -0
  36. package/dist/tui/components/Header.js +6 -0
  37. package/dist/tui/components/Header.js.map +1 -0
  38. package/dist/tui/components/JobsPanel.d.ts +11 -0
  39. package/dist/tui/components/JobsPanel.js +55 -0
  40. package/dist/tui/components/JobsPanel.js.map +1 -0
  41. package/dist/tui/components/Pager.d.ts +13 -0
  42. package/dist/tui/components/Pager.js +46 -0
  43. package/dist/tui/components/Pager.js.map +1 -0
  44. package/dist/tui/components/StatusLine.d.ts +7 -0
  45. package/dist/tui/components/StatusLine.js +25 -0
  46. package/dist/tui/components/StatusLine.js.map +1 -0
  47. package/dist/tui/components/items.d.ts +5 -0
  48. package/dist/tui/components/items.js +57 -0
  49. package/dist/tui/components/items.js.map +1 -0
  50. package/dist/tui/confirm.d.ts +16 -0
  51. package/dist/tui/confirm.js +41 -0
  52. package/dist/tui/confirm.js.map +1 -0
  53. package/dist/tui/hooks/useAgentRunner.d.ts +36 -0
  54. package/dist/tui/hooks/useAgentRunner.js +90 -0
  55. package/dist/tui/hooks/useAgentRunner.js.map +1 -0
  56. package/dist/tui/hooks/useJobs.d.ts +7 -0
  57. package/dist/tui/hooks/useJobs.js +39 -0
  58. package/dist/tui/hooks/useJobs.js.map +1 -0
  59. package/dist/tui/hooks/useSpinner.d.ts +5 -0
  60. package/dist/tui/hooks/useSpinner.js +19 -0
  61. package/dist/tui/hooks/useSpinner.js.map +1 -0
  62. package/dist/tui/index.d.ts +8 -0
  63. package/dist/tui/index.js +22 -0
  64. package/dist/tui/index.js.map +1 -0
  65. package/dist/tui/state.d.ts +101 -0
  66. package/dist/tui/state.js +323 -0
  67. package/dist/tui/state.js.map +1 -0
  68. package/dist/types.d.ts +1 -1
  69. package/package.json +5 -2
@@ -16,7 +16,7 @@ import { auditLog } from "../store/logs.js";
16
16
  import { loadProjectContext } from "../store/project.js";
17
17
  import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
18
18
  import { ensureProviderConfigured } from "../commands/providers.js";
19
- import { rememberThinkingFromText, renderThinkingSummary, } from "../ui/thinking.js";
19
+ import { createThinkingStreamParser, rememberThinkingFromText, renderThinkingSummary, } from "../ui/thinking.js";
20
20
  import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
21
21
  import { startThinkingSpinner } from "../ui/spinner.js";
22
22
  import { safeCwd } from "../os/cwd.js";
@@ -740,7 +740,7 @@ const PRE_APPROVAL_ALLOWED_TOOLS = new Set([
740
740
  export function isPreApprovalAllowedTool(name) {
741
741
  return PRE_APPROVAL_ALLOWED_TOOLS.has(name);
742
742
  }
743
- function styleToolChatter(call, text) {
743
+ export function styleToolChatter(call, text) {
744
744
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
745
745
  }
746
746
  function isAbortError(error, signal) {
@@ -819,7 +819,21 @@ function formatToolContext(call, result) {
819
819
  : "";
820
820
  return `${summary.text}${saved}`.trim();
821
821
  }
822
- async function ensurePentestAuthorization(call, autoConfirm, session) {
822
+ const inquirerConfirmPort = {
823
+ async confirmTool(call) {
824
+ return confirm({
825
+ message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
826
+ default: true,
827
+ });
828
+ },
829
+ async confirmPentest() {
830
+ return confirm({
831
+ message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
832
+ default: false,
833
+ });
834
+ },
835
+ };
836
+ async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
823
837
  if (!isPentestToolCall(call))
824
838
  return true;
825
839
  // Persistent auth (via `clai authorize-pentest AGREE`) wins.
@@ -834,16 +848,13 @@ async function ensurePentestAuthorization(call, autoConfirm, session) {
834
848
  session.pentestAuthorized.value = true;
835
849
  return true;
836
850
  }
837
- const ok = await confirm({
838
- message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
839
- default: false,
840
- });
851
+ const ok = await confirmPort.confirmPentest();
841
852
  if (!ok)
842
853
  return false;
843
854
  session.pentestAuthorized.value = true;
844
855
  return true;
845
856
  }
846
- async function confirmToolExecution(call, autoConfirm, session) {
857
+ async function confirmToolExecution(call, autoConfirm, session, confirmPort) {
847
858
  const config = getConfig();
848
859
  if (autoConfirm)
849
860
  return true;
@@ -854,10 +865,7 @@ async function confirmToolExecution(call, autoConfirm, session) {
854
865
  // set so authorizations never leak across processes.
855
866
  if (config.allowAlwaysTools.includes(call.name))
856
867
  return true;
857
- return confirm({
858
- message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
859
- default: true,
860
- });
868
+ return confirmPort.confirmTool(call);
861
869
  }
862
870
  /** Build the system-context block describing the session's active plan. */
863
871
  function planContextMessage(plan, approved) {
@@ -950,6 +958,7 @@ async function handlePlanTool(call, session, ctx) {
950
958
  return {
951
959
  handled: true,
952
960
  ok: true,
961
+ plan,
953
962
  display,
954
963
  modelNote: `Plan saved with ${plan.tasks.length} task(s). STOP here and wait — produce NO other tool calls now. ` +
955
964
  "Do NOT start executing tasks until the user approves with /implement. " +
@@ -1008,6 +1017,7 @@ async function handlePlanTool(call, session, ctx) {
1008
1017
  return {
1009
1018
  handled: true,
1010
1019
  ok: true,
1020
+ plan,
1011
1021
  display: checklist + "\n",
1012
1022
  modelNote: allDone
1013
1023
  ? "Task updated. ALL tasks are now finished. Verify the result and give your final summary."
@@ -1015,8 +1025,89 @@ async function handlePlanTool(call, session, ctx) {
1015
1025
  };
1016
1026
  }
1017
1027
  export async function runAgentLoop(prompt, options = {}) {
1028
+ const writesDirectly = !options.onEvent;
1029
+ const emit = (event) => options.onEvent?.(event);
1030
+ const noopSpinner = {
1031
+ setLabel: () => { },
1032
+ bumpReasoning: () => { },
1033
+ pushPreview: () => { },
1034
+ stop: () => { },
1035
+ };
1036
+ const writeStatus = (text, rendered = chalk.dim(text)) => {
1037
+ emit({ type: "status", text });
1038
+ if (writesDirectly)
1039
+ process.stdout.write(rendered);
1040
+ };
1041
+ const writeNotice = (level, text, rendered) => {
1042
+ emit({ type: "notice", level, text });
1043
+ if (writesDirectly)
1044
+ process.stdout.write(rendered);
1045
+ };
1046
+ const writeAssistantMessage = (text) => {
1047
+ emit({ type: "assistant-message", text });
1048
+ const rendered = renderMarkdown(text);
1049
+ if (writesDirectly) {
1050
+ process.stdout.write(text.endsWith("\n") ? rendered : `${rendered}\n`);
1051
+ }
1052
+ };
1053
+ const writeThinkingBlock = (content) => {
1054
+ emit({ type: "thinking-block", content });
1055
+ if (writesDirectly)
1056
+ process.stdout.write(`${renderThinkingSummary(content)}\n`);
1057
+ };
1058
+ const writeToolOutput = (id, chunk, rendered) => {
1059
+ emit({ type: "tool-output", id, chunk });
1060
+ if (writesDirectly)
1061
+ process.stdout.write(rendered);
1062
+ };
1063
+ const writeToolCall = (id, call, rendered) => {
1064
+ emit({
1065
+ type: "tool-call",
1066
+ id,
1067
+ name: call.name,
1068
+ argsDisplay: formatToolArgs(call),
1069
+ });
1070
+ if (writesDirectly)
1071
+ process.stdout.write(rendered);
1072
+ };
1073
+ const writePlanUpdate = (plan, rendered) => {
1074
+ emit({ type: "plan-update", plan });
1075
+ if (writesDirectly)
1076
+ process.stdout.write(rendered);
1077
+ };
1078
+ const writeToolBlocked = (id, name, reason, rendered) => {
1079
+ emit({ type: "tool-blocked", id, name, reason });
1080
+ if (writesDirectly)
1081
+ process.stdout.write(rendered);
1082
+ };
1083
+ const writeAbort = () => {
1084
+ emit({ type: "turn-aborted" });
1085
+ if (writesDirectly)
1086
+ process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
1087
+ };
1088
+ const emitToolResult = (id, result, summary, artifactPath) => {
1089
+ const event = {
1090
+ type: "tool-result",
1091
+ id,
1092
+ ok: result.ok,
1093
+ summary,
1094
+ };
1095
+ if (typeof result.exitCode === "number") {
1096
+ event.exitCode = result.exitCode;
1097
+ }
1098
+ if (artifactPath) {
1099
+ event.artifactPath = artifactPath;
1100
+ }
1101
+ emit(event);
1102
+ };
1103
+ const finishTurn = (answer, steps) => {
1104
+ emit({ type: "turn-end", finalAnswer: answer, steps });
1105
+ return answer;
1106
+ };
1107
+ emit({ type: "turn-start", prompt });
1018
1108
  const config = getConfig();
1019
1109
  const maxSteps = options.maxSteps ?? 70;
1110
+ const confirmPort = options.confirm ?? inquirerConfirmPort;
1020
1111
  const projectContext = await loadProjectContext();
1021
1112
  const toolNames = availableToolNames();
1022
1113
  // Build / scaffold / continuation turns must NEVER be diverted into a
@@ -1157,6 +1248,7 @@ export async function runAgentLoop(prompt, options = {}) {
1157
1248
  const maxIterations = stepBudget * 3;
1158
1249
  let productiveSteps = 0;
1159
1250
  let step = -1;
1251
+ let nextToolEventId = 0;
1160
1252
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1161
1253
  // `step` is the productive-step index (used for display + audit). It only
1162
1254
  // advances when the previous iteration actually executed a tool.
@@ -1175,16 +1267,33 @@ export async function runAgentLoop(prompt, options = {}) {
1175
1267
  // when the batch was parsed.
1176
1268
  call = pendingCalls.shift();
1177
1269
  assistantText = { visible: "", thinkContent: "", hasThinking: false };
1178
- process.stdout.write(chalk.dim(` ↳ continuing batch (${pendingCalls.length} more queued)\n`));
1270
+ const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
1271
+ writeStatus(batchStatus, chalk.dim(batchStatus));
1179
1272
  }
1180
1273
  else {
1181
1274
  // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1182
1275
  // Status messages (rate-limit retries, fallback hints) still surface live.
1183
1276
  // A spinner gives the user feedback during long thinking phases on
1184
1277
  // models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
1185
- const spinner = startThinkingSpinner(step === 0 ? "waiting for model" : `step ${step + 1}`, options.signal);
1278
+ const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
1279
+ const spinner = writesDirectly
1280
+ ? startThinkingSpinner(streamLabel, options.signal)
1281
+ : noopSpinner;
1282
+ if (!writesDirectly) {
1283
+ emit({ type: "status", text: streamLabel });
1284
+ }
1186
1285
  let sawReasoning = false;
1187
1286
  let inThinking = false;
1287
+ let emittedThinkingStatus = false;
1288
+ const deltaParser = writesDirectly
1289
+ ? undefined
1290
+ : createThinkingStreamParser((text) => emit({ type: "assistant-delta", text }), (text) => {
1291
+ if (!emittedThinkingStatus) {
1292
+ emittedThinkingStatus = true;
1293
+ emit({ type: "status", text: "thinking" });
1294
+ }
1295
+ emit({ type: "thinking-delta", text });
1296
+ });
1188
1297
  let completion;
1189
1298
  try {
1190
1299
  completion = await streamWithProvider({
@@ -1203,6 +1312,7 @@ export async function runAgentLoop(prompt, options = {}) {
1203
1312
  signal: options.signal,
1204
1313
  thinking: config.thinking,
1205
1314
  }, (token) => {
1315
+ deltaParser?.push(token);
1206
1316
  // Heuristic: <think>… markers and reasoning_content tokens flow
1207
1317
  // through onToken. Surface activity in the spinner so the screen
1208
1318
  // is never empty for minutes.
@@ -1210,6 +1320,8 @@ export async function runAgentLoop(prompt, options = {}) {
1210
1320
  sawReasoning = true;
1211
1321
  inThinking = true;
1212
1322
  spinner.setLabel("thinking");
1323
+ if (!writesDirectly)
1324
+ emit({ type: "status", text: "thinking" });
1213
1325
  }
1214
1326
  if (/<\/think>/i.test(token)) {
1215
1327
  inThinking = false;
@@ -1230,7 +1342,7 @@ export async function runAgentLoop(prompt, options = {}) {
1230
1342
  }
1231
1343
  }, (status) => {
1232
1344
  spinner.stop();
1233
- process.stdout.write(chalk.dim(status));
1345
+ writeStatus(status, chalk.dim(status));
1234
1346
  });
1235
1347
  }
1236
1348
  finally {
@@ -1239,6 +1351,7 @@ export async function runAgentLoop(prompt, options = {}) {
1239
1351
  }
1240
1352
  provider = completion.provider;
1241
1353
  model = completion.model;
1354
+ deltaParser?.finish();
1242
1355
  const assistantTextResult = rememberThinkingFromText(completion.text);
1243
1356
  assistantText = assistantTextResult;
1244
1357
  // Try visible text first, then thinking content — some models (e.g. glm-5.1)
@@ -1253,7 +1366,7 @@ export async function runAgentLoop(prompt, options = {}) {
1253
1366
  strict: getConfig().parserStrict,
1254
1367
  });
1255
1368
  if (call) {
1256
- process.stdout.write(chalk.dim(" ℹ recovered tool call from thinking content\n"));
1369
+ writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
1257
1370
  }
1258
1371
  }
1259
1372
  // ── Thinking-only recovery ────────────────────────────────────────
@@ -1264,8 +1377,8 @@ export async function runAgentLoop(prompt, options = {}) {
1264
1377
  if (!assistantText.visible.trim() && !call && assistantText.hasThinking) {
1265
1378
  emptyVisibleRetries += 1;
1266
1379
  if (emptyVisibleRetries <= 2) {
1267
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1268
- process.stdout.write(chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
1380
+ writeThinkingBlock(assistantText.thinkContent);
1381
+ writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
1269
1382
  messages.push({
1270
1383
  role: "assistant",
1271
1384
  content: collapseRepeatedText(completion.text),
@@ -1304,7 +1417,7 @@ export async function runAgentLoop(prompt, options = {}) {
1304
1417
  if (bare?.call) {
1305
1418
  call = bare.call;
1306
1419
  recoveredFromBareJson = true;
1307
- process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
1420
+ writeNotice("info", "recovered an unfenced tool call from bare JSON", chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
1308
1421
  }
1309
1422
  else if (bare?.argsOnly) {
1310
1423
  bareArgsOnly = true;
@@ -1316,7 +1429,7 @@ export async function runAgentLoop(prompt, options = {}) {
1316
1429
  if (bareThink?.call) {
1317
1430
  call = bareThink.call;
1318
1431
  recoveredFromBareJson = true;
1319
- process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from thinking content\n"));
1432
+ writeNotice("info", "recovered an unfenced tool call from thinking content", chalk.dim(" ℹ recovered an unfenced tool call from thinking content\n"));
1320
1433
  }
1321
1434
  else if (bareThink?.argsOnly) {
1322
1435
  bareArgsOnly = true;
@@ -1326,7 +1439,7 @@ export async function runAgentLoop(prompt, options = {}) {
1326
1439
  if (bareArgsOnly) {
1327
1440
  bareToolJsonRetries += 1;
1328
1441
  if (bareToolJsonRetries <= 3) {
1329
- process.stdout.write(chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
1442
+ 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"));
1330
1443
  messages.push({ role: "assistant", content: assistantText.visible });
1331
1444
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1332
1445
  ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
@@ -1348,7 +1461,7 @@ export async function runAgentLoop(prompt, options = {}) {
1348
1461
  // raw tokens looks like a crash to the user — instead, ask the
1349
1462
  // model to retry the tool call in a clean JSON format.
1350
1463
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1351
- process.stdout.write(chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
1464
+ 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"));
1352
1465
  messages.push({ role: "assistant", content: assistantText.visible });
1353
1466
  messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1354
1467
  "Reply with ONLY a fenced ```tool block containing valid JSON " +
@@ -1363,7 +1476,7 @@ export async function runAgentLoop(prompt, options = {}) {
1363
1476
  if (looksLikeTruncatedToolCall(assistantText.visible)) {
1364
1477
  truncatedToolRetries += 1;
1365
1478
  if (truncatedToolRetries <= 3) {
1366
- process.stdout.write(chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry in smaller pieces\n"));
1479
+ 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"));
1367
1480
  messages.push({ role: "assistant", content: assistantText.visible });
1368
1481
  messages.push({
1369
1482
  role: "user",
@@ -1390,7 +1503,7 @@ export async function runAgentLoop(prompt, options = {}) {
1390
1503
  if (hasFencedCallShape) {
1391
1504
  malformedFenceRetries += 1;
1392
1505
  if (malformedFenceRetries <= 3) {
1393
- process.stdout.write(chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
1506
+ 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"));
1394
1507
  messages.push({ role: "assistant", content: assistantText.visible });
1395
1508
  messages.push({
1396
1509
  role: "user",
@@ -1427,7 +1540,7 @@ export async function runAgentLoop(prompt, options = {}) {
1427
1540
  if (activePlan && session.planApproved.value) {
1428
1541
  nudge =
1429
1542
  "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.";
1430
- process.stdout.write(chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
1543
+ 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"));
1431
1544
  }
1432
1545
  else if (planNarrated || productiveSteps > 0) {
1433
1546
  // It explored and/or wrote a plan as prose but never called
@@ -1437,14 +1550,14 @@ export async function runAgentLoop(prompt, options = {}) {
1437
1550
  "You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved and the user cannot /implement it. Emit it as a real tool call NOW — exactly one ```tool block:\n" +
1438
1551
  '```tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack/approach and how you\'ll verify>","tasks":["task 1","task 2","task 3"],"kind":"coding"}}\n```\n' +
1439
1552
  "Do not describe the plan again in prose — just emit the plan.create tool block.";
1440
- process.stdout.write(chalk.yellow(" ⚠ plan was written as text, not created — nudging it to call plan.create\n"));
1553
+ writeNotice("warn", "plan was written as text, not created — nudging it to call plan.create", chalk.yellow(" ⚠ plan was written as text, not created — nudging it to call plan.create\n"));
1441
1554
  }
1442
1555
  else {
1443
1556
  nudge =
1444
1557
  "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW. For this build task, explore first like this:\n" +
1445
1558
  '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1446
1559
  "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.";
1447
- process.stdout.write(chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
1560
+ 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"));
1448
1561
  }
1449
1562
  messages.push({ role: "assistant", content: assistantText.visible });
1450
1563
  messages.push(recoveryUserMessage(nudge));
@@ -1452,7 +1565,7 @@ export async function runAgentLoop(prompt, options = {}) {
1452
1565
  }
1453
1566
  if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
1454
1567
  freshnessRetryUsed = true;
1455
- process.stdout.write(chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
1568
+ writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
1456
1569
  messages.push({ role: "assistant", content: assistantText.visible });
1457
1570
  messages.push({
1458
1571
  role: "user",
@@ -1473,7 +1586,7 @@ export async function runAgentLoop(prompt, options = {}) {
1473
1586
  if (livePlan && unfinished && unfinished.length > 0) {
1474
1587
  prematureCompletionRetries += 1;
1475
1588
  const next = unfinished[0];
1476
- process.stdout.write(chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
1589
+ 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`));
1477
1590
  messages.push({ role: "assistant", content: assistantText.visible });
1478
1591
  messages.push({
1479
1592
  role: "user",
@@ -1493,10 +1606,14 @@ export async function runAgentLoop(prompt, options = {}) {
1493
1606
  // unchallenged — append an explicit, honest status so the user knows the
1494
1607
  // build did not actually complete.
1495
1608
  let completionWarning = "";
1609
+ let completionWarningText = "";
1496
1610
  if (session.planApproved.value) {
1497
1611
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1498
1612
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1499
1613
  if (livePlan && unfinished && unfinished.length > 0) {
1614
+ completionWarningText =
1615
+ `${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete. ` +
1616
+ "The summary above may overstate progress.";
1500
1617
  completionWarning =
1501
1618
  chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
1502
1619
  unfinished
@@ -1506,19 +1623,17 @@ export async function runAgentLoop(prompt, options = {}) {
1506
1623
  }
1507
1624
  }
1508
1625
  if (cleaned) {
1509
- process.stdout.write(renderMarkdown(cleaned));
1510
- if (!cleaned.endsWith("\n"))
1511
- process.stdout.write("\n");
1626
+ writeAssistantMessage(cleaned);
1512
1627
  }
1513
1628
  if (completionWarning) {
1514
- process.stdout.write(completionWarning);
1629
+ writeNotice("warn", completionWarningText, completionWarning);
1515
1630
  }
1516
1631
  if (assistantText.hasThinking) {
1517
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1632
+ writeThinkingBlock(assistantText.thinkContent);
1518
1633
  }
1519
1634
  await auditLog("agent.final", { provider, model, steps: step + 1 });
1520
1635
  lastAnswer = cleaned;
1521
- return lastAnswer;
1636
+ return finishTurn(lastAnswer, step + 1);
1522
1637
  }
1523
1638
  // A valid primary tool call exists for this fresh model turn. Show any
1524
1639
  // prose / thinking that preceded it, record the assistant message ONCE,
@@ -1528,10 +1643,10 @@ export async function runAgentLoop(prompt, options = {}) {
1528
1643
  ? ""
1529
1644
  : textBeforeToolCall(assistantText.visible);
1530
1645
  if (beforeTool) {
1531
- process.stdout.write(renderMarkdown(beforeTool) + "\n");
1646
+ writeAssistantMessage(beforeTool);
1532
1647
  }
1533
1648
  if (assistantText.hasThinking) {
1534
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1649
+ writeThinkingBlock(assistantText.thinkContent);
1535
1650
  }
1536
1651
  messages.push({
1537
1652
  role: "assistant",
@@ -1543,7 +1658,7 @@ export async function runAgentLoop(prompt, options = {}) {
1543
1658
  allCalls[0] &&
1544
1659
  sameToolCall(allCalls[0], call)) {
1545
1660
  pendingCalls = allCalls.slice(1);
1546
- process.stdout.write(chalk.dim(` ℹ ${allCalls.length} tool calls in this message — running them in order\n`));
1661
+ 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`));
1547
1662
  }
1548
1663
  }
1549
1664
  }
@@ -1556,6 +1671,7 @@ export async function runAgentLoop(prompt, options = {}) {
1556
1671
  // runs and is safety-classified as the shell command it really is —
1557
1672
  // instead of dead-ending on "Unknown tool: sed".
1558
1673
  call = normalizeToolCall(call);
1674
+ const toolEventId = `tool-${++nextToolEventId}`;
1559
1675
  // ── Duplicate-call detection ──────────────────────────────────────────
1560
1676
  // If the model calls the exact same tool with the exact same args
1561
1677
  // repeatedly, it's stuck in a loop. Inject a corrective message
@@ -1565,7 +1681,8 @@ export async function runAgentLoop(prompt, options = {}) {
1565
1681
  const isWrite = call.name === "fs.write" ||
1566
1682
  call.name === "fs.writeMany" ||
1567
1683
  call.name === "fs.edit";
1568
- process.stdout.write(chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
1684
+ const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
1685
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
1569
1686
  // A repeat means this batch went off the rails — drop any queued calls
1570
1687
  // and let the model react. The assistant message was already recorded.
1571
1688
  pendingCalls = [];
@@ -1581,7 +1698,7 @@ export async function runAgentLoop(prompt, options = {}) {
1581
1698
  continue;
1582
1699
  }
1583
1700
  if (loopCheck.reason) {
1584
- process.stdout.write(chalk.dim(` ℹ ${loopCheck.reason}\n`));
1701
+ writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1585
1702
  }
1586
1703
  // ── Plan / task tools (session-scoped, handled inline) ─────────────
1587
1704
  // These don't go through the generic registry because they need the
@@ -1594,7 +1711,9 @@ export async function runAgentLoop(prompt, options = {}) {
1594
1711
  if (planResult.handled) {
1595
1712
  productiveSteps += 1;
1596
1713
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1597
- process.stdout.write(planResult.display);
1714
+ if (planResult.plan) {
1715
+ writePlanUpdate(planResult.plan, planResult.display);
1716
+ }
1598
1717
  // plan.create means "STOP and wait for /implement" — abandon any
1599
1718
  // other calls the model batched alongside it.
1600
1719
  if (call.name === "plan.create")
@@ -1625,7 +1744,8 @@ export async function runAgentLoop(prompt, options = {}) {
1625
1744
  if (activePlan &&
1626
1745
  !session.planApproved.value &&
1627
1746
  !isPreApprovalAllowedTool(call.name)) {
1628
- process.stdout.write(chalk.yellow(`plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
1747
+ const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1748
+ writeNotice("warn", reason, chalk.yellow(` ⚠ plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
1629
1749
  pendingCalls = [];
1630
1750
  messages.push({
1631
1751
  role: "user",
@@ -1642,23 +1762,23 @@ export async function runAgentLoop(prompt, options = {}) {
1642
1762
  }
1643
1763
  // Show tool call
1644
1764
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1645
- process.stdout.write(styleToolChatter(call, toolCallLine) + "\n");
1765
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1646
1766
  const scopeTarget = scopeTargetForToolCall(call);
1647
1767
  if (scopeTarget &&
1648
1768
  (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
1649
- process.stdout.write(chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1769
+ writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1650
1770
  }
1651
1771
  if (decision.level === "block") {
1652
- process.stdout.write(chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1772
+ writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1653
1773
  lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
1654
- return lastAnswer;
1774
+ return finishTurn(lastAnswer, productiveSteps);
1655
1775
  }
1656
1776
  // Pentest authorization — if user confirms this, skip the per-tool confirm
1657
1777
  let pentestJustConfirmed = false;
1658
1778
  const needsPentestAuth = isPentestToolCall(call) &&
1659
1779
  !getConfig().pentestAuthorized &&
1660
1780
  !session.pentestAuthorized.value;
1661
- const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session);
1781
+ const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
1662
1782
  // inquirer's confirm() creates its own readline interface which resets
1663
1783
  // raw mode AND pauses stdin when it finishes. Re-assert raw mode and
1664
1784
  // resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
@@ -1666,8 +1786,8 @@ export async function runAgentLoop(prompt, options = {}) {
1666
1786
  restoreInteractiveStdin();
1667
1787
  if (!authorized) {
1668
1788
  lastAnswer = "Pentest authorization not confirmed.";
1669
- process.stdout.write(chalk.red(` ✗ ${lastAnswer}`) + "\n");
1670
- return lastAnswer;
1789
+ writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1790
+ return finishTurn(lastAnswer, productiveSteps);
1671
1791
  }
1672
1792
  if (needsPentestAuth) {
1673
1793
  pentestJustConfirmed = true;
@@ -1676,14 +1796,14 @@ export async function runAgentLoop(prompt, options = {}) {
1676
1796
  // fs.delete and shell deletions NEVER auto-confirm even with -y flag.
1677
1797
  const forceManualConfirm = call.name === "fs.delete";
1678
1798
  if (decision.level === "confirm" && !pentestJustConfirmed) {
1679
- const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session);
1799
+ const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
1680
1800
  // Re-assert raw mode and resume stdin after inquirer's confirm()
1681
1801
  // (see restoreInteractiveStdin / the comment above).
1682
1802
  restoreInteractiveStdin();
1683
1803
  if (!ok) {
1684
1804
  lastAnswer = "Cancelled.";
1685
- process.stdout.write(chalk.yellow(` ✗ cancelled`) + "\n");
1686
- return lastAnswer;
1805
+ writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1806
+ return finishTurn(lastAnswer, productiveSteps);
1687
1807
  }
1688
1808
  }
1689
1809
  // Execute tool
@@ -1697,7 +1817,7 @@ export async function runAgentLoop(prompt, options = {}) {
1697
1817
  typeof call.args.command === "string" &&
1698
1818
  looksInteractiveStdin(call.args.command);
1699
1819
  if (interactiveCommand && process.stdin.isTTY) {
1700
- process.stdout.write(chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
1820
+ 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"));
1701
1821
  }
1702
1822
  let result;
1703
1823
  let liveBytes = 0;
@@ -1721,8 +1841,8 @@ export async function runAgentLoop(prompt, options = {}) {
1721
1841
  if (liveBytes >= liveCap) {
1722
1842
  if (!liveTruncatedNotified) {
1723
1843
  liveTruncatedNotified = true;
1724
- process.stdout.write(chalk.dim("\n … live preview truncated, full output saved\n"));
1725
- process.stdout.write(chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
1844
+ writeNotice("info", "live preview truncated, full output saved", chalk.dim("\n … live preview truncated, full output saved\n"));
1845
+ writeNotice("info", "tool still running — ESC or Ctrl+C to abort", chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
1726
1846
  lastProgressAt = Date.now();
1727
1847
  }
1728
1848
  // After truncation, show a dot every 5 seconds so the user knows
@@ -1730,7 +1850,7 @@ export async function runAgentLoop(prompt, options = {}) {
1730
1850
  const now = Date.now();
1731
1851
  if (now - lastProgressAt > 5_000) {
1732
1852
  lastProgressAt = now;
1733
- process.stdout.write(chalk.dim("."));
1853
+ writeToolOutput(toolEventId, ".", chalk.dim("."));
1734
1854
  }
1735
1855
  return;
1736
1856
  }
@@ -1743,7 +1863,7 @@ export async function runAgentLoop(prompt, options = {}) {
1743
1863
  // Skip the dim wrapper for interactive commands so a sudo password
1744
1864
  // prompt is rendered at full brightness; everything else stays dim
1745
1865
  // so tool chatter is visually distinct from the model's prose.
1746
- process.stdout.write(shouldDimLive ? chalk.dim(body) : body);
1866
+ writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
1747
1867
  };
1748
1868
  try {
1749
1869
  result = await runToolCall(call, {
@@ -1755,13 +1875,14 @@ export async function runAgentLoop(prompt, options = {}) {
1755
1875
  },
1756
1876
  });
1757
1877
  // Newline separator if live output or progress dots didn't already end with one.
1758
- if (liveBytes > 0 || liveTruncatedNotified)
1759
- process.stdout.write("\n");
1878
+ if (liveBytes > 0 || liveTruncatedNotified) {
1879
+ writeToolOutput(toolEventId, "\n", "\n");
1880
+ }
1760
1881
  }
1761
1882
  catch (toolError) {
1762
1883
  if (isAbortError(toolError, options.signal)) {
1763
1884
  lastAnswer = "Aborted.";
1764
- process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
1885
+ writeAbort();
1765
1886
  return lastAnswer;
1766
1887
  }
1767
1888
  const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
@@ -1771,7 +1892,8 @@ export async function runAgentLoop(prompt, options = {}) {
1771
1892
  // from the same message so the model sees the failure and decides what to
1772
1893
  // do next instead of blindly running steps that depended on it.
1773
1894
  if (!result.ok && pendingCalls.length > 0) {
1774
- process.stdout.write(chalk.dim(` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`));
1895
+ const cancelledQueuedStatus = ` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`;
1896
+ writeStatus(cancelledQueuedStatus, chalk.dim(cancelledQueuedStatus));
1775
1897
  pendingCalls = [];
1776
1898
  }
1777
1899
  const output = result.output.trim();
@@ -1788,6 +1910,8 @@ export async function runAgentLoop(prompt, options = {}) {
1788
1910
  outputPath: savedOutputPath,
1789
1911
  truncated: result.truncated ?? Boolean(savedOutputPath),
1790
1912
  };
1913
+ const contextOutput = formatToolContext(call, resultWithArtifact);
1914
+ emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
1791
1915
  options.onToolResult?.(call, resultWithArtifact);
1792
1916
  await auditLog("tool.result", {
1793
1917
  call,
@@ -1814,7 +1938,7 @@ export async function runAgentLoop(prompt, options = {}) {
1814
1938
  ? "tesseract"
1815
1939
  : undefined;
1816
1940
  if (cmdName) {
1817
- process.stdout.write(chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
1941
+ writeNotice("warn", `${cmdName} not found — asking model to install and retry`, chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
1818
1942
  messages.push({
1819
1943
  role: "tool",
1820
1944
  content: `Tool failed: "${cmdName}" is not installed.\n` +
@@ -1832,7 +1956,7 @@ export async function runAgentLoop(prompt, options = {}) {
1832
1956
  // -S / askpass / piping a password.
1833
1957
  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;
1834
1958
  if (!result.ok && SUDO_NEEDS_TTY_RE.test(output)) {
1835
- process.stdout.write(chalk.yellow(" ⚠ sudo needs an interactive terminal — asking the model to retry without -S/askpass\n"));
1959
+ 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"));
1836
1960
  messages.push({
1837
1961
  role: "tool",
1838
1962
  content: "Tool failed: sudo could not read a password.\n" +
@@ -1845,7 +1969,7 @@ export async function runAgentLoop(prompt, options = {}) {
1845
1969
  }
1846
1970
  // Print tool result
1847
1971
  const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
1848
- process.stdout.write(statusIcon + "\n");
1972
+ writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
1849
1973
  if (output) {
1850
1974
  const displaySummary = summarizeOutput(output, displayMax);
1851
1975
  const displayText = displaySummary.truncated
@@ -1855,20 +1979,19 @@ export async function runAgentLoop(prompt, options = {}) {
1855
1979
  // the same bytes. Just note where the full output lives if it was saved.
1856
1980
  if (liveBytes > 0) {
1857
1981
  if (savedOutputPath) {
1858
- process.stdout.write(chalk.dim(` full output saved to ${savedOutputPath}\n`));
1982
+ writeNotice("info", `full output saved to ${savedOutputPath}`, chalk.dim(` full output saved to ${savedOutputPath}\n`));
1859
1983
  }
1860
1984
  }
1861
1985
  else {
1862
1986
  const renderedOutput = indentAndWrapText(displayText);
1863
- process.stdout.write(styleToolChatter(call, renderedOutput) + "\n");
1987
+ writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
1864
1988
  }
1865
1989
  }
1866
1990
  if (isAbortError(undefined, options.signal)) {
1867
1991
  lastAnswer = "Aborted.";
1868
- process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
1992
+ writeAbort();
1869
1993
  return lastAnswer;
1870
1994
  }
1871
- const contextOutput = formatToolContext(call, resultWithArtifact);
1872
1995
  // Register a collapse/expand viewport so the user can pull the full raw
1873
1996
  // output back with Ctrl+O or `/output last` after the AI summary lands.
1874
1997
  if (output) {
@@ -1882,7 +2005,8 @@ export async function runAgentLoop(prompt, options = {}) {
1882
2005
  // (large output saved to disk). Avoid spamming the hint for
1883
2006
  // every tiny tool call — the user can always use /output last.
1884
2007
  if (savedOutputPath) {
1885
- process.stdout.write(`${formatViewportHint(viewport)}\n`);
2008
+ const viewportHint = `${formatViewportHint(viewport)}\n`;
2009
+ writeStatus(viewportHint, viewportHint);
1886
2010
  }
1887
2011
  }
1888
2012
  messages.push({
@@ -1903,7 +2027,7 @@ export async function runAgentLoop(prompt, options = {}) {
1903
2027
  }
1904
2028
  }
1905
2029
  lastAnswer = `Stopped after ${productiveSteps} steps.`;
1906
- process.stdout.write(" " + chalk.yellow(lastAnswer) + "\n");
1907
- return lastAnswer;
2030
+ writeNotice("warn", lastAnswer, " " + chalk.yellow(lastAnswer) + "\n");
2031
+ return finishTurn(lastAnswer, productiveSteps);
1908
2032
  }
1909
2033
  //# sourceMappingURL=runner.js.map