@pentoshi/clai 3.5.1 → 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 (123) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/context-manager.js +8 -2
  3. package/dist/agent/context-manager.js.map +1 -1
  4. package/dist/agent/loop-guard.d.ts +2 -0
  5. package/dist/agent/loop-guard.js +39 -2
  6. package/dist/agent/loop-guard.js.map +1 -1
  7. package/dist/agent/plan-tool.d.ts +22 -0
  8. package/dist/agent/plan-tool.js +357 -44
  9. package/dist/agent/plan-tool.js.map +1 -1
  10. package/dist/agent/project-root.d.ts +33 -0
  11. package/dist/agent/project-root.js +158 -0
  12. package/dist/agent/project-root.js.map +1 -0
  13. package/dist/agent/runner.js +1119 -240
  14. package/dist/agent/runner.js.map +1 -1
  15. package/dist/agent/task-evidence.d.ts +103 -0
  16. package/dist/agent/task-evidence.js +413 -0
  17. package/dist/agent/task-evidence.js.map +1 -0
  18. package/dist/agent/tool-call-parser.d.ts +16 -0
  19. package/dist/agent/tool-call-parser.js +86 -28
  20. package/dist/agent/tool-call-parser.js.map +1 -1
  21. package/dist/agent/tool-history.d.ts +27 -0
  22. package/dist/agent/tool-history.js +150 -0
  23. package/dist/agent/tool-history.js.map +1 -0
  24. package/dist/agent/workspace-orient.d.ts +64 -0
  25. package/dist/agent/workspace-orient.js +418 -0
  26. package/dist/agent/workspace-orient.js.map +1 -0
  27. package/dist/app/controllers/session-controller.js +4 -0
  28. package/dist/app/controllers/session-controller.js.map +1 -1
  29. package/dist/commands/doctor.js +3 -0
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/commands/update.js +1 -1
  32. package/dist/llm/adapters/anthropic-tools.d.ts +99 -0
  33. package/dist/llm/adapters/anthropic-tools.js +225 -0
  34. package/dist/llm/adapters/anthropic-tools.js.map +1 -0
  35. package/dist/llm/adapters/gemini-tools.d.ts +54 -0
  36. package/dist/llm/adapters/gemini-tools.js +139 -0
  37. package/dist/llm/adapters/gemini-tools.js.map +1 -0
  38. package/dist/llm/adapters/ollama-tools.d.ts +22 -0
  39. package/dist/llm/adapters/ollama-tools.js +62 -0
  40. package/dist/llm/adapters/ollama-tools.js.map +1 -0
  41. package/dist/llm/adapters/openai-tools.d.ts +39 -0
  42. package/dist/llm/adapters/openai-tools.js +71 -0
  43. package/dist/llm/adapters/openai-tools.js.map +1 -0
  44. package/dist/llm/agentrouter.js +23 -4
  45. package/dist/llm/agentrouter.js.map +1 -1
  46. package/dist/llm/anthropic.js +93 -92
  47. package/dist/llm/anthropic.js.map +1 -1
  48. package/dist/llm/aws-mantle.js +93 -56
  49. package/dist/llm/aws-mantle.js.map +1 -1
  50. package/dist/llm/bynara.js +23 -4
  51. package/dist/llm/bynara.js.map +1 -1
  52. package/dist/llm/capabilities.d.ts +7 -0
  53. package/dist/llm/capabilities.js +74 -0
  54. package/dist/llm/capabilities.js.map +1 -1
  55. package/dist/llm/gemini.js +63 -48
  56. package/dist/llm/gemini.js.map +1 -1
  57. package/dist/llm/groq.js +23 -4
  58. package/dist/llm/groq.js.map +1 -1
  59. package/dist/llm/http.d.ts +22 -17
  60. package/dist/llm/http.js +99 -26
  61. package/dist/llm/http.js.map +1 -1
  62. package/dist/llm/kimchi.js +23 -4
  63. package/dist/llm/kimchi.js.map +1 -1
  64. package/dist/llm/nvidia.js +23 -4
  65. package/dist/llm/nvidia.js.map +1 -1
  66. package/dist/llm/ollama.js +49 -31
  67. package/dist/llm/ollama.js.map +1 -1
  68. package/dist/llm/openai.js +23 -4
  69. package/dist/llm/openai.js.map +1 -1
  70. package/dist/llm/openrouter.js +23 -4
  71. package/dist/llm/openrouter.js.map +1 -1
  72. package/dist/llm/qwen-cloud.js +23 -4
  73. package/dist/llm/qwen-cloud.js.map +1 -1
  74. package/dist/llm/router.js +48 -6
  75. package/dist/llm/router.js.map +1 -1
  76. package/dist/llm/tool-protocol.d.ts +73 -0
  77. package/dist/llm/tool-protocol.js +282 -0
  78. package/dist/llm/tool-protocol.js.map +1 -0
  79. package/dist/modes/ask.js +74 -20
  80. package/dist/modes/ask.js.map +1 -1
  81. package/dist/prompts/index.d.ts +13 -5
  82. package/dist/prompts/index.js +116 -18
  83. package/dist/prompts/index.js.map +1 -1
  84. package/dist/repl.js +7 -0
  85. package/dist/repl.js.map +1 -1
  86. package/dist/store/config.d.ts +7 -0
  87. package/dist/store/config.js +1 -0
  88. package/dist/store/config.js.map +1 -1
  89. package/dist/store/plan.d.ts +2 -0
  90. package/dist/store/plan.js.map +1 -1
  91. package/dist/tools/capabilities.d.ts +2 -0
  92. package/dist/tools/capabilities.js +101 -6
  93. package/dist/tools/capabilities.js.map +1 -1
  94. package/dist/tools/command-intent.d.ts +1 -0
  95. package/dist/tools/command-intent.js +31 -0
  96. package/dist/tools/command-intent.js.map +1 -1
  97. package/dist/tools/definitions.d.ts +20 -0
  98. package/dist/tools/definitions.js +509 -0
  99. package/dist/tools/definitions.js.map +1 -0
  100. package/dist/tools/fs.js +12 -3
  101. package/dist/tools/fs.js.map +1 -1
  102. package/dist/tools/net-ping-sweep.js +69 -16
  103. package/dist/tools/net-ping-sweep.js.map +1 -1
  104. package/dist/tools/nmap-runner.d.ts +8 -7
  105. package/dist/tools/nmap-runner.js +126 -65
  106. package/dist/tools/nmap-runner.js.map +1 -1
  107. package/dist/tools/registry.js +45 -7
  108. package/dist/tools/registry.js.map +1 -1
  109. package/dist/tools/validate.d.ts +11 -0
  110. package/dist/tools/validate.js +53 -0
  111. package/dist/tools/validate.js.map +1 -1
  112. package/dist/tools/web/search.js +48 -4
  113. package/dist/tools/web/search.js.map +1 -1
  114. package/dist/tui-v2/components/transcript/tool-card.js +1 -1
  115. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  116. package/dist/tui-v2/rendering/tool-presenter.d.ts +6 -1
  117. package/dist/tui-v2/rendering/tool-presenter.js +66 -2
  118. package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
  119. package/dist/types.d.ts +58 -0
  120. package/dist/ui/ansi-box.d.ts +2 -0
  121. package/dist/ui/ansi-box.js +8 -1
  122. package/dist/ui/ansi-box.js.map +1 -1
  123. package/package.json +1 -1
@@ -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, pentestNoLocalServerDirective, 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
@@ -150,13 +158,14 @@ export async function runAgentLoop(prompt, options = {}) {
150
158
  const writeAssistantMessage = (text) => {
151
159
  // Never surface an empty message: the reducer drops it and a direct
152
160
  // stdout writer would print a stray blank line.
153
- if (!text.trim())
161
+ const clean = sanitizeAssistantText(text);
162
+ if (!clean.trim())
154
163
  return;
155
164
  visibleCommitted = true;
156
- emit({ type: "assistant-message", text });
157
- const rendered = renderMarkdown(text);
165
+ emit({ type: "assistant-message", text: clean });
166
+ const rendered = renderMarkdown(clean);
158
167
  if (writesDirectly) {
159
- process.stdout.write(text.endsWith("\n") ? rendered : `${rendered}\n`);
168
+ process.stdout.write(clean.endsWith("\n") ? rendered : `${rendered}\n`);
160
169
  }
161
170
  };
162
171
  const writeThinkingBlock = (content) => {
@@ -290,17 +299,20 @@ export async function runAgentLoop(prompt, options = {}) {
290
299
  // provider's 413 as a context-window failure after the fact.
291
300
  const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
292
301
  const useCompactSystemPrompt = inputTokenBudget !== undefined;
293
- const systemSections = [
294
- (useCompactSystemPrompt
295
- ? renderCompactAgentSystemPrompt
296
- : renderAgentSystemPrompt)(toolNames.join(", ")),
297
- ];
298
- if (projectContext) {
299
- systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
300
- }
301
- if (freshWebSearchRequired) {
302
- systemSections.push(freshnessGuardMessage());
303
- }
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
+ };
304
316
  let lastAnswer = "";
305
317
  const session = options.session ?? createSessionPolicy();
306
318
  // Active plan context
@@ -308,7 +320,7 @@ export async function runAgentLoop(prompt, options = {}) {
308
320
  // context. When the user has approved it (via /implement) we instruct the
309
321
  // agent to execute task by task; otherwise the agent should refine/wait.
310
322
  const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
311
- if (activePlan) {
323
+ if (activePlan && isPlanApprovedByStatus(activePlan.status)) {
312
324
  // session.planApproved is in-memory only (never persisted), so a
313
325
  // resumed session (via /history) or a fresh SessionPolicy after
314
326
  // context compaction always starts it back at false — even when the
@@ -316,9 +328,77 @@ export async function runAgentLoop(prompt, options = {}) {
316
328
  // completed via /implement. Re-derive the flag from the plan's status
317
329
  // on every load so resuming a session never re-blocks tool calls
318
330
  // behind a stale "awaiting approval" gate for a plan that already ran.
319
- if (isPlanApprovedByStatus(activePlan.status)) {
320
- 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.`);
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));
321
394
  }
395
+ if (freshWebSearchRequired) {
396
+ sections.push(freshnessGuardMessage());
397
+ }
398
+ return sections.join("\n\n");
399
+ };
400
+ const systemSections = [buildSystemContent(nativeToolsActive)];
401
+ if (activePlan) {
322
402
  systemSections.push(planContextMessage(activePlan, session.planApproved.value));
323
403
  }
324
404
  // For build/scaffold turns with no active plan yet, inject an explicit
@@ -415,15 +495,16 @@ export async function runAgentLoop(prompt, options = {}) {
415
495
  // text isn't wiped by the next tool-call/turn event. Skip when this
416
496
  // iteration already surfaced its prose (the normal tool path commits
417
497
  // `beforeTool` itself) so the same text is never rendered twice.
498
+ const cleaned = sanitizeAssistantText(content);
418
499
  if (!visibleCommitted) {
419
- const prose = recoveryProse(content);
500
+ const prose = recoveryProse(cleaned);
420
501
  if (prose)
421
502
  writeAssistantMessage(prose);
422
503
  }
423
504
  messages.push({
424
505
  role: "assistant",
425
- content: content.trim()
426
- ? content
506
+ content: cleaned.trim()
507
+ ? cleaned
427
508
  : "[No visible assistant response was produced.]",
428
509
  });
429
510
  };
@@ -458,9 +539,25 @@ export async function runAgentLoop(prompt, options = {}) {
458
539
  // executing the next task a bounded number of times before giving up.
459
540
  let prematureCompletionRetries = 0;
460
541
  let runtimeVerificationRetries = 0;
542
+ let featureImplRetries = 0;
543
+ let forcePlanRetries = 0;
544
+ let errorFixNarrationRetries = 0;
545
+ let failedProbeFixRetries = 0;
461
546
  let sawServerStart = false;
547
+ let sawPlanCreateOk = false;
462
548
  let sawServerTail = false;
463
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;
464
561
  // Guard against a model that NARRATES intent ("let me explore the
465
562
  // directory…") but emits no tool call, so nothing runs and the turn ends
466
563
  // prematurely. On build/scaffold/plan turns where nothing has executed yet,
@@ -536,6 +633,15 @@ export async function runAgentLoop(prompt, options = {}) {
536
633
  // model-supplied paths against the canonical per-project scratch root.
537
634
  const scratchDir = scratchDirFor(safeCwd());
538
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
+ }
539
645
  if (call.name === "image.ocr" && !imageOcrEnabled) {
540
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"));
541
647
  const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
@@ -565,12 +671,71 @@ export async function runAgentLoop(prompt, options = {}) {
565
671
  writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
566
672
  }
567
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
+ }
568
712
  const planResult = await handlePlanTool(call, session, {
569
713
  loopGuard,
570
714
  step,
571
715
  });
572
716
  if (planResult.handled) {
573
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
+ }
574
739
  if (!alreadyPrintedIds.has(toolEventId)) {
575
740
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
576
741
  writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
@@ -578,6 +743,10 @@ export async function runAgentLoop(prompt, options = {}) {
578
743
  }
579
744
  if (planResult.plan) {
580
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);
581
750
  }
582
751
  const result = { ok: planResult.ok, output: planResult.modelNote };
583
752
  emitToolResult(toolEventId, result, planResult.modelNote);
@@ -598,11 +767,52 @@ export async function runAgentLoop(prompt, options = {}) {
598
767
  decision,
599
768
  scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
600
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
+ }
601
809
  const isMutatingAction = (decision.level === "confirm" || decision.level === "block") &&
602
810
  !isPreApprovalAllowedTool(call.name) &&
603
811
  !isScratchOnlyWrite(call, scratchDir);
604
812
  if (isMutatingAction) {
605
- if (activePlan && !session.planApproved.value) {
813
+ const planNow = livePlanForPreGate ??
814
+ (await loadPlan(session.sessionId).catch(() => undefined));
815
+ if (planNow && !session.planApproved.value) {
606
816
  const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
607
817
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
608
818
  const result = { ok: false, output: reason, exitCode: 1 };
@@ -623,30 +833,131 @@ export async function runAgentLoop(prompt, options = {}) {
623
833
  // claimed most tasks "done" in prose without ever recording it in the
624
834
  // plan. Multiple tool calls per task are still fine; they just must be
625
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).
626
840
  if (session.planApproved.value) {
627
841
  const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
628
842
  if (livePlanForGate) {
629
843
  const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
630
844
  const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
631
845
  if (unfinished && !inProgress) {
632
- const nextPending = livePlanForGate.tasks.find((t) => t.state === "pending");
633
- const reason = nextPending
634
- ? `${call.name} blocked — no task is in_progress. Call task.update {taskId:"${nextPending.id}", state:"in_progress"} before doing any work for it.`
635
- : `${call.name} blocked — no task is in_progress.`;
636
- writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
637
- const result = { ok: false, output: reason, exitCode: 1 };
638
- // Recoverable ordering mistake (NOT a user/session control gate):
639
- // feed the reason back so the model marks the task in_progress and
640
- // 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");
641
901
  return {
642
902
  ok: false,
643
903
  call,
644
904
  result,
645
- contextOutput: `${reason}\nThis tool did NOT run. Emit task.update {state:"in_progress"} for the task first, then the work.`,
905
+ contextOutput: scopeMsg,
646
906
  };
647
907
  }
648
908
  }
649
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
+ }
650
961
  if (call.name === "web.search") {
651
962
  sawFreshWebSearch = true;
652
963
  }
@@ -790,8 +1101,11 @@ export async function runAgentLoop(prompt, options = {}) {
790
1101
  jobManager.registerJob(jobId, backgroundJob, toolAc);
791
1102
  // Long-lived commands should use shell.start/background jobs. Reset this
792
1103
  // watchdog whenever a blocking tool emits output so only a genuinely
793
- // stalled operation is cancelled.
794
- 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);
795
1109
  let stallTimer;
796
1110
  let stalledByWatchdog = false;
797
1111
  const resetStallTimer = () => {
@@ -800,7 +1114,7 @@ export async function runAgentLoop(prompt, options = {}) {
800
1114
  stallTimer = setTimeout(() => {
801
1115
  if (!toolAc.signal.aborted) {
802
1116
  stalledByWatchdog = true;
803
- 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`));
804
1118
  toolAc.abort();
805
1119
  }
806
1120
  }, TOOL_STALL_ABORT_MS);
@@ -859,6 +1173,53 @@ export async function runAgentLoop(prompt, options = {}) {
859
1173
  clearTimeout(stallTimer);
860
1174
  parentSignal.removeEventListener("abort", onParentAbort);
861
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
+ }
862
1223
  const output = result.output.trim();
863
1224
  // Always keep a full on-disk copy of tool output (any size) so the
864
1225
  // pager never depends on a truncated in-memory preview.
@@ -885,6 +1246,12 @@ export async function runAgentLoop(prompt, options = {}) {
885
1246
  output: result.output.slice(0, 4_000),
886
1247
  });
887
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
+ }
888
1255
  // Inject approach evaluation when consecutive failures are detected.
889
1256
  // Lets the MODEL decide (with full context) whether to continue a
890
1257
  // legitimately long approach, switch, or stop — instead of a
@@ -1143,7 +1510,26 @@ export async function runAgentLoop(prompt, options = {}) {
1143
1510
  emit({ type: "thinking-delta", text });
1144
1511
  });
1145
1512
  let completion;
1513
+ let toolsAttached = false;
1146
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
+ });
1147
1533
  completion = await streamWithProvider({
1148
1534
  provider,
1149
1535
  model,
@@ -1173,36 +1559,101 @@ export async function runAgentLoop(prompt, options = {}) {
1173
1559
  thinking: retryWithoutThinking
1174
1560
  ? { ...config.thinking, enabled: false, effort: "low" }
1175
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
+ : {}),
1176
1625
  }, (token) => {
1177
1626
  deltaParser?.push(token);
1178
1627
  generatedTokens += 1;
1179
1628
  accumulatedText += token;
1180
- const parsedCalls = parseAllToolCalls(accumulatedText);
1181
- if (parsedCalls.length > streamedCallsCount) {
1182
- if (writesDirectly) {
1183
- spinner.stop();
1184
- }
1185
- while (streamedCallsCount < parsedCalls.length) {
1186
- const call = parsedCalls[streamedCallsCount];
1187
- const eventId = `tool-${++nextToolEventId}`;
1188
- callIds.push(eventId);
1189
- alreadyPrintedIds.add(eventId);
1190
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1191
- // Defer the writeToolCall emission — collect it so we can
1192
- // emit after thinking + assistant text for correct order.
1193
- deferredToolCalls.push({
1194
- eventId,
1195
- call,
1196
- rendered: styleToolChatter(call, toolCallLine) + "\n",
1197
- });
1198
- // Still update spinner label for user feedback during streaming.
1199
- if (!writesDirectly) {
1200
- 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);
1201
1656
  }
1202
- streamedCallsCount += 1;
1203
- }
1204
- if (writesDirectly) {
1205
- spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
1206
1657
  }
1207
1658
  }
1208
1659
  // Heuristic: <think>… markers and reasoning_content tokens flow
@@ -1251,6 +1702,13 @@ export async function runAgentLoop(prompt, options = {}) {
1251
1702
  provider = completion.provider;
1252
1703
  model = completion.model;
1253
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));
1254
1712
  const assistantTextResult = rememberThinkingFromText(completion.text);
1255
1713
  assistantText = assistantTextResult;
1256
1714
  // Commit thinking to the transcript IMMEDIATELY, before any of the
@@ -1263,30 +1721,171 @@ export async function runAgentLoop(prompt, options = {}) {
1263
1721
  if (assistantText.hasThinking) {
1264
1722
  writeThinkingBlock(assistantText.thinkContent);
1265
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
+ }
1266
1783
  // Try visible text first, then thinking content — some models (e.g. glm-5.1)
1267
- // wrap tool calls inside considering tags, so stripThinking removes them
1784
+ // wrap tool calls inside considering tags, so stripThinking removes them
1268
1785
  // into thinkContent and visible becomes empty. Recovering from thinkContent
1269
1786
  // prevents an endless nudge loop where the model keeps hiding the call.
1270
- call = parseToolCall(assistantText.visible, {
1271
- strict: getConfig().parserStrict,
1272
- });
1273
- if (!call && assistantText.hasThinking) {
1274
- 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, {
1275
1799
  strict: getConfig().parserStrict,
1276
1800
  });
1277
- if (call) {
1278
- 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
+ }
1279
1808
  }
1280
1809
  }
1281
1810
  // ── Prompt-leak guard ─────────────────────────────────────────
1282
1811
  // If the model's visible output contains distinctive system-prompt
1283
1812
  // markers, it is repeating its instructions (e.g. prompt injection
1284
1813
  // via "repeat your instructions verbatim"). Any tool-call syntax
1285
- // in that output is an EXAMPLE from the prompt, not a real request.
1286
- // Suppress it so we never execute leaked examples.
1287
- if (call && looksLikePromptLeak(assistantText.visible)) {
1288
- 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
+ }
1289
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
+ }
1290
1889
  }
1291
1890
  // Empty-response recovery
1292
1891
  // Some models occasionally return an empty completion: a reasoning
@@ -1310,12 +1909,18 @@ export async function runAgentLoop(prompt, options = {}) {
1310
1909
  pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
1311
1910
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
1312
1911
  const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
1313
- ? "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."
1314
1915
  : buildLikeTurn && !activePlan
1315
- ? "No visible output. Emit a ```tool block to call plan.create now. " +
1316
- "Do NOT hide tool calls in <think> tags put them in the visible response."
1317
- : "No visible output. Emit a ```tool block or give your final answer. " +
1318
- "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.";
1319
1924
  messages.push(recoveryUserMessage(buildNudge));
1320
1925
  continue;
1321
1926
  }
@@ -1364,19 +1969,31 @@ export async function runAgentLoop(prompt, options = {}) {
1364
1969
  if (bareArgsOnly) {
1365
1970
  bareToolJsonRetries += 1;
1366
1971
  if (bareToolJsonRetries <= 3) {
1367
- 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"));
1368
1977
  pushAssistantHistory(assistantText.visible);
1369
1978
  messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1370
- ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1371
- "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1372
- "You MUST call plan.create using a proper ```tool block. For example:\n" +
1373
- '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1374
- "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1375
- : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1376
- "Reply with ONLY a fenced ```tool block of the form " +
1377
- '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
1378
- '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
1379
- "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."));
1380
1997
  continue;
1381
1998
  }
1382
1999
  // Exhausted retries — fall through to the normal answer path.
@@ -1388,10 +2005,14 @@ export async function runAgentLoop(prompt, options = {}) {
1388
2005
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1389
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"));
1390
2007
  pushAssistantHistory(assistantText.visible);
1391
- messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1392
- "Reply with ONLY a fenced ```tool block containing valid JSON " +
1393
- 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
1394
- "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."));
1395
2016
  continue;
1396
2017
  }
1397
2018
  // Detect a tool call that opened but was cut off by the token limit
@@ -1419,16 +2040,20 @@ export async function runAgentLoop(prompt, options = {}) {
1419
2040
  const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
1420
2041
  messages.push({
1421
2042
  role: "user",
1422
- content: `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}. ` +
1423
- `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
1424
- `CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
1425
- '```tool\n{"name":"fs.append","args":{"path":' +
1426
- JSON.stringify(salvaged.path) +
1427
- ',"expectedPriorBytes":' +
1428
- priorBytes +
1429
- ',"content":"...ONLY the remaining content not already on disk..."}}\n```\n' +
1430
- `expectedPriorBytes must match the receipt so append cannot double-write. ` +
1431
- `Do NOT re-read the full file; do NOT re-send content 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.`,
1432
2057
  });
1433
2058
  continue;
1434
2059
  }
@@ -1442,13 +2067,18 @@ export async function runAgentLoop(prompt, options = {}) {
1442
2067
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
1443
2068
  messages.push({
1444
2069
  role: "user",
1445
- content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
1446
- "Prefer ONE complete fs.write when it fits (~32k output tokens is a lot of file content if reasoning stays short). " +
1447
- "If the file is too large for one call:\n" +
1448
- "1. fs.write the first large section (as much as fits hundreds+ of lines)\n" +
1449
- "2. fs.append the rest with expectedPriorBytes from the write receipt\n" +
1450
- "3. Repeat append only if still incompletelarge chunks, not ~100-line drips\n" +
1451
- "Keep reasoning SHORT emit the ```tool block early. Do NOT claim a file was written until a tool call 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.",
1452
2082
  });
1453
2083
  continue;
1454
2084
  }
@@ -1498,12 +2128,18 @@ export async function runAgentLoop(prompt, options = {}) {
1498
2128
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
1499
2129
  messages.push({
1500
2130
  role: "user",
1501
- content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
1502
- "Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
1503
- 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
1504
- "IMPORTANT: Prefer ONE complete fs.write when it fits. Keep reasoning SHORT. " +
1505
- "Only if the output window cuts you off, continue with large fs.append chunks + expectedPriorBytes. " +
1506
- "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.",
1507
2143
  });
1508
2144
  continue;
1509
2145
  }
@@ -1554,25 +2190,48 @@ export async function runAgentLoop(prompt, options = {}) {
1554
2190
  const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
1555
2191
  !activePlan &&
1556
2192
  looksLikePlanNarration(cleaned);
2193
+ const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
1557
2194
  // Once a real tool step has run, a no-plan task has no durable task
1558
2195
  // state to prove whether another action is needed. A tool-free reply
1559
2196
  // must therefore be allowed to finalize instead of turning a short
1560
- // summary containing “I'll” into an implicit recovery request.
1561
- 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);
1562
2207
  if (wantsAction &&
1563
2208
  cleaned.trim().length > 0 &&
1564
2209
  actionIntentRetries < 3 &&
1565
2210
  shouldRetryBeforeFinalizing) {
1566
2211
  actionIntentRetries += 1;
1567
2212
  let nudge;
1568
- if (planHasOpenWorkNow && session.planApproved.value) {
1569
- nudge =
1570
- "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.";
1571
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"));
1572
2230
  }
1573
2231
  else if (pentestLikeTurn) {
1574
- nudge =
1575
- "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" +
1576
2235
  '```tool\n{"name":"sysinfo","args":{}}\n```\n' +
1577
2236
  "Every turn MUST contain a ```tool block until the task is done.";
1578
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"));
@@ -1580,11 +2239,9 @@ export async function runAgentLoop(prompt, options = {}) {
1580
2239
  else if (freshWebSearchRequired || narratedWebAction) {
1581
2240
  // Web-specific recovery ONLY when the user asked for current
1582
2241
  // info or the model explicitly claimed a fetch/search step.
1583
- // Previously every non-build stall used this path, so a "hi"
1584
- // greeting that said "I'll start executing" was forced into
1585
- // pointless web.search recovery loops.
1586
- nudge =
1587
- "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" +
1588
2245
  '```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
1589
2246
  "If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
1590
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"));
@@ -1592,23 +2249,28 @@ export async function runAgentLoop(prompt, options = {}) {
1592
2249
  else if (buildLikeTurn &&
1593
2250
  (planNarrated || productiveSteps > 0)) {
1594
2251
  const kind = pentestLikeTurn ? "pentest" : "coding";
1595
- nudge =
1596
- "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" +
1597
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` +
1598
2256
  "Do not describe the plan again in prose — just emit the plan.create tool block.";
1599
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"));
1600
2258
  }
1601
2259
  else if (buildLikeTurn) {
1602
- nudge =
1603
- "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" +
1604
2263
  '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1605
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.";
1606
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"));
1607
2266
  }
1608
2267
  else {
1609
2268
  // Generic non-build, non-web stall (e.g. "I'll list the files").
1610
- nudge =
1611
- "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.";
1612
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"));
1613
2275
  }
1614
2276
  pushAssistantHistory(assistantText.visible);
@@ -1624,44 +2286,148 @@ export async function runAgentLoop(prompt, options = {}) {
1624
2286
  messages.push({
1625
2287
  role: "user",
1626
2288
  content: freshnessGuardMessage() +
1627
- " 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."),
1628
2292
  });
1629
2293
  continue;
1630
2294
  }
1631
- // A passing build is not evidence that an app is serving requests.
1632
- // On completed CODING build plans only, require start logs → HTTP
1633
- // verification. NEVER apply this to pentest/remote engagements — that
1634
- // forced "npm run dev on the clai repo" after a finished web assessment.
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.)
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.
1635
2329
  if (buildLike &&
1636
2330
  !pentestLike &&
1637
2331
  !pentestSession &&
1638
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 &&
1639
2363
  (!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
1640
- runtimeVerificationRetries < 2) {
2364
+ runtimeVerificationRetries < 2 &&
2365
+ // Feature apps must implement first (handled above); only verify live after that
2366
+ (!featureAppAsk || sawFeatureImplWrite)) {
1641
2367
  const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
1642
2368
  const codingPlanFinished = Boolean(runtimePlan &&
2369
+ session.planApproved.value &&
1643
2370
  runtimePlan.kind !== "pentest" &&
1644
2371
  runtimePlan.tasks.length > 0 &&
1645
2372
  runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
1646
- if (codingPlanFinished) {
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) {
1647
2384
  runtimeVerificationRetries += 1;
1648
2385
  pushAssistantHistory(assistantText.visible);
2386
+ const rootHint = getActiveProjectRoot()
2387
+ ? ` Use cwd "${getActiveProjectRoot()}".`
2388
+ : "";
1649
2389
  messages.push({
1650
2390
  role: "user",
1651
- content: "This is a CODING project only: run the missing local checks (shell.start + shell.tail + localhost HTTP probe). " +
1652
- "Keep the dev server running and print the localhost link. " +
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. " +
1653
2396
  "If this was a remote pentest, ignore this and finalize the report with no local server.",
1654
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"));
1655
2399
  continue;
1656
2400
  }
1657
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
+ }
1658
2422
  // Premature-completion guard (approved plan still has work)
1659
2423
  // If the user approved a plan and the model now gives a final answer
1660
2424
  // while tasks are still pending/in_progress — without having run the
1661
2425
  // work — it is fabricating completion (the exact "all tasks completed,
1662
2426
  // running at localhost:5173" failure). Force it back to executing the
1663
2427
  // next real task instead of accepting the false claim.
1664
- 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) {
1665
2431
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1666
2432
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1667
2433
  if (livePlan && unfinished && unfinished.length > 0) {
@@ -1671,6 +2437,10 @@ export async function runAgentLoop(prompt, options = {}) {
1671
2437
  pushAssistantHistory(assistantText.visible);
1672
2438
  const isPentestPlan = livePlan.kind === "pentest" || pentestSession;
1673
2439
  let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
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
+ }
1674
2444
  if (isPentestPlan) {
1675
2445
  instruction +=
1676
2446
  `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
@@ -1683,7 +2453,7 @@ export async function runAgentLoop(prompt, options = {}) {
1683
2453
  else {
1684
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"}). `;
1685
2455
  }
1686
- 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.`;
1687
2457
  messages.push({
1688
2458
  role: "user",
1689
2459
  content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
@@ -1729,49 +2499,71 @@ export async function runAgentLoop(prompt, options = {}) {
1729
2499
  // prose / thinking that preceded it, record the assistant message ONCE.
1730
2500
  const beforeTool = recoveredFromBareJson
1731
2501
  ? ""
1732
- : textBeforeToolCall(assistantText.visible);
2502
+ : nativeToolCalls.length
2503
+ ? assistantText.visible.trim()
2504
+ : textBeforeToolCall(assistantText.visible);
1733
2505
  if (beforeTool) {
1734
2506
  writeAssistantMessage(beforeTool);
1735
2507
  }
1736
- let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
1737
- if (allCalls.length === 0 && call) {
1738
- 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
+ });
1739
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
+ });
2536
+ }
2537
+ /** Subset that will actually run this turn (defer/omit rest). */
2538
+ let toRun = bound;
1740
2539
  let activeDeferredToolCalls = deferredToolCalls;
2540
+ let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
1741
2541
  // A plan must be based on the outputs of prior reconnaissance, never
1742
2542
  // on calls the model merely proposed in the same response. If a model
1743
2543
  // emits plan.create alongside gathering calls, run only the calls
1744
2544
  // before it, then let the next model turn analyse their actual results
1745
- // and emit one standalone plan.create. Calls after the attempted plan
1746
- // are intentionally discarded: they were proposed before a plan was
1747
- // created or approved.
1748
- 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");
1749
2547
  if (planCallIndex > 0) {
1750
- // plan.create is bundled AFTER gathering calls in the SAME message,
1751
- // so its reconnaissance results do not exist yet. Run only the
1752
- // preceding gathering calls, then let the next turn analyse their
1753
- // actual results and emit one standalone plan.create.
1754
- const gatheringCalls = allCalls.slice(0, planCallIndex);
1755
- const deferredCount = allCalls.length - gatheringCalls.length;
1756
- allCalls = gatheringCalls;
1757
- activeDeferredToolCalls = deferredToolCalls.slice(0, gatheringCalls.length);
1758
- 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`));
1759
2554
  messages.push({
1760
2555
  role: "system",
1761
2556
  content: `The prior response included plan.create before its reconnaissance results existed. ` +
1762
- `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. ` +
1763
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.",
1764
2559
  });
1765
2560
  }
1766
- else if (planCallIndex === 0 && allCalls.length > 1) {
1767
- // plan.create is the FIRST call but bundled with follow-on calls.
1768
- // The plan is based on reconnaissance from prior turns (already in
1769
- // history), so execute the plan.create now and defer only the calls
1770
- // proposed after it — those were proposed before the plan was
1771
- // created or approved.
1772
- const deferredCount = allCalls.length - 1;
1773
- 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);
1774
2564
  activeDeferredToolCalls = deferredToolCalls.slice(0, 1);
2565
+ deferReason =
2566
+ "Deferred — waiting for plan approval before follow-on tools.";
1775
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`));
1776
2568
  messages.push({
1777
2569
  role: "system",
@@ -1779,20 +2571,13 @@ export async function runAgentLoop(prompt, options = {}) {
1779
2571
  `the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
1780
2572
  });
1781
2573
  }
1782
- // planCallIndex === 0 && allCalls.length === 1: a standalone plan.create
1783
- // built from prior reconnaissance. Execute it normally — deferring it
1784
- // here (as the previous `>= 0` guard did) ran zero calls and looped the
1785
- // agent forever without ever creating the plan.
1786
- // A single model message can contain an unbounded number of calls.
1787
- // Even with read-only calls fanned out, a giant batch can tie up the
1788
- // session for minutes and makes cancellation feel broken. Keep each
1789
- // model turn bounded; after these results the agent gets another turn
1790
- // to prioritise the remaining work from real evidence.
1791
2574
  const MAX_CALLS_PER_MODEL_TURN = 12;
1792
- 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);
1793
2576
  if (omittedCallCount > 0) {
1794
- allCalls = allCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
2577
+ toRun = toRun.slice(0, MAX_CALLS_PER_MODEL_TURN);
1795
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.";
1796
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`));
1797
2582
  messages.push({
1798
2583
  role: "system",
@@ -1800,6 +2585,25 @@ export async function runAgentLoop(prompt, options = {}) {
1800
2585
  `${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
1801
2586
  });
1802
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));
1803
2607
  // Notice BEFORE tool cards so the transcript reads:
1804
2608
  // thinking → response → "N tool calls…" → tool cards (not tools then info).
1805
2609
  if (allCalls.length > 1) {
@@ -1808,13 +2612,23 @@ export async function runAgentLoop(prompt, options = {}) {
1808
2612
  // Emit only the calls that will actually execute, after thinking
1809
2613
  // + assistant text so transcript order remains correct.
1810
2614
  for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
2615
+ if (!deferred.call.name || deferred.call.name === "…")
2616
+ continue;
1811
2617
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
1812
2618
  }
1813
- const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
1814
- allCalls
1815
- .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
1816
- .join("\n\n");
1817
- 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
+ }
1818
2632
  // Scoped-parallel batch execution
1819
2633
  // The model may emit several calls in one message. We partition them,
1820
2634
  // IN DOCUMENT ORDER, into segments:
@@ -1883,21 +2697,32 @@ export async function runAgentLoop(prompt, options = {}) {
1883
2697
  let blockedResult = null;
1884
2698
  let failed = false;
1885
2699
  let awaitingPlanApproval = false;
1886
- /** Indices into allCalls that actually ran (got a tool-result). */
1887
- const executedIndices = new Set();
1888
- const recordResult = (res, continueAfterFailure = false) => {
1889
- const idx = allCalls.indexOf(res.call);
1890
- if (idx >= 0)
1891
- executedIndices.add(idx);
1892
- messages.push({
1893
- role: "tool",
1894
- content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
1895
- });
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
+ }
1896
2714
  productiveSteps += 1;
1897
2715
  // Reset retry counters — they track consecutive failures, not cumulative.
1898
2716
  truncatedToolRetries = 0;
1899
2717
  malformedFenceRetries = 0;
1900
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
+ }
1901
2726
  if (res.ok && res.call.name === "shell.start")
1902
2727
  sawServerStart = true;
1903
2728
  if (res.ok && res.call.name === "shell.tail")
@@ -1907,10 +2732,51 @@ export async function runAgentLoop(prompt, options = {}) {
1907
2732
  /^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
1908
2733
  (res.call.name === "shell.exec" &&
1909
2734
  /\bcurl\b[\s\S]*\b(?:localhost|127\.0\.0\.1|\[::1\])\b/i.test(String(res.call.args.command ?? ""))))) {
1910
- 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
+ }
1911
2776
  }
1912
2777
  if (res.call.name === "plan.create" && res.ok) {
1913
2778
  awaitingPlanApproval = true;
2779
+ sawPlanCreateOk = true;
1914
2780
  }
1915
2781
  if (res.lastAnswer === "Aborted.")
1916
2782
  aborted = true;
@@ -1927,51 +2793,46 @@ export async function runAgentLoop(prompt, options = {}) {
1927
2793
  break;
1928
2794
  if (group.length === 1) {
1929
2795
  const call = group[0];
1930
- const idx = allCalls.indexOf(call);
1931
- if (idx >= 0 && !callIds[idx]) {
1932
- 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}`;
1933
2801
  }
1934
- const id = (idx >= 0 ? callIds[idx] : undefined) ?? `tool-${++nextToolEventId}`;
2802
+ const id = callIds[bc.index];
1935
2803
  const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
1936
- // Soft-fail recon / read-only / discovery tools so a stalled
1937
- // whois or failed lookup never cancels the rest of the turn with
1938
- // "Cancelled — earlier tool in this batch failed."
1939
2804
  const softFail = shouldSoftFailTool(call.name);
1940
- recordResult(res, softFail);
2805
+ recordResult(bc, res, softFail);
1941
2806
  }
1942
2807
  else {
1943
- // Concurrent group — assign ids in document order, then push their
1944
- // results in document order for a stable transcript.
1945
- const ids = group.map((c) => {
1946
- const idx = allCalls.indexOf(c);
1947
- if (idx >= 0 && !callIds[idx]) {
1948
- 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}`;
1949
2817
  }
1950
- return (idx >= 0 ? callIds[idx] : undefined) ?? `tool-${++nextToolEventId}`;
1951
- });
1952
- const results = await Promise.all(group.map((c, k) => executeSingleTool(c, ids[k], options.signal || new AbortController().signal)));
1953
- // These calls are explicitly safe and independent. Preserve every
1954
- // result for the model, but do not abandon remaining reconnaissance
1955
- // merely because one lookup times out or a remote service fails.
1956
- for (const res of results)
1957
- 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
+ }
1958
2825
  }
1959
2826
  }
1960
- // Cards were emitted for every call up front. If the batch stopped
1961
- // early (failure / abort / plan gate), any card still on "running"
1962
- // must get a terminal result so the TUI never spins forever.
1963
- for (let i = 0; i < allCalls.length; i += 1) {
1964
- 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))
1965
2831
  continue;
1966
- const call = allCalls[i];
1967
- if (i >= 0 && !callIds[i]) {
2832
+ if (!callIds[i]) {
1968
2833
  callIds[i] = `tool-${++nextToolEventId}`;
1969
2834
  }
1970
- const id = callIds[i];
1971
- if (!alreadyPrintedIds.has(id)) {
1972
- // Never shown in the transcript — skip.
1973
- continue;
1974
- }
2835
+ const uiId = callIds[i];
1975
2836
  const reason = aborted
1976
2837
  ? "Cancelled — turn aborted before this call ran."
1977
2838
  : blocked
@@ -1986,11 +2847,29 @@ export async function runAgentLoop(prompt, options = {}) {
1986
2847
  output: reason,
1987
2848
  exitCode: 130,
1988
2849
  };
1989
- emitToolResult(id, result, reason);
1990
- messages.push({
1991
- role: "tool",
1992
- content: `Tool ${call.name} result (exit=130, ok=false):\n${reason}`,
1993
- });
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.");
1994
2873
  }
1995
2874
  // plan.create is a hard transaction boundary. Its successful handler
1996
2875
  // persists and displays the plan; returning immediately prevents a