@pentoshi/clai 1.2.5 → 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 (71) 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 +12 -4
  8. package/dist/agent/runner.js +236 -83
  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/prompts/index.d.ts +1 -1
  22. package/dist/prompts/index.js +1 -1
  23. package/dist/repl.js +57 -15
  24. package/dist/repl.js.map +1 -1
  25. package/dist/tui/App.d.ts +8 -0
  26. package/dist/tui/App.js +273 -0
  27. package/dist/tui/App.js.map +1 -0
  28. package/dist/tui/can-use-tui.d.ts +20 -0
  29. package/dist/tui/can-use-tui.js +28 -0
  30. package/dist/tui/can-use-tui.js.map +1 -0
  31. package/dist/tui/components/Composer.d.ts +6 -0
  32. package/dist/tui/components/Composer.js +122 -0
  33. package/dist/tui/components/Composer.js.map +1 -0
  34. package/dist/tui/components/ConfirmModal.d.ts +6 -0
  35. package/dist/tui/components/ConfirmModal.js +16 -0
  36. package/dist/tui/components/ConfirmModal.js.map +1 -0
  37. package/dist/tui/components/Header.d.ts +7 -0
  38. package/dist/tui/components/Header.js +6 -0
  39. package/dist/tui/components/Header.js.map +1 -0
  40. package/dist/tui/components/JobsPanel.d.ts +11 -0
  41. package/dist/tui/components/JobsPanel.js +55 -0
  42. package/dist/tui/components/JobsPanel.js.map +1 -0
  43. package/dist/tui/components/Pager.d.ts +13 -0
  44. package/dist/tui/components/Pager.js +46 -0
  45. package/dist/tui/components/Pager.js.map +1 -0
  46. package/dist/tui/components/StatusLine.d.ts +7 -0
  47. package/dist/tui/components/StatusLine.js +25 -0
  48. package/dist/tui/components/StatusLine.js.map +1 -0
  49. package/dist/tui/components/items.d.ts +5 -0
  50. package/dist/tui/components/items.js +57 -0
  51. package/dist/tui/components/items.js.map +1 -0
  52. package/dist/tui/confirm.d.ts +16 -0
  53. package/dist/tui/confirm.js +41 -0
  54. package/dist/tui/confirm.js.map +1 -0
  55. package/dist/tui/hooks/useAgentRunner.d.ts +36 -0
  56. package/dist/tui/hooks/useAgentRunner.js +90 -0
  57. package/dist/tui/hooks/useAgentRunner.js.map +1 -0
  58. package/dist/tui/hooks/useJobs.d.ts +7 -0
  59. package/dist/tui/hooks/useJobs.js +39 -0
  60. package/dist/tui/hooks/useJobs.js.map +1 -0
  61. package/dist/tui/hooks/useSpinner.d.ts +5 -0
  62. package/dist/tui/hooks/useSpinner.js +19 -0
  63. package/dist/tui/hooks/useSpinner.js.map +1 -0
  64. package/dist/tui/index.d.ts +8 -0
  65. package/dist/tui/index.js +22 -0
  66. package/dist/tui/index.js.map +1 -0
  67. package/dist/tui/state.d.ts +101 -0
  68. package/dist/tui/state.js +323 -0
  69. package/dist/tui/state.js.map +1 -0
  70. package/dist/types.d.ts +1 -1
  71. 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";
@@ -525,31 +525,60 @@ const INCOMPLETE_RE = /\b(?:not\s+complete|incomplete|isn'?t\s+(?:done|complete|
525
525
  // would otherwise trip the volatile-info freshness guard and divert the run
526
526
  // into a pointless web.search instead of executing the plan.
527
527
  const PLAN_EXECUTION_RE = /\b(?:approve the plan|execute it (?:now|task by task)|task by task|execute the plan|implement the plan)\b/i;
528
+ // Informational / comparison / explanation intent. These questions want an
529
+ // ANSWER, not a build — even when they mention a framework or an install
530
+ // step (e.g. "compare installation steps in react vite", "how do I set up
531
+ // tailwind", "tailwind 3 vs 4"). They must NOT trigger the explore→plan
532
+ // build workflow.
533
+ const INFORMATIONAL_SIGNAL_RE = /\b(?:compare|comparison|contrast|differ(?:ence|ences|s)?|pros\s+and\s+cons|trade-?offs?|versus|vs\.?|cheat\s*sheet|explain|describe|summari[sz]e|overview)\b/i;
534
+ const INTERROGATIVE_LEAD_RE = /^(?:what|which|why|how|when|who|where|is|are|do|does|did|can|could|should|would|will)\b/i;
528
535
  /**
529
- * Decide whether this turn should get a generous step budget because it is
530
- * a multi-file build, a continuation of one, or a "it's not done yet" nudge.
531
- * Looks at the current prompt first, then falls back to the most recent
532
- * user/assistant turns so a terse follow-up inherits the build context.
536
+ * Does a single message imply an actual build/scaffold task (as opposed to a
537
+ * question about one)? Comparison/explanation signals and plain questions are
538
+ * treated as informational and return false even when they name a stack.
539
+ */
540
+ function messageImpliesBuild(text) {
541
+ if (!text)
542
+ return false;
543
+ if (INFORMATIONAL_SIGNAL_RE.test(text))
544
+ return false;
545
+ // Explicit "build/create/scaffold … <thing>" is always a build.
546
+ if (BUILD_TASK_RE.test(text))
547
+ return true;
548
+ // A bare question (interrogative lead or trailing "?") that merely mentions
549
+ // a stack is informational, not a build.
550
+ if (text.endsWith("?") || INTERROGATIVE_LEAD_RE.test(text))
551
+ return false;
552
+ return BUILD_STACK_RE.test(text);
553
+ }
554
+ /**
555
+ * Decide whether this turn should get the build workflow (explore → plan →
556
+ * implement) and a generous step budget. Looks at the current prompt first,
557
+ * then falls back to recent USER turns so a terse follow-up inherits an
558
+ * ongoing build — but NOT the agent's own (possibly mistaken) plan narration.
533
559
  */
534
560
  export function looksLikeBuildTask(prompt, history) {
535
561
  const text = prompt.replace(/\s+/g, " ").trim();
536
- if (BUILD_TASK_RE.test(text) ||
537
- BUILD_STACK_RE.test(text) ||
538
- CONTINUATION_RE.test(text) ||
562
+ // Continuation / "not done yet" / plan-execution always count as build.
563
+ if (CONTINUATION_RE.test(text) ||
539
564
  INCOMPLETE_RE.test(text) ||
540
565
  PLAN_EXECUTION_RE.test(text)) {
541
566
  return true;
542
567
  }
543
- // Inspect recent history: if the conversation was already about building
544
- // something, treat a terse follow-up as part of that build.
568
+ if (messageImpliesBuild(text)) {
569
+ return true;
570
+ }
571
+ // Inspect recent USER turns only: if the user was already building
572
+ // something, treat a terse follow-up as part of that build. (Assistant
573
+ // turns are excluded so a misfired plan can't keep re-triggering build.)
545
574
  if (history && history.length > 0) {
546
575
  const recent = history.slice(-6);
547
576
  for (const msg of recent) {
548
- if (msg.role !== "user" && msg.role !== "assistant")
577
+ if (msg.role !== "user")
549
578
  continue;
550
- const h = msg.content.replace(/\s+/g, " ");
551
- if (BUILD_TASK_RE.test(h) || BUILD_STACK_RE.test(h))
579
+ if (messageImpliesBuild(msg.content.replace(/\s+/g, " ").trim())) {
552
580
  return true;
581
+ }
553
582
  }
554
583
  }
555
584
  return false;
@@ -711,7 +740,7 @@ const PRE_APPROVAL_ALLOWED_TOOLS = new Set([
711
740
  export function isPreApprovalAllowedTool(name) {
712
741
  return PRE_APPROVAL_ALLOWED_TOOLS.has(name);
713
742
  }
714
- function styleToolChatter(call, text) {
743
+ export function styleToolChatter(call, text) {
715
744
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
716
745
  }
717
746
  function isAbortError(error, signal) {
@@ -790,7 +819,21 @@ function formatToolContext(call, result) {
790
819
  : "";
791
820
  return `${summary.text}${saved}`.trim();
792
821
  }
793
- 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) {
794
837
  if (!isPentestToolCall(call))
795
838
  return true;
796
839
  // Persistent auth (via `clai authorize-pentest AGREE`) wins.
@@ -805,16 +848,13 @@ async function ensurePentestAuthorization(call, autoConfirm, session) {
805
848
  session.pentestAuthorized.value = true;
806
849
  return true;
807
850
  }
808
- const ok = await confirm({
809
- message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
810
- default: false,
811
- });
851
+ const ok = await confirmPort.confirmPentest();
812
852
  if (!ok)
813
853
  return false;
814
854
  session.pentestAuthorized.value = true;
815
855
  return true;
816
856
  }
817
- async function confirmToolExecution(call, autoConfirm, session) {
857
+ async function confirmToolExecution(call, autoConfirm, session, confirmPort) {
818
858
  const config = getConfig();
819
859
  if (autoConfirm)
820
860
  return true;
@@ -825,10 +865,7 @@ async function confirmToolExecution(call, autoConfirm, session) {
825
865
  // set so authorizations never leak across processes.
826
866
  if (config.allowAlwaysTools.includes(call.name))
827
867
  return true;
828
- return confirm({
829
- message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
830
- default: true,
831
- });
868
+ return confirmPort.confirmTool(call);
832
869
  }
833
870
  /** Build the system-context block describing the session's active plan. */
834
871
  function planContextMessage(plan, approved) {
@@ -921,6 +958,7 @@ async function handlePlanTool(call, session, ctx) {
921
958
  return {
922
959
  handled: true,
923
960
  ok: true,
961
+ plan,
924
962
  display,
925
963
  modelNote: `Plan saved with ${plan.tasks.length} task(s). STOP here and wait — produce NO other tool calls now. ` +
926
964
  "Do NOT start executing tasks until the user approves with /implement. " +
@@ -979,6 +1017,7 @@ async function handlePlanTool(call, session, ctx) {
979
1017
  return {
980
1018
  handled: true,
981
1019
  ok: true,
1020
+ plan,
982
1021
  display: checklist + "\n",
983
1022
  modelNote: allDone
984
1023
  ? "Task updated. ALL tasks are now finished. Verify the result and give your final summary."
@@ -986,8 +1025,89 @@ async function handlePlanTool(call, session, ctx) {
986
1025
  };
987
1026
  }
988
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 });
989
1108
  const config = getConfig();
990
1109
  const maxSteps = options.maxSteps ?? 70;
1110
+ const confirmPort = options.confirm ?? inquirerConfirmPort;
991
1111
  const projectContext = await loadProjectContext();
992
1112
  const toolNames = availableToolNames();
993
1113
  // Build / scaffold / continuation turns must NEVER be diverted into a
@@ -1128,6 +1248,7 @@ export async function runAgentLoop(prompt, options = {}) {
1128
1248
  const maxIterations = stepBudget * 3;
1129
1249
  let productiveSteps = 0;
1130
1250
  let step = -1;
1251
+ let nextToolEventId = 0;
1131
1252
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1132
1253
  // `step` is the productive-step index (used for display + audit). It only
1133
1254
  // advances when the previous iteration actually executed a tool.
@@ -1146,16 +1267,33 @@ export async function runAgentLoop(prompt, options = {}) {
1146
1267
  // when the batch was parsed.
1147
1268
  call = pendingCalls.shift();
1148
1269
  assistantText = { visible: "", thinkContent: "", hasThinking: false };
1149
- 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));
1150
1272
  }
1151
1273
  else {
1152
1274
  // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1153
1275
  // Status messages (rate-limit retries, fallback hints) still surface live.
1154
1276
  // A spinner gives the user feedback during long thinking phases on
1155
1277
  // models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
1156
- 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
+ }
1157
1285
  let sawReasoning = false;
1158
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
+ });
1159
1297
  let completion;
1160
1298
  try {
1161
1299
  completion = await streamWithProvider({
@@ -1174,6 +1312,7 @@ export async function runAgentLoop(prompt, options = {}) {
1174
1312
  signal: options.signal,
1175
1313
  thinking: config.thinking,
1176
1314
  }, (token) => {
1315
+ deltaParser?.push(token);
1177
1316
  // Heuristic: <think>… markers and reasoning_content tokens flow
1178
1317
  // through onToken. Surface activity in the spinner so the screen
1179
1318
  // is never empty for minutes.
@@ -1181,6 +1320,8 @@ export async function runAgentLoop(prompt, options = {}) {
1181
1320
  sawReasoning = true;
1182
1321
  inThinking = true;
1183
1322
  spinner.setLabel("thinking");
1323
+ if (!writesDirectly)
1324
+ emit({ type: "status", text: "thinking" });
1184
1325
  }
1185
1326
  if (/<\/think>/i.test(token)) {
1186
1327
  inThinking = false;
@@ -1201,7 +1342,7 @@ export async function runAgentLoop(prompt, options = {}) {
1201
1342
  }
1202
1343
  }, (status) => {
1203
1344
  spinner.stop();
1204
- process.stdout.write(chalk.dim(status));
1345
+ writeStatus(status, chalk.dim(status));
1205
1346
  });
1206
1347
  }
1207
1348
  finally {
@@ -1210,6 +1351,7 @@ export async function runAgentLoop(prompt, options = {}) {
1210
1351
  }
1211
1352
  provider = completion.provider;
1212
1353
  model = completion.model;
1354
+ deltaParser?.finish();
1213
1355
  const assistantTextResult = rememberThinkingFromText(completion.text);
1214
1356
  assistantText = assistantTextResult;
1215
1357
  // Try visible text first, then thinking content — some models (e.g. glm-5.1)
@@ -1224,7 +1366,7 @@ export async function runAgentLoop(prompt, options = {}) {
1224
1366
  strict: getConfig().parserStrict,
1225
1367
  });
1226
1368
  if (call) {
1227
- 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"));
1228
1370
  }
1229
1371
  }
1230
1372
  // ── Thinking-only recovery ────────────────────────────────────────
@@ -1235,8 +1377,8 @@ export async function runAgentLoop(prompt, options = {}) {
1235
1377
  if (!assistantText.visible.trim() && !call && assistantText.hasThinking) {
1236
1378
  emptyVisibleRetries += 1;
1237
1379
  if (emptyVisibleRetries <= 2) {
1238
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1239
- 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"));
1240
1382
  messages.push({
1241
1383
  role: "assistant",
1242
1384
  content: collapseRepeatedText(completion.text),
@@ -1275,7 +1417,7 @@ export async function runAgentLoop(prompt, options = {}) {
1275
1417
  if (bare?.call) {
1276
1418
  call = bare.call;
1277
1419
  recoveredFromBareJson = true;
1278
- 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"));
1279
1421
  }
1280
1422
  else if (bare?.argsOnly) {
1281
1423
  bareArgsOnly = true;
@@ -1287,7 +1429,7 @@ export async function runAgentLoop(prompt, options = {}) {
1287
1429
  if (bareThink?.call) {
1288
1430
  call = bareThink.call;
1289
1431
  recoveredFromBareJson = true;
1290
- 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"));
1291
1433
  }
1292
1434
  else if (bareThink?.argsOnly) {
1293
1435
  bareArgsOnly = true;
@@ -1297,7 +1439,7 @@ export async function runAgentLoop(prompt, options = {}) {
1297
1439
  if (bareArgsOnly) {
1298
1440
  bareToolJsonRetries += 1;
1299
1441
  if (bareToolJsonRetries <= 3) {
1300
- 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"));
1301
1443
  messages.push({ role: "assistant", content: assistantText.visible });
1302
1444
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1303
1445
  ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
@@ -1319,7 +1461,7 @@ export async function runAgentLoop(prompt, options = {}) {
1319
1461
  // raw tokens looks like a crash to the user — instead, ask the
1320
1462
  // model to retry the tool call in a clean JSON format.
1321
1463
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1322
- 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"));
1323
1465
  messages.push({ role: "assistant", content: assistantText.visible });
1324
1466
  messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1325
1467
  "Reply with ONLY a fenced ```tool block containing valid JSON " +
@@ -1334,7 +1476,7 @@ export async function runAgentLoop(prompt, options = {}) {
1334
1476
  if (looksLikeTruncatedToolCall(assistantText.visible)) {
1335
1477
  truncatedToolRetries += 1;
1336
1478
  if (truncatedToolRetries <= 3) {
1337
- 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"));
1338
1480
  messages.push({ role: "assistant", content: assistantText.visible });
1339
1481
  messages.push({
1340
1482
  role: "user",
@@ -1361,7 +1503,7 @@ export async function runAgentLoop(prompt, options = {}) {
1361
1503
  if (hasFencedCallShape) {
1362
1504
  malformedFenceRetries += 1;
1363
1505
  if (malformedFenceRetries <= 3) {
1364
- 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"));
1365
1507
  messages.push({ role: "assistant", content: assistantText.visible });
1366
1508
  messages.push({
1367
1509
  role: "user",
@@ -1398,7 +1540,7 @@ export async function runAgentLoop(prompt, options = {}) {
1398
1540
  if (activePlan && session.planApproved.value) {
1399
1541
  nudge =
1400
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.";
1401
- 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"));
1402
1544
  }
1403
1545
  else if (planNarrated || productiveSteps > 0) {
1404
1546
  // It explored and/or wrote a plan as prose but never called
@@ -1408,14 +1550,14 @@ export async function runAgentLoop(prompt, options = {}) {
1408
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" +
1409
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' +
1410
1552
  "Do not describe the plan again in prose — just emit the plan.create tool block.";
1411
- 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"));
1412
1554
  }
1413
1555
  else {
1414
1556
  nudge =
1415
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" +
1416
1558
  '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1417
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.";
1418
- 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"));
1419
1561
  }
1420
1562
  messages.push({ role: "assistant", content: assistantText.visible });
1421
1563
  messages.push(recoveryUserMessage(nudge));
@@ -1423,7 +1565,7 @@ export async function runAgentLoop(prompt, options = {}) {
1423
1565
  }
1424
1566
  if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
1425
1567
  freshnessRetryUsed = true;
1426
- 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"));
1427
1569
  messages.push({ role: "assistant", content: assistantText.visible });
1428
1570
  messages.push({
1429
1571
  role: "user",
@@ -1444,7 +1586,7 @@ export async function runAgentLoop(prompt, options = {}) {
1444
1586
  if (livePlan && unfinished && unfinished.length > 0) {
1445
1587
  prematureCompletionRetries += 1;
1446
1588
  const next = unfinished[0];
1447
- 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`));
1448
1590
  messages.push({ role: "assistant", content: assistantText.visible });
1449
1591
  messages.push({
1450
1592
  role: "user",
@@ -1464,10 +1606,14 @@ export async function runAgentLoop(prompt, options = {}) {
1464
1606
  // unchallenged — append an explicit, honest status so the user knows the
1465
1607
  // build did not actually complete.
1466
1608
  let completionWarning = "";
1609
+ let completionWarningText = "";
1467
1610
  if (session.planApproved.value) {
1468
1611
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1469
1612
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1470
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.";
1471
1617
  completionWarning =
1472
1618
  chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
1473
1619
  unfinished
@@ -1477,19 +1623,17 @@ export async function runAgentLoop(prompt, options = {}) {
1477
1623
  }
1478
1624
  }
1479
1625
  if (cleaned) {
1480
- process.stdout.write(renderMarkdown(cleaned));
1481
- if (!cleaned.endsWith("\n"))
1482
- process.stdout.write("\n");
1626
+ writeAssistantMessage(cleaned);
1483
1627
  }
1484
1628
  if (completionWarning) {
1485
- process.stdout.write(completionWarning);
1629
+ writeNotice("warn", completionWarningText, completionWarning);
1486
1630
  }
1487
1631
  if (assistantText.hasThinking) {
1488
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1632
+ writeThinkingBlock(assistantText.thinkContent);
1489
1633
  }
1490
1634
  await auditLog("agent.final", { provider, model, steps: step + 1 });
1491
1635
  lastAnswer = cleaned;
1492
- return lastAnswer;
1636
+ return finishTurn(lastAnswer, step + 1);
1493
1637
  }
1494
1638
  // A valid primary tool call exists for this fresh model turn. Show any
1495
1639
  // prose / thinking that preceded it, record the assistant message ONCE,
@@ -1499,10 +1643,10 @@ export async function runAgentLoop(prompt, options = {}) {
1499
1643
  ? ""
1500
1644
  : textBeforeToolCall(assistantText.visible);
1501
1645
  if (beforeTool) {
1502
- process.stdout.write(renderMarkdown(beforeTool) + "\n");
1646
+ writeAssistantMessage(beforeTool);
1503
1647
  }
1504
1648
  if (assistantText.hasThinking) {
1505
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1649
+ writeThinkingBlock(assistantText.thinkContent);
1506
1650
  }
1507
1651
  messages.push({
1508
1652
  role: "assistant",
@@ -1514,7 +1658,7 @@ export async function runAgentLoop(prompt, options = {}) {
1514
1658
  allCalls[0] &&
1515
1659
  sameToolCall(allCalls[0], call)) {
1516
1660
  pendingCalls = allCalls.slice(1);
1517
- 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`));
1518
1662
  }
1519
1663
  }
1520
1664
  }
@@ -1527,6 +1671,7 @@ export async function runAgentLoop(prompt, options = {}) {
1527
1671
  // runs and is safety-classified as the shell command it really is —
1528
1672
  // instead of dead-ending on "Unknown tool: sed".
1529
1673
  call = normalizeToolCall(call);
1674
+ const toolEventId = `tool-${++nextToolEventId}`;
1530
1675
  // ── Duplicate-call detection ──────────────────────────────────────────
1531
1676
  // If the model calls the exact same tool with the exact same args
1532
1677
  // repeatedly, it's stuck in a loop. Inject a corrective message
@@ -1536,7 +1681,8 @@ export async function runAgentLoop(prompt, options = {}) {
1536
1681
  const isWrite = call.name === "fs.write" ||
1537
1682
  call.name === "fs.writeMany" ||
1538
1683
  call.name === "fs.edit";
1539
- 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`));
1540
1686
  // A repeat means this batch went off the rails — drop any queued calls
1541
1687
  // and let the model react. The assistant message was already recorded.
1542
1688
  pendingCalls = [];
@@ -1552,7 +1698,7 @@ export async function runAgentLoop(prompt, options = {}) {
1552
1698
  continue;
1553
1699
  }
1554
1700
  if (loopCheck.reason) {
1555
- process.stdout.write(chalk.dim(` ℹ ${loopCheck.reason}\n`));
1701
+ writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1556
1702
  }
1557
1703
  // ── Plan / task tools (session-scoped, handled inline) ─────────────
1558
1704
  // These don't go through the generic registry because they need the
@@ -1565,7 +1711,9 @@ export async function runAgentLoop(prompt, options = {}) {
1565
1711
  if (planResult.handled) {
1566
1712
  productiveSteps += 1;
1567
1713
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1568
- process.stdout.write(planResult.display);
1714
+ if (planResult.plan) {
1715
+ writePlanUpdate(planResult.plan, planResult.display);
1716
+ }
1569
1717
  // plan.create means "STOP and wait for /implement" — abandon any
1570
1718
  // other calls the model batched alongside it.
1571
1719
  if (call.name === "plan.create")
@@ -1596,7 +1744,8 @@ export async function runAgentLoop(prompt, options = {}) {
1596
1744
  if (activePlan &&
1597
1745
  !session.planApproved.value &&
1598
1746
  !isPreApprovalAllowedTool(call.name)) {
1599
- 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`));
1600
1749
  pendingCalls = [];
1601
1750
  messages.push({
1602
1751
  role: "user",
@@ -1613,23 +1762,23 @@ export async function runAgentLoop(prompt, options = {}) {
1613
1762
  }
1614
1763
  // Show tool call
1615
1764
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1616
- process.stdout.write(styleToolChatter(call, toolCallLine) + "\n");
1765
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1617
1766
  const scopeTarget = scopeTargetForToolCall(call);
1618
1767
  if (scopeTarget &&
1619
1768
  (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
1620
- process.stdout.write(chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1769
+ writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1621
1770
  }
1622
1771
  if (decision.level === "block") {
1623
- process.stdout.write(chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1772
+ writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1624
1773
  lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
1625
- return lastAnswer;
1774
+ return finishTurn(lastAnswer, productiveSteps);
1626
1775
  }
1627
1776
  // Pentest authorization — if user confirms this, skip the per-tool confirm
1628
1777
  let pentestJustConfirmed = false;
1629
1778
  const needsPentestAuth = isPentestToolCall(call) &&
1630
1779
  !getConfig().pentestAuthorized &&
1631
1780
  !session.pentestAuthorized.value;
1632
- const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session);
1781
+ const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
1633
1782
  // inquirer's confirm() creates its own readline interface which resets
1634
1783
  // raw mode AND pauses stdin when it finishes. Re-assert raw mode and
1635
1784
  // resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
@@ -1637,8 +1786,8 @@ export async function runAgentLoop(prompt, options = {}) {
1637
1786
  restoreInteractiveStdin();
1638
1787
  if (!authorized) {
1639
1788
  lastAnswer = "Pentest authorization not confirmed.";
1640
- process.stdout.write(chalk.red(` ✗ ${lastAnswer}`) + "\n");
1641
- return lastAnswer;
1789
+ writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1790
+ return finishTurn(lastAnswer, productiveSteps);
1642
1791
  }
1643
1792
  if (needsPentestAuth) {
1644
1793
  pentestJustConfirmed = true;
@@ -1647,14 +1796,14 @@ export async function runAgentLoop(prompt, options = {}) {
1647
1796
  // fs.delete and shell deletions NEVER auto-confirm even with -y flag.
1648
1797
  const forceManualConfirm = call.name === "fs.delete";
1649
1798
  if (decision.level === "confirm" && !pentestJustConfirmed) {
1650
- const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session);
1799
+ const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
1651
1800
  // Re-assert raw mode and resume stdin after inquirer's confirm()
1652
1801
  // (see restoreInteractiveStdin / the comment above).
1653
1802
  restoreInteractiveStdin();
1654
1803
  if (!ok) {
1655
1804
  lastAnswer = "Cancelled.";
1656
- process.stdout.write(chalk.yellow(` ✗ cancelled`) + "\n");
1657
- return lastAnswer;
1805
+ writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1806
+ return finishTurn(lastAnswer, productiveSteps);
1658
1807
  }
1659
1808
  }
1660
1809
  // Execute tool
@@ -1668,7 +1817,7 @@ export async function runAgentLoop(prompt, options = {}) {
1668
1817
  typeof call.args.command === "string" &&
1669
1818
  looksInteractiveStdin(call.args.command);
1670
1819
  if (interactiveCommand && process.stdin.isTTY) {
1671
- 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"));
1672
1821
  }
1673
1822
  let result;
1674
1823
  let liveBytes = 0;
@@ -1692,8 +1841,8 @@ export async function runAgentLoop(prompt, options = {}) {
1692
1841
  if (liveBytes >= liveCap) {
1693
1842
  if (!liveTruncatedNotified) {
1694
1843
  liveTruncatedNotified = true;
1695
- process.stdout.write(chalk.dim("\n … live preview truncated, full output saved\n"));
1696
- 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"));
1697
1846
  lastProgressAt = Date.now();
1698
1847
  }
1699
1848
  // After truncation, show a dot every 5 seconds so the user knows
@@ -1701,7 +1850,7 @@ export async function runAgentLoop(prompt, options = {}) {
1701
1850
  const now = Date.now();
1702
1851
  if (now - lastProgressAt > 5_000) {
1703
1852
  lastProgressAt = now;
1704
- process.stdout.write(chalk.dim("."));
1853
+ writeToolOutput(toolEventId, ".", chalk.dim("."));
1705
1854
  }
1706
1855
  return;
1707
1856
  }
@@ -1714,7 +1863,7 @@ export async function runAgentLoop(prompt, options = {}) {
1714
1863
  // Skip the dim wrapper for interactive commands so a sudo password
1715
1864
  // prompt is rendered at full brightness; everything else stays dim
1716
1865
  // so tool chatter is visually distinct from the model's prose.
1717
- process.stdout.write(shouldDimLive ? chalk.dim(body) : body);
1866
+ writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
1718
1867
  };
1719
1868
  try {
1720
1869
  result = await runToolCall(call, {
@@ -1726,13 +1875,14 @@ export async function runAgentLoop(prompt, options = {}) {
1726
1875
  },
1727
1876
  });
1728
1877
  // Newline separator if live output or progress dots didn't already end with one.
1729
- if (liveBytes > 0 || liveTruncatedNotified)
1730
- process.stdout.write("\n");
1878
+ if (liveBytes > 0 || liveTruncatedNotified) {
1879
+ writeToolOutput(toolEventId, "\n", "\n");
1880
+ }
1731
1881
  }
1732
1882
  catch (toolError) {
1733
1883
  if (isAbortError(toolError, options.signal)) {
1734
1884
  lastAnswer = "Aborted.";
1735
- process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
1885
+ writeAbort();
1736
1886
  return lastAnswer;
1737
1887
  }
1738
1888
  const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
@@ -1742,7 +1892,8 @@ export async function runAgentLoop(prompt, options = {}) {
1742
1892
  // from the same message so the model sees the failure and decides what to
1743
1893
  // do next instead of blindly running steps that depended on it.
1744
1894
  if (!result.ok && pendingCalls.length > 0) {
1745
- 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));
1746
1897
  pendingCalls = [];
1747
1898
  }
1748
1899
  const output = result.output.trim();
@@ -1759,6 +1910,8 @@ export async function runAgentLoop(prompt, options = {}) {
1759
1910
  outputPath: savedOutputPath,
1760
1911
  truncated: result.truncated ?? Boolean(savedOutputPath),
1761
1912
  };
1913
+ const contextOutput = formatToolContext(call, resultWithArtifact);
1914
+ emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
1762
1915
  options.onToolResult?.(call, resultWithArtifact);
1763
1916
  await auditLog("tool.result", {
1764
1917
  call,
@@ -1785,7 +1938,7 @@ export async function runAgentLoop(prompt, options = {}) {
1785
1938
  ? "tesseract"
1786
1939
  : undefined;
1787
1940
  if (cmdName) {
1788
- 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`));
1789
1942
  messages.push({
1790
1943
  role: "tool",
1791
1944
  content: `Tool failed: "${cmdName}" is not installed.\n` +
@@ -1803,7 +1956,7 @@ export async function runAgentLoop(prompt, options = {}) {
1803
1956
  // -S / askpass / piping a password.
1804
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;
1805
1958
  if (!result.ok && SUDO_NEEDS_TTY_RE.test(output)) {
1806
- 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"));
1807
1960
  messages.push({
1808
1961
  role: "tool",
1809
1962
  content: "Tool failed: sudo could not read a password.\n" +
@@ -1816,7 +1969,7 @@ export async function runAgentLoop(prompt, options = {}) {
1816
1969
  }
1817
1970
  // Print tool result
1818
1971
  const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
1819
- process.stdout.write(statusIcon + "\n");
1972
+ writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
1820
1973
  if (output) {
1821
1974
  const displaySummary = summarizeOutput(output, displayMax);
1822
1975
  const displayText = displaySummary.truncated
@@ -1826,20 +1979,19 @@ export async function runAgentLoop(prompt, options = {}) {
1826
1979
  // the same bytes. Just note where the full output lives if it was saved.
1827
1980
  if (liveBytes > 0) {
1828
1981
  if (savedOutputPath) {
1829
- 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`));
1830
1983
  }
1831
1984
  }
1832
1985
  else {
1833
1986
  const renderedOutput = indentAndWrapText(displayText);
1834
- process.stdout.write(styleToolChatter(call, renderedOutput) + "\n");
1987
+ writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
1835
1988
  }
1836
1989
  }
1837
1990
  if (isAbortError(undefined, options.signal)) {
1838
1991
  lastAnswer = "Aborted.";
1839
- process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
1992
+ writeAbort();
1840
1993
  return lastAnswer;
1841
1994
  }
1842
- const contextOutput = formatToolContext(call, resultWithArtifact);
1843
1995
  // Register a collapse/expand viewport so the user can pull the full raw
1844
1996
  // output back with Ctrl+O or `/output last` after the AI summary lands.
1845
1997
  if (output) {
@@ -1853,7 +2005,8 @@ export async function runAgentLoop(prompt, options = {}) {
1853
2005
  // (large output saved to disk). Avoid spamming the hint for
1854
2006
  // every tiny tool call — the user can always use /output last.
1855
2007
  if (savedOutputPath) {
1856
- process.stdout.write(`${formatViewportHint(viewport)}\n`);
2008
+ const viewportHint = `${formatViewportHint(viewport)}\n`;
2009
+ writeStatus(viewportHint, viewportHint);
1857
2010
  }
1858
2011
  }
1859
2012
  messages.push({
@@ -1874,7 +2027,7 @@ export async function runAgentLoop(prompt, options = {}) {
1874
2027
  }
1875
2028
  }
1876
2029
  lastAnswer = `Stopped after ${productiveSteps} steps.`;
1877
- process.stdout.write(" " + chalk.yellow(lastAnswer) + "\n");
1878
- return lastAnswer;
2030
+ writeNotice("warn", lastAnswer, " " + chalk.yellow(lastAnswer) + "\n");
2031
+ return finishTurn(lastAnswer, productiveSteps);
1879
2032
  }
1880
2033
  //# sourceMappingURL=runner.js.map