@pentoshi/clai 1.2.6 → 1.2.8

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 +974 -837
  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 +91 -45
  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 +6 -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,895 +1025,1022 @@ async function handlePlanTool(call, session, ctx) {
1015
1025
  };
1016
1026
  }
1017
1027
  export async function runAgentLoop(prompt, options = {}) {
1018
- const config = getConfig();
1019
- const maxSteps = options.maxSteps ?? 70;
1020
- const projectContext = await loadProjectContext();
1021
- const toolNames = availableToolNames();
1022
- // Build / scaffold / continuation turns must NEVER be diverted into a
1023
- // web.search for "current info". The /implement directive ("Execute it
1024
- // now…") and prompts like "create a react app" contain words such as
1025
- // "now"/"latest" that trip the volatile-info regex; without this guard the
1026
- // agent burns its turn searching the date instead of writing files.
1027
- const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
1028
- const freshWebSearchRequired = !buildLikeTurn &&
1029
- toolNames.includes("web.search") &&
1030
- requiresFreshWebSearch(prompt);
1031
- const systemSections = [renderAgentSystemPrompt(toolNames.join(", "))];
1032
- if (projectContext) {
1033
- systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
1034
- }
1035
- if (freshWebSearchRequired) {
1036
- systemSections.push(freshnessGuardMessage());
1037
- }
1038
- let provider = options.provider ?? config.defaultProvider;
1039
- await ensureProviderConfigured(provider);
1040
- let model = options.model ?? config.defaultModel;
1041
- let lastAnswer = "";
1042
- const session = options.session ?? createSessionPolicy();
1043
- // ── Active plan context ────────────────────────────────────────────
1044
- // If this session already has a plan, inject it so the model keeps it in
1045
- // context. When the user has approved it (via /implement) we instruct the
1046
- // agent to execute task by task; otherwise the agent should refine/wait.
1047
- const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
1048
- if (activePlan) {
1049
- systemSections.push(planContextMessage(activePlan, session.planApproved.value));
1050
- }
1051
- // For build/scaffold turns with no active plan yet, inject an explicit
1052
- // workflow so the agent does NOT rush to write files in one shot. It must
1053
- // explore the directory, read the relevant existing files to understand
1054
- // what's already there, create a comprehensive multi-task plan, then
1055
- // implement task by task until the goal is met. This mirrors how a careful
1056
- // coding agent (Claude Code) operates.
1057
- if (buildLikeTurn && !activePlan) {
1058
- systemSections.push(buildWorkflowDirective());
1059
- }
1060
- const fullSystemPrompt = systemSections.join("\n\n");
1061
- const userMessage = { role: "user", content: prompt };
1062
- if (options.images && options.images.length > 0) {
1063
- userMessage.images = options.images;
1064
- }
1065
- const messages = [
1066
- { role: "system", content: fullSystemPrompt },
1067
- ...(options.history ?? []),
1068
- userMessage,
1069
- ];
1070
- const recoveryUserMessage = (content) => {
1071
- const message = { role: "user", content };
1072
- if (options.images && options.images.length > 0) {
1073
- // Some OpenAI-compatible gateways/models attend most strongly to the
1074
- // latest user turn. Keep the image attached on recovery nudges so a
1075
- // thinking-only retry does not degrade into OCR/tool guessing.
1076
- message.images = options.images;
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`);
1077
1051
  }
1078
- return message;
1079
1052
  };
1080
- // Track recent tool calls to detect models stuck in a loop calling the
1081
- // same tool with the same arguments over and over (e.g. pentest.recon
1082
- // called 3× on the same target without summarizing).
1083
- const loopGuard = new LoopGuard();
1084
- // Track consecutive thinking-only responses so we can nudge the model
1085
- // to actually act instead of silently returning an empty answer.
1086
- let emptyVisibleRetries = 0;
1087
- // Track tool calls truncated by the token limit so we can ask the model
1088
- // to retry in smaller pieces instead of leaking broken JSON as an answer.
1089
- let truncatedToolRetries = 0;
1090
- // Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
1091
- // so we can nudge the model to re-emit a proper fenced call a few times
1092
- // before giving up, instead of leaking the JSON as a final answer.
1093
- let bareToolJsonRetries = 0;
1094
- // Track a ```tool fence that is present but whose JSON could not be parsed
1095
- // (e.g. malformed extra/missing braces that are NOT simple truncation). We
1096
- // retry instead of leaking the raw block as the final answer.
1097
- let malformedFenceRetries = 0;
1098
- // For volatile live-info prompts, make one corrective pass if a model
1099
- // ignores the freshness guard and tries to answer from stale memory.
1100
- let sawFreshWebSearch = false;
1101
- let freshnessRetryUsed = false;
1102
- // Guard against a model that declares an approved plan "complete" while
1103
- // tasks are still pending and it never ran the work. We nudge it back to
1104
- // executing the next task a bounded number of times before giving up.
1105
- let prematureCompletionRetries = 0;
1106
- // Guard against a model that NARRATES intent ("let me explore the
1107
- // directory…") but emits no tool call, so nothing runs and the turn ends
1108
- // prematurely. On build/scaffold/plan turns where nothing has executed yet,
1109
- // we nudge it to emit a real tool call instead of accepting the narration
1110
- // as a final answer. Bounded so a model that truly can't emit the format
1111
- // still terminates.
1112
- let actionIntentRetries = 0;
1113
- // ── Multi-tool execution queue ─────────────────────────────────────
1114
- // Models naturally emit several tool calls in one message — e.g. the
1115
- // plan-execution rhythm "task.update in_progress do the work →
1116
- // task.update done", or a batch of fs.write calls. Rather than running
1117
- // only the first and discarding the rest (which made models believe work
1118
- // ran when it didn't, and broke plan execution), we parse ALL calls in a
1119
- // message, run the first this iteration, and queue the rest here to run on
1120
- // subsequent iterations WITHOUT another model round-trip. The queue is
1121
- // cleared whenever a call fails, is blocked, or needs the model to react,
1122
- // so the model always sees errors and stays in control.
1123
- let pendingCalls = [];
1124
- // ── Step budget ───────────────────────────────────────────────────
1125
- // The budget governs how many *productive* steps (a tool execution or a
1126
- // final answer) the agent may take. Recovery iterations — nudging a model
1127
- // that only produced thinking, asking it to re-emit a malformed tool call,
1128
- // a freshness retry, or a loop-guard summary — do NOT consume this budget;
1129
- // they get a separate hard ceiling so a wedged model can't spin forever.
1130
- //
1131
- // Complexity is a coarse signal from prompt length, but short follow-up
1132
- // prompts ("do it", "build fully on your own", "app is not complete") in
1133
- // the middle of a multi-file build must NOT be capped like a one-shot
1134
- // lookup — that was the reason a React scaffold stopped half-built after
1135
- // 10 steps. We bump the budget when the prompt (or recent history) looks
1136
- // like a build/scaffold or a continuation of one.
1137
- const analysis = analyzeTask(prompt);
1138
- const hasHistory = (options.history?.length ?? 0) > 0;
1139
- const buildLike = buildLikeTurn;
1140
- let stepBudget = analysis.complexity === "simple"
1141
- ? 20
1142
- : analysis.complexity === "standard"
1143
- ? 40
1144
- : maxSteps;
1145
- if (buildLike) {
1146
- // Scaffolding / multi-file work needs room: many file writes plus a
1147
- // verify/build step. Continuation prompts ("do it") inherit this too.
1148
- stepBudget = Math.max(stepBudget, maxSteps);
1149
- }
1150
- else if (hasHistory) {
1151
- // A follow-up to an ongoing task should never be capped tighter than a
1152
- // standard one-shot, even if it's only a couple of words.
1153
- stepBudget = Math.max(stepBudget, 40);
1154
- }
1155
- // Hard ceiling on total loop iterations (productive + recovery) so a model
1156
- // stuck emitting only thinking or malformed calls can't loop indefinitely.
1157
- const maxIterations = stepBudget * 3;
1158
- let productiveSteps = 0;
1159
- let step = -1;
1160
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1161
- // `step` is the productive-step index (used for display + audit). It only
1162
- // advances when the previous iteration actually executed a tool.
1163
- step = productiveSteps;
1164
- if (productiveSteps >= stepBudget)
1165
- break;
1166
- options.signal?.throwIfAborted();
1167
- // `call` and `assistantText` are shared by both paths below: a fresh
1168
- // model round-trip, or draining a previously-queued tool call.
1169
- let call;
1170
- let assistantText;
1171
- let recoveredFromBareJson = false;
1172
- if (pendingCalls.length > 0) {
1173
- // Drain the next queued call from the previous model message — no new
1174
- // round-trip. The assistant message and any prose were already shown
1175
- // when the batch was parsed.
1176
- call = pendingCalls.shift();
1177
- assistantText = { visible: "", thinkContent: "", hasThinking: false };
1178
- process.stdout.write(chalk.dim(` ↳ continuing batch (${pendingCalls.length} more queued)\n`));
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;
1179
1097
  }
1180
- else {
1181
- // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1182
- // Status messages (rate-limit retries, fallback hints) still surface live.
1183
- // A spinner gives the user feedback during long thinking phases on
1184
- // 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);
1186
- let sawReasoning = false;
1187
- let inThinking = false;
1188
- let completion;
1189
- try {
1190
- completion = await streamWithProvider({
1191
- provider,
1192
- model,
1193
- messages,
1194
- temperature: 0.2,
1195
- // Reasoning models can spend a lot on hidden thinking; give
1196
- // them headroom so the visible answer / tool call isn't
1197
- // truncated to silence. The non-thinking budget must be large
1198
- // enough for a single-file fs.write / multi-file fs.writeMany
1199
- // payload a truncated tool-call JSON fails to parse and leaks a
1200
- // broken (and syntactically invalid) file. 8k was too small for a
1201
- // full component, so allow more room for the visible tool call.
1202
- maxTokens: config.thinking?.enabled ? 16_384 : 12_288,
1203
- signal: options.signal,
1204
- thinking: config.thinking,
1205
- }, (token) => {
1206
- // Heuristic: <think>… markers and reasoning_content tokens flow
1207
- // through onToken. Surface activity in the spinner so the screen
1208
- // is never empty for minutes.
1209
- if (!sawReasoning && /<think/i.test(token)) {
1210
- sawReasoning = true;
1211
- inThinking = true;
1212
- spinner.setLabel("thinking");
1213
- }
1214
- if (/<\/think>/i.test(token)) {
1215
- inThinking = false;
1216
- }
1217
- // Only push reasoning tokens to the spinner preview. Visible
1218
- // answer / tool-call tokens should NOT go through the dim
1219
- // spinner preview doing so makes the final answer appear
1220
- // "diluted" in light font when the spinner's last render
1221
- // briefly shows the answer text before being erased.
1222
- if (inThinking) {
1223
- const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
1224
- if (cleaned) {
1225
- spinner.pushPreview(cleaned);
1226
- const approx = cleaned.split(/\s+/).filter(Boolean).length;
1227
- if (approx > 0)
1228
- spinner.bumpReasoning(approx);
1229
- }
1230
- }
1231
- }, (status) => {
1232
- spinner.stop();
1233
- process.stdout.write(chalk.dim(status));
1234
- });
1235
- }
1236
- finally {
1237
- // Always clear the spinner — abort, network error, or success.
1238
- spinner.stop();
1239
- }
1240
- provider = completion.provider;
1241
- model = completion.model;
1242
- const assistantTextResult = rememberThinkingFromText(completion.text);
1243
- assistantText = assistantTextResult;
1244
- // Try visible text first, then thinking content some models (e.g. glm-5.1)
1245
- // wrap tool calls inside considering tags, so stripThinking removes them
1246
- // into thinkContent and visible becomes empty. Recovering from thinkContent
1247
- // prevents an endless nudge loop where the model keeps hiding the call.
1248
- call = parseToolCall(assistantText.visible, {
1249
- strict: getConfig().parserStrict,
1250
- });
1251
- if (!call && assistantText.hasThinking) {
1252
- call = parseToolCall(assistantText.thinkContent, {
1253
- strict: getConfig().parserStrict,
1254
- });
1255
- if (call) {
1256
- process.stdout.write(chalk.dim(" ℹ recovered tool call from thinking content\n"));
1257
- }
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
+ try {
1108
+ emit({ type: "turn-start", prompt });
1109
+ const config = getConfig();
1110
+ const maxSteps = options.maxSteps ?? 70;
1111
+ const confirmPort = options.confirm ?? inquirerConfirmPort;
1112
+ const projectContext = await loadProjectContext();
1113
+ const toolNames = availableToolNames();
1114
+ // Build / scaffold / continuation turns must NEVER be diverted into a
1115
+ // web.search for "current info". The /implement directive ("Execute it
1116
+ // now…") and prompts like "create a react app" contain words such as
1117
+ // "now"/"latest" that trip the volatile-info regex; without this guard the
1118
+ // agent burns its turn searching the date instead of writing files.
1119
+ const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
1120
+ const freshWebSearchRequired = !buildLikeTurn &&
1121
+ toolNames.includes("web.search") &&
1122
+ requiresFreshWebSearch(prompt);
1123
+ const systemSections = [renderAgentSystemPrompt(toolNames.join(", "))];
1124
+ if (projectContext) {
1125
+ systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
1126
+ }
1127
+ if (freshWebSearchRequired) {
1128
+ systemSections.push(freshnessGuardMessage());
1129
+ }
1130
+ let provider = options.provider ?? config.defaultProvider;
1131
+ await ensureProviderConfigured(provider);
1132
+ let model = options.model ?? config.defaultModel;
1133
+ let lastAnswer = "";
1134
+ const session = options.session ?? createSessionPolicy();
1135
+ // ── Active plan context ────────────────────────────────────────────
1136
+ // If this session already has a plan, inject it so the model keeps it in
1137
+ // context. When the user has approved it (via /implement) we instruct the
1138
+ // agent to execute task by task; otherwise the agent should refine/wait.
1139
+ const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
1140
+ if (activePlan) {
1141
+ systemSections.push(planContextMessage(activePlan, session.planApproved.value));
1142
+ }
1143
+ // For build/scaffold turns with no active plan yet, inject an explicit
1144
+ // workflow so the agent does NOT rush to write files in one shot. It must
1145
+ // explore the directory, read the relevant existing files to understand
1146
+ // what's already there, create a comprehensive multi-task plan, then
1147
+ // implement task by task until the goal is met. This mirrors how a careful
1148
+ // coding agent (Claude Code) operates.
1149
+ if (buildLikeTurn && !activePlan) {
1150
+ systemSections.push(buildWorkflowDirective());
1151
+ }
1152
+ const fullSystemPrompt = systemSections.join("\n\n");
1153
+ const userMessage = { role: "user", content: prompt };
1154
+ if (options.images && options.images.length > 0) {
1155
+ userMessage.images = options.images;
1156
+ }
1157
+ const messages = [
1158
+ { role: "system", content: fullSystemPrompt },
1159
+ ...(options.history ?? []),
1160
+ userMessage,
1161
+ ];
1162
+ const recoveryUserMessage = (content) => {
1163
+ const message = { role: "user", content };
1164
+ if (options.images && options.images.length > 0) {
1165
+ // Some OpenAI-compatible gateways/models attend most strongly to the
1166
+ // latest user turn. Keep the image attached on recovery nudges so a
1167
+ // thinking-only retry does not degrade into OCR/tool guessing.
1168
+ message.images = options.images;
1258
1169
  }
1259
- // ── Thinking-only recovery ────────────────────────────────────────
1260
- // Some models (eg gpt-oss-20b on NVIDIA NIM) occasionally spend their
1261
- // entire budget on hidden <think> reasoning and emit no visible text
1262
- // or tool call. Without this guard the agent silently returns an empty
1263
- // answer and the user has to re-submit the same prompt.
1264
- if (!assistantText.visible.trim() && !call && assistantText.hasThinking) {
1265
- emptyVisibleRetries += 1;
1266
- 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"));
1269
- messages.push({
1270
- role: "assistant",
1271
- content: collapseRepeatedText(completion.text),
1272
- });
1273
- const buildNudge = buildLikeTurn && !activePlan
1274
- ? "You only produced internal reasoning with no visible answer or tool call. " +
1275
- "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1276
- "You MUST call plan.create using the ```tool format to create a comprehensive plan BEFORE writing any files or running any commands. " +
1277
- "Do NOT use fs.write, fs.writeMany, fs.edit, shell.exec, shell.start, or pkg.install yet. " +
1278
- "Your ONLY allowed action right now is plan.create (or read/list for exploration)."
1279
- : "You only produced internal reasoning with no visible answer or tool call. " +
1280
- "You MUST either call a tool using the ```tool format or provide your final answer. " +
1281
- "Do NOT wrap your tool call inside considering or reasoning tags — put it in the VISIBLE response, not hidden. " +
1282
- "If images are attached, inspect them directly for visual details (text, colors, layout, spacing, style) instead of using OCR unless explicitly needed. " +
1283
- "Do NOT just think take action NOW.";
1284
- messages.push(recoveryUserMessage(buildNudge));
1285
- continue;
1286
- }
1287
- // Exhausted retries fall through to the normal empty-answer path
1288
- // which will print a warning and return.
1170
+ return message;
1171
+ };
1172
+ // Track recent tool calls to detect models stuck in a loop calling the
1173
+ // same tool with the same arguments over and over (e.g. pentest.recon
1174
+ // called on the same target without summarizing).
1175
+ const loopGuard = new LoopGuard();
1176
+ // Track consecutive thinking-only responses so we can nudge the model
1177
+ // to actually act instead of silently returning an empty answer.
1178
+ let emptyVisibleRetries = 0;
1179
+ // Track tool calls truncated by the token limit so we can ask the model
1180
+ // to retry in smaller pieces instead of leaking broken JSON as an answer.
1181
+ let truncatedToolRetries = 0;
1182
+ // Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
1183
+ // so we can nudge the model to re-emit a proper fenced call a few times
1184
+ // before giving up, instead of leaking the JSON as a final answer.
1185
+ let bareToolJsonRetries = 0;
1186
+ // Track a ```tool fence that is present but whose JSON could not be parsed
1187
+ // (e.g. malformed extra/missing braces that are NOT simple truncation). We
1188
+ // retry instead of leaking the raw block as the final answer.
1189
+ let malformedFenceRetries = 0;
1190
+ // For volatile live-info prompts, make one corrective pass if a model
1191
+ // ignores the freshness guard and tries to answer from stale memory.
1192
+ let sawFreshWebSearch = false;
1193
+ let freshnessRetryUsed = false;
1194
+ // Guard against a model that declares an approved plan "complete" while
1195
+ // tasks are still pending and it never ran the work. We nudge it back to
1196
+ // executing the next task a bounded number of times before giving up.
1197
+ let prematureCompletionRetries = 0;
1198
+ // Guard against a model that NARRATES intent ("let me explore the
1199
+ // directory…") but emits no tool call, so nothing runs and the turn ends
1200
+ // prematurely. On build/scaffold/plan turns where nothing has executed yet,
1201
+ // we nudge it to emit a real tool call instead of accepting the narration
1202
+ // as a final answer. Bounded so a model that truly can't emit the format
1203
+ // still terminates.
1204
+ let actionIntentRetries = 0;
1205
+ // ── Multi-tool execution queue ─────────────────────────────────────
1206
+ // Models naturally emit several tool calls in one message — e.g. the
1207
+ // plan-execution rhythm "task.update in_progress → do the work →
1208
+ // task.update done", or a batch of fs.write calls. Rather than running
1209
+ // only the first and discarding the rest (which made models believe work
1210
+ // ran when it didn't, and broke plan execution), we parse ALL calls in a
1211
+ // message, run the first this iteration, and queue the rest here to run on
1212
+ // subsequent iterations WITHOUT another model round-trip. The queue is
1213
+ // cleared whenever a call fails, is blocked, or needs the model to react,
1214
+ // so the model always sees errors and stays in control.
1215
+ let pendingCalls = [];
1216
+ // ── Step budget ───────────────────────────────────────────────────
1217
+ // The budget governs how many *productive* steps (a tool execution or a
1218
+ // final answer) the agent may take. Recovery iterations — nudging a model
1219
+ // that only produced thinking, asking it to re-emit a malformed tool call,
1220
+ // a freshness retry, or a loop-guard summary — do NOT consume this budget;
1221
+ // they get a separate hard ceiling so a wedged model can't spin forever.
1222
+ //
1223
+ // Complexity is a coarse signal from prompt length, but short follow-up
1224
+ // prompts ("do it", "build fully on your own", "app is not complete") in
1225
+ // the middle of a multi-file build must NOT be capped like a one-shot
1226
+ // lookup — that was the reason a React scaffold stopped half-built after
1227
+ // 10 steps. We bump the budget when the prompt (or recent history) looks
1228
+ // like a build/scaffold or a continuation of one.
1229
+ const analysis = analyzeTask(prompt);
1230
+ const hasHistory = (options.history?.length ?? 0) > 0;
1231
+ const buildLike = buildLikeTurn;
1232
+ let stepBudget = analysis.complexity === "simple"
1233
+ ? 20
1234
+ : analysis.complexity === "standard"
1235
+ ? 40
1236
+ : maxSteps;
1237
+ if (buildLike) {
1238
+ // Scaffolding / multi-file work needs room: many file writes plus a
1239
+ // verify/build step. Continuation prompts ("do it") inherit this too.
1240
+ stepBudget = Math.max(stepBudget, maxSteps);
1241
+ }
1242
+ else if (hasHistory) {
1243
+ // A follow-up to an ongoing task should never be capped tighter than a
1244
+ // standard one-shot, even if it's only a couple of words.
1245
+ stepBudget = Math.max(stepBudget, 40);
1246
+ }
1247
+ // Hard ceiling on total loop iterations (productive + recovery) so a model
1248
+ // stuck emitting only thinking or malformed calls can't loop indefinitely.
1249
+ const maxIterations = stepBudget * 3;
1250
+ let productiveSteps = 0;
1251
+ let step = -1;
1252
+ let nextToolEventId = 0;
1253
+ for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1254
+ // `step` is the productive-step index (used for display + audit). It only
1255
+ // advances when the previous iteration actually executed a tool.
1256
+ step = productiveSteps;
1257
+ if (productiveSteps >= stepBudget)
1258
+ break;
1259
+ options.signal?.throwIfAborted();
1260
+ // `call` and `assistantText` are shared by both paths below: a fresh
1261
+ // model round-trip, or draining a previously-queued tool call.
1262
+ let call;
1263
+ let assistantText;
1264
+ let recoveredFromBareJson = false;
1265
+ if (pendingCalls.length > 0) {
1266
+ // Drain the next queued call from the previous model message — no new
1267
+ // round-trip. The assistant message and any prose were already shown
1268
+ // when the batch was parsed.
1269
+ call = pendingCalls.shift();
1270
+ assistantText = { visible: "", thinkContent: "", hasThinking: false };
1271
+ const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
1272
+ writeStatus(batchStatus, chalk.dim(batchStatus));
1289
1273
  }
1290
1274
  else {
1291
- // Reset the counter on any successful visible output or recovered call.
1292
- emptyVisibleRetries = 0;
1293
- }
1294
- // `call` was already extracted above (from visible text or thinking content).
1295
- // Recovery: the model meant to call a tool but emitted a bare JSON object
1296
- // with no ```tool fence — either a complete {name,args} the strict
1297
- // matchers missed (recover it directly), or just an args object like
1298
- // {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
1299
- // the requested action runs instead of the JSON leaking as the answer).
1300
- let bareArgsOnly = false;
1301
- recoveredFromBareJson = false;
1302
- if (!call) {
1303
- const bare = recognizeBareToolJson(assistantText.visible);
1304
- if (bare?.call) {
1305
- call = bare.call;
1306
- recoveredFromBareJson = true;
1307
- process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
1275
+ // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1276
+ // Status messages (rate-limit retries, fallback hints) still surface live.
1277
+ // A spinner gives the user feedback during long thinking phases on
1278
+ // models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
1279
+ const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
1280
+ const spinner = writesDirectly
1281
+ ? startThinkingSpinner(streamLabel, options.signal)
1282
+ : noopSpinner;
1283
+ if (!writesDirectly) {
1284
+ emit({ type: "status", text: streamLabel });
1308
1285
  }
1309
- else if (bare?.argsOnly) {
1310
- bareArgsOnly = true;
1286
+ let sawReasoning = false;
1287
+ let inThinking = false;
1288
+ let emittedThinkingStatus = false;
1289
+ const deltaParser = writesDirectly
1290
+ ? undefined
1291
+ : createThinkingStreamParser((text) => emit({ type: "assistant-delta", text }), (text) => {
1292
+ if (!emittedThinkingStatus) {
1293
+ emittedThinkingStatus = true;
1294
+ emit({ type: "status", text: "thinking" });
1295
+ }
1296
+ emit({ type: "thinking-delta", text });
1297
+ });
1298
+ let completion;
1299
+ try {
1300
+ completion = await streamWithProvider({
1301
+ provider,
1302
+ model,
1303
+ messages,
1304
+ temperature: 0.2,
1305
+ // Reasoning models can spend a lot on hidden thinking; give
1306
+ // them headroom so the visible answer / tool call isn't
1307
+ // truncated to silence. The non-thinking budget must be large
1308
+ // enough for a single-file fs.write / multi-file fs.writeMany
1309
+ // payload — a truncated tool-call JSON fails to parse and leaks a
1310
+ // broken (and syntactically invalid) file. 8k was too small for a
1311
+ // full component, so allow more room for the visible tool call.
1312
+ maxTokens: config.thinking?.enabled ? 16_384 : 12_288,
1313
+ signal: options.signal,
1314
+ thinking: config.thinking,
1315
+ }, (token) => {
1316
+ deltaParser?.push(token);
1317
+ // Heuristic: <think>… markers and reasoning_content tokens flow
1318
+ // through onToken. Surface activity in the spinner so the screen
1319
+ // is never empty for minutes.
1320
+ if (!sawReasoning && /<think/i.test(token)) {
1321
+ sawReasoning = true;
1322
+ inThinking = true;
1323
+ spinner.setLabel("thinking");
1324
+ if (!writesDirectly)
1325
+ emit({ type: "status", text: "thinking" });
1326
+ }
1327
+ if (/<\/think>/i.test(token)) {
1328
+ inThinking = false;
1329
+ }
1330
+ // Only push reasoning tokens to the spinner preview. Visible
1331
+ // answer / tool-call tokens should NOT go through the dim
1332
+ // spinner preview — doing so makes the final answer appear
1333
+ // "diluted" in light font when the spinner's last render
1334
+ // briefly shows the answer text before being erased.
1335
+ if (inThinking) {
1336
+ const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
1337
+ if (cleaned) {
1338
+ spinner.pushPreview(cleaned);
1339
+ const approx = cleaned.split(/\s+/).filter(Boolean).length;
1340
+ if (approx > 0)
1341
+ spinner.bumpReasoning(approx);
1342
+ }
1343
+ }
1344
+ }, (status) => {
1345
+ spinner.stop();
1346
+ writeStatus(status, chalk.dim(status));
1347
+ });
1311
1348
  }
1312
- }
1313
- // Also check thinking content for bare JSON calls.
1314
- if (!call && assistantText.hasThinking) {
1315
- const bareThink = recognizeBareToolJson(assistantText.thinkContent);
1316
- if (bareThink?.call) {
1317
- call = bareThink.call;
1318
- recoveredFromBareJson = true;
1319
- process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from thinking content\n"));
1349
+ finally {
1350
+ // Always clear the spinner abort, network error, or success.
1351
+ spinner.stop();
1320
1352
  }
1321
- else if (bareThink?.argsOnly) {
1322
- bareArgsOnly = true;
1353
+ provider = completion.provider;
1354
+ model = completion.model;
1355
+ deltaParser?.finish();
1356
+ const assistantTextResult = rememberThinkingFromText(completion.text);
1357
+ assistantText = assistantTextResult;
1358
+ // Try visible text first, then thinking content — some models (e.g. glm-5.1)
1359
+ // wrap tool calls inside considering tags, so stripThinking removes them
1360
+ // into thinkContent and visible becomes empty. Recovering from thinkContent
1361
+ // prevents an endless nudge loop where the model keeps hiding the call.
1362
+ call = parseToolCall(assistantText.visible, {
1363
+ strict: getConfig().parserStrict,
1364
+ });
1365
+ if (!call && assistantText.hasThinking) {
1366
+ call = parseToolCall(assistantText.thinkContent, {
1367
+ strict: getConfig().parserStrict,
1368
+ });
1369
+ if (call) {
1370
+ writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
1371
+ }
1323
1372
  }
1324
- }
1325
- if (!call) {
1326
- if (bareArgsOnly) {
1327
- bareToolJsonRetries += 1;
1328
- 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"));
1330
- messages.push({ role: "assistant", content: assistantText.visible });
1331
- messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1332
- ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1373
+ // ── Thinking-only recovery ────────────────────────────────────────
1374
+ // Some models (eg gpt-oss-20b on NVIDIA NIM) occasionally spend their
1375
+ // entire budget on hidden <think> reasoning and emit no visible text
1376
+ // or tool call. Without this guard the agent silently returns an empty
1377
+ // answer and the user has to re-submit the same prompt.
1378
+ if (!assistantText.visible.trim() && !call && assistantText.hasThinking) {
1379
+ emptyVisibleRetries += 1;
1380
+ if (emptyVisibleRetries <= 2) {
1381
+ writeThinkingBlock(assistantText.thinkContent);
1382
+ writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
1383
+ messages.push({
1384
+ role: "assistant",
1385
+ content: collapseRepeatedText(completion.text),
1386
+ });
1387
+ const buildNudge = buildLikeTurn && !activePlan
1388
+ ? "You only produced internal reasoning with no visible answer or tool call. " +
1333
1389
  "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1334
- "You MUST call plan.create using a proper ```tool block. For example:\n" +
1335
- '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1336
- "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1337
- : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1338
- "Reply with ONLY a fenced ```tool block of the form " +
1339
- '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
1340
- '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
1341
- "Choose the correct tool name for the task and include those args."));
1390
+ "You MUST call plan.create using the ```tool format to create a comprehensive plan BEFORE writing any files or running any commands. " +
1391
+ "Do NOT use fs.write, fs.writeMany, fs.edit, shell.exec, shell.start, or pkg.install yet. " +
1392
+ "Your ONLY allowed action right now is plan.create (or read/list for exploration)."
1393
+ : "You only produced internal reasoning with no visible answer or tool call. " +
1394
+ "You MUST either call a tool using the ```tool format or provide your final answer. " +
1395
+ "Do NOT wrap your tool call inside considering or reasoning tags — put it in the VISIBLE response, not hidden. " +
1396
+ "If images are attached, inspect them directly for visual details (text, colors, layout, spacing, style) instead of using OCR unless explicitly needed. " +
1397
+ "Do NOT just think take action NOW.";
1398
+ messages.push(recoveryUserMessage(buildNudge));
1342
1399
  continue;
1343
1400
  }
1344
- // Exhausted retries — fall through to the normal answer path.
1401
+ // Exhausted retries — fall through to the normal empty-answer path
1402
+ // which will print a warning and return.
1345
1403
  }
1346
- // Detect the case where the model emitted sentinel-style tool-call
1347
- // markers but the body was malformed or truncated. Printing those
1348
- // raw tokens looks like a crash to the user — instead, ask the
1349
- // model to retry the tool call in a clean JSON format.
1350
- 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"));
1352
- messages.push({ role: "assistant", content: assistantText.visible });
1353
- messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1354
- "Reply with ONLY a fenced ```tool block containing valid JSON " +
1355
- 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
1356
- "Do not use <|tool_call_begin|> markers."));
1357
- continue;
1404
+ else {
1405
+ // Reset the counter on any successful visible output or recovered call.
1406
+ emptyVisibleRetries = 0;
1358
1407
  }
1359
- // Detect a tool call that opened but was cut off by the token limit
1360
- // (most common with a large multi-file fs.writeMany). Retrying with a
1361
- // nudge to split the work is far better than rendering broken JSON as
1362
- // a final answer and leaving the project half-created.
1363
- if (looksLikeTruncatedToolCall(assistantText.visible)) {
1364
- truncatedToolRetries += 1;
1365
- 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"));
1367
- messages.push({ role: "assistant", content: assistantText.visible });
1368
- messages.push({
1369
- role: "user",
1370
- content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
1371
- "Retry now with a COMPLETE, valid ```tool block. " +
1372
- "If it was a large fs.writeMany, split it into SMALLER batches (3-5 files per call, and keep each file's content concise) " +
1373
- "so the whole JSON fits in one response. Do NOT claim any file was written until a tool call actually succeeds.",
1374
- });
1375
- continue;
1408
+ // `call` was already extracted above (from visible text or thinking content).
1409
+ // Recovery: the model meant to call a tool but emitted a bare JSON object
1410
+ // with no ```tool fence either a complete {name,args} the strict
1411
+ // matchers missed (recover it directly), or just an args object like
1412
+ // {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
1413
+ // the requested action runs instead of the JSON leaking as the answer).
1414
+ let bareArgsOnly = false;
1415
+ recoveredFromBareJson = false;
1416
+ if (!call) {
1417
+ const bare = recognizeBareToolJson(assistantText.visible);
1418
+ if (bare?.call) {
1419
+ call = bare.call;
1420
+ recoveredFromBareJson = true;
1421
+ writeNotice("info", "recovered an unfenced tool call from bare JSON", chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
1422
+ }
1423
+ else if (bare?.argsOnly) {
1424
+ bareArgsOnly = true;
1376
1425
  }
1377
- // Exhausted retries — fall through so we don't loop forever, but the
1378
- // user at least sees the (broken) output and the stop notice.
1379
1426
  }
1380
- // Detect a ```tool fence whose JSON could NOT be parsed for any other
1381
- // reason (malformed braces, trailing junk, a stray `}` — NOT plain
1382
- // truncation, which is handled above). Without this, the raw block
1383
- // leaks to the screen as a code fence and the requested action (often
1384
- // a whole fs.writeMany scaffold) silently never runs — exactly the
1385
- // "fs.writeMany printed but nothing created" failure. Require the fence
1386
- // to actually look like an intended call (mentions name/args) so a
1387
- // genuine ```tool code example in prose isn't mistaken for one.
1388
- const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
1389
- /```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
1390
- if (hasFencedCallShape) {
1391
- malformedFenceRetries += 1;
1392
- 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"));
1427
+ // Also check thinking content for bare JSON calls.
1428
+ if (!call && assistantText.hasThinking) {
1429
+ const bareThink = recognizeBareToolJson(assistantText.thinkContent);
1430
+ if (bareThink?.call) {
1431
+ call = bareThink.call;
1432
+ recoveredFromBareJson = true;
1433
+ writeNotice("info", "recovered an unfenced tool call from thinking content", chalk.dim(" ℹ recovered an unfenced tool call from thinking content\n"));
1434
+ }
1435
+ else if (bareThink?.argsOnly) {
1436
+ bareArgsOnly = true;
1437
+ }
1438
+ }
1439
+ if (!call) {
1440
+ if (bareArgsOnly) {
1441
+ bareToolJsonRetries += 1;
1442
+ if (bareToolJsonRetries <= 3) {
1443
+ 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"));
1444
+ messages.push({ role: "assistant", content: assistantText.visible });
1445
+ messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1446
+ ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1447
+ "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1448
+ "You MUST call plan.create using a proper ```tool block. For example:\n" +
1449
+ '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1450
+ "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1451
+ : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1452
+ "Reply with ONLY a fenced ```tool block of the form " +
1453
+ '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
1454
+ '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
1455
+ "Choose the correct tool name for the task and include those args."));
1456
+ continue;
1457
+ }
1458
+ // Exhausted retries — fall through to the normal answer path.
1459
+ }
1460
+ // Detect the case where the model emitted sentinel-style tool-call
1461
+ // markers but the body was malformed or truncated. Printing those
1462
+ // raw tokens looks like a crash to the user — instead, ask the
1463
+ // model to retry the tool call in a clean JSON format.
1464
+ if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1465
+ 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"));
1394
1466
  messages.push({ role: "assistant", content: assistantText.visible });
1395
- messages.push({
1396
- role: "user",
1397
- content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
1398
- "Common causes: an extra or missing `}` / `]`, a trailing brace after the closing `}`, or unescaped quotes/newlines inside a string value. " +
1399
- 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
1400
- "If it was a large fs.writeMany, split it into SMALLER batches (3-5 files) so the JSON is easy to keep valid. " +
1401
- "Do NOT claim any file was written until a tool call actually succeeds.",
1402
- });
1467
+ messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1468
+ "Reply with ONLY a fenced ```tool block containing valid JSON " +
1469
+ 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
1470
+ "Do not use <|tool_call_begin|> markers."));
1403
1471
  continue;
1404
1472
  }
1405
- // Exhausted retries fall through to the normal path.
1406
- }
1407
- // Normal final-answer path: strip any stray sentinel tokens that
1408
- // somehow leaked into prose so the answer renders cleanly.
1409
- const cleaned = stripSentinelTokens(assistantText.visible);
1410
- // ── Act, don't narrate ────────────────────────────────────────────
1411
- // Build/scaffold/plan turns must DO something. If the model returns
1412
- // prose with NO tool call, it is narrating intent ("Let me first
1413
- // explore the directory…") or writing a PLAN as prose ("Goal: Tasks:
1414
- // … please approve") instead of calling a tool — accepting it as a
1415
- // final answer ends the turn with nothing done and no real plan saved.
1416
- // Nudge it to emit a real tool call, with a concrete example.
1417
- const wantsAction = buildLikeTurn || (activePlan && session.planApproved.value);
1418
- const planNarrated = buildLikeTurn && !activePlan && looksLikePlanNarration(cleaned);
1419
- if (wantsAction &&
1420
- cleaned.trim().length > 0 &&
1421
- actionIntentRetries < 3 &&
1422
- (productiveSteps === 0 ||
1423
- planNarrated ||
1424
- looksLikeActionNarration(cleaned))) {
1425
- actionIntentRetries += 1;
1426
- let nudge;
1427
- if (activePlan && session.planApproved.value) {
1428
- nudge =
1429
- "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"));
1473
+ // Detect a tool call that opened but was cut off by the token limit
1474
+ // (most common with a large multi-file fs.writeMany). Retrying with a
1475
+ // nudge to split the work is far better than rendering broken JSON as
1476
+ // a final answer and leaving the project half-created.
1477
+ if (looksLikeTruncatedToolCall(assistantText.visible)) {
1478
+ truncatedToolRetries += 1;
1479
+ if (truncatedToolRetries <= 3) {
1480
+ 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"));
1481
+ messages.push({ role: "assistant", content: assistantText.visible });
1482
+ messages.push({
1483
+ role: "user",
1484
+ content: "Your previous tool call was cut off before it finished the JSON was incomplete, so NOTHING ran. " +
1485
+ "Retry now with a COMPLETE, valid ```tool block. " +
1486
+ "If it was a large fs.writeMany, split it into SMALLER batches (3-5 files per call, and keep each file's content concise) " +
1487
+ "so the whole JSON fits in one response. Do NOT claim any file was written until a tool call actually succeeds.",
1488
+ });
1489
+ continue;
1490
+ }
1491
+ // Exhausted retries — fall through so we don't loop forever, but the
1492
+ // user at least sees the (broken) output and the stop notice.
1431
1493
  }
1432
- else if (planNarrated || productiveSteps > 0) {
1433
- // It explored and/or wrote a plan as prose but never called
1434
- // plan.create emit the plan as a real tool call so the user gets
1435
- // a checklist and the /implement gate.
1436
- nudge =
1437
- "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
- '```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
- "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"));
1494
+ // Detect a ```tool fence whose JSON could NOT be parsed for any other
1495
+ // reason (malformed braces, trailing junk, a stray `}` NOT plain
1496
+ // truncation, which is handled above). Without this, the raw block
1497
+ // leaks to the screen as a code fence and the requested action (often
1498
+ // a whole fs.writeMany scaffold) silently never runs — exactly the
1499
+ // "fs.writeMany printed but nothing created" failure. Require the fence
1500
+ // to actually look like an intended call (mentions name/args) so a
1501
+ // genuine ```tool code example in prose isn't mistaken for one.
1502
+ const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
1503
+ /```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
1504
+ if (hasFencedCallShape) {
1505
+ malformedFenceRetries += 1;
1506
+ if (malformedFenceRetries <= 3) {
1507
+ 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"));
1508
+ messages.push({ role: "assistant", content: assistantText.visible });
1509
+ messages.push({
1510
+ role: "user",
1511
+ content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
1512
+ "Common causes: an extra or missing `}` / `]`, a trailing brace after the closing `}`, or unescaped quotes/newlines inside a string value. " +
1513
+ 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
1514
+ "If it was a large fs.writeMany, split it into SMALLER batches (3-5 files) so the JSON is easy to keep valid. " +
1515
+ "Do NOT claim any file was written until a tool call actually succeeds.",
1516
+ });
1517
+ continue;
1518
+ }
1519
+ // Exhausted retries — fall through to the normal path.
1441
1520
  }
1442
- else {
1443
- nudge =
1444
- "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
- '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1446
- "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"));
1521
+ // Normal final-answer path: strip any stray sentinel tokens that
1522
+ // somehow leaked into prose so the answer renders cleanly.
1523
+ const cleaned = stripSentinelTokens(assistantText.visible);
1524
+ // ── Act, don't narrate ────────────────────────────────────────────
1525
+ // Build/scaffold/plan turns must DO something. If the model returns
1526
+ // prose with NO tool call, it is narrating intent ("Let me first
1527
+ // explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
1528
+ // … please approve") instead of calling a tool — accepting it as a
1529
+ // final answer ends the turn with nothing done and no real plan saved.
1530
+ // Nudge it to emit a real tool call, with a concrete example.
1531
+ const wantsAction = buildLikeTurn || (activePlan && session.planApproved.value);
1532
+ const planNarrated = buildLikeTurn && !activePlan && looksLikePlanNarration(cleaned);
1533
+ if (wantsAction &&
1534
+ cleaned.trim().length > 0 &&
1535
+ actionIntentRetries < 3 &&
1536
+ (productiveSteps === 0 ||
1537
+ planNarrated ||
1538
+ looksLikeActionNarration(cleaned))) {
1539
+ actionIntentRetries += 1;
1540
+ let nudge;
1541
+ if (activePlan && session.planApproved.value) {
1542
+ nudge =
1543
+ "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.";
1544
+ 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"));
1545
+ }
1546
+ else if (planNarrated || productiveSteps > 0) {
1547
+ // It explored and/or wrote a plan as prose but never called
1548
+ // plan.create — emit the plan as a real tool call so the user gets
1549
+ // a checklist and the /implement gate.
1550
+ nudge =
1551
+ "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" +
1552
+ '```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' +
1553
+ "Do not describe the plan again in prose — just emit the plan.create tool block.";
1554
+ 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"));
1555
+ }
1556
+ else {
1557
+ nudge =
1558
+ "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" +
1559
+ '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1560
+ "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.";
1561
+ 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"));
1562
+ }
1563
+ messages.push({ role: "assistant", content: assistantText.visible });
1564
+ messages.push(recoveryUserMessage(nudge));
1565
+ continue;
1448
1566
  }
1449
- messages.push({ role: "assistant", content: assistantText.visible });
1450
- messages.push(recoveryUserMessage(nudge));
1451
- continue;
1452
- }
1453
- if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
1454
- freshnessRetryUsed = true;
1455
- process.stdout.write(chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
1456
- messages.push({ role: "assistant", content: assistantText.visible });
1457
- messages.push({
1458
- role: "user",
1459
- content: freshnessGuardMessage() +
1460
- " Reply with ONLY a fenced ```tool block for web.search now.",
1461
- });
1462
- continue;
1463
- }
1464
- // ── Premature-completion guard (approved plan still has work) ──────
1465
- // If the user approved a plan and the model now gives a final answer
1466
- // while tasks are still pending/in_progress — without having run the
1467
- // work — it is fabricating completion (the exact "all tasks completed,
1468
- // running at localhost:5173" failure). Force it back to executing the
1469
- // next real task instead of accepting the false claim.
1470
- if (session.planApproved.value && prematureCompletionRetries < 3) {
1471
- const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1472
- const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1473
- if (livePlan && unfinished && unfinished.length > 0) {
1474
- prematureCompletionRetries += 1;
1475
- 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`));
1567
+ if (freshWebSearchRequired && !sawFreshWebSearch && !freshnessRetryUsed) {
1568
+ freshnessRetryUsed = true;
1569
+ writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
1477
1570
  messages.push({ role: "assistant", content: assistantText.visible });
1478
1571
  messages.push({
1479
1572
  role: "user",
1480
- content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
1481
- `(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
1482
- `Do NOT claim the work is complete, that files were created, or that a server is running ` +
1483
- `unless a tool call actually succeeded and you saw the output. ` +
1484
- `Resume now with the NEXT task ${next.id} ("${next.title}"): call task.update {taskId:"${next.id}", state:"in_progress"}, ` +
1485
- `then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. ` +
1486
- `Continue task by task until EVERY task is actually finished.`,
1573
+ content: freshnessGuardMessage() +
1574
+ " Reply with ONLY a fenced ```tool block for web.search now.",
1487
1575
  });
1488
1576
  continue;
1489
1577
  }
1490
- }
1491
- // If we still print a final answer while an approved plan has unfinished
1492
- // tasks (retries exhausted), do NOT let a fabricated "it's done" stand
1493
- // unchallengedappend an explicit, honest status so the user knows the
1494
- // build did not actually complete.
1495
- let completionWarning = "";
1496
- if (session.planApproved.value) {
1497
- const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1498
- const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1499
- if (livePlan && unfinished && unfinished.length > 0) {
1500
- completionWarning =
1501
- chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
1502
- unfinished
1503
- .map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
1504
- .join("\n") +
1505
- chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
1578
+ // ── Premature-completion guard (approved plan still has work) ──────
1579
+ // If the user approved a plan and the model now gives a final answer
1580
+ // while tasks are still pending/in_progress without having run the
1581
+ // workit is fabricating completion (the exact "all tasks completed,
1582
+ // running at localhost:5173" failure). Force it back to executing the
1583
+ // next real task instead of accepting the false claim.
1584
+ if (session.planApproved.value && prematureCompletionRetries < 3) {
1585
+ const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1586
+ const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1587
+ if (livePlan && unfinished && unfinished.length > 0) {
1588
+ prematureCompletionRetries += 1;
1589
+ const next = unfinished[0];
1590
+ 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`));
1591
+ messages.push({ role: "assistant", content: assistantText.visible });
1592
+ messages.push({
1593
+ role: "user",
1594
+ content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
1595
+ `(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
1596
+ `Do NOT claim the work is complete, that files were created, or that a server is running ` +
1597
+ `unless a tool call actually succeeded and you saw the output. ` +
1598
+ `Resume now with the NEXT task ${next.id} ("${next.title}"): call task.update {taskId:"${next.id}", state:"in_progress"}, ` +
1599
+ `then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. ` +
1600
+ `Continue task by task until EVERY task is actually finished.`,
1601
+ });
1602
+ continue;
1603
+ }
1506
1604
  }
1605
+ // If we still print a final answer while an approved plan has unfinished
1606
+ // tasks (retries exhausted), do NOT let a fabricated "it's done" stand
1607
+ // unchallenged — append an explicit, honest status so the user knows the
1608
+ // build did not actually complete.
1609
+ let completionWarning = "";
1610
+ let completionWarningText = "";
1611
+ if (session.planApproved.value) {
1612
+ const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1613
+ const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1614
+ if (livePlan && unfinished && unfinished.length > 0) {
1615
+ completionWarningText =
1616
+ `${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete. ` +
1617
+ "The summary above may overstate progress.";
1618
+ completionWarning =
1619
+ chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
1620
+ unfinished
1621
+ .map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
1622
+ .join("\n") +
1623
+ chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
1624
+ }
1625
+ }
1626
+ if (cleaned) {
1627
+ writeAssistantMessage(cleaned);
1628
+ }
1629
+ if (completionWarning) {
1630
+ writeNotice("warn", completionWarningText, completionWarning);
1631
+ }
1632
+ if (assistantText.hasThinking) {
1633
+ writeThinkingBlock(assistantText.thinkContent);
1634
+ }
1635
+ await auditLog("agent.final", { provider, model, steps: step + 1 });
1636
+ lastAnswer = cleaned;
1637
+ return finishTurn(lastAnswer, step + 1);
1507
1638
  }
1508
- if (cleaned) {
1509
- process.stdout.write(renderMarkdown(cleaned));
1510
- if (!cleaned.endsWith("\n"))
1511
- process.stdout.write("\n");
1512
- }
1513
- if (completionWarning) {
1514
- process.stdout.write(completionWarning);
1639
+ // A valid primary tool call exists for this fresh model turn. Show any
1640
+ // prose / thinking that preceded it, record the assistant message ONCE,
1641
+ // then queue any additional tool calls from the same message so they
1642
+ // run in order on the next iterations (no extra round-trip).
1643
+ const beforeTool = recoveredFromBareJson
1644
+ ? ""
1645
+ : textBeforeToolCall(assistantText.visible);
1646
+ if (beforeTool) {
1647
+ writeAssistantMessage(beforeTool);
1515
1648
  }
1516
1649
  if (assistantText.hasThinking) {
1517
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1650
+ writeThinkingBlock(assistantText.thinkContent);
1651
+ }
1652
+ messages.push({
1653
+ role: "assistant",
1654
+ content: collapseRepeatedText(assistantText.visible),
1655
+ });
1656
+ if (!recoveredFromBareJson && call) {
1657
+ const allCalls = parseAllToolCalls(assistantText.visible);
1658
+ if (allCalls.length > 1 &&
1659
+ allCalls[0] &&
1660
+ sameToolCall(allCalls[0], call)) {
1661
+ pendingCalls = allCalls.slice(1);
1662
+ 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`));
1663
+ }
1518
1664
  }
1519
- await auditLog("agent.final", { provider, model, steps: step + 1 });
1520
- lastAnswer = cleaned;
1521
- return lastAnswer;
1522
1665
  }
1523
- // A valid primary tool call exists for this fresh model turn. Show any
1524
- // prose / thinking that preceded it, record the assistant message ONCE,
1525
- // then queue any additional tool calls from the same message so they
1526
- // run in order on the next iterations (no extra round-trip).
1527
- const beforeTool = recoveredFromBareJson
1528
- ? ""
1529
- : textBeforeToolCall(assistantText.visible);
1530
- if (beforeTool) {
1531
- process.stdout.write(renderMarkdown(beforeTool) + "\n");
1666
+ // Type guard: every path above either set `call` or returned/continued.
1667
+ if (!call)
1668
+ continue;
1669
+ // Models often emit a bare CLI name as the tool (e.g. {"name":"sed",...})
1670
+ // instead of wrapping it in shell.exec. Rewrite such unknown, un-namespaced
1671
+ // names into a shell.exec call BEFORE classification so the command both
1672
+ // runs and is safety-classified as the shell command it really is —
1673
+ // instead of dead-ending on "Unknown tool: sed".
1674
+ call = normalizeToolCall(call);
1675
+ const toolEventId = `tool-${++nextToolEventId}`;
1676
+ // ── Duplicate-call detection ──────────────────────────────────────────
1677
+ // If the model calls the exact same tool with the exact same args
1678
+ // repeatedly, it's stuck in a loop. Inject a corrective message
1679
+ // telling it to summarize the results it already has.
1680
+ const loopCheck = loopGuard.shouldBlock(call.name, call.args);
1681
+ if (loopCheck.block) {
1682
+ const isWrite = call.name === "fs.write" ||
1683
+ call.name === "fs.writeMany" ||
1684
+ call.name === "fs.edit";
1685
+ const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
1686
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
1687
+ // A repeat means this batch went off the rails — drop any queued calls
1688
+ // and let the model react. The assistant message was already recorded.
1689
+ pendingCalls = [];
1690
+ messages.push({
1691
+ role: "user",
1692
+ content: isWrite
1693
+ ? `You already wrote that exact file with ${call.name}. It is saved. ` +
1694
+ "Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
1695
+ "verify the project (list the tree, run the build/install command) and give your final answer."
1696
+ : `You already called ${call.name} with the same arguments and received results. ` +
1697
+ "Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
1698
+ });
1699
+ continue;
1532
1700
  }
1533
- if (assistantText.hasThinking) {
1534
- process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
1701
+ if (loopCheck.reason) {
1702
+ writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
1535
1703
  }
1536
- messages.push({
1537
- role: "assistant",
1538
- content: collapseRepeatedText(assistantText.visible),
1539
- });
1540
- if (!recoveredFromBareJson && call) {
1541
- const allCalls = parseAllToolCalls(assistantText.visible);
1542
- if (allCalls.length > 1 &&
1543
- allCalls[0] &&
1544
- sameToolCall(allCalls[0], call)) {
1545
- pendingCalls = allCalls.slice(1);
1546
- process.stdout.write(chalk.dim(` ℹ ${allCalls.length} tool calls in this message — running them in order\n`));
1704
+ // ── Plan / task tools (session-scoped, handled inline) ─────────────
1705
+ // These don't go through the generic registry because they need the
1706
+ // session id and mutate the live plan that the user can view (Ctrl+P).
1707
+ if (call.name === "plan.create" || call.name === "task.update") {
1708
+ const planResult = await handlePlanTool(call, session, {
1709
+ loopGuard,
1710
+ step,
1711
+ });
1712
+ if (planResult.handled) {
1713
+ productiveSteps += 1;
1714
+ loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1715
+ if (planResult.plan) {
1716
+ writePlanUpdate(planResult.plan, planResult.display);
1717
+ }
1718
+ // plan.create means "STOP and wait for /implement" — abandon any
1719
+ // other calls the model batched alongside it.
1720
+ if (call.name === "plan.create")
1721
+ pendingCalls = [];
1722
+ messages.push({
1723
+ role: "tool",
1724
+ content: `Tool ${call.name} result (ok=${planResult.ok}):\n${planResult.modelNote}`,
1725
+ });
1726
+ continue;
1547
1727
  }
1548
1728
  }
1549
- }
1550
- // Type guard: every path above either set `call` or returned/continued.
1551
- if (!call)
1552
- continue;
1553
- // Models often emit a bare CLI name as the tool (e.g. {"name":"sed",...})
1554
- // instead of wrapping it in shell.exec. Rewrite such unknown, un-namespaced
1555
- // names into a shell.exec call BEFORE classification so the command both
1556
- // runs and is safety-classified as the shell command it really is —
1557
- // instead of dead-ending on "Unknown tool: sed".
1558
- call = normalizeToolCall(call);
1559
- // ── Duplicate-call detection ──────────────────────────────────────────
1560
- // If the model calls the exact same tool with the exact same args
1561
- // repeatedly, it's stuck in a loop. Inject a corrective message
1562
- // telling it to summarize the results it already has.
1563
- const loopCheck = loopGuard.shouldBlock(call.name, call.args);
1564
- if (loopCheck.block) {
1565
- const isWrite = call.name === "fs.write" ||
1566
- call.name === "fs.writeMany" ||
1567
- 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`));
1569
- // A repeat means this batch went off the rails — drop any queued calls
1570
- // and let the model react. The assistant message was already recorded.
1571
- pendingCalls = [];
1572
- messages.push({
1573
- role: "user",
1574
- content: isWrite
1575
- ? `You already wrote that exact file with ${call.name}. It is saved. ` +
1576
- "Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
1577
- "verify the project (list the tree, run the build/install command) and give your final answer."
1578
- : `You already called ${call.name} with the same arguments and received results. ` +
1579
- "Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
1729
+ const scope = await loadScope();
1730
+ const decision = classifyToolCall(call, { scope });
1731
+ await auditLog("tool.classified", {
1732
+ call,
1733
+ decision,
1734
+ scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
1580
1735
  });
1581
- continue;
1582
- }
1583
- if (loopCheck.reason) {
1584
- process.stdout.write(chalk.dim(` ℹ ${loopCheck.reason}\n`));
1585
- }
1586
- // ── Plan / task tools (session-scoped, handled inline) ─────────────
1587
- // These don't go through the generic registry because they need the
1588
- // session id and mutate the live plan that the user can view (Ctrl+P).
1589
- if (call.name === "plan.create" || call.name === "task.update") {
1590
- const planResult = await handlePlanTool(call, session, {
1591
- loopGuard,
1592
- step,
1593
- });
1594
- if (planResult.handled) {
1595
- productiveSteps += 1;
1596
- loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
1597
- process.stdout.write(planResult.display);
1598
- // plan.create means "STOP and wait for /implement" — abandon any
1599
- // other calls the model batched alongside it.
1600
- if (call.name === "plan.create")
1601
- pendingCalls = [];
1736
+ // ── Plan-awaiting-approval gate ────────────────────────────────────
1737
+ // When an active plan exists but the user has NOT approved it with
1738
+ // /implement, the agent must NOT execute the plan. Any free-text the
1739
+ // user typed after the plan was shown is a PLAN REVISION, not a "go"
1740
+ // signal — the agent should re-plan (plan.create) and wait again. We
1741
+ // hard-block execution tools here so a model that ignores the prompt
1742
+ // directive (or recovers a stray tool call) can't start running the
1743
+ // plan. Read-only exploration is still allowed so it can refine the
1744
+ // plan intelligently.
1745
+ if (activePlan &&
1746
+ !session.planApproved.value &&
1747
+ !isPreApprovalAllowedTool(call.name)) {
1748
+ const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1749
+ writeNotice("warn", reason, chalk.yellow(` ⚠ plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
1750
+ pendingCalls = [];
1602
1751
  messages.push({
1603
- role: "tool",
1604
- content: `Tool ${call.name} result (ok=${planResult.ok}):\n${planResult.modelNote}`,
1752
+ role: "user",
1753
+ content: `There is an ACTIVE PLAN that has NOT been approved yet, so you must NOT execute it — ` +
1754
+ `you tried to call ${call.name}, which is blocked. The user's latest message is a PLAN REVISION, ` +
1755
+ `not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
1756
+ `revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
1757
+ `Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
1605
1758
  });
1606
1759
  continue;
1607
1760
  }
1608
- }
1609
- const scope = await loadScope();
1610
- const decision = classifyToolCall(call, { scope });
1611
- await auditLog("tool.classified", {
1612
- call,
1613
- decision,
1614
- scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
1615
- });
1616
- // ── Plan-awaiting-approval gate ────────────────────────────────────
1617
- // When an active plan exists but the user has NOT approved it with
1618
- // /implement, the agent must NOT execute the plan. Any free-text the
1619
- // user typed after the plan was shown is a PLAN REVISION, not a "go"
1620
- // signal the agent should re-plan (plan.create) and wait again. We
1621
- // hard-block execution tools here so a model that ignores the prompt
1622
- // directive (or recovers a stray tool call) can't start running the
1623
- // plan. Read-only exploration is still allowed so it can refine the
1624
- // plan intelligently.
1625
- if (activePlan &&
1626
- !session.planApproved.value &&
1627
- !isPreApprovalAllowedTool(call.name)) {
1628
- process.stdout.write(chalk.yellow(` ⚠ plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
1629
- pendingCalls = [];
1630
- messages.push({
1631
- role: "user",
1632
- content: `There is an ACTIVE PLAN that has NOT been approved yet, so you must NOT execute it — ` +
1633
- `you tried to call ${call.name}, which is blocked. The user's latest message is a PLAN REVISION, ` +
1634
- `not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
1635
- `revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
1636
- `Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
1637
- });
1638
- continue;
1639
- }
1640
- if (call.name === "web.search") {
1641
- sawFreshWebSearch = true;
1642
- }
1643
- // Show tool call
1644
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1645
- process.stdout.write(styleToolChatter(call, toolCallLine) + "\n");
1646
- const scopeTarget = scopeTargetForToolCall(call);
1647
- if (scopeTarget &&
1648
- (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
1649
- process.stdout.write(chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1650
- }
1651
- if (decision.level === "block") {
1652
- process.stdout.write(chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1653
- lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
1654
- return lastAnswer;
1655
- }
1656
- // Pentest authorization — if user confirms this, skip the per-tool confirm
1657
- let pentestJustConfirmed = false;
1658
- const needsPentestAuth = isPentestToolCall(call) &&
1659
- !getConfig().pentestAuthorized &&
1660
- !session.pentestAuthorized.value;
1661
- const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session);
1662
- // inquirer's confirm() creates its own readline interface which resets
1663
- // raw mode AND pauses stdin when it finishes. Re-assert raw mode and
1664
- // resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
1665
- // output pane) keeps working during the next streaming/tool phase.
1666
- restoreInteractiveStdin();
1667
- if (!authorized) {
1668
- lastAnswer = "Pentest authorization not confirmed.";
1669
- process.stdout.write(chalk.red(` ✗ ${lastAnswer}`) + "\n");
1670
- return lastAnswer;
1671
- }
1672
- if (needsPentestAuth) {
1673
- pentestJustConfirmed = true;
1674
- }
1675
- // Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
1676
- // fs.delete and shell deletions NEVER auto-confirm even with -y flag.
1677
- const forceManualConfirm = call.name === "fs.delete";
1678
- if (decision.level === "confirm" && !pentestJustConfirmed) {
1679
- const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session);
1680
- // Re-assert raw mode and resume stdin after inquirer's confirm()
1681
- // (see restoreInteractiveStdin / the comment above).
1761
+ if (call.name === "web.search") {
1762
+ sawFreshWebSearch = true;
1763
+ }
1764
+ // Show tool call
1765
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1766
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1767
+ const scopeTarget = scopeTargetForToolCall(call);
1768
+ if (scopeTarget &&
1769
+ (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
1770
+ writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1771
+ }
1772
+ if (decision.level === "block") {
1773
+ writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
1774
+ lastAnswer = `Blocked: ${call.name} ${decision.reason}`;
1775
+ return finishTurn(lastAnswer, productiveSteps);
1776
+ }
1777
+ // Pentest authorization — if user confirms this, skip the per-tool confirm
1778
+ let pentestJustConfirmed = false;
1779
+ const needsPentestAuth = isPentestToolCall(call) &&
1780
+ !getConfig().pentestAuthorized &&
1781
+ !session.pentestAuthorized.value;
1782
+ const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
1783
+ // inquirer's confirm() creates its own readline interface which resets
1784
+ // raw mode AND pauses stdin when it finishes. Re-assert raw mode and
1785
+ // resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
1786
+ // output pane) keeps working during the next streaming/tool phase.
1682
1787
  restoreInteractiveStdin();
1683
- if (!ok) {
1684
- lastAnswer = "Cancelled.";
1685
- process.stdout.write(chalk.yellow(` ✗ cancelled`) + "\n");
1686
- return lastAnswer;
1788
+ if (!authorized) {
1789
+ lastAnswer = "Pentest authorization not confirmed.";
1790
+ writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
1791
+ return finishTurn(lastAnswer, productiveSteps);
1687
1792
  }
1688
- }
1689
- // Execute tool
1690
- options.signal?.throwIfAborted();
1691
- options.onToolStart?.(call);
1692
- // Heads-up when the command is about to run something that may pause
1693
- // for a password prompt (sudo, ssh, gpg, ...). The shell tool already
1694
- // routes such commands through inherited stdin so the user can type
1695
- // directly into the controlling TTY; we just warn them to expect it.
1696
- const interactiveCommand = call.name === "shell.exec" &&
1697
- typeof call.args.command === "string" &&
1698
- looksInteractiveStdin(call.args.command);
1699
- if (interactiveCommand && process.stdin.isTTY) {
1700
- process.stdout.write(chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
1701
- }
1702
- let result;
1703
- let liveBytes = 0;
1704
- const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
1705
- let liveTruncatedNotified = false;
1706
- let lastProgressAt = 0;
1707
- // When the underlying command may pause for a password prompt
1708
- // (sudo / ssh / etc.) we stream the live preview *without* the dim
1709
- // attribute so the prompt is fully readable. Otherwise we keep the
1710
- // dim styling that makes ordinary tool chatter visually distinct
1711
- // from the model's prose.
1712
- const shouldDimLive = !interactiveCommand;
1713
- const printLive = (chunk) => {
1714
- // Suppress live preview for fs.read / fs.list — those are read-only
1715
- // and the final summary is already concise. Stream shell-style tools
1716
- // (shell.exec, net.scan, pentest.recon, pkg.install).
1717
- if (call.name === "fs.read" ||
1718
- call.name === "fs.list" ||
1719
- call.name === "fs.search")
1720
- return;
1721
- if (liveBytes >= liveCap) {
1722
- if (!liveTruncatedNotified) {
1723
- 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"));
1726
- lastProgressAt = Date.now();
1793
+ if (needsPentestAuth) {
1794
+ pentestJustConfirmed = true;
1795
+ }
1796
+ // Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
1797
+ // fs.delete and shell deletions NEVER auto-confirm even with -y flag.
1798
+ const forceManualConfirm = call.name === "fs.delete";
1799
+ if (decision.level === "confirm" && !pentestJustConfirmed) {
1800
+ const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
1801
+ // Re-assert raw mode and resume stdin after inquirer's confirm()
1802
+ // (see restoreInteractiveStdin / the comment above).
1803
+ restoreInteractiveStdin();
1804
+ if (!ok) {
1805
+ lastAnswer = "Cancelled.";
1806
+ writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
1807
+ return finishTurn(lastAnswer, productiveSteps);
1727
1808
  }
1728
- // After truncation, show a dot every 5 seconds so the user knows
1729
- // the tool is still running and the terminal isn't frozen.
1730
- const now = Date.now();
1731
- if (now - lastProgressAt > 5_000) {
1732
- lastProgressAt = now;
1733
- process.stdout.write(chalk.dim("."));
1809
+ }
1810
+ // Execute tool
1811
+ options.signal?.throwIfAborted();
1812
+ options.onToolStart?.(call);
1813
+ // Heads-up when the command is about to run something that may pause
1814
+ // for a password prompt (sudo, ssh, gpg, ...). The shell tool already
1815
+ // routes such commands through inherited stdin so the user can type
1816
+ // directly into the controlling TTY; we just warn them to expect it.
1817
+ const interactiveCommand = call.name === "shell.exec" &&
1818
+ typeof call.args.command === "string" &&
1819
+ looksInteractiveStdin(call.args.command);
1820
+ if (interactiveCommand && process.stdin.isTTY) {
1821
+ 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"));
1822
+ }
1823
+ let result;
1824
+ let liveBytes = 0;
1825
+ const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
1826
+ let liveTruncatedNotified = false;
1827
+ let lastProgressAt = 0;
1828
+ // When the underlying command may pause for a password prompt
1829
+ // (sudo / ssh / etc.) we stream the live preview *without* the dim
1830
+ // attribute so the prompt is fully readable. Otherwise we keep the
1831
+ // dim styling that makes ordinary tool chatter visually distinct
1832
+ // from the model's prose.
1833
+ const shouldDimLive = !interactiveCommand;
1834
+ const printLive = (chunk) => {
1835
+ // Suppress live preview for fs.read / fs.list — those are read-only
1836
+ // and the final summary is already concise. Stream shell-style tools
1837
+ // (shell.exec, net.scan, pentest.recon, pkg.install).
1838
+ if (call.name === "fs.read" ||
1839
+ call.name === "fs.list" ||
1840
+ call.name === "fs.search")
1841
+ return;
1842
+ if (liveBytes >= liveCap) {
1843
+ if (!liveTruncatedNotified) {
1844
+ liveTruncatedNotified = true;
1845
+ writeNotice("info", "live preview truncated, full output saved", chalk.dim("\n … live preview truncated, full output saved\n"));
1846
+ writeNotice("info", "tool still running — ESC or Ctrl+C to abort", chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
1847
+ lastProgressAt = Date.now();
1848
+ }
1849
+ // After truncation, show a dot every 5 seconds so the user knows
1850
+ // the tool is still running and the terminal isn't frozen.
1851
+ const now = Date.now();
1852
+ if (now - lastProgressAt > 5_000) {
1853
+ lastProgressAt = now;
1854
+ writeToolOutput(toolEventId, ".", chalk.dim("."));
1855
+ }
1856
+ return;
1857
+ }
1858
+ const remaining = liveCap - liveBytes;
1859
+ const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
1860
+ liveBytes += slice.length;
1861
+ // Indent each line so live output lines up under the tool call.
1862
+ const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
1863
+ const body = indented.startsWith("\n") ? indented : ` ${indented}`;
1864
+ // Skip the dim wrapper for interactive commands so a sudo password
1865
+ // prompt is rendered at full brightness; everything else stays dim
1866
+ // so tool chatter is visually distinct from the model's prose.
1867
+ writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
1868
+ };
1869
+ try {
1870
+ result = await runToolCall(call, {
1871
+ signal: options.signal,
1872
+ onOutput: (chunk) => {
1873
+ if (options.signal?.aborted)
1874
+ return;
1875
+ printLive(chunk);
1876
+ },
1877
+ });
1878
+ // Newline separator if live output or progress dots didn't already end with one.
1879
+ if (liveBytes > 0 || liveTruncatedNotified) {
1880
+ writeToolOutput(toolEventId, "\n", "\n");
1734
1881
  }
1735
- return;
1736
1882
  }
1737
- const remaining = liveCap - liveBytes;
1738
- const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
1739
- liveBytes += slice.length;
1740
- // Indent each line so live output lines up under the tool call.
1741
- const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
1742
- const body = indented.startsWith("\n") ? indented : ` ${indented}`;
1743
- // Skip the dim wrapper for interactive commands so a sudo password
1744
- // prompt is rendered at full brightness; everything else stays dim
1745
- // so tool chatter is visually distinct from the model's prose.
1746
- process.stdout.write(shouldDimLive ? chalk.dim(body) : body);
1747
- };
1748
- try {
1749
- result = await runToolCall(call, {
1750
- signal: options.signal,
1751
- onOutput: (chunk) => {
1752
- if (options.signal?.aborted)
1753
- return;
1754
- printLive(chunk);
1755
- },
1883
+ catch (toolError) {
1884
+ if (isAbortError(toolError, options.signal)) {
1885
+ lastAnswer = "Aborted.";
1886
+ writeAbort();
1887
+ return lastAnswer;
1888
+ }
1889
+ const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
1890
+ result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
1891
+ }
1892
+ // Stop-on-error: if this call failed, abandon any remaining queued calls
1893
+ // from the same message so the model sees the failure and decides what to
1894
+ // do next instead of blindly running steps that depended on it.
1895
+ if (!result.ok && pendingCalls.length > 0) {
1896
+ const cancelledQueuedStatus = ` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`;
1897
+ writeStatus(cancelledQueuedStatus, chalk.dim(cancelledQueuedStatus));
1898
+ pendingCalls = [];
1899
+ }
1900
+ const output = result.output.trim();
1901
+ const displayMax = 6_000;
1902
+ // If the tool already produced an artifact (shell.exec now streams to one
1903
+ // as it runs), respect that path. Otherwise, fall back to the post-hoc
1904
+ // save for tools that return their full output in memory.
1905
+ const savedOutputPath = result.outputPath ??
1906
+ (output.length > displayMax
1907
+ ? await saveToolOutput(call, output)
1908
+ : undefined);
1909
+ const resultWithArtifact = {
1910
+ ...result,
1911
+ outputPath: savedOutputPath,
1912
+ truncated: result.truncated ?? Boolean(savedOutputPath),
1913
+ };
1914
+ const contextOutput = formatToolContext(call, resultWithArtifact);
1915
+ emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
1916
+ options.onToolResult?.(call, resultWithArtifact);
1917
+ await auditLog("tool.result", {
1918
+ call,
1919
+ ok: result.ok,
1920
+ exitCode: result.exitCode,
1921
+ output: result.output.slice(0, 4_000),
1756
1922
  });
1757
- // Newline separator if live output or progress dots didn't already end with one.
1758
- if (liveBytes > 0 || liveTruncatedNotified)
1759
- process.stdout.write("\n");
1760
- }
1761
- catch (toolError) {
1762
- if (isAbortError(toolError, options.signal)) {
1763
- lastAnswer = "Aborted.";
1764
- process.stdout.write(chalk.yellow(" Aborted.\n"));
1765
- return lastAnswer;
1923
+ // Record the attempt in the loop guard for dedup tracking.
1924
+ loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1925
+ // A tool actually executed this iteration — count it against the
1926
+ // productive-step budget. Recovery iterations (thinking-only nudges,
1927
+ // malformed-call retries, freshness/loop-guard prompts) reach `continue`
1928
+ // before this point and therefore never consume the budget.
1929
+ productiveSteps += 1;
1930
+ // ── Auto-retry on "command not found" ──────────────────────────
1931
+ // Detect missing tools and instruct the model to install + retry.
1932
+ const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
1933
+ if (!result.ok && NOT_FOUND_RE.test(output)) {
1934
+ const cmdName = call.name === "shell.exec"
1935
+ ? String(call.args.command ?? "").split(/\s+/)[0]
1936
+ : call.name === "net.scan"
1937
+ ? "nmap"
1938
+ : call.name === "image.ocr"
1939
+ ? "tesseract"
1940
+ : undefined;
1941
+ if (cmdName) {
1942
+ writeNotice("warn", `${cmdName} not found — asking model to install and retry`, chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
1943
+ messages.push({
1944
+ role: "tool",
1945
+ content: `Tool failed: "${cmdName}" is not installed.\n` +
1946
+ `You MUST: 1) use pkg.install to install "${cmdName}", ` +
1947
+ `2) then RETRY the original command. Do NOT stop or give up.`,
1948
+ });
1949
+ continue;
1950
+ }
1766
1951
  }
1767
- const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
1768
- result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
1769
- }
1770
- // Stop-on-error: if this call failed, abandon any remaining queued calls
1771
- // from the same message so the model sees the failure and decides what to
1772
- // do next instead of blindly running steps that depended on it.
1773
- if (!result.ok && pendingCalls.length > 0) {
1774
- process.stdout.write(chalk.dim(` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`));
1775
- pendingCalls = [];
1776
- }
1777
- const output = result.output.trim();
1778
- const displayMax = 6_000;
1779
- // If the tool already produced an artifact (shell.exec now streams to one
1780
- // as it runs), respect that path. Otherwise, fall back to the post-hoc
1781
- // save for tools that return their full output in memory.
1782
- const savedOutputPath = result.outputPath ??
1783
- (output.length > displayMax
1784
- ? await saveToolOutput(call, output)
1785
- : undefined);
1786
- const resultWithArtifact = {
1787
- ...result,
1788
- outputPath: savedOutputPath,
1789
- truncated: result.truncated ?? Boolean(savedOutputPath),
1790
- };
1791
- options.onToolResult?.(call, resultWithArtifact);
1792
- await auditLog("tool.result", {
1793
- call,
1794
- ok: result.ok,
1795
- exitCode: result.exitCode,
1796
- output: result.output.slice(0, 4_000),
1797
- });
1798
- // Record the attempt in the loop guard for dedup tracking.
1799
- loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1800
- // A tool actually executed this iteration — count it against the
1801
- // productive-step budget. Recovery iterations (thinking-only nudges,
1802
- // malformed-call retries, freshness/loop-guard prompts) reach `continue`
1803
- // before this point and therefore never consume the budget.
1804
- productiveSteps += 1;
1805
- // ── Auto-retry on "command not found" ──────────────────────────
1806
- // Detect missing tools and instruct the model to install + retry.
1807
- const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
1808
- if (!result.ok && NOT_FOUND_RE.test(output)) {
1809
- const cmdName = call.name === "shell.exec"
1810
- ? String(call.args.command ?? "").split(/\s+/)[0]
1811
- : call.name === "net.scan"
1812
- ? "nmap"
1813
- : call.name === "image.ocr"
1814
- ? "tesseract"
1815
- : undefined;
1816
- if (cmdName) {
1817
- process.stdout.write(chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
1952
+ // ── Auto-retry on "a terminal is required" sudo error ──────────────
1953
+ // Older Node versions and some non-TTY contexts can still surface the
1954
+ // canonical sudo "a terminal is required" or "no askpass program"
1955
+ // failure. Tell the model to retry through plain `sudo …` (which the
1956
+ // shell tool now inherits stdin for) instead of getting clever with
1957
+ // -S / askpass / piping a password.
1958
+ 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;
1959
+ if (!result.ok && SUDO_NEEDS_TTY_RE.test(output)) {
1960
+ 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"));
1818
1961
  messages.push({
1819
1962
  role: "tool",
1820
- content: `Tool failed: "${cmdName}" is not installed.\n` +
1821
- `You MUST: 1) use pkg.install to install "${cmdName}", ` +
1822
- `2) then RETRY the original command. Do NOT stop or give up.`,
1963
+ content: "Tool failed: sudo could not read a password.\n" +
1964
+ "On the next attempt: call shell.exec with `sudo <command>` directly. " +
1965
+ "clai inherits stdin from the user's terminal, so the user can type the password live. " +
1966
+ 'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
1967
+ "Just run `sudo <command>` and the password prompt will be visible.",
1823
1968
  });
1824
1969
  continue;
1825
1970
  }
1826
- }
1827
- // ── Auto-retry on "a terminal is required" sudo error ──────────────
1828
- // Older Node versions and some non-TTY contexts can still surface the
1829
- // canonical sudo "a terminal is required" or "no askpass program"
1830
- // failure. Tell the model to retry through plain `sudo …` (which the
1831
- // shell tool now inherits stdin for) instead of getting clever with
1832
- // -S / askpass / piping a password.
1833
- 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
- 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"));
1971
+ // Print tool result
1972
+ const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
1973
+ writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
1974
+ if (output) {
1975
+ const displaySummary = summarizeOutput(output, displayMax);
1976
+ const displayText = displaySummary.truncated
1977
+ ? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
1978
+ : displaySummary.text;
1979
+ // If we already streamed live output for this call, skip re-printing
1980
+ // the same bytes. Just note where the full output lives if it was saved.
1981
+ if (liveBytes > 0) {
1982
+ if (savedOutputPath) {
1983
+ writeNotice("info", `full output saved to ${savedOutputPath}`, chalk.dim(` full output saved to ${savedOutputPath}\n`));
1984
+ }
1985
+ }
1986
+ else {
1987
+ const renderedOutput = indentAndWrapText(displayText);
1988
+ writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
1989
+ }
1990
+ }
1991
+ if (isAbortError(undefined, options.signal)) {
1992
+ lastAnswer = "Aborted.";
1993
+ writeAbort();
1994
+ return lastAnswer;
1995
+ }
1996
+ // Register a collapse/expand viewport so the user can pull the full raw
1997
+ // output back with Ctrl+O or `/output last` after the AI summary lands.
1998
+ if (output) {
1999
+ const viewport = registerViewport({
2000
+ toolName: call.name,
2001
+ argsDisplay: formatToolArgs(call),
2002
+ artifactPath: savedOutputPath,
2003
+ summary: contextOutput,
2004
+ });
2005
+ // Only print the Ctrl+O hint when there's a real artifact file
2006
+ // (large output saved to disk). Avoid spamming the hint for
2007
+ // every tiny tool call — the user can always use /output last.
2008
+ if (savedOutputPath) {
2009
+ const viewportHint = `${formatViewportHint(viewport)}\n`;
2010
+ writeStatus(viewportHint, viewportHint);
2011
+ }
2012
+ }
1836
2013
  messages.push({
1837
2014
  role: "tool",
1838
- content: "Tool failed: sudo could not read a password.\n" +
1839
- "On the next attempt: call shell.exec with `sudo <command>` directly. " +
1840
- "clai inherits stdin from the user's terminal, so the user can type the password live. " +
1841
- 'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
1842
- "Just run `sudo <command>` and the password prompt will be visible.",
2015
+ content: `Tool ${call.name} result (exit=${result.exitCode ?? 0}, ok=${result.ok}):\n${contextOutput}`,
1843
2016
  });
1844
- continue;
1845
- }
1846
- // Print tool result
1847
- const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
1848
- process.stdout.write(statusIcon + "\n");
1849
- if (output) {
1850
- const displaySummary = summarizeOutput(output, displayMax);
1851
- const displayText = displaySummary.truncated
1852
- ? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
1853
- : displaySummary.text;
1854
- // If we already streamed live output for this call, skip re-printing
1855
- // the same bytes. Just note where the full output lives if it was saved.
1856
- if (liveBytes > 0) {
1857
- if (savedOutputPath) {
1858
- process.stdout.write(chalk.dim(` full output saved to ${savedOutputPath}\n`));
2017
+ // Compact older messages when the running estimate exceeds budget so
2018
+ // free-tier context windows are not blown by long pentest sessions.
2019
+ if (estimateMessagesTokens(messages) > 24_000) {
2020
+ const compacted = compactMessages(messages);
2021
+ if (compacted.length < messages.length) {
2022
+ messages.splice(0, messages.length, ...compacted);
2023
+ await auditLog("agent.compact", {
2024
+ newLength: messages.length,
2025
+ estimatedTokens: estimateMessagesTokens(messages),
2026
+ });
1859
2027
  }
1860
2028
  }
1861
- else {
1862
- const renderedOutput = indentAndWrapText(displayText);
1863
- process.stdout.write(styleToolChatter(call, renderedOutput) + "\n");
1864
- }
1865
- }
1866
- if (isAbortError(undefined, options.signal)) {
1867
- lastAnswer = "Aborted.";
1868
- process.stdout.write(chalk.yellow(" ⏹ Aborted.\n"));
1869
- return lastAnswer;
1870
2029
  }
1871
- const contextOutput = formatToolContext(call, resultWithArtifact);
1872
- // Register a collapse/expand viewport so the user can pull the full raw
1873
- // output back with Ctrl+O or `/output last` after the AI summary lands.
1874
- if (output) {
1875
- const viewport = registerViewport({
1876
- toolName: call.name,
1877
- argsDisplay: formatToolArgs(call),
1878
- artifactPath: savedOutputPath,
1879
- summary: contextOutput,
1880
- });
1881
- // Only print the Ctrl+O hint when there's a real artifact file
1882
- // (large output saved to disk). Avoid spamming the hint for
1883
- // every tiny tool call — the user can always use /output last.
1884
- if (savedOutputPath) {
1885
- process.stdout.write(`${formatViewportHint(viewport)}\n`);
1886
- }
2030
+ lastAnswer = `Stopped after ${productiveSteps} steps.`;
2031
+ writeNotice("warn", lastAnswer, " " + chalk.yellow(lastAnswer) + "\n");
2032
+ return finishTurn(lastAnswer, productiveSteps);
2033
+ }
2034
+ catch (error) {
2035
+ if (isAbortError(error, options.signal)) {
2036
+ writeAbort();
2037
+ return "Aborted.";
1887
2038
  }
1888
- messages.push({
1889
- role: "tool",
1890
- content: `Tool ${call.name} result (exit=${result.exitCode ?? 0}, ok=${result.ok}):\n${contextOutput}`,
2039
+ emit({
2040
+ type: "turn-error",
2041
+ message: error instanceof Error ? error.message : String(error),
1891
2042
  });
1892
- // Compact older messages when the running estimate exceeds budget so
1893
- // free-tier context windows are not blown by long pentest sessions.
1894
- if (estimateMessagesTokens(messages) > 24_000) {
1895
- const compacted = compactMessages(messages);
1896
- if (compacted.length < messages.length) {
1897
- messages.splice(0, messages.length, ...compacted);
1898
- await auditLog("agent.compact", {
1899
- newLength: messages.length,
1900
- estimatedTokens: estimateMessagesTokens(messages),
1901
- });
1902
- }
1903
- }
2043
+ throw error;
1904
2044
  }
1905
- lastAnswer = `Stopped after ${productiveSteps} steps.`;
1906
- process.stdout.write(" " + chalk.yellow(lastAnswer) + "\n");
1907
- return lastAnswer;
1908
2045
  }
1909
2046
  //# sourceMappingURL=runner.js.map