@pentoshi/clai 1.2.7 → 1.2.9

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