@pentoshi/clai 3.0.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/classic-renderer.js +9 -1
  3. package/dist/agent/classic-renderer.js.map +1 -1
  4. package/dist/agent/context-manager.js +8 -2
  5. package/dist/agent/context-manager.js.map +1 -1
  6. package/dist/agent/events.d.ts +5 -0
  7. package/dist/agent/loop-guard.d.ts +2 -0
  8. package/dist/agent/loop-guard.js +39 -2
  9. package/dist/agent/loop-guard.js.map +1 -1
  10. package/dist/agent/plan-tool.d.ts +22 -0
  11. package/dist/agent/plan-tool.js +434 -49
  12. package/dist/agent/plan-tool.js.map +1 -1
  13. package/dist/agent/project-root.d.ts +33 -0
  14. package/dist/agent/project-root.js +158 -0
  15. package/dist/agent/project-root.js.map +1 -0
  16. package/dist/agent/runner.js +1204 -253
  17. package/dist/agent/runner.js.map +1 -1
  18. package/dist/agent/task-evidence.d.ts +103 -0
  19. package/dist/agent/task-evidence.js +413 -0
  20. package/dist/agent/task-evidence.js.map +1 -0
  21. package/dist/agent/tool-call-parser.d.ts +21 -0
  22. package/dist/agent/tool-call-parser.js +101 -28
  23. package/dist/agent/tool-call-parser.js.map +1 -1
  24. package/dist/agent/tool-history.d.ts +27 -0
  25. package/dist/agent/tool-history.js +150 -0
  26. package/dist/agent/tool-history.js.map +1 -0
  27. package/dist/agent/workspace-orient.d.ts +64 -0
  28. package/dist/agent/workspace-orient.js +418 -0
  29. package/dist/agent/workspace-orient.js.map +1 -0
  30. package/dist/app/adapters/agent-event-adapter.js +5 -0
  31. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  32. package/dist/app/controllers/session-controller.js +4 -0
  33. package/dist/app/controllers/session-controller.js.map +1 -1
  34. package/dist/app/events/app-event.d.ts +4 -0
  35. package/dist/app/events/app-event.js.map +1 -1
  36. package/dist/commands/doctor.js +14 -1
  37. package/dist/commands/doctor.js.map +1 -1
  38. package/dist/commands/update.js +1 -1
  39. package/dist/index.js +40 -8
  40. package/dist/index.js.map +1 -1
  41. package/dist/llm/adapters/anthropic-tools.d.ts +99 -0
  42. package/dist/llm/adapters/anthropic-tools.js +225 -0
  43. package/dist/llm/adapters/anthropic-tools.js.map +1 -0
  44. package/dist/llm/adapters/gemini-tools.d.ts +54 -0
  45. package/dist/llm/adapters/gemini-tools.js +139 -0
  46. package/dist/llm/adapters/gemini-tools.js.map +1 -0
  47. package/dist/llm/adapters/ollama-tools.d.ts +22 -0
  48. package/dist/llm/adapters/ollama-tools.js +62 -0
  49. package/dist/llm/adapters/ollama-tools.js.map +1 -0
  50. package/dist/llm/adapters/openai-tools.d.ts +39 -0
  51. package/dist/llm/adapters/openai-tools.js +71 -0
  52. package/dist/llm/adapters/openai-tools.js.map +1 -0
  53. package/dist/llm/agentrouter.js +23 -4
  54. package/dist/llm/agentrouter.js.map +1 -1
  55. package/dist/llm/anthropic.js +93 -92
  56. package/dist/llm/anthropic.js.map +1 -1
  57. package/dist/llm/aws-mantle.js +93 -56
  58. package/dist/llm/aws-mantle.js.map +1 -1
  59. package/dist/llm/bynara.js +23 -4
  60. package/dist/llm/bynara.js.map +1 -1
  61. package/dist/llm/capabilities.d.ts +7 -0
  62. package/dist/llm/capabilities.js +74 -0
  63. package/dist/llm/capabilities.js.map +1 -1
  64. package/dist/llm/gemini.js +63 -48
  65. package/dist/llm/gemini.js.map +1 -1
  66. package/dist/llm/groq.js +23 -4
  67. package/dist/llm/groq.js.map +1 -1
  68. package/dist/llm/http.d.ts +22 -17
  69. package/dist/llm/http.js +99 -26
  70. package/dist/llm/http.js.map +1 -1
  71. package/dist/llm/kimchi.js +23 -4
  72. package/dist/llm/kimchi.js.map +1 -1
  73. package/dist/llm/nvidia.js +23 -4
  74. package/dist/llm/nvidia.js.map +1 -1
  75. package/dist/llm/ollama.js +49 -31
  76. package/dist/llm/ollama.js.map +1 -1
  77. package/dist/llm/openai.js +23 -4
  78. package/dist/llm/openai.js.map +1 -1
  79. package/dist/llm/openrouter.js +23 -4
  80. package/dist/llm/openrouter.js.map +1 -1
  81. package/dist/llm/qwen-cloud.js +23 -4
  82. package/dist/llm/qwen-cloud.js.map +1 -1
  83. package/dist/llm/router.js +48 -6
  84. package/dist/llm/router.js.map +1 -1
  85. package/dist/llm/tool-protocol.d.ts +73 -0
  86. package/dist/llm/tool-protocol.js +282 -0
  87. package/dist/llm/tool-protocol.js.map +1 -0
  88. package/dist/modes/ask.js +74 -20
  89. package/dist/modes/ask.js.map +1 -1
  90. package/dist/prompts/index.d.ts +13 -5
  91. package/dist/prompts/index.js +122 -23
  92. package/dist/prompts/index.js.map +1 -1
  93. package/dist/repl.js +7 -0
  94. package/dist/repl.js.map +1 -1
  95. package/dist/store/config.d.ts +7 -0
  96. package/dist/store/config.js +1 -0
  97. package/dist/store/config.js.map +1 -1
  98. package/dist/store/plan.d.ts +2 -0
  99. package/dist/store/plan.js.map +1 -1
  100. package/dist/tools/capabilities.d.ts +2 -0
  101. package/dist/tools/capabilities.js +101 -6
  102. package/dist/tools/capabilities.js.map +1 -1
  103. package/dist/tools/command-intent.d.ts +1 -0
  104. package/dist/tools/command-intent.js +31 -0
  105. package/dist/tools/command-intent.js.map +1 -1
  106. package/dist/tools/definitions.d.ts +20 -0
  107. package/dist/tools/definitions.js +509 -0
  108. package/dist/tools/definitions.js.map +1 -0
  109. package/dist/tools/fs.d.ts +6 -0
  110. package/dist/tools/fs.js +68 -9
  111. package/dist/tools/fs.js.map +1 -1
  112. package/dist/tools/net-ping-sweep.js +69 -16
  113. package/dist/tools/net-ping-sweep.js.map +1 -1
  114. package/dist/tools/nmap-runner.d.ts +8 -7
  115. package/dist/tools/nmap-runner.js +126 -65
  116. package/dist/tools/nmap-runner.js.map +1 -1
  117. package/dist/tools/registry.js +122 -33
  118. package/dist/tools/registry.js.map +1 -1
  119. package/dist/tools/validate.d.ts +11 -0
  120. package/dist/tools/validate.js +53 -0
  121. package/dist/tools/validate.js.map +1 -1
  122. package/dist/tools/web/search.js +48 -4
  123. package/dist/tools/web/search.js.map +1 -1
  124. package/dist/tui/runtime.d.ts +19 -0
  125. package/dist/tui/runtime.js +92 -0
  126. package/dist/tui/runtime.js.map +1 -0
  127. package/dist/tui/state.js +2 -0
  128. package/dist/tui/state.js.map +1 -1
  129. package/dist/tui-v2/app/App.d.ts +3 -3
  130. package/dist/tui-v2/app/App.js +22 -10
  131. package/dist/tui-v2/app/App.js.map +1 -1
  132. package/dist/tui-v2/app/plan-lifecycle.d.ts +6 -0
  133. package/dist/tui-v2/app/plan-lifecycle.js +43 -7
  134. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  135. package/dist/tui-v2/components/modal/confirm-modal.d.ts +5 -6
  136. package/dist/tui-v2/components/modal/confirm-modal.js +60 -21
  137. package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
  138. package/dist/tui-v2/components/modal/secret-modal.d.ts +3 -9
  139. package/dist/tui-v2/components/modal/secret-modal.js +17 -17
  140. package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
  141. package/dist/tui-v2/components/overlay/overlay-host.d.ts +12 -3
  142. package/dist/tui-v2/components/overlay/overlay-host.js +19 -4
  143. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  144. package/dist/tui-v2/components/plan/plan-view.d.ts +1 -1
  145. package/dist/tui-v2/components/plan/plan-view.js +8 -4
  146. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  147. package/dist/tui-v2/components/status/status-line.js +10 -1
  148. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  149. package/dist/tui-v2/components/transcript/assistant-message.js +6 -0
  150. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  151. package/dist/tui-v2/components/transcript/compacted-row.d.ts +5 -5
  152. package/dist/tui-v2/components/transcript/compacted-row.js +15 -40
  153. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  154. package/dist/tui-v2/components/transcript/tool-card.js +43 -13
  155. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  156. package/dist/tui-v2/components/transcript/transcript-row.js +8 -1
  157. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  158. package/dist/tui-v2/rendering/batch-sections.d.ts +12 -0
  159. package/dist/tui-v2/rendering/batch-sections.js +68 -0
  160. package/dist/tui-v2/rendering/batch-sections.js.map +1 -1
  161. package/dist/tui-v2/rendering/strip-tool-surfaces.d.ts +10 -0
  162. package/dist/tui-v2/rendering/strip-tool-surfaces.js +32 -0
  163. package/dist/tui-v2/rendering/strip-tool-surfaces.js.map +1 -0
  164. package/dist/tui-v2/rendering/tool-presenter.d.ts +6 -1
  165. package/dist/tui-v2/rendering/tool-presenter.js +68 -2
  166. package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
  167. package/dist/tui-v2/state/transcript-hydrate.js +6 -2
  168. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  169. package/dist/tui-v2/state/transcript-reducer.js +74 -16
  170. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  171. package/dist/tui-v2/state/transcript-types.d.ts +1 -1
  172. package/dist/types.d.ts +58 -0
  173. package/dist/ui/ansi-box.d.ts +2 -0
  174. package/dist/ui/ansi-box.js +8 -1
  175. package/dist/ui/ansi-box.js.map +1 -1
  176. package/package.json +13 -4
@@ -2,13 +2,18 @@ import chalk from "chalk";
2
2
  import { homedir } from "node:os";
3
3
  import { join, relative, resolve } from "node:path";
4
4
  import { streamWithProvider, completeWithProvider } from "../llm/router.js";
5
+ import { resolveToolDialect } from "../llm/capabilities.js";
6
+ import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
7
+ import { sanitizeAssistantText } from "../ui/ansi-box.js";
5
8
  import { randomUUID } from "node:crypto";
6
9
  import { jobManager } from "../tools/jobs.js";
7
- import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, } from "../prompts/index.js";
10
+ import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
8
11
  import { getConfig } from "../store/config.js";
9
12
  import { groqInputTokenBudget } from "../llm/groq.js";
10
13
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
11
14
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
15
+ import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
16
+ import { appendAssistantWithTools, appendToolResult, fillMissingToolResults, } from "./tool-history.js";
12
17
  import { looksInteractiveStdin } from "../tools/shell.js";
13
18
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
14
19
  import { compactMessagesWithSummary, estimateTokens, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
@@ -22,12 +27,15 @@ import { startThinkingSpinner } from "../ui/spinner.js";
22
27
  import { safeCwd } from "../os/cwd.js";
23
28
  import { analyzeTask } from "./task-analyzer.js";
24
29
  import { LoopGuard } from "./loop-guard.js";
25
- import { loadPlan } from "../store/plan.js";
30
+ import { loadPlan, savePlan, markTask, } from "../store/plan.js";
26
31
  import { pathInsideSandbox, fsWrite } from "../tools/fs.js";
27
- import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, pentestWorkflowDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
32
+ import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, looksLikeErrorDiagnosisWithFixIntent, localHttpProbeIsFailure, localHttpProbeIsSuccess, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
28
33
  import { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
29
34
  import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
30
- import { planContextMessage, handlePlanTool, } from "./plan-tool.js";
35
+ import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
36
+ import { applyDestinationCwd, canMarkTaskDone, codingBuildRequiresPlan, incompleteFeatureBeforeServerMessage, isBuildPrePlanAllowedTool, isEvidenceWorkTool, isFeatureImplementationCall, isPlanPreflightTool, isReadOnlyVersionProbeCommand, isScaffoldCreateCommand, openTaskLedger, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, toolStallBudgetMs, userAskedForFeatureApp, workOutOfScopeForTask, } from "./task-evidence.js";
37
+ import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
38
+ import { buildWorkspaceOrientation, guessProjectFolderName, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, } from "./workspace-orient.js";
31
39
  import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
32
40
  import { buildRichStopSummary } from "./stop-summary.js";
33
41
  // Re-exported so existing imports of these names from "./runner.js" keep
@@ -129,8 +137,16 @@ export async function runAgentLoop(prompt, options = {}) {
129
137
  };
130
138
  const writeStatus = (text, rendered = chalk.dim(text)) => {
131
139
  // Footer activity is single-line; strip classic stdout newlines/indents.
132
- const cleaned = text.replace(/\s+/g, " ").trim();
133
- emit({ type: "status", text: cleaned || text });
140
+ // Never surface /output path hints as activity (garbles the status bar).
141
+ let cleaned = text.replace(/\s+/g, " ").trim();
142
+ if (/\/output\b|open full output|Ctrl\+O or|\.clai\/outputs/i.test(cleaned)) {
143
+ return;
144
+ }
145
+ if (cleaned.length > 64) {
146
+ const short = cleaned.match(/^[\w./-]+/);
147
+ cleaned = short ? short[0] : cleaned.slice(0, 61) + "…";
148
+ }
149
+ emit({ type: "status", text: cleaned || "working" });
134
150
  if (writesDirectly)
135
151
  process.stdout.write(rendered);
136
152
  };
@@ -142,13 +158,14 @@ export async function runAgentLoop(prompt, options = {}) {
142
158
  const writeAssistantMessage = (text) => {
143
159
  // Never surface an empty message: the reducer drops it and a direct
144
160
  // stdout writer would print a stray blank line.
145
- if (!text.trim())
161
+ const clean = sanitizeAssistantText(text);
162
+ if (!clean.trim())
146
163
  return;
147
164
  visibleCommitted = true;
148
- emit({ type: "assistant-message", text });
149
- const rendered = renderMarkdown(text);
165
+ emit({ type: "assistant-message", text: clean });
166
+ const rendered = renderMarkdown(clean);
150
167
  if (writesDirectly) {
151
- process.stdout.write(text.endsWith("\n") ? rendered : `${rendered}\n`);
168
+ process.stdout.write(clean.endsWith("\n") ? rendered : `${rendered}\n`);
152
169
  }
153
170
  };
154
171
  const writeThinkingBlock = (content) => {
@@ -282,17 +299,20 @@ export async function runAgentLoop(prompt, options = {}) {
282
299
  // provider's 413 as a context-window failure after the fact.
283
300
  const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
284
301
  const useCompactSystemPrompt = inputTokenBudget !== undefined;
285
- const systemSections = [
286
- (useCompactSystemPrompt
287
- ? renderCompactAgentSystemPrompt
288
- : renderAgentSystemPrompt)(toolNames.join(", ")),
289
- ];
290
- if (projectContext) {
291
- systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
292
- }
293
- if (freshWebSearchRequired) {
294
- systemSections.push(freshnessGuardMessage());
295
- }
302
+ const resolveNativeTools = (p, m) => {
303
+ const dialect = resolveToolDialect(p, m, config.toolCalling);
304
+ return { dialect, native: dialect !== "none" };
305
+ };
306
+ let { dialect: toolDialect, native: nativeToolsActive } = resolveNativeTools(provider, model);
307
+ const selectToolDefs = (native, compact) => {
308
+ if (!native)
309
+ return undefined;
310
+ const base = compact
311
+ ? getCompactToolDefinitions()
312
+ : getToolDefinitions();
313
+ const allow = new Set([...toolNames, ...PLAN_TOOL_NAMES]);
314
+ return base.filter((d) => allow.has(d.name));
315
+ };
296
316
  let lastAnswer = "";
297
317
  const session = options.session ?? createSessionPolicy();
298
318
  // Active plan context
@@ -300,7 +320,7 @@ export async function runAgentLoop(prompt, options = {}) {
300
320
  // context. When the user has approved it (via /implement) we instruct the
301
321
  // agent to execute task by task; otherwise the agent should refine/wait.
302
322
  const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
303
- if (activePlan) {
323
+ if (activePlan && isPlanApprovedByStatus(activePlan.status)) {
304
324
  // session.planApproved is in-memory only (never persisted), so a
305
325
  // resumed session (via /history) or a fresh SessionPolicy after
306
326
  // context compaction always starts it back at false — even when the
@@ -308,9 +328,77 @@ export async function runAgentLoop(prompt, options = {}) {
308
328
  // completed via /implement. Re-derive the flag from the plan's status
309
329
  // on every load so resuming a session never re-blocks tool calls
310
330
  // behind a stale "awaiting approval" gate for a plan that already ran.
311
- if (isPlanApprovedByStatus(activePlan.status)) {
312
- session.planApproved.value = true;
331
+ session.planApproved.value = true;
332
+ }
333
+ const destinationHint = resolveUserDestinationHint(prompt);
334
+ // Sticky project root so relative fs paths never hit the agent package.
335
+ // Only pin paths that already exist (or were previously validated) — never
336
+ // invent Desktop/todo-app before the folder is real, and never pin bare Desktop.
337
+ {
338
+ const fromPrompt = extractProjectRootFromText(prompt);
339
+ const fromPlan = extractProjectRootFromPlan(activePlan);
340
+ const root = fromPlan ?? fromPrompt;
341
+ if (root)
342
+ setActiveProjectRootIfValid(root);
343
+ // Do NOT setActiveProjectRoot(destinationHint) — bare Desktop is a parent only.
344
+ }
345
+ const buildSystemContent = (native) => {
346
+ const sections = [
347
+ (useCompactSystemPrompt
348
+ ? renderCompactAgentSystemPrompt
349
+ : renderAgentSystemPrompt)(toolNames.join(", "), {
350
+ nativeTools: native,
351
+ }),
352
+ ];
353
+ if (projectContext) {
354
+ sections.push(`Project context from .clai/context.md:\n${projectContext}`);
355
+ }
356
+ const projectRoot = getActiveProjectRoot();
357
+ if (projectRoot) {
358
+ sections.push(`ACTIVE PROJECT ROOT: ${projectRoot}\n` +
359
+ `All relative paths (./src/…, manifests, configs) resolve under this directory — NOT the agent process cwd. ` +
360
+ `Prefer absolute paths under this root. shell cwd for install / run / build must be this root ` +
361
+ `(or its parent when creating a NEW named subfolder with a scaffolder). ` +
362
+ `Never write user app source into the agent package tree.`);
363
+ }
364
+ else if (destinationHint) {
365
+ sections.push(`USER DESTINATION: create or continue work under "${destinationHint}" (parent folder). ` +
366
+ `Pick or detect a project subfolder; do not scaffold into the agent working tree unless the user asked for that.`);
313
367
  }
368
+ // Stack-agnostic PWD / existing-project snapshot so weak models cannot
369
+ // skip explore and re-scaffold into non-empty dirs.
370
+ if (buildLikeTurn &&
371
+ !informationalQuery &&
372
+ !idleOrSocialPrompt) {
373
+ const guessedName = guessProjectFolderName([prompt, activePlan?.goal, activePlan?.detail, activePlan?.tasks.map((t) => t.title).join(" ")]
374
+ .filter(Boolean)
375
+ .join("\n"));
376
+ const extraPaths = [];
377
+ if (destinationHint && guessedName) {
378
+ extraPaths.push(join(destinationHint, guessedName));
379
+ }
380
+ const fromText = extractProjectRootFromPlan(activePlan) ??
381
+ extractProjectRootFromText(prompt);
382
+ if (fromText)
383
+ extraPaths.push(fromText);
384
+ const orientInput = {
385
+ cwd: safeCwd(),
386
+ extraPaths,
387
+ };
388
+ if (destinationHint)
389
+ orientInput.destinationHint = destinationHint;
390
+ const candidate = getActiveProjectRoot() ?? fromText;
391
+ if (candidate)
392
+ orientInput.candidateProject = candidate;
393
+ sections.push(buildWorkspaceOrientation(orientInput));
394
+ }
395
+ if (freshWebSearchRequired) {
396
+ sections.push(freshnessGuardMessage());
397
+ }
398
+ return sections.join("\n\n");
399
+ };
400
+ const systemSections = [buildSystemContent(nativeToolsActive)];
401
+ if (activePlan) {
314
402
  systemSections.push(planContextMessage(activePlan, session.planApproved.value));
315
403
  }
316
404
  // For build/scaffold turns with no active plan yet, inject an explicit
@@ -337,6 +425,16 @@ export async function runAgentLoop(prompt, options = {}) {
337
425
  !idleOrSocialPrompt) {
338
426
  systemSections.push(pentestWorkflowDirective());
339
427
  }
428
+ // Any remote/security engagement (even with an existing or completed plan)
429
+ // must never fall into the coding "start localhost dev server" habit.
430
+ const pentestSession = pentestLikeTurn ||
431
+ activePlan?.kind === "pentest" ||
432
+ (activePlan?.kind !== "coding" &&
433
+ Boolean(activePlan?.goal &&
434
+ /pentest|vulnerab|recon|security assess|attack surface|red team/i.test(activePlan.goal)));
435
+ if (pentestSession && !idleOrSocialPrompt) {
436
+ systemSections.push(pentestNoLocalServerDirective());
437
+ }
340
438
  const renderedSystemPrompt = systemSections.join("\n\n");
341
439
  // Reserve most of a constrained model's input budget for the user message,
342
440
  // recent conversation, tool results, and provider framing. Dynamic project
@@ -397,15 +495,16 @@ export async function runAgentLoop(prompt, options = {}) {
397
495
  // text isn't wiped by the next tool-call/turn event. Skip when this
398
496
  // iteration already surfaced its prose (the normal tool path commits
399
497
  // `beforeTool` itself) so the same text is never rendered twice.
498
+ const cleaned = sanitizeAssistantText(content);
400
499
  if (!visibleCommitted) {
401
- const prose = recoveryProse(content);
500
+ const prose = recoveryProse(cleaned);
402
501
  if (prose)
403
502
  writeAssistantMessage(prose);
404
503
  }
405
504
  messages.push({
406
505
  role: "assistant",
407
- content: content.trim()
408
- ? content
506
+ content: cleaned.trim()
507
+ ? cleaned
409
508
  : "[No visible assistant response was produced.]",
410
509
  });
411
510
  };
@@ -440,9 +539,25 @@ export async function runAgentLoop(prompt, options = {}) {
440
539
  // executing the next task a bounded number of times before giving up.
441
540
  let prematureCompletionRetries = 0;
442
541
  let runtimeVerificationRetries = 0;
542
+ let featureImplRetries = 0;
543
+ let forcePlanRetries = 0;
544
+ let errorFixNarrationRetries = 0;
545
+ let failedProbeFixRetries = 0;
443
546
  let sawServerStart = false;
547
+ let sawPlanCreateOk = false;
444
548
  let sawServerTail = false;
445
549
  let sawLocalHttpProbe = false;
550
+ /** Last localhost probe returned 4xx/5xx / connection refused — must fix. */
551
+ let sawFailedLocalHttpProbe = false;
552
+ /** Local app was scaffolded/installed/written this turn (plan optional). */
553
+ let sawLocalAppMaterialWork = false;
554
+ /** Official scaffolder succeeded this turn. */
555
+ let sawScaffoldOk = false;
556
+ /** Real product source written (not just scaffold defaults). */
557
+ let sawFeatureImplWrite = false;
558
+ const featureAppAsk = userAskedForFeatureApp(prompt);
559
+ /** Successful work tools under the current in_progress plan task. */
560
+ let taskWorkLedger = null;
446
561
  // Guard against a model that NARRATES intent ("let me explore the
447
562
  // directory…") but emits no tool call, so nothing runs and the turn ends
448
563
  // prematurely. On build/scaffold/plan turns where nothing has executed yet,
@@ -518,6 +633,15 @@ export async function runAgentLoop(prompt, options = {}) {
518
633
  // model-supplied paths against the canonical per-project scratch root.
519
634
  const scratchDir = scratchDirFor(safeCwd());
520
635
  let call = normalizeToolCall(rawCall);
636
+ if (call.args?.__nativeParseError) {
637
+ const raw = String(call.args._raw ?? "").slice(0, 200);
638
+ const reason = "Tool call arguments were not valid JSON (truncated or malformed). " +
639
+ "Retry with smaller content, or use fs.writeMany / fs.append continuation. " +
640
+ (raw ? `Partial: ${raw}` : "");
641
+ const result = { ok: false, output: reason, exitCode: 1 };
642
+ emitToolResult(toolEventId, result, reason);
643
+ return { ok: false, call, result, contextOutput: reason };
644
+ }
521
645
  if (call.name === "image.ocr" && !imageOcrEnabled) {
522
646
  writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
523
647
  const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
@@ -547,12 +671,71 @@ export async function runAgentLoop(prompt, options = {}) {
547
671
  writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
548
672
  }
549
673
  if (call.name === "plan.create" || call.name === "task.update") {
674
+ // Evidence gate: refuse done until at least one successful work tool
675
+ // ran under this task (model must see results and be satisfied).
676
+ if (call.name === "task.update") {
677
+ const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
678
+ const taskIdRaw = typeof call.args.taskId === "string"
679
+ ? call.args.taskId
680
+ : typeof call.args.id === "string"
681
+ ? call.args.id
682
+ : "";
683
+ if (stateRaw === "done" && taskIdRaw) {
684
+ const live = await loadPlan(session.sessionId).catch(() => undefined);
685
+ const resolved = (live ? resolvePlanTaskId(live, taskIdRaw) : undefined) ??
686
+ taskIdRaw;
687
+ const gate = canMarkTaskDone(taskWorkLedger, resolved);
688
+ if (!gate.ok) {
689
+ writeNotice("warn", gate.reason, chalk.yellow(` ⚠ ${gate.reason}\n`));
690
+ if (!alreadyPrintedIds.has(toolEventId)) {
691
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
692
+ chalk.gray(` ${formatToolArgs(call)}`);
693
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
694
+ alreadyPrintedIds.add(toolEventId);
695
+ }
696
+ const result = {
697
+ ok: false,
698
+ output: gate.reason,
699
+ exitCode: 1,
700
+ };
701
+ emitToolResult(toolEventId, result, gate.reason);
702
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
703
+ return {
704
+ ok: false,
705
+ call,
706
+ result,
707
+ contextOutput: gate.reason,
708
+ };
709
+ }
710
+ }
711
+ }
550
712
  const planResult = await handlePlanTool(call, session, {
551
713
  loopGuard,
552
714
  step,
553
715
  });
554
716
  if (planResult.handled) {
555
717
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
718
+ if (planResult.ok && call.name === "task.update") {
719
+ const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
720
+ const taskIdRaw = typeof call.args.taskId === "string"
721
+ ? call.args.taskId
722
+ : typeof call.args.id === "string"
723
+ ? call.args.id
724
+ : "";
725
+ const resolved = (planResult.plan
726
+ ? resolvePlanTaskId(planResult.plan, taskIdRaw)
727
+ : undefined) ?? taskIdRaw;
728
+ if (stateRaw === "in_progress" && resolved) {
729
+ taskWorkLedger = openTaskLedger(resolved);
730
+ }
731
+ else if (stateRaw === "done" && resolved) {
732
+ taskWorkLedger = null;
733
+ }
734
+ else if ((stateRaw === "failed" || stateRaw === "skipped") &&
735
+ taskWorkLedger?.taskId === resolved) {
736
+ taskWorkLedger = null;
737
+ }
738
+ }
556
739
  if (!alreadyPrintedIds.has(toolEventId)) {
557
740
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
558
741
  writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
@@ -560,6 +743,10 @@ export async function runAgentLoop(prompt, options = {}) {
560
743
  }
561
744
  if (planResult.plan) {
562
745
  writePlanUpdate(planResult.plan, planResult.display);
746
+ // Refresh sticky root only if path already exists (not bare Desktop).
747
+ const root = extractProjectRootFromPlan(planResult.plan);
748
+ if (root)
749
+ setActiveProjectRootIfValid(root);
563
750
  }
564
751
  const result = { ok: planResult.ok, output: planResult.modelNote };
565
752
  emitToolResult(toolEventId, result, planResult.modelNote);
@@ -580,11 +767,52 @@ export async function runAgentLoop(prompt, options = {}) {
580
767
  decision,
581
768
  scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
582
769
  });
770
+ // Coding builds: no freestyle scaffold/write until plan.create exists.
771
+ // Explore (fs.list/read, tool.check) + plan.create only; then wait for /implement.
772
+ const livePlanForPreGate = await loadPlan(session.sessionId).catch(() => undefined);
773
+ const codingNeedsPlan = buildLikeTurn &&
774
+ codingBuildRequiresPlan(prompt, {
775
+ informational: informationalQuery,
776
+ idle: idleOrSocialPrompt,
777
+ pentest: pentestLikeTurn,
778
+ });
779
+ if (codingNeedsPlan &&
780
+ !livePlanForPreGate &&
781
+ !sawPlanCreateOk &&
782
+ !isScratchOnlyWrite(call, scratchDir)) {
783
+ const cmd = typeof call.args.command === "string" ? call.args.command : "";
784
+ const allowedPrePlan = isBuildPrePlanAllowedTool(call.name) ||
785
+ isPreApprovalAllowedTool(call.name) ||
786
+ (call.name === "shell.exec" && isReadOnlyVersionProbeCommand(cmd));
787
+ if (!allowedPrePlan) {
788
+ const reason = `plan required first — ${call.name} is blocked on coding builds until plan.create. ` +
789
+ `Explore with fs.list / fs.read / tool.check if needed, then call plan.create ` +
790
+ `(kind=coding, 4–8 tasks including feature work + final run/verify). Stop and wait for /implement.`;
791
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
792
+ if (!alreadyPrintedIds.has(toolEventId)) {
793
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
794
+ chalk.gray(` ${formatToolArgs(call)}`);
795
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
796
+ alreadyPrintedIds.add(toolEventId);
797
+ }
798
+ const result = { ok: false, output: reason, exitCode: 1 };
799
+ emitToolResult(toolEventId, result, reason);
800
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
801
+ return {
802
+ ok: false,
803
+ call,
804
+ result,
805
+ contextOutput: reason,
806
+ };
807
+ }
808
+ }
583
809
  const isMutatingAction = (decision.level === "confirm" || decision.level === "block") &&
584
810
  !isPreApprovalAllowedTool(call.name) &&
585
811
  !isScratchOnlyWrite(call, scratchDir);
586
812
  if (isMutatingAction) {
587
- if (activePlan && !session.planApproved.value) {
813
+ const planNow = livePlanForPreGate ??
814
+ (await loadPlan(session.sessionId).catch(() => undefined));
815
+ if (planNow && !session.planApproved.value) {
588
816
  const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
589
817
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
590
818
  const result = { ok: false, output: reason, exitCode: 1 };
@@ -605,30 +833,131 @@ export async function runAgentLoop(prompt, options = {}) {
605
833
  // claimed most tasks "done" in prose without ever recording it in the
606
834
  // plan. Multiple tool calls per task are still fine; they just must be
607
835
  // bracketed by task.update in_progress → (work) → task.update done.
836
+ //
837
+ // GPT-OSS etc. often mark tN done then immediately fs.list/read for tN+1
838
+ // without opening the next task. Auto-start the first pending task so
839
+ // work continues without a wasted blocked turn (still recorded in plan).
608
840
  if (session.planApproved.value) {
609
841
  const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
610
842
  if (livePlanForGate) {
611
843
  const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
612
844
  const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
613
845
  if (unfinished && !inProgress) {
614
- const nextPending = livePlanForGate.tasks.find((t) => t.state === "pending");
615
- const reason = nextPending
616
- ? `${call.name} blocked — no task is in_progress. Call task.update {taskId:"${nextPending.id}", state:"in_progress"} before doing any work for it.`
617
- : `${call.name} blocked — no task is in_progress.`;
618
- writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
619
- const result = { ok: false, output: reason, exitCode: 1 };
620
- // Recoverable ordering mistake (NOT a user/session control gate):
621
- // feed the reason back so the model marks the task in_progress and
622
- // retries within the same turn, rather than ending the turn.
846
+ // tool.check / fs.list preflight: allow without auto-opening a task
847
+ // (auto-start on preflight made models skip task.update and confused scope).
848
+ if (isPlanPreflightTool(call.name)) {
849
+ // fall through — no task ledger yet
850
+ }
851
+ else {
852
+ const pending = livePlanForGate.tasks.filter((t) => t.state === "pending");
853
+ // Match tool task (npm install must not auto-start "localStorage")
854
+ const nextPending = pickPendingTaskForToolCall(pending, call, livePlanForGate.tasks.map((t) => t.title));
855
+ if (nextPending) {
856
+ markTask(livePlanForGate, nextPending.id, "in_progress");
857
+ if (livePlanForGate.status === "draft" ||
858
+ livePlanForGate.status === "approved") {
859
+ livePlanForGate.status = "in_progress";
860
+ }
861
+ await savePlan(livePlanForGate).catch(() => undefined);
862
+ taskWorkLedger = openTaskLedger(nextPending.id);
863
+ writePlanUpdate(livePlanForGate, renderPlanForTerminal(livePlanForGate) + "\n");
864
+ writeNotice("info", `auto-started [${nextPending.id}] so work can continue`, chalk.dim(` ℹ no task was in_progress — auto-started [${nextPending.id}] "${nextPending.title}" before ${call.name}\n`));
865
+ }
866
+ else {
867
+ const reason = `${call.name} blocked — no matching pending task is in_progress for this tool. ` +
868
+ `Call task.update in_progress on the correct task (e.g. install vs implement vs run/verify), then retry.`;
869
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
870
+ const result = { ok: false, output: reason, exitCode: 1 };
871
+ return {
872
+ ok: false,
873
+ call,
874
+ result,
875
+ contextOutput: reason,
876
+ };
877
+ }
878
+ }
879
+ }
880
+ }
881
+ }
882
+ // Keep work inside the open task (no early server start during install).
883
+ if (session.planApproved.value) {
884
+ const liveForScope = await loadPlan(session.sessionId).catch(() => undefined);
885
+ const openTask = liveForScope?.tasks.find((t) => t.state === "in_progress");
886
+ if (openTask) {
887
+ const scopeMsg = workOutOfScopeForTask(openTask.title, call, liveForScope?.tasks
888
+ ? { planTaskTitles: liveForScope.tasks.map((t) => t.title) }
889
+ : undefined);
890
+ if (scopeMsg) {
891
+ writeNotice("warn", scopeMsg, chalk.yellow(` ⚠ ${scopeMsg}\n`));
892
+ if (!alreadyPrintedIds.has(toolEventId)) {
893
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
894
+ chalk.gray(` ${formatToolArgs(call)}`);
895
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
896
+ alreadyPrintedIds.add(toolEventId);
897
+ }
898
+ const result = { ok: false, output: scopeMsg, exitCode: 1 };
899
+ emitToolResult(toolEventId, result, scopeMsg);
900
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
623
901
  return {
624
902
  ok: false,
625
903
  call,
626
904
  result,
627
- contextOutput: `${reason}\nThis tool did NOT run. Emit task.update {state:"in_progress"} for the task first, then the work.`,
905
+ contextOutput: scopeMsg,
628
906
  };
629
907
  }
630
908
  }
631
909
  }
910
+ // Freestyle or any path: block server start until product feature exists.
911
+ {
912
+ const featureBlock = incompleteFeatureBeforeServerMessage(prompt, sawFeatureImplWrite, call);
913
+ if (featureBlock) {
914
+ writeNotice("warn", featureBlock, chalk.yellow(` ⚠ ${featureBlock}\n`));
915
+ if (!alreadyPrintedIds.has(toolEventId)) {
916
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
917
+ chalk.gray(` ${formatToolArgs(call)}`);
918
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
919
+ alreadyPrintedIds.add(toolEventId);
920
+ }
921
+ const result = { ok: false, output: featureBlock, exitCode: 1 };
922
+ emitToolResult(toolEventId, result, featureBlock);
923
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
924
+ return {
925
+ ok: false,
926
+ call,
927
+ result,
928
+ contextOutput: featureBlock,
929
+ };
930
+ }
931
+ }
932
+ // Prefer user Desktop (etc.) as cwd when model omitted it.
933
+ // Also prefer sticky project root for install/run when set.
934
+ call = applyDestinationCwd(call, destinationHint ?? getActiveProjectRoot());
935
+ // Soft preflight: refuse scaffold into an existing non-empty project
936
+ // (avoids endless "Operation cancelled" retries across all stacks).
937
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
938
+ typeof call.args.command === "string" &&
939
+ isScaffoldCreateCommand(call.args.command)) {
940
+ const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
941
+ const conflict = scaffoldTargetConflictMessage(call.args.command, cwdArg);
942
+ if (conflict) {
943
+ writeNotice("warn", conflict, chalk.yellow(` ⚠ ${conflict}\n`));
944
+ if (!alreadyPrintedIds.has(toolEventId)) {
945
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
946
+ chalk.gray(` ${formatToolArgs(call)}`);
947
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
948
+ alreadyPrintedIds.add(toolEventId);
949
+ }
950
+ const result = { ok: false, output: conflict, exitCode: 1 };
951
+ emitToolResult(toolEventId, result, conflict);
952
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
953
+ return {
954
+ ok: false,
955
+ call,
956
+ result,
957
+ contextOutput: conflict,
958
+ };
959
+ }
960
+ }
632
961
  if (call.name === "web.search") {
633
962
  sawFreshWebSearch = true;
634
963
  }
@@ -729,6 +1058,9 @@ export async function runAgentLoop(prompt, options = {}) {
729
1058
  }
730
1059
  parentSignal.throwIfAborted();
731
1060
  options.onToolStart?.(call);
1061
+ // Card was "queued" since writeToolCall; flip to running only when work starts.
1062
+ emit({ type: "tool-start", id: toolEventId });
1063
+ writeStatus(call.name, chalk.dim(` → ${call.name}\n`));
732
1064
  const interactiveCommand = (call.name === "shell.exec" &&
733
1065
  typeof call.args.command === "string" &&
734
1066
  looksInteractiveStdin(call.args.command)) ||
@@ -769,8 +1101,11 @@ export async function runAgentLoop(prompt, options = {}) {
769
1101
  jobManager.registerJob(jobId, backgroundJob, toolAc);
770
1102
  // Long-lived commands should use shell.start/background jobs. Reset this
771
1103
  // watchdog whenever a blocking tool emits output so only a genuinely
772
- // stalled operation is cancelled.
773
- const TOOL_STALL_ABORT_MS = 60_000; // 1 minute
1104
+ // stalled operation is cancelled. Scaffold/install can go quiet for many
1105
+ // minutes while downloading packages use a much larger budget there
1106
+ // (otherwise create-next-app is SIGINT'd mid-install → exit 130 + partial tree).
1107
+ const TOOL_STALL_ABORT_MS = toolStallBudgetMs(call);
1108
+ const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
774
1109
  let stallTimer;
775
1110
  let stalledByWatchdog = false;
776
1111
  const resetStallTimer = () => {
@@ -779,7 +1114,7 @@ export async function runAgentLoop(prompt, options = {}) {
779
1114
  stallTimer = setTimeout(() => {
780
1115
  if (!toolAc.signal.aborted) {
781
1116
  stalledByWatchdog = true;
782
- writeNotice("warn", `${call.name} has been running for >60s — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >60s — cancelling\n`));
1117
+ writeNotice("warn", `${call.name} has been running for >${stallSecs}s without output — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >${stallSecs}s without output — cancelling\n`));
783
1118
  toolAc.abort();
784
1119
  }
785
1120
  }, TOOL_STALL_ABORT_MS);
@@ -838,6 +1173,53 @@ export async function runAgentLoop(prompt, options = {}) {
838
1173
  clearTimeout(stallTimer);
839
1174
  parentSignal.removeEventListener("abort", onParentAbort);
840
1175
  }
1176
+ // After a REAL successful scaffold, pin project root. Cancelled / empty
1177
+ // targets must NOT pin a root or count as success (exit 0 + "cancelled").
1178
+ // Must run before emit/evidence so the model sees failure, not a false ok.
1179
+ // If the process was aborted mid-install but a usable tree is already on
1180
+ // disk, pin the root and tell the model to CONTINUE (do not re-scaffold).
1181
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1182
+ typeof call.args.command === "string" &&
1183
+ isScaffoldCreateCommand(call.args.command)) {
1184
+ const cmd = call.args.command;
1185
+ const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
1186
+ const fromScaffold = extractProjectRootFromScaffold(cmd, cwdArg);
1187
+ const cancelled = isScaffoldCancelledOutput(result.output ?? "");
1188
+ const materialized = scaffoldLooksMaterialized(fromScaffold);
1189
+ const abortedMid = !result.ok &&
1190
+ (result.exitCode === 124 ||
1191
+ result.exitCode === 130 ||
1192
+ /timed out|aborted|Command aborted/i.test(result.output ?? ""));
1193
+ if (result.ok && (cancelled || !materialized)) {
1194
+ result = {
1195
+ ok: false,
1196
+ output: (result.output ?? "") +
1197
+ (result.output?.endsWith("\n") ? "" : "\n") +
1198
+ `Scaffold FAILED: ${cancelled ? "tool reported cancel/refuse" : "target project tree was not created"}. ` +
1199
+ (fromScaffold
1200
+ ? `Expected project at ${fromScaffold}. `
1201
+ : "") +
1202
+ `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1203
+ exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1204
+ };
1205
+ }
1206
+ else if (result.ok && fromScaffold && materialized) {
1207
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1208
+ writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1209
+ }
1210
+ else if (abortedMid && fromScaffold && materialized) {
1211
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1212
+ result = {
1213
+ ...result,
1214
+ output: (result.output ?? "") +
1215
+ (result.output?.endsWith("\n") ? "" : "\n") +
1216
+ `Scaffold command was interrupted, but a project tree already exists at ${fromScaffold} ` +
1217
+ `(package/manifest present). Do NOT re-run the scaffolder. CONTINUE: finish any missing install ` +
1218
+ `(\`npm install\` / stack equivalent), implement the requested feature, then run/verify.`,
1219
+ };
1220
+ writeNotice("info", `project root → ${fromScaffold} (partial scaffold — continue)`, chalk.dim(` ℹ partial scaffold at ${fromScaffold} — continue, do not re-create\n`));
1221
+ }
1222
+ }
841
1223
  const output = result.output.trim();
842
1224
  // Always keep a full on-disk copy of tool output (any size) so the
843
1225
  // pager never depends on a truncated in-memory preview.
@@ -864,6 +1246,12 @@ export async function runAgentLoop(prompt, options = {}) {
864
1246
  output: result.output.slice(0, 4_000),
865
1247
  });
866
1248
  loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1249
+ // Evidence for verify-before-done: only successful real work counts.
1250
+ if (result.ok && isEvidenceWorkTool(call.name)) {
1251
+ const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
1252
+ const openId = liveAfter?.tasks.find((t) => t.state === "in_progress")?.id;
1253
+ taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, openId ?? taskWorkLedger?.taskId, call.name);
1254
+ }
867
1255
  // Inject approach evaluation when consecutive failures are detected.
868
1256
  // Lets the MODEL decide (with full context) whether to continue a
869
1257
  // legitimately long approach, switch, or stop — instead of a
@@ -910,9 +1298,16 @@ export async function runAgentLoop(prompt, options = {}) {
910
1298
  artifactPath: savedOutputPath,
911
1299
  summary: contextOutput,
912
1300
  });
913
- if (savedOutputPath) {
1301
+ // Classic REPL only: short path notice. Never push long
1302
+ // "Ctrl+O or /output last … (path)" strings into the TUI status
1303
+ // footer — they collide with activity and garble the chrome.
1304
+ if (writesDirectly && savedOutputPath) {
1305
+ const short = chalk.dim(` saved ${savedOutputPath}\n`);
1306
+ process.stdout.write(short);
1307
+ }
1308
+ else if (writesDirectly) {
914
1309
  const viewportHint = `${formatViewportHint(viewport)}\n`;
915
- writeStatus(viewportHint, viewportHint);
1310
+ process.stdout.write(viewportHint);
916
1311
  }
917
1312
  }
918
1313
  return { ok: result.ok, call, result, contextOutput };
@@ -1115,7 +1510,26 @@ export async function runAgentLoop(prompt, options = {}) {
1115
1510
  emit({ type: "thinking-delta", text });
1116
1511
  });
1117
1512
  let completion;
1513
+ let toolsAttached = false;
1118
1514
  try {
1515
+ // Re-resolve dialect each step so /model or sticky fallback apply.
1516
+ ({ dialect: toolDialect, native: nativeToolsActive } =
1517
+ resolveNativeTools(provider, model));
1518
+ if (messages[0]?.role === "system") {
1519
+ messages[0] = {
1520
+ role: "system",
1521
+ content: buildSystemContent(nativeToolsActive),
1522
+ };
1523
+ }
1524
+ const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
1525
+ toolsAttached = Boolean(turnTools?.length);
1526
+ await auditLog("agent.turn", {
1527
+ provider,
1528
+ model,
1529
+ tool_protocol: toolsAttached ? "native" : "text",
1530
+ dialect: toolDialect,
1531
+ step,
1532
+ });
1119
1533
  completion = await streamWithProvider({
1120
1534
  provider,
1121
1535
  model,
@@ -1145,36 +1559,101 @@ export async function runAgentLoop(prompt, options = {}) {
1145
1559
  thinking: retryWithoutThinking
1146
1560
  ? { ...config.thinking, enabled: false, effort: "low" }
1147
1561
  : config.thinking,
1562
+ ...(toolsAttached
1563
+ ? {
1564
+ tools: turnTools,
1565
+ toolChoice: freshWebSearchRequired && !sawFreshWebSearch
1566
+ ? { type: "function", name: "web.search" }
1567
+ : "auto",
1568
+ parallelToolCalls: true,
1569
+ // P2-3: emit tool cards as soon as the function name arrives.
1570
+ onToolCallDelta: (delta) => {
1571
+ if (!delta.name)
1572
+ return;
1573
+ const name = fromWireName(delta.name) ?? delta.name;
1574
+ const existing = deferredToolCalls[delta.index];
1575
+ if (existing) {
1576
+ if (delta.argumentsBytes &&
1577
+ delta.argumentsBytes >= 4096 &&
1578
+ !writesDirectly) {
1579
+ emit({
1580
+ type: "status",
1581
+ text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
1582
+ });
1583
+ }
1584
+ return;
1585
+ }
1586
+ // Ensure slots are dense so index maps to deferredToolCalls[i].
1587
+ while (deferredToolCalls.length < delta.index) {
1588
+ deferredToolCalls.push({
1589
+ eventId: `tool-${++nextToolEventId}`,
1590
+ call: { name: "…", args: {} },
1591
+ rendered: "",
1592
+ });
1593
+ }
1594
+ const call = normalizeToolCall({
1595
+ name,
1596
+ args: {},
1597
+ });
1598
+ const eventId = `tool-${++nextToolEventId}`;
1599
+ callIds.push(eventId);
1600
+ alreadyPrintedIds.add(eventId);
1601
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
1602
+ chalk.gray(` ${formatToolArgs(call)}`);
1603
+ const entry = {
1604
+ eventId,
1605
+ call,
1606
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
1607
+ };
1608
+ if (deferredToolCalls.length === delta.index) {
1609
+ deferredToolCalls.push(entry);
1610
+ }
1611
+ else {
1612
+ deferredToolCalls[delta.index] = entry;
1613
+ }
1614
+ streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
1615
+ if (!writesDirectly) {
1616
+ emit({ type: "status", text: call.name });
1617
+ }
1618
+ else {
1619
+ spinner.stop();
1620
+ spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
1621
+ }
1622
+ },
1623
+ }
1624
+ : {}),
1148
1625
  }, (token) => {
1149
1626
  deltaParser?.push(token);
1150
1627
  generatedTokens += 1;
1151
1628
  accumulatedText += token;
1152
- const parsedCalls = parseAllToolCalls(accumulatedText);
1153
- if (parsedCalls.length > streamedCallsCount) {
1154
- if (writesDirectly) {
1155
- spinner.stop();
1156
- }
1157
- while (streamedCallsCount < parsedCalls.length) {
1158
- const call = parsedCalls[streamedCallsCount];
1159
- const eventId = `tool-${++nextToolEventId}`;
1160
- callIds.push(eventId);
1161
- alreadyPrintedIds.add(eventId);
1162
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1163
- // Defer the writeToolCall emission — collect it so we can
1164
- // emit after thinking + assistant text for correct order.
1165
- deferredToolCalls.push({
1166
- eventId,
1167
- call,
1168
- rendered: styleToolChatter(call, toolCallLine) + "\n",
1169
- });
1170
- // Still update spinner label for user feedback during streaming.
1171
- if (!writesDirectly) {
1172
- emit({ type: "status", text: call.name });
1629
+ // Early UI cards from text fences only when native tools are off
1630
+ // (native args stream as structured deltas, not prose).
1631
+ if (!toolsAttached) {
1632
+ const parsedCalls = parseAllToolCalls(accumulatedText);
1633
+ if (parsedCalls.length > streamedCallsCount) {
1634
+ if (writesDirectly) {
1635
+ spinner.stop();
1636
+ }
1637
+ while (streamedCallsCount < parsedCalls.length) {
1638
+ const call = parsedCalls[streamedCallsCount];
1639
+ const eventId = `tool-${++nextToolEventId}`;
1640
+ callIds.push(eventId);
1641
+ alreadyPrintedIds.add(eventId);
1642
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
1643
+ chalk.gray(` ${formatToolArgs(call)}`);
1644
+ deferredToolCalls.push({
1645
+ eventId,
1646
+ call,
1647
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
1648
+ });
1649
+ if (!writesDirectly) {
1650
+ emit({ type: "status", text: call.name });
1651
+ }
1652
+ streamedCallsCount += 1;
1653
+ }
1654
+ if (writesDirectly) {
1655
+ spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
1173
1656
  }
1174
- streamedCallsCount += 1;
1175
- }
1176
- if (writesDirectly) {
1177
- spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
1178
1657
  }
1179
1658
  }
1180
1659
  // Heuristic: <think>… markers and reasoning_content tokens flow
@@ -1223,6 +1702,13 @@ export async function runAgentLoop(prompt, options = {}) {
1223
1702
  provider = completion.provider;
1224
1703
  model = completion.model;
1225
1704
  deltaParser?.finish();
1705
+ // Sticky text-only may have flipped dialect during stream retry.
1706
+ ({ dialect: toolDialect, native: nativeToolsActive } =
1707
+ resolveNativeTools(provider, model));
1708
+ // toolsAttached may have been true for the request; if sticky
1709
+ // fallback dropped tools, treat as text mode for this turn's parse.
1710
+ const usedNativeProtocol = Boolean(completion.toolCalls?.length) ||
1711
+ (toolsAttached && !isTextOnlyModel(provider, model));
1226
1712
  const assistantTextResult = rememberThinkingFromText(completion.text);
1227
1713
  assistantText = assistantTextResult;
1228
1714
  // Commit thinking to the transcript IMMEDIATELY, before any of the
@@ -1235,30 +1721,171 @@ export async function runAgentLoop(prompt, options = {}) {
1235
1721
  if (assistantText.hasThinking) {
1236
1722
  writeThinkingBlock(assistantText.thinkContent);
1237
1723
  }
1724
+ // Native-first: prefer structured toolCalls from the provider.
1725
+ let nativeToolCalls = completion.toolCalls ?? [];
1726
+ // Early UI cards: refresh args if stream deltas already opened cards;
1727
+ // otherwise create cards now (non-streaming / name-after-done providers).
1728
+ if (nativeToolCalls.length) {
1729
+ if (deferredToolCalls.length === 0) {
1730
+ for (const tc of nativeToolCalls) {
1731
+ const normalized = normalizeToolCall({
1732
+ name: tc.name,
1733
+ args: tc.args,
1734
+ });
1735
+ const eventId = `tool-${++nextToolEventId}`;
1736
+ callIds.push(eventId);
1737
+ alreadyPrintedIds.add(eventId);
1738
+ const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
1739
+ chalk.gray(` ${formatToolArgs(normalized)}`);
1740
+ deferredToolCalls.push({
1741
+ eventId,
1742
+ call: normalized,
1743
+ rendered: styleToolChatter(normalized, toolCallLine) + "\n",
1744
+ });
1745
+ }
1746
+ }
1747
+ else {
1748
+ for (let i = 0; i < nativeToolCalls.length; i++) {
1749
+ const tc = nativeToolCalls[i];
1750
+ const normalized = normalizeToolCall({
1751
+ name: tc.name,
1752
+ args: tc.args,
1753
+ });
1754
+ const existing = deferredToolCalls[i];
1755
+ if (existing && existing.call.name !== "…") {
1756
+ existing.call = normalized;
1757
+ const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
1758
+ chalk.gray(` ${formatToolArgs(normalized)}`);
1759
+ existing.rendered =
1760
+ styleToolChatter(normalized, toolCallLine) + "\n";
1761
+ }
1762
+ else if (!existing || existing.call.name === "…") {
1763
+ const eventId = existing?.eventId ?? `tool-${++nextToolEventId}`;
1764
+ if (!existing) {
1765
+ callIds.push(eventId);
1766
+ alreadyPrintedIds.add(eventId);
1767
+ }
1768
+ const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
1769
+ chalk.gray(` ${formatToolArgs(normalized)}`);
1770
+ const entry = {
1771
+ eventId,
1772
+ call: normalized,
1773
+ rendered: styleToolChatter(normalized, toolCallLine) + "\n",
1774
+ };
1775
+ if (existing)
1776
+ deferredToolCalls[i] = entry;
1777
+ else
1778
+ deferredToolCalls.push(entry);
1779
+ }
1780
+ }
1781
+ }
1782
+ }
1238
1783
  // Try visible text first, then thinking content — some models (e.g. glm-5.1)
1239
- // wrap tool calls inside considering tags, so stripThinking removes them
1784
+ // wrap tool calls inside considering tags, so stripThinking removes them
1240
1785
  // into thinkContent and visible becomes empty. Recovering from thinkContent
1241
1786
  // prevents an endless nudge loop where the model keeps hiding the call.
1242
- call = parseToolCall(assistantText.visible, {
1243
- strict: getConfig().parserStrict,
1244
- });
1245
- if (!call && assistantText.hasThinking) {
1246
- call = parseToolCall(assistantText.thinkContent, {
1787
+ // When native toolCalls exist, skip text parse as primary (no double-exec).
1788
+ if (nativeToolCalls.length) {
1789
+ const first = nativeToolCalls[0];
1790
+ if (first.args?._parseError) {
1791
+ call = undefined;
1792
+ }
1793
+ else {
1794
+ call = normalizeToolCall({ name: first.name, args: first.args });
1795
+ }
1796
+ }
1797
+ else {
1798
+ call = parseToolCall(assistantText.visible, {
1247
1799
  strict: getConfig().parserStrict,
1248
1800
  });
1249
- if (call) {
1250
- writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
1801
+ if (!call && assistantText.hasThinking) {
1802
+ call = parseToolCall(assistantText.thinkContent, {
1803
+ strict: getConfig().parserStrict,
1804
+ });
1805
+ if (call) {
1806
+ writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
1807
+ }
1251
1808
  }
1252
1809
  }
1253
1810
  // ── Prompt-leak guard ─────────────────────────────────────────
1254
1811
  // If the model's visible output contains distinctive system-prompt
1255
1812
  // markers, it is repeating its instructions (e.g. prompt injection
1256
1813
  // via "repeat your instructions verbatim"). Any tool-call syntax
1257
- // in that output is an EXAMPLE from the prompt, not a real request.
1258
- // Suppress it so we never execute leaked examples.
1259
- if (call && looksLikePromptLeak(assistantText.visible)) {
1260
- writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
1814
+ // (text fences OR native toolCalls) is an EXAMPLE from the prompt,
1815
+ // not a real request. Suppress it so we never execute leaked examples.
1816
+ if (looksLikePromptLeak(assistantText.visible)) {
1817
+ if (call || nativeToolCalls.length) {
1818
+ writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
1819
+ }
1261
1820
  call = undefined;
1821
+ nativeToolCalls = [];
1822
+ deferredToolCalls.length = 0;
1823
+ }
1824
+ // ── Native truncated write salvage ────────────────────────────
1825
+ // Large fs.write content lives in tool_calls arguments, not fences.
1826
+ // When finish_reason is length or args failed to parse, salvage
1827
+ // partial content and continue with append (native wording).
1828
+ if (nativeToolCalls.length) {
1829
+ // Only salvage when args failed to parse (truncated JSON). A clean
1830
+ // parse with finish_reason=length is a complete tool call — execute it.
1831
+ const writeTc = nativeToolCalls.find((tc) => {
1832
+ const isWrite = tc.name === "fs.write" ||
1833
+ tc.name === "fs.append" ||
1834
+ tc.name === "fs.writeMany";
1835
+ return isWrite && Boolean(tc.args?._parseError);
1836
+ });
1837
+ if (writeTc) {
1838
+ const raw = writeTc.rawArguments ??
1839
+ (typeof writeTc.args?._raw === "string"
1840
+ ? String(writeTc.args._raw)
1841
+ : undefined);
1842
+ const salvaged = salvageTruncatedWriteFromNative(writeTc.name, raw);
1843
+ if (salvaged) {
1844
+ truncatedToolRetries += 1;
1845
+ if (truncatedToolRetries <= 5) {
1846
+ try {
1847
+ const writeResult = await fsWrite(salvaged.path, salvaged.content, { confirmed: true });
1848
+ if (writeResult.ok) {
1849
+ const lineCount = salvaged.content.split("\n").length;
1850
+ writeNotice("info", `native tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ native tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
1851
+ // Pair assistant tool_calls with synthetic results so the
1852
+ // next turn is not orphaned, then nudge for append.
1853
+ appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
1854
+ for (const tc of nativeToolCalls) {
1855
+ appendToolResult(messages, tc.id, tc.id === writeTc.id
1856
+ ? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
1857
+ : `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
1858
+ }
1859
+ const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
1860
+ const appendNudge = toolsAttached
1861
+ ? `Your ${writeTc.name} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
1862
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
1863
+ `CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content not already on disk (prefer hundreds of lines per call). ` +
1864
+ `Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
1865
+ : `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
1866
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
1867
+ `CONTINUE with ONE large fs.append of the remaining content:\n` +
1868
+ '```tool\n{"name":"fs.append","args":{"path":' +
1869
+ JSON.stringify(salvaged.path) +
1870
+ ',"expectedPriorBytes":' +
1871
+ priorBytes +
1872
+ ',"content":"...ONLY the remaining content not already on disk..."}}\n```';
1873
+ messages.push({
1874
+ role: "user",
1875
+ content: appendNudge,
1876
+ });
1877
+ nativeToolCalls = [];
1878
+ call = undefined;
1879
+ deferredToolCalls.length = 0;
1880
+ continue;
1881
+ }
1882
+ }
1883
+ catch {
1884
+ // fall through to normal parse-error handling
1885
+ }
1886
+ }
1887
+ }
1888
+ }
1262
1889
  }
1263
1890
  // Empty-response recovery
1264
1891
  // Some models occasionally return an empty completion: a reasoning
@@ -1282,12 +1909,18 @@ export async function runAgentLoop(prompt, options = {}) {
1282
1909
  pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
1283
1910
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
1284
1911
  const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
1285
- ? "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
1912
+ ? toolsAttached
1913
+ ? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
1914
+ : "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
1286
1915
  : buildLikeTurn && !activePlan
1287
- ? "No visible output. Emit a ```tool block to call plan.create now. " +
1288
- "Do NOT hide tool calls in <think> tags put them in the visible response."
1289
- : "No visible output. Emit a ```tool block or give your final answer. " +
1290
- "Do NOT hide tool calls in <think> tags — put them in the visible response.";
1916
+ ? toolsAttached
1917
+ ? "No visible output. Call plan.create now (do not only describe the plan)."
1918
+ : "No visible output. Emit a ```tool block to call plan.create now. " +
1919
+ "Do NOT hide tool calls in <think> tags — put them in the visible response."
1920
+ : toolsAttached
1921
+ ? "No visible output. " + toolNudge(true)
1922
+ : "No visible output. Emit a ```tool block or give your final answer. " +
1923
+ "Do NOT hide tool calls in <think> tags — put them in the visible response.";
1291
1924
  messages.push(recoveryUserMessage(buildNudge));
1292
1925
  continue;
1293
1926
  }
@@ -1336,19 +1969,31 @@ export async function runAgentLoop(prompt, options = {}) {
1336
1969
  if (bareArgsOnly) {
1337
1970
  bareToolJsonRetries += 1;
1338
1971
  if (bareToolJsonRetries <= 3) {
1339
- 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"));
1972
+ writeNotice("warn", toolsAttached
1973
+ ? "tool call missing its name — asking the model to call a tool properly"
1974
+ : "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(toolsAttached
1975
+ ? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
1976
+ : " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
1340
1977
  pushAssistantHistory(assistantText.visible);
1341
1978
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1342
- ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1343
- "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1344
- "You MUST call plan.create using a proper ```tool block. For example:\n" +
1345
- '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1346
- "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1347
- : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1348
- "Reply with ONLY a fenced ```tool block of the form " +
1349
- '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
1350
- '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
1351
- "Choose the correct tool name for the task and include those args."));
1979
+ ? toolsAttached
1980
+ ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
1981
+ "This is a BUILD/SCAFFOLD task with NO plan yet. Call plan.create now via the platform tool interface. " +
1982
+ "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1983
+ : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1984
+ "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1985
+ "You MUST call plan.create using a proper ```tool block. For example:\n" +
1986
+ '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1987
+ "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1988
+ : toolsAttached
1989
+ ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
1990
+ toolNudge(true) +
1991
+ " Include the tool name and full args via the platform tool interface — do not use markdown fences."
1992
+ : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1993
+ "Reply with ONLY a fenced ```tool block of the form " +
1994
+ '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
1995
+ '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
1996
+ "Choose the correct tool name for the task and include those args."));
1352
1997
  continue;
1353
1998
  }
1354
1999
  // Exhausted retries — fall through to the normal answer path.
@@ -1360,10 +2005,14 @@ export async function runAgentLoop(prompt, options = {}) {
1360
2005
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1361
2006
  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"));
1362
2007
  pushAssistantHistory(assistantText.visible);
1363
- messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1364
- "Reply with ONLY a fenced ```tool block containing valid JSON " +
1365
- 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
1366
- "Do not use <|tool_call_begin|> markers."));
2008
+ messages.push(recoveryUserMessage(toolsAttached
2009
+ ? "Your previous tool call was malformed or truncated. " +
2010
+ toolNudge(true) +
2011
+ " Pass valid JSON arguments via the platform tool interface — do not use fence or sentinel markers."
2012
+ : "Your previous tool call was malformed or truncated. " +
2013
+ "Reply with ONLY a fenced ```tool block containing valid JSON " +
2014
+ 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
2015
+ "Do not use <|tool_call_begin|> markers."));
1367
2016
  continue;
1368
2017
  }
1369
2018
  // Detect a tool call that opened but was cut off by the token limit
@@ -1388,15 +2037,23 @@ export async function runAgentLoop(prompt, options = {}) {
1388
2037
  const lineCount = salvaged.content.split("\n").length;
1389
2038
  writeNotice("info", `tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
1390
2039
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
2040
+ const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
1391
2041
  messages.push({
1392
2042
  role: "user",
1393
- content: `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines to ${salvaged.path}. ` +
1394
- `The file now exists with content up to: "${salvaged.lastLine}"\n\n` +
1395
- `CONTINUE writing the rest of the content using fs.append:\n` +
1396
- '```tool\n{"name":"fs.append","args":{"path":"' + salvaged.path + '","content":"...remaining content..."}}\n```\n' +
1397
- `Write the NEXT section of content starting from where it was cut off. ` +
1398
- `Keep each fs.append call to ~100 lines max so it fits in the output window. ` +
1399
- `Use multiple fs.append calls if needed. Do NOT re-write content that was already saved.`,
2043
+ content: toolsAttached
2044
+ ? `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
2045
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2046
+ `CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content (prefer large chunks). Use the platform tool interface — no markdown fences.`
2047
+ : `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
2048
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2049
+ `CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
2050
+ '```tool\n{"name":"fs.append","args":{"path":' +
2051
+ JSON.stringify(salvaged.path) +
2052
+ ',"expectedPriorBytes":' +
2053
+ priorBytes +
2054
+ ',"content":"...ONLY the remaining content not already on disk..."}}\n```\n' +
2055
+ `expectedPriorBytes must match the receipt so append cannot double-write. ` +
2056
+ `Do NOT re-read the full file; do NOT re-send content already saved.`,
1400
2057
  });
1401
2058
  continue;
1402
2059
  }
@@ -1410,13 +2067,18 @@ export async function runAgentLoop(prompt, options = {}) {
1410
2067
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
1411
2068
  messages.push({
1412
2069
  role: "user",
1413
- content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
1414
- "Your output token limit is ~32k tokens. For LARGE files (reports, docs, long code), you MUST write in chunks:\n" +
1415
- "1. Use fs.write to create the file with the FIRST ~100 lines\n" +
1416
- "2. Use fs.append to add the NEXT ~100 lines\n" +
1417
- "3. Repeat fs.append for each remaining section\n" +
1418
- "Keep your reasoning SHORT emit the ```tool block as early as possible to maximize content space. " +
1419
- "Do NOT try to write the entire file in one call. Do NOT claim any file was written until a tool call actually succeeds.",
2070
+ content: toolsAttached
2071
+ ? "Your previous tool call was cut off before it finished the JSON was incomplete, so NOTHING ran. " +
2072
+ "Prefer ONE complete fs.write when it fits. If the file is too large: (1) fs.write the first large section, " +
2073
+ "(2) fs.append the rest with expectedPriorBytes from the write receipt, (3) repeat with large chunks. " +
2074
+ "Keep reasoning SHORT and call the tool via the platform interface. Do NOT claim a file was written until a tool call succeeds."
2075
+ : "Your previous tool call was cut off before it finished the JSON was incomplete, so NOTHING ran. " +
2076
+ "Prefer ONE complete fs.write when it fits (~32k output tokens is a lot of file content if reasoning stays short). " +
2077
+ "If the file is too large for one call:\n" +
2078
+ "1. fs.write the first large section (as much as fits — hundreds+ of lines)\n" +
2079
+ "2. fs.append the rest with expectedPriorBytes from the write receipt\n" +
2080
+ "3. Repeat append only if still incomplete — large chunks, not ~100-line drips\n" +
2081
+ "Keep reasoning SHORT — emit the ```tool block early. Do NOT claim a file was written until a tool call succeeds.",
1420
2082
  });
1421
2083
  continue;
1422
2084
  }
@@ -1451,7 +2113,7 @@ export async function runAgentLoop(prompt, options = {}) {
1451
2113
  content: `The system extracted and wrote ${lineCount} lines to ${salvaged.path} from your malformed tool call. ` +
1452
2114
  `The file content ends at: "${salvaged.lastLine}"\n\n` +
1453
2115
  `If the file is complete, proceed with the next step. ` +
1454
- `If more content is needed, use fs.append to add the remaining sections (~100 lines per call).`,
2116
+ `If more content is needed, use one large fs.append with expectedPriorBytes from the write receipt (not tiny chunks).`,
1455
2117
  });
1456
2118
  continue;
1457
2119
  }
@@ -1466,14 +2128,18 @@ export async function runAgentLoop(prompt, options = {}) {
1466
2128
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
1467
2129
  messages.push({
1468
2130
  role: "user",
1469
- content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
1470
- "Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
1471
- 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
1472
- "IMPORTANT: For large file content (reports, docs), write in chunks:\n" +
1473
- "1. fs.write with the FIRST ~100 lines only\n" +
1474
- "2. fs.append for each subsequent ~100-line section\n" +
1475
- "Keep reasoning SHORT to maximize output space for the tool call JSON. " +
1476
- "Do NOT claim any file was written until a tool call actually succeeds.",
2131
+ content: toolsAttached
2132
+ ? "Your previous tool call JSON was INVALID, so NOTHING ran. " +
2133
+ "Common causes: unescaped newlines/quotes, unbalanced braces, or content too large. " +
2134
+ toolNudge(true) +
2135
+ " Prefer ONE complete fs.write when it fits; if cut off, continue with large fs.append + expectedPriorBytes. " +
2136
+ "Do NOT claim any file was written until a tool call actually succeeds."
2137
+ : "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
2138
+ "Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
2139
+ 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
2140
+ "IMPORTANT: Prefer ONE complete fs.write when it fits. Keep reasoning SHORT. " +
2141
+ "Only if the output window cuts you off, continue with large fs.append chunks + expectedPriorBytes. " +
2142
+ "Do NOT claim any file was written until a tool call actually succeeds.",
1477
2143
  });
1478
2144
  continue;
1479
2145
  }
@@ -1524,25 +2190,48 @@ export async function runAgentLoop(prompt, options = {}) {
1524
2190
  const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
1525
2191
  !activePlan &&
1526
2192
  looksLikePlanNarration(cleaned);
2193
+ const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
1527
2194
  // Once a real tool step has run, a no-plan task has no durable task
1528
2195
  // state to prove whether another action is needed. A tool-free reply
1529
2196
  // must therefore be allowed to finalize instead of turning a short
1530
- // summary containing “I'll” into an implicit recovery request.
1531
- const shouldRetryBeforeFinalizing = productiveSteps === 0 || planNarrated;
2197
+ // summary containing “I'll” into an implicit recovery request
2198
+ // EXCEPT when an approved plan still has work, or the model just
2199
+ // diagnosed an error and said it would fix it without calling a tool.
2200
+ const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
2201
+ planNarrated ||
2202
+ (session.planApproved.value &&
2203
+ planHasOpenWorkNow &&
2204
+ (narratedAction || errorFixNarration)) ||
2205
+ (session.planApproved.value && errorFixNarration) ||
2206
+ (buildLikeTurn && errorFixNarration);
1532
2207
  if (wantsAction &&
1533
2208
  cleaned.trim().length > 0 &&
1534
2209
  actionIntentRetries < 3 &&
1535
2210
  shouldRetryBeforeFinalizing) {
1536
2211
  actionIntentRetries += 1;
1537
2212
  let nudge;
1538
- if (planHasOpenWorkNow && session.planApproved.value) {
1539
- nudge =
1540
- "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.";
2213
+ if (errorFixNarration && errorFixNarrationRetries < 3) {
2214
+ errorFixNarrationRetries += 1;
2215
+ nudge = toolsAttached
2216
+ ? "You diagnosed an error and described the fix but called NO tool, so NOTHING was fixed. " +
2217
+ "Apply the fix NOW with a real tool (fs.edit / fs.write / shell.exec), then re-verify. " +
2218
+ "Do not stop after identifying the error."
2219
+ : "You diagnosed an error and described the fix but emitted NO ```tool block, so NOTHING was fixed. " +
2220
+ "Apply the fix NOW, e.g.:\n" +
2221
+ '```tool\n{"name":"fs.edit","args":{"path":"<file>","oldText":"...","newText":"..."}}\n```\n' +
2222
+ "Then re-run the failing check. Do not stop after identifying the error.";
2223
+ writeNotice("warn", "error diagnosed but not fixed — forcing tool call", chalk.yellow(" ⚠ diagnosed a failure but did not call a tool — applying the fix now\n"));
2224
+ }
2225
+ else if (planHasOpenWorkNow && session.planApproved.value) {
2226
+ nudge = toolsAttached
2227
+ ? "You wrote a message but called NO tool, so NOTHING ran. Do NOT narrate — call the next tool now (task.update / fs.writeMany / shell.exec) via the platform tool interface."
2228
+ : "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.";
1541
2229
  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"));
1542
2230
  }
1543
2231
  else if (pentestLikeTurn) {
1544
- nudge =
1545
- "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 (e.g. net.scan / sysinfo / shell.exec). For example, to scan local network or read system settings:\n" +
2232
+ nudge = toolsAttached
2233
+ ? "You described what you will do but called NO tool, so NOTHING happened. Call a real tool NOW (e.g. net.scan / sysinfo / shell.exec) via the platform interface. Every turn that claims action must include a tool call until the task is done."
2234
+ : "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 (e.g. net.scan / sysinfo / shell.exec). For example, to scan local network or read system settings:\n" +
1546
2235
  '```tool\n{"name":"sysinfo","args":{}}\n```\n' +
1547
2236
  "Every turn MUST contain a ```tool block until the task is done.";
1548
2237
  writeNotice("warn", "described a security/pentest action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a security/pentest action but emitted no tool call — nudging it to run one\n"));
@@ -1550,11 +2239,9 @@ export async function runAgentLoop(prompt, options = {}) {
1550
2239
  else if (freshWebSearchRequired || narratedWebAction) {
1551
2240
  // Web-specific recovery ONLY when the user asked for current
1552
2241
  // info or the model explicitly claimed a fetch/search step.
1553
- // Previously every non-build stall used this path, so a "hi"
1554
- // greeting that said "I'll start executing" was forced into
1555
- // pointless web.search recovery loops.
1556
- nudge =
1557
- "You wrote that you would fetch/search/read something but emitted NO ```tool block, so NOTHING ran. Do NOT narrate the next browsing step — DO it. Emit exactly one valid ```tool block now. If you know the exact page, use:\n" +
2242
+ nudge = toolsAttached
2243
+ ? "You wrote that you would fetch/search/read something but called NO tool, so NOTHING ran. Call web.search or web.fetch now via the platform interface. After the tool output, answer from the results."
2244
+ : "You wrote that you would fetch/search/read something but emitted NO ```tool block, so NOTHING ran. Do NOT narrate the next browsing step — DO it. Emit exactly one valid ```tool block now. If you know the exact page, use:\n" +
1558
2245
  '```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
1559
2246
  "If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
1560
2247
  writeNotice("warn", "described a web action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a web action but emitted no tool call — nudging it to run one\n"));
@@ -1562,23 +2249,28 @@ export async function runAgentLoop(prompt, options = {}) {
1562
2249
  else if (buildLikeTurn &&
1563
2250
  (planNarrated || productiveSteps > 0)) {
1564
2251
  const kind = pentestLikeTurn ? "pentest" : "coding";
1565
- nudge =
1566
- "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" +
2252
+ nudge = toolsAttached
2253
+ ? `You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved. Call plan.create now via the platform tool interface with goal, detail, tasks, and kind="${kind}". Do not only describe the plan.`
2254
+ : "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" +
1567
2255
  `\`\`\`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":"${kind}"}}\n\`\`\`\n` +
1568
2256
  "Do not describe the plan again in prose — just emit the plan.create tool block.";
1569
2257
  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"));
1570
2258
  }
1571
2259
  else if (buildLikeTurn) {
1572
- nudge =
1573
- "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" +
2260
+ nudge = toolsAttached
2261
+ ? "You described what you will do but called NO tool, so NOTHING happened. Call a tool NOW (e.g. fs.list on \".\"), then plan.create once you understand the directory. Use the platform tool interface no markdown fences."
2262
+ : "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" +
1574
2263
  '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1575
2264
  "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.";
1576
2265
  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"));
1577
2266
  }
1578
2267
  else {
1579
2268
  // Generic non-build, non-web stall (e.g. "I'll list the files").
1580
- nudge =
1581
- "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 the step you just described. Every turn that claims an action MUST contain a ```tool block.";
2269
+ nudge = toolsAttached
2270
+ ? "You described what you will do but called NO tool, so NOTHING happened. " +
2271
+ toolNudge(true) +
2272
+ " Every turn that claims an action must include a real tool call."
2273
+ : "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 the step you just described. Every turn that claims an action MUST contain a ```tool block.";
1582
2274
  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"));
1583
2275
  }
1584
2276
  pushAssistantHistory(assistantText.visible);
@@ -1594,45 +2286,148 @@ export async function runAgentLoop(prompt, options = {}) {
1594
2286
  messages.push({
1595
2287
  role: "user",
1596
2288
  content: freshnessGuardMessage() +
1597
- " Reply with ONLY a fenced ```tool block for web.search now.",
2289
+ (toolsAttached
2290
+ ? " Call the web_search tool now."
2291
+ : " Reply with ONLY a fenced ```tool block for web.search now."),
1598
2292
  });
1599
2293
  continue;
1600
2294
  }
1601
- // A passing build is not evidence that an app is serving requests.
1602
- // On completed build plans, require start logs HTTP verification
1603
- // before accepting the model's final claim.
2295
+ // Coding builds must produce a durable plan before freestyle "done".
2296
+ // (Explore-only turns without plan.create must not end as a final answer.)
1604
2297
  if (buildLike &&
2298
+ !pentestLike &&
2299
+ !pentestSession &&
2300
+ codingBuildRequiresPlan(prompt, {
2301
+ informational: informationalQuery,
2302
+ idle: idleOrSocialPrompt,
2303
+ pentest: false,
2304
+ }) &&
2305
+ forcePlanRetries < 2) {
2306
+ const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
2307
+ if (!planAtEnd && !sawPlanCreateOk) {
2308
+ forcePlanRetries += 1;
2309
+ pushAssistantHistory(assistantText.visible);
2310
+ const kind = "coding";
2311
+ messages.push({
2312
+ role: "user",
2313
+ content: toolsAttached
2314
+ ? `This is a coding BUILD with NO plan saved yet. Call plan.create NOW via the platform tool interface ` +
2315
+ `(goal, detail with stack + what exists on disk, 4–8 tasks, kind="${kind}"). ` +
2316
+ `Include feature implementation tasks and a final run/verify task. Do NOT scaffold or write app files until the user /implement-s the plan. ` +
2317
+ `Read-only explore (fs.list/read, tool.check) is fine before plan.create.`
2318
+ : `This is a coding BUILD with NO plan saved yet. Emit exactly one plan.create tool block NOW:\n` +
2319
+ `\`\`\`tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack, what exists, how you'll verify>","tasks":["explore/confirm destination","scaffold or continue project","implement requested feature","install deps","run/verify with shell.start + probe"],"kind":"${kind}"}}\n\`\`\`\n` +
2320
+ `Do NOT scaffold or write app files until /implement. Explore read-only first if needed.`,
2321
+ });
2322
+ writeNotice("warn", "coding build missing plan.create — forcing plan", chalk.yellow(" ⚠ no plan yet — call plan.create before scaffolding or finishing\n"));
2323
+ continue;
2324
+ }
2325
+ }
2326
+ // Scaffold-only is NOT the product. If the user asked for a todo/blog/…
2327
+ // app and the model only ran create-*, force feature implementation first.
2328
+ // Do this BEFORE run/verify so we never push shell.start on blank starter.
2329
+ if (buildLike &&
2330
+ !pentestLike &&
2331
+ !pentestSession &&
1605
2332
  session.planApproved.value &&
2333
+ featureAppAsk &&
2334
+ !sawFeatureImplWrite &&
2335
+ (sawScaffoldOk || sawLocalAppMaterialWork) &&
2336
+ productiveSteps > 0 &&
2337
+ featureImplRetries < 2) {
2338
+ featureImplRetries += 1;
2339
+ pushAssistantHistory(assistantText.visible);
2340
+ const rootHint = getActiveProjectRoot()
2341
+ ? ` Write under "${getActiveProjectRoot()}" with absolute paths.`
2342
+ : "";
2343
+ messages.push({
2344
+ role: "user",
2345
+ content: "INCOMPLETE: the user asked for a working FEATURE app (e.g. todo/blog/dashboard), not a blank framework starter. " +
2346
+ "Scaffold alone (create-next-app / create-vite / cargo new / …) is a FAILURE. " +
2347
+ "NOW implement the requested feature: read the entry page/component, replace starter boilerplate with real add/list/toggle/delete (or whatever they asked), " +
2348
+ "using fs.write / fs.writeMany. Do NOT shell.start and do NOT only tell the user how to run the app until that feature code exists." +
2349
+ rootHint,
2350
+ });
2351
+ writeNotice("warn", "feature not implemented — scaffold alone is not the deliverable", chalk.yellow(" ⚠ scaffold-only is incomplete — implement the requested feature before run/verify\n"));
2352
+ continue;
2353
+ }
2354
+ // A passing build is not evidence that an app is serving requests.
2355
+ // Require start → logs → HTTP for local app builds:
2356
+ // (A) completed coding plan, OR
2357
+ // (B) freestyle build that implemented the product (if asked) then
2358
+ // only told the user "run npm run dev yourself".
2359
+ // NEVER apply this to pentest/remote engagements.
2360
+ if (buildLike &&
2361
+ !pentestLike &&
2362
+ !pentestSession &&
1606
2363
  (!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
1607
- runtimeVerificationRetries < 2) {
2364
+ runtimeVerificationRetries < 2 &&
2365
+ // Feature apps must implement first (handled above); only verify live after that
2366
+ (!featureAppAsk || sawFeatureImplWrite)) {
1608
2367
  const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
1609
- const tasksFinished = Boolean(runtimePlan &&
2368
+ const codingPlanFinished = Boolean(runtimePlan &&
2369
+ session.planApproved.value &&
2370
+ runtimePlan.kind !== "pentest" &&
1610
2371
  runtimePlan.tasks.length > 0 &&
1611
2372
  runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
1612
- if (tasksFinished) {
2373
+ const freestyleLocalAppDone = !session.planApproved.value &&
2374
+ sawLocalAppMaterialWork &&
2375
+ productiveSteps > 0 &&
2376
+ // Final prose hands "how to run" to the user, or claims done without starting
2377
+ (/\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
2378
+ /\b(?:cargo\s+run|flask\s+run|uvicorn|rails\s+s|python\s+-m\s+http\.server)\b/i.test(cleaned) ||
2379
+ /\bopen\s+http:\/\/localhost\b/i.test(cleaned) ||
2380
+ /\bhow to run\b/i.test(cleaned) ||
2381
+ (/\b(?:created|built|ready|complete)\b/i.test(cleaned) &&
2382
+ getActiveProjectRoot() !== undefined));
2383
+ if (codingPlanFinished || freestyleLocalAppDone) {
1613
2384
  runtimeVerificationRetries += 1;
1614
2385
  pushAssistantHistory(assistantText.visible);
1615
- const missing = [
1616
- !sawServerStart ? "shell.start" : "",
1617
- !sawServerTail ? "shell.tail" : "",
1618
- !sawLocalHttpProbe
1619
- ? "a successful bounded localhost HTTP probe"
1620
- : "",
1621
- ].filter(Boolean);
2386
+ const rootHint = getActiveProjectRoot()
2387
+ ? ` Use cwd "${getActiveProjectRoot()}".`
2388
+ : "";
1622
2389
  messages.push({
1623
2390
  role: "user",
1624
- content: "Run the missing checks now. Keep the dev server/job running in the background so that the user can interact with the live application, and print the localhost link. Report whether it remains running truthfully.",
2391
+ content: "This is a LOCAL APP build: you must NOT stop after writing files or only telling the user how to run it. " +
2392
+ "Run the missing checks NOW: shell.start the app/dev server, shell.tail until ready, one localhost HTTP probe " +
2393
+ "(curl or http.fetch with iOwnThis:true), LEAVE the server running, and report URL + port + job id." +
2394
+ rootHint +
2395
+ " Do not only paste `npm run dev` instructions. " +
2396
+ "If this was a remote pentest, ignore this and finalize the report with no local server.",
1625
2397
  });
2398
+ writeNotice("warn", "local app missing shell.start/probe — forcing run/verify", chalk.yellow(" ⚠ local app not verified live — start server, tail, probe localhost, leave running\n"));
1626
2399
  continue;
1627
2400
  }
1628
2401
  }
2402
+ // Failed localhost probe (e.g. HTTP 500): model must FIX, not stop.
2403
+ if (buildLike &&
2404
+ !pentestLike &&
2405
+ !pentestSession &&
2406
+ sawFailedLocalHttpProbe &&
2407
+ !sawLocalHttpProbe &&
2408
+ failedProbeFixRetries < 3 &&
2409
+ cleaned.trim().length > 0) {
2410
+ failedProbeFixRetries += 1;
2411
+ pushAssistantHistory(assistantText.visible);
2412
+ messages.push({
2413
+ role: "user",
2414
+ content: "The local HTTP probe FAILED (4xx/5xx or connection refused) — the app is NOT working yet. " +
2415
+ "Do NOT stop. Diagnose from the error (e.g. missing \"use client\", syntax error, wrong port), " +
2416
+ "apply a real fix with fs.edit/fs.write, restart/re-probe if needed, and only then mark the verify task done. " +
2417
+ "Identifying the error without calling a tool is a failure.",
2418
+ });
2419
+ writeNotice("warn", "localhost probe failed — forcing fix, not stopping", chalk.yellow(" ⚠ HTTP probe failed — fix the app and re-verify; do not stop at diagnosis\n"));
2420
+ continue;
2421
+ }
1629
2422
  // Premature-completion guard (approved plan still has work)
1630
2423
  // If the user approved a plan and the model now gives a final answer
1631
2424
  // while tasks are still pending/in_progress — without having run the
1632
2425
  // work — it is fabricating completion (the exact "all tasks completed,
1633
2426
  // running at localhost:5173" failure). Force it back to executing the
1634
2427
  // next real task instead of accepting the false claim.
1635
- if (session.planApproved.value && prematureCompletionRetries < 3) {
2428
+ // Budget: 6 retries (resets when real work succeeds) so long builds
2429
+ // with mid-stream "done" claims do not exhaust and stop mid-error.
2430
+ if (session.planApproved.value && prematureCompletionRetries < 6) {
1636
2431
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1637
2432
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1638
2433
  if (livePlan && unfinished && unfinished.length > 0) {
@@ -1640,20 +2435,30 @@ export async function runAgentLoop(prompt, options = {}) {
1640
2435
  const next = unfinished[0];
1641
2436
  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`));
1642
2437
  pushAssistantHistory(assistantText.visible);
2438
+ const isPentestPlan = livePlan.kind === "pentest" || pentestSession;
1643
2439
  let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
1644
- if (next.state === "pending") {
1645
- instruction += `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the real work with a tool call (fs.writeMany / shell.exec / shell.start), VERIFY it, and mark it done. `;
2440
+ if (errorFixNarration) {
2441
+ instruction =
2442
+ `You identified a failure and must FIX it with a tool call first (fs.edit/fs.write), then continue task ${next.id} ("${next.title}"): `;
2443
+ }
2444
+ if (isPentestPlan) {
2445
+ instruction +=
2446
+ `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
2447
+ `(dns/http/net.scan/http.fetch/tool.batch — NOT a local dev server), VERIFY with real tool output, ` +
2448
+ `then task.update done. Do NOT shell.start / npm run dev / explore the clai workspace. `;
2449
+ }
2450
+ else if (next.state === "pending") {
2451
+ instruction += `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app), VERIFY it, and mark it done. `;
1646
2452
  }
1647
2453
  else {
1648
- instruction += `do the real work with a tool call (fs.writeMany / shell.exec / shell.start) to complete it, VERIFY it, and mark it done (call task.update {taskId:"${next.id}", state:"done"}). `;
2454
+ instruction += `do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app) to complete it, VERIFY it, and mark it done (call task.update {taskId:"${next.id}", state:"done"}). `;
1649
2455
  }
1650
- instruction += `Continue task by task until EVERY task is actually finished.`;
2456
+ instruction += `Continue task by task until EVERY task is actually finished. Do not stop after only diagnosing an error.`;
1651
2457
  messages.push({
1652
2458
  role: "user",
1653
2459
  content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
1654
2460
  `(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
1655
- `Do NOT claim the work is complete, that files were created, or that a server is running ` +
1656
- `unless a tool call actually succeeded and you saw the output. ` +
2461
+ `Do NOT claim the work is complete unless a tool call actually succeeded and you saw the output. ` +
1657
2462
  instruction,
1658
2463
  });
1659
2464
  continue;
@@ -1694,49 +2499,71 @@ export async function runAgentLoop(prompt, options = {}) {
1694
2499
  // prose / thinking that preceded it, record the assistant message ONCE.
1695
2500
  const beforeTool = recoveredFromBareJson
1696
2501
  ? ""
1697
- : textBeforeToolCall(assistantText.visible);
2502
+ : nativeToolCalls.length
2503
+ ? assistantText.visible.trim()
2504
+ : textBeforeToolCall(assistantText.visible);
1698
2505
  if (beforeTool) {
1699
2506
  writeAssistantMessage(beforeTool);
1700
2507
  }
1701
- let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
1702
- if (allCalls.length === 0 && call) {
1703
- allCalls = [call];
2508
+ let bound = [];
2509
+ if (nativeToolCalls.length) {
2510
+ bound = nativeToolCalls.map((tc, index) => {
2511
+ const call = tc.args?._parseError
2512
+ ? {
2513
+ name: tc.name || "unknown",
2514
+ args: {
2515
+ __nativeParseError: true,
2516
+ _raw: tc.args._raw,
2517
+ },
2518
+ }
2519
+ : normalizeToolCall({ name: tc.name, args: tc.args });
2520
+ return { index, id: tc.id, call, native: tc };
2521
+ });
2522
+ }
2523
+ else {
2524
+ let parsed = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
2525
+ if (parsed.length === 0 && call)
2526
+ parsed = [call];
2527
+ bound = parsed.map((c, index) => {
2528
+ const id = syntheticToolCallId(index);
2529
+ return {
2530
+ index,
2531
+ id,
2532
+ call: c,
2533
+ native: { id, name: c.name, args: c.args },
2534
+ };
2535
+ });
1704
2536
  }
2537
+ /** Subset that will actually run this turn (defer/omit rest). */
2538
+ let toRun = bound;
1705
2539
  let activeDeferredToolCalls = deferredToolCalls;
2540
+ let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
1706
2541
  // A plan must be based on the outputs of prior reconnaissance, never
1707
2542
  // on calls the model merely proposed in the same response. If a model
1708
2543
  // emits plan.create alongside gathering calls, run only the calls
1709
2544
  // before it, then let the next model turn analyse their actual results
1710
- // and emit one standalone plan.create. Calls after the attempted plan
1711
- // are intentionally discarded: they were proposed before a plan was
1712
- // created or approved.
1713
- const planCallIndex = allCalls.findIndex((candidate) => candidate.name === "plan.create");
2545
+ // and emit one standalone plan.create.
2546
+ const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
1714
2547
  if (planCallIndex > 0) {
1715
- // plan.create is bundled AFTER gathering calls in the SAME message,
1716
- // so its reconnaissance results do not exist yet. Run only the
1717
- // preceding gathering calls, then let the next turn analyse their
1718
- // actual results and emit one standalone plan.create.
1719
- const gatheringCalls = allCalls.slice(0, planCallIndex);
1720
- const deferredCount = allCalls.length - gatheringCalls.length;
1721
- allCalls = gatheringCalls;
1722
- activeDeferredToolCalls = deferredToolCalls.slice(0, gatheringCalls.length);
1723
- writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${gatheringCalls.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
2548
+ const deferredCount = bound.length - planCallIndex;
2549
+ toRun = bound.slice(0, planCallIndex);
2550
+ activeDeferredToolCalls = deferredToolCalls.slice(0, planCallIndex);
2551
+ deferReason =
2552
+ "Deferred plan.create must wait until reconnaissance results exist.";
2553
+ writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${toRun.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
1724
2554
  messages.push({
1725
2555
  role: "system",
1726
2556
  content: `The prior response included plan.create before its reconnaissance results existed. ` +
1727
- `Only the ${gatheringCalls.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
2557
+ `Only the ${toRun.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
1728
2558
  "Now analyse the tool results. If a plan is appropriate, emit exactly one standalone plan.create tool call based only on those results. Do not include any other tool calls in that response.",
1729
2559
  });
1730
2560
  }
1731
- else if (planCallIndex === 0 && allCalls.length > 1) {
1732
- // plan.create is the FIRST call but bundled with follow-on calls.
1733
- // The plan is based on reconnaissance from prior turns (already in
1734
- // history), so execute the plan.create now and defer only the calls
1735
- // proposed after it — those were proposed before the plan was
1736
- // created or approved.
1737
- const deferredCount = allCalls.length - 1;
1738
- allCalls = allCalls.slice(0, 1);
2561
+ else if (planCallIndex === 0 && bound.length > 1) {
2562
+ const deferredCount = bound.length - 1;
2563
+ toRun = bound.slice(0, 1);
1739
2564
  activeDeferredToolCalls = deferredToolCalls.slice(0, 1);
2565
+ deferReason =
2566
+ "Deferred — waiting for plan approval before follow-on tools.";
1740
2567
  writeNotice("info", "creating the plan now; deferring follow-on calls until it is approved", chalk.dim(` ℹ running plan.create from prior reconnaissance; ${deferredCount} follow-on call(s) deferred until after approval\n`));
1741
2568
  messages.push({
1742
2569
  role: "system",
@@ -1744,20 +2571,13 @@ export async function runAgentLoop(prompt, options = {}) {
1744
2571
  `the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
1745
2572
  });
1746
2573
  }
1747
- // planCallIndex === 0 && allCalls.length === 1: a standalone plan.create
1748
- // built from prior reconnaissance. Execute it normally — deferring it
1749
- // here (as the previous `>= 0` guard did) ran zero calls and looped the
1750
- // agent forever without ever creating the plan.
1751
- // A single model message can contain an unbounded number of calls.
1752
- // Even with read-only calls fanned out, a giant batch can tie up the
1753
- // session for minutes and makes cancellation feel broken. Keep each
1754
- // model turn bounded; after these results the agent gets another turn
1755
- // to prioritise the remaining work from real evidence.
1756
2574
  const MAX_CALLS_PER_MODEL_TURN = 12;
1757
- const omittedCallCount = Math.max(0, allCalls.length - MAX_CALLS_PER_MODEL_TURN);
2575
+ const omittedCallCount = Math.max(0, toRun.length - MAX_CALLS_PER_MODEL_TURN);
1758
2576
  if (omittedCallCount > 0) {
1759
- allCalls = allCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
2577
+ toRun = toRun.slice(0, MAX_CALLS_PER_MODEL_TURN);
1760
2578
  activeDeferredToolCalls = activeDeferredToolCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
2579
+ deferReason =
2580
+ "Deferred — exceeded max tool calls per model turn; re-prioritise next batch.";
1761
2581
  writeNotice("warn", `limited this model response to ${MAX_CALLS_PER_MODEL_TURN} tool calls`, chalk.yellow(` ⚠ executing the first ${MAX_CALLS_PER_MODEL_TURN} tool calls; ${omittedCallCount} more were deferred for reprioritisation\n`));
1762
2582
  messages.push({
1763
2583
  role: "system",
@@ -1765,6 +2585,25 @@ export async function runAgentLoop(prompt, options = {}) {
1765
2585
  `${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
1766
2586
  });
1767
2587
  }
2588
+ // X4: if the batch mixes work tools with task.update(in_progress),
2589
+ // run the in_progress updates first so the plan gate does not block
2590
+ // work that the model intended to open in the same message.
2591
+ {
2592
+ const isInProgressUpdate = (b) => b.call.name === "task.update" &&
2593
+ String(b.call.args?.state ?? "") === "in_progress";
2594
+ const updates = toRun.filter(isInProgressUpdate);
2595
+ if (updates.length > 0 && updates.length < toRun.length) {
2596
+ const rest = toRun.filter((b) => !isInProgressUpdate(b));
2597
+ toRun = [...updates, ...rest];
2598
+ }
2599
+ }
2600
+ // Re-index toRun positions for UI callIds[] (0..n-1 this turn).
2601
+ toRun = toRun.map((b, index) => ({ ...b, index }));
2602
+ const allCalls = toRun.map((b) => b.call);
2603
+ /** Stable call→Bound map (object identity; no indexOf for result ids). */
2604
+ const callToBound = new Map(toRun.map((b) => [b.call, b]));
2605
+ const historyNativeCalls = bound.map((b) => b.native);
2606
+ const runIds = new Set(toRun.map((b) => b.id));
1768
2607
  // Notice BEFORE tool cards so the transcript reads:
1769
2608
  // thinking → response → "N tool calls…" → tool cards (not tools then info).
1770
2609
  if (allCalls.length > 1) {
@@ -1773,13 +2612,23 @@ export async function runAgentLoop(prompt, options = {}) {
1773
2612
  // Emit only the calls that will actually execute, after thinking
1774
2613
  // + assistant text so transcript order remains correct.
1775
2614
  for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
2615
+ if (!deferred.call.name || deferred.call.name === "…")
2616
+ continue;
1776
2617
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
1777
2618
  }
1778
- const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
1779
- allCalls
1780
- .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
1781
- .join("\n\n");
1782
- pushAssistantHistory(standardizedContent);
2619
+ // Dialect-neutral history: full assistant toolCalls (including deferred
2620
+ // ids) so providers never see orphan tool_call ids. Missing results are
2621
+ // filled with synthetic cancelled messages after the batch.
2622
+ if (historyNativeCalls.length) {
2623
+ appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
2624
+ }
2625
+ else {
2626
+ const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
2627
+ allCalls
2628
+ .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
2629
+ .join("\n\n");
2630
+ pushAssistantHistory(standardizedContent);
2631
+ }
1783
2632
  // Scoped-parallel batch execution
1784
2633
  // The model may emit several calls in one message. We partition them,
1785
2634
  // IN DOCUMENT ORDER, into segments:
@@ -1799,7 +2648,22 @@ export async function runAgentLoop(prompt, options = {}) {
1799
2648
  // later recon from running; aborts, blocks, and sequential-barrier
1800
2649
  // failures still stop the batch so the model can react safely.
1801
2650
  const scopeForBatch = await loadScope().catch(() => undefined);
2651
+ /**
2652
+ * Tools that may share a concurrent wave. Pure read-only BATCH_SAFE
2653
+ * tools always qualify when classified safe. Heavy discovery wrappers
2654
+ * (pentest.recon, net.context) also run concurrent with dns/http so
2655
+ * nmap does not serialize the entire recon wave.
2656
+ * net.scan stays a barrier when it needs confirm/sudo UX.
2657
+ */
1802
2658
  const isParallelSafe = (c) => {
2659
+ if (c.name === "pentest.recon" ||
2660
+ c.name === "net.context" ||
2661
+ c.name === "tool.batch" ||
2662
+ c.name === "tool.check" ||
2663
+ c.name === "shell.jobs" ||
2664
+ c.name === "shell.tail") {
2665
+ return true;
2666
+ }
1803
2667
  if (!BATCH_SAFE_TOOLS.has(c.name))
1804
2668
  return false;
1805
2669
  try {
@@ -1809,27 +2673,56 @@ export async function runAgentLoop(prompt, options = {}) {
1809
2673
  return false;
1810
2674
  }
1811
2675
  };
1812
- const PARALLEL_LIMIT = 4;
2676
+ /** Tools whose failure must NOT cancel sibling calls in this turn. */
2677
+ const shouldSoftFailTool = (name) => {
2678
+ if (name === "tool.batch")
2679
+ return true;
2680
+ if (BATCH_SAFE_TOOLS.has(name))
2681
+ return true;
2682
+ if (name === "pentest.recon" ||
2683
+ name === "net.scan" ||
2684
+ name === "net.pingSweep" ||
2685
+ name === "net.context" ||
2686
+ name === "shell.jobs" ||
2687
+ name === "shell.tail" ||
2688
+ name === "tool.check") {
2689
+ return true;
2690
+ }
2691
+ return false;
2692
+ };
2693
+ // Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
2694
+ const PARALLEL_LIMIT = 8;
1813
2695
  let aborted = false;
1814
2696
  let blocked = false;
1815
2697
  let blockedResult = null;
1816
2698
  let failed = false;
1817
2699
  let awaitingPlanApproval = false;
1818
- /** Indices into allCalls that actually ran (got a tool-result). */
1819
- const executedIndices = new Set();
1820
- const recordResult = (res, continueAfterFailure = false) => {
1821
- const idx = allCalls.indexOf(res.call);
1822
- if (idx >= 0)
1823
- executedIndices.add(idx);
1824
- messages.push({
1825
- role: "tool",
1826
- content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
1827
- });
2700
+ /** Native tool_call ids that already have a role:tool history entry. */
2701
+ const recordedNativeIds = new Set();
2702
+ const recordResult = (boundCall, res, continueAfterFailure = false) => {
2703
+ recordedNativeIds.add(boundCall.id);
2704
+ const toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`;
2705
+ if (historyNativeCalls.length) {
2706
+ appendToolResult(messages, boundCall.id, toolContent, res.call.name, res.result.ok);
2707
+ }
2708
+ else {
2709
+ messages.push({
2710
+ role: "tool",
2711
+ content: toolContent,
2712
+ });
2713
+ }
1828
2714
  productiveSteps += 1;
1829
2715
  // Reset retry counters — they track consecutive failures, not cumulative.
1830
2716
  truncatedToolRetries = 0;
1831
2717
  malformedFenceRetries = 0;
1832
2718
  bareToolJsonRetries = 0;
2719
+ // Successful real work restores premature-done budget so long builds
2720
+ // don't exhaust retries mid-stream and stop after diagnosing an error.
2721
+ if (res.ok && isEvidenceWorkTool(res.call.name)) {
2722
+ prematureCompletionRetries = 0;
2723
+ actionIntentRetries = 0;
2724
+ errorFixNarrationRetries = 0;
2725
+ }
1833
2726
  if (res.ok && res.call.name === "shell.start")
1834
2727
  sawServerStart = true;
1835
2728
  if (res.ok && res.call.name === "shell.tail")
@@ -1839,10 +2732,51 @@ export async function runAgentLoop(prompt, options = {}) {
1839
2732
  /^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
1840
2733
  (res.call.name === "shell.exec" &&
1841
2734
  /\bcurl\b[\s\S]*\b(?:localhost|127\.0\.0\.1|\[::1\])\b/i.test(String(res.call.args.command ?? ""))))) {
1842
- sawLocalHttpProbe = true;
2735
+ const out = res.result.output ?? res.contextOutput ?? "";
2736
+ if (localHttpProbeIsFailure(out)) {
2737
+ sawFailedLocalHttpProbe = true;
2738
+ sawLocalHttpProbe = false;
2739
+ }
2740
+ else if (localHttpProbeIsSuccess(out)) {
2741
+ sawLocalHttpProbe = true;
2742
+ sawFailedLocalHttpProbe = false;
2743
+ failedProbeFixRetries = 0;
2744
+ }
2745
+ else if (res.call.name === "shell.exec" &&
2746
+ !localHttpProbeIsFailure(out)) {
2747
+ // curl without status line — soft success
2748
+ sawLocalHttpProbe = true;
2749
+ sawFailedLocalHttpProbe = false;
2750
+ }
2751
+ }
2752
+ // Track freestyle local-app materialization (scaffold / install / feature write)
2753
+ if (res.ok) {
2754
+ const cmd = typeof res.call.args.command === "string"
2755
+ ? res.call.args.command
2756
+ : "";
2757
+ const pathArg = typeof res.call.args.path === "string" ? res.call.args.path : "";
2758
+ if (isScaffoldCreateCommand(cmd)) {
2759
+ sawScaffoldOk = true;
2760
+ sawLocalAppMaterialWork = true;
2761
+ }
2762
+ if (isFeatureImplementationCall(res.call)) {
2763
+ sawFeatureImplWrite = true;
2764
+ sawLocalAppMaterialWork = true;
2765
+ }
2766
+ if (/\b(?:npm|pnpm|yarn|bun)\s+i(?:nstall)?\b/i.test(cmd) ||
2767
+ res.call.name === "fs.write" ||
2768
+ res.call.name === "fs.writeMany" ||
2769
+ res.call.name === "fs.edit" ||
2770
+ (pathArg &&
2771
+ getActiveProjectRoot() &&
2772
+ (pathArg.includes(getActiveProjectRoot()) ||
2773
+ !pathArg.startsWith("/")))) {
2774
+ sawLocalAppMaterialWork = true;
2775
+ }
1843
2776
  }
1844
2777
  if (res.call.name === "plan.create" && res.ok) {
1845
2778
  awaitingPlanApproval = true;
2779
+ sawPlanCreateOk = true;
1846
2780
  }
1847
2781
  if (res.lastAnswer === "Aborted.")
1848
2782
  aborted = true;
@@ -1859,47 +2793,46 @@ export async function runAgentLoop(prompt, options = {}) {
1859
2793
  break;
1860
2794
  if (group.length === 1) {
1861
2795
  const call = group[0];
1862
- const idx = allCalls.indexOf(call);
1863
- if (idx >= 0 && !callIds[idx]) {
1864
- callIds[idx] = `tool-${++nextToolEventId}`;
2796
+ const bc = callToBound.get(call);
2797
+ if (!bc)
2798
+ continue;
2799
+ if (!callIds[bc.index]) {
2800
+ callIds[bc.index] = `tool-${++nextToolEventId}`;
1865
2801
  }
1866
- const id = (idx >= 0 ? callIds[idx] : undefined) ?? `tool-${++nextToolEventId}`;
2802
+ const id = callIds[bc.index];
1867
2803
  const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
1868
- recordResult(res);
2804
+ const softFail = shouldSoftFailTool(call.name);
2805
+ recordResult(bc, res, softFail);
1869
2806
  }
1870
2807
  else {
1871
- // Concurrent group — assign ids in document order, then push their
1872
- // results in document order for a stable transcript.
1873
- const ids = group.map((c) => {
1874
- const idx = allCalls.indexOf(c);
1875
- if (idx >= 0 && !callIds[idx]) {
1876
- callIds[idx] = `tool-${++nextToolEventId}`;
2808
+ // Concurrent group — BoundCall via Map; record in document order.
2809
+ const groupBound = [];
2810
+ const uiIds = [];
2811
+ for (const c of group) {
2812
+ const bc = callToBound.get(c);
2813
+ if (!bc)
2814
+ continue;
2815
+ if (!callIds[bc.index]) {
2816
+ callIds[bc.index] = `tool-${++nextToolEventId}`;
1877
2817
  }
1878
- return (idx >= 0 ? callIds[idx] : undefined) ?? `tool-${++nextToolEventId}`;
1879
- });
1880
- const results = await Promise.all(group.map((c, k) => executeSingleTool(c, ids[k], options.signal || new AbortController().signal)));
1881
- // These calls are explicitly safe and independent. Preserve every
1882
- // result for the model, but do not abandon remaining reconnaissance
1883
- // merely because one lookup times out or a remote service fails.
1884
- for (const res of results)
1885
- recordResult(res, true);
2818
+ groupBound.push(bc);
2819
+ uiIds.push(callIds[bc.index]);
2820
+ }
2821
+ const results = await Promise.all(groupBound.map((bc, k) => executeSingleTool(bc.call, uiIds[k], options.signal || new AbortController().signal)));
2822
+ for (let k = 0; k < results.length; k += 1) {
2823
+ recordResult(groupBound[k], results[k], true);
2824
+ }
1886
2825
  }
1887
2826
  }
1888
- // Cards were emitted for every call up front. If the batch stopped
1889
- // early (failure / abort / plan gate), any card still on "running"
1890
- // must get a terminal result so the TUI never spins forever.
1891
- for (let i = 0; i < allCalls.length; i += 1) {
1892
- if (executedIndices.has(i))
2827
+ // Cards still "running" get a terminal UI result; history always pairs.
2828
+ for (let i = 0; i < toRun.length; i += 1) {
2829
+ const bc = toRun[i];
2830
+ if (recordedNativeIds.has(bc.id))
1893
2831
  continue;
1894
- const call = allCalls[i];
1895
- if (i >= 0 && !callIds[i]) {
2832
+ if (!callIds[i]) {
1896
2833
  callIds[i] = `tool-${++nextToolEventId}`;
1897
2834
  }
1898
- const id = callIds[i];
1899
- if (!alreadyPrintedIds.has(id)) {
1900
- // Never shown in the transcript — skip.
1901
- continue;
1902
- }
2835
+ const uiId = callIds[i];
1903
2836
  const reason = aborted
1904
2837
  ? "Cancelled — turn aborted before this call ran."
1905
2838
  : blocked
@@ -1914,11 +2847,29 @@ export async function runAgentLoop(prompt, options = {}) {
1914
2847
  output: reason,
1915
2848
  exitCode: 130,
1916
2849
  };
1917
- emitToolResult(id, result, reason);
1918
- messages.push({
1919
- role: "tool",
1920
- content: `Tool ${call.name} result (exit=130, ok=false):\n${reason}`,
1921
- });
2850
+ if (alreadyPrintedIds.has(uiId)) {
2851
+ emitToolResult(uiId, result, reason);
2852
+ }
2853
+ if (historyNativeCalls.length) {
2854
+ appendToolResult(messages, bc.id, `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`, bc.call.name, false);
2855
+ recordedNativeIds.add(bc.id);
2856
+ }
2857
+ else {
2858
+ messages.push({
2859
+ role: "tool",
2860
+ content: `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`,
2861
+ });
2862
+ }
2863
+ }
2864
+ // Synthetic results for deferred/omitted ids still listed on assistant.
2865
+ if (historyNativeCalls.length) {
2866
+ for (const b of bound) {
2867
+ if (runIds.has(b.id) || recordedNativeIds.has(b.id))
2868
+ continue;
2869
+ appendToolResult(messages, b.id, `Tool ${b.call.name} result (exit=130, ok=false):\n${deferReason}`, b.call.name, false);
2870
+ recordedNativeIds.add(b.id);
2871
+ }
2872
+ fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
1922
2873
  }
1923
2874
  // plan.create is a hard transaction boundary. Its successful handler
1924
2875
  // persists and displays the plan; returning immediately prevents a