@pentoshi/clai 3.9.7 → 3.11.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -9
  3. package/dist/agent/context-manager.d.ts +2 -1
  4. package/dist/agent/context-manager.js +16 -2
  5. package/dist/agent/context-manager.js.map +1 -1
  6. package/dist/agent/loop-guard.d.ts +18 -0
  7. package/dist/agent/loop-guard.js +50 -2
  8. package/dist/agent/loop-guard.js.map +1 -1
  9. package/dist/agent/must-continue.js +4 -8
  10. package/dist/agent/must-continue.js.map +1 -1
  11. package/dist/agent/prompt-composer.js +8 -8
  12. package/dist/agent/prompt-composer.js.map +1 -1
  13. package/dist/agent/runner.d.ts +1 -0
  14. package/dist/agent/runner.js +238 -74
  15. package/dist/agent/runner.js.map +1 -1
  16. package/dist/agent/session-policy.d.ts +1 -2
  17. package/dist/agent/session-policy.js +3 -2
  18. package/dist/agent/session-policy.js.map +1 -1
  19. package/dist/agent/tool-call-parser.js +6 -6
  20. package/dist/agent/tool-call-parser.js.map +1 -1
  21. package/dist/app/adapters/current-agent-adapter.js +1 -0
  22. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  23. package/dist/app/controllers/session-controller.js +2 -0
  24. package/dist/app/controllers/session-controller.js.map +1 -1
  25. package/dist/app/controllers/session-turn-request.d.ts +1 -0
  26. package/dist/app/controllers/session-turn-request.js +4 -1
  27. package/dist/app/controllers/session-turn-request.js.map +1 -1
  28. package/dist/app/ports/agent-port.d.ts +1 -0
  29. package/dist/attachments/clipboard-image.js +5 -4
  30. package/dist/attachments/clipboard-image.js.map +1 -1
  31. package/dist/attachments/image-content.d.ts +20 -2
  32. package/dist/attachments/image-content.js +201 -1
  33. package/dist/attachments/image-content.js.map +1 -1
  34. package/dist/attachments/image-prepare.d.ts +25 -0
  35. package/dist/attachments/image-prepare.js +390 -0
  36. package/dist/attachments/image-prepare.js.map +1 -0
  37. package/dist/attachments/service.d.ts +4 -2
  38. package/dist/attachments/service.js +51 -21
  39. package/dist/attachments/service.js.map +1 -1
  40. package/dist/commands/providers.d.ts +5 -2
  41. package/dist/commands/providers.js +121 -7
  42. package/dist/commands/providers.js.map +1 -1
  43. package/dist/index.js +12 -4
  44. package/dist/index.js.map +1 -1
  45. package/dist/llm/agentrouter.js +2 -2
  46. package/dist/llm/agentrouter.js.map +1 -1
  47. package/dist/llm/anthropic.d.ts +5 -4
  48. package/dist/llm/anthropic.js +28 -25
  49. package/dist/llm/anthropic.js.map +1 -1
  50. package/dist/llm/aws-mantle.js +19 -20
  51. package/dist/llm/aws-mantle.js.map +1 -1
  52. package/dist/llm/bynara.js +2 -2
  53. package/dist/llm/bynara.js.map +1 -1
  54. package/dist/llm/capabilities.d.ts +19 -0
  55. package/dist/llm/capabilities.js +273 -16
  56. package/dist/llm/capabilities.js.map +1 -1
  57. package/dist/llm/gemini.js +16 -8
  58. package/dist/llm/gemini.js.map +1 -1
  59. package/dist/llm/groq.js +2 -2
  60. package/dist/llm/groq.js.map +1 -1
  61. package/dist/llm/http.d.ts +7 -1
  62. package/dist/llm/http.js +210 -8
  63. package/dist/llm/http.js.map +1 -1
  64. package/dist/llm/kimchi.js +2 -2
  65. package/dist/llm/kimchi.js.map +1 -1
  66. package/dist/llm/lightning.d.ts +11 -0
  67. package/dist/llm/lightning.js +108 -0
  68. package/dist/llm/lightning.js.map +1 -0
  69. package/dist/llm/modal.d.ts +16 -0
  70. package/dist/llm/modal.js +182 -0
  71. package/dist/llm/modal.js.map +1 -0
  72. package/dist/llm/nvidia.js +2 -2
  73. package/dist/llm/nvidia.js.map +1 -1
  74. package/dist/llm/ollama.js +3 -3
  75. package/dist/llm/ollama.js.map +1 -1
  76. package/dist/llm/openai.js +2 -2
  77. package/dist/llm/openai.js.map +1 -1
  78. package/dist/llm/openrouter.js +2 -2
  79. package/dist/llm/openrouter.js.map +1 -1
  80. package/dist/llm/provider.d.ts +11 -0
  81. package/dist/llm/provider.js +316 -1
  82. package/dist/llm/provider.js.map +1 -1
  83. package/dist/llm/qwen-cloud.js +2 -5
  84. package/dist/llm/qwen-cloud.js.map +1 -1
  85. package/dist/llm/router.js +127 -7
  86. package/dist/llm/router.js.map +1 -1
  87. package/dist/llm/system-messages.d.ts +5 -0
  88. package/dist/llm/system-messages.js +14 -0
  89. package/dist/llm/system-messages.js.map +1 -1
  90. package/dist/llm/token-usage.d.ts +13 -0
  91. package/dist/llm/token-usage.js +31 -0
  92. package/dist/llm/token-usage.js.map +1 -1
  93. package/dist/llm/tokenrouter.d.ts +15 -0
  94. package/dist/llm/tokenrouter.js +106 -0
  95. package/dist/llm/tokenrouter.js.map +1 -0
  96. package/dist/modes/agent.d.ts +1 -0
  97. package/dist/modes/agent.js.map +1 -1
  98. package/dist/modes/ask.js +15 -4
  99. package/dist/modes/ask.js.map +1 -1
  100. package/dist/prompts/embedded.js +2 -2
  101. package/dist/prompts/embedded.js.map +1 -1
  102. package/dist/prompts/index.d.ts +6 -0
  103. package/dist/prompts/index.js +39 -21
  104. package/dist/prompts/index.js.map +1 -1
  105. package/dist/prompts/system.agent.md +15 -27
  106. package/dist/prompts/system.ask.md +1 -1
  107. package/dist/repl/slash-commands.js +53 -0
  108. package/dist/repl/slash-commands.js.map +1 -1
  109. package/dist/repl.js +16 -9
  110. package/dist/repl.js.map +1 -1
  111. package/dist/store/config.d.ts +55 -0
  112. package/dist/store/config.js +127 -0
  113. package/dist/store/config.js.map +1 -1
  114. package/dist/store/history.d.ts +2 -1
  115. package/dist/store/history.js +15 -16
  116. package/dist/store/history.js.map +1 -1
  117. package/dist/store/keys.js +41 -2
  118. package/dist/store/keys.js.map +1 -1
  119. package/dist/tools/definitions.js +31 -6
  120. package/dist/tools/definitions.js.map +1 -1
  121. package/dist/tools/image.d.ts +0 -7
  122. package/dist/tools/image.js +76 -22
  123. package/dist/tools/image.js.map +1 -1
  124. package/dist/tools/jobs.d.ts +11 -1
  125. package/dist/tools/jobs.js +67 -0
  126. package/dist/tools/jobs.js.map +1 -1
  127. package/dist/tools/ocr.d.ts +28 -0
  128. package/dist/tools/ocr.js +316 -0
  129. package/dist/tools/ocr.js.map +1 -0
  130. package/dist/tools/pdf.d.ts +0 -12
  131. package/dist/tools/pdf.js +436 -150
  132. package/dist/tools/pdf.js.map +1 -1
  133. package/dist/tools/registry.js +5 -0
  134. package/dist/tools/registry.js.map +1 -1
  135. package/dist/tui/format-keys.js +10 -0
  136. package/dist/tui/format-keys.js.map +1 -1
  137. package/dist/tui-v2/actions/action-id.d.ts +1 -1
  138. package/dist/tui-v2/actions/action-id.js +3 -0
  139. package/dist/tui-v2/actions/action-id.js.map +1 -1
  140. package/dist/tui-v2/actions/format-shortcuts.js +4 -1
  141. package/dist/tui-v2/actions/format-shortcuts.js.map +1 -1
  142. package/dist/tui-v2/actions/keymap.js +6 -1
  143. package/dist/tui-v2/actions/keymap.js.map +1 -1
  144. package/dist/tui-v2/app/App.js +3 -1
  145. package/dist/tui-v2/app/App.js.map +1 -1
  146. package/dist/tui-v2/app/commands/key-commands.js +117 -15
  147. package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
  148. package/dist/tui-v2/app/commands/picker-commands.js +80 -11
  149. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  150. package/dist/tui-v2/bootstrap/console-guard.d.ts +20 -0
  151. package/dist/tui-v2/bootstrap/console-guard.js +73 -0
  152. package/dist/tui-v2/bootstrap/console-guard.js.map +1 -0
  153. package/dist/tui-v2/bootstrap/start-tui-v2.js +14 -0
  154. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  155. package/dist/tui-v2/components/composer/composer-input-box.js +5 -1
  156. package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
  157. package/dist/tui-v2/components/jobs/jobs-panel.js +25 -1
  158. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  159. package/dist/tui-v2/components/modal/keys-modal.js +15 -8
  160. package/dist/tui-v2/components/modal/keys-modal.js.map +1 -1
  161. package/dist/tui-v2/components/modal/secret-modal.js +13 -4
  162. package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
  163. package/dist/tui-v2/components/pager/pager.js +103 -12
  164. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  165. package/dist/tui-v2/components/status/status-line.d.ts +8 -2
  166. package/dist/tui-v2/components/status/status-line.js +12 -8
  167. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  168. package/dist/tui-v2/components/transcript/thinking-block.d.ts +14 -4
  169. package/dist/tui-v2/components/transcript/thinking-block.js +53 -8
  170. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  171. package/dist/tui-v2/components/transcript/transcript-row.d.ts +2 -0
  172. package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
  173. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  174. package/dist/tui-v2/components/transcript/transcript-view.js +8 -1
  175. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  176. package/dist/tui-v2/composer/composer-action-port.d.ts +15 -3
  177. package/dist/tui-v2/composer/composer-action-port.js +49 -6
  178. package/dist/tui-v2/composer/composer-action-port.js.map +1 -1
  179. package/dist/tui-v2/composer/composer-editor.js +37 -44
  180. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  181. package/dist/tui-v2/composer/draft-actions.d.ts +31 -0
  182. package/dist/tui-v2/composer/draft-actions.js +45 -0
  183. package/dist/tui-v2/composer/draft-actions.js.map +1 -0
  184. package/dist/tui-v2/composer/textarea-keybindings.d.ts +1 -1
  185. package/dist/tui-v2/composer/textarea-keybindings.js +33 -0
  186. package/dist/tui-v2/composer/textarea-keybindings.js.map +1 -1
  187. package/dist/tui-v2/composer/use-draft-actions.d.ts +32 -0
  188. package/dist/tui-v2/composer/use-draft-actions.js +59 -0
  189. package/dist/tui-v2/composer/use-draft-actions.js.map +1 -0
  190. package/dist/tui-v2/controllers/overlay-controller.d.ts +13 -0
  191. package/dist/tui-v2/controllers/overlay-controller.js +5 -2
  192. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  193. package/dist/tui-v2/rendering/artifact-pager-source.d.ts +10 -0
  194. package/dist/tui-v2/rendering/artifact-pager-source.js +6 -0
  195. package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -1
  196. package/dist/tui-v2/rendering/job-tail-source.d.ts +21 -0
  197. package/dist/tui-v2/rendering/job-tail-source.js +87 -0
  198. package/dist/tui-v2/rendering/job-tail-source.js.map +1 -0
  199. package/dist/tui-v2/state/use-has-draft.d.ts +6 -0
  200. package/dist/tui-v2/state/use-has-draft.js +13 -0
  201. package/dist/tui-v2/state/use-has-draft.js.map +1 -0
  202. package/dist/types.d.ts +7 -1
  203. package/dist/types.js +3 -0
  204. package/dist/types.js.map +1 -1
  205. package/dist/ui/mentions.d.ts +7 -2
  206. package/dist/ui/mentions.js +52 -117
  207. package/dist/ui/mentions.js.map +1 -1
  208. package/dist/version.generated.d.ts +2 -2
  209. package/dist/version.generated.js +2 -2
  210. package/dist/version.generated.js.map +1 -1
  211. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { sanitizeAssistantText } from "../ui/ansi-box.js";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { jobManager, } from "../tools/jobs.js";
11
11
  import { isResponderResultLedgerMessage, responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
12
- import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
12
+ import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, renderRequestEnvironmentContext, scratchDirFor, toolNudge, } from "../prompts/index.js";
13
13
  import { getConfig } from "../store/config.js";
14
14
  import { beginSessionWorkspace, getActiveSessionWorkspace, } from "../store/session-workspace.js";
15
15
  import { groqInputTokenBudget } from "../llm/groq.js";
@@ -314,7 +314,20 @@ export async function runAgentTurn(prompt, options = {}) {
314
314
  let suppressOutcomeDiagnostics = false;
315
315
  const unreadResponderNotificationIds = new Set();
316
316
  const releaseUnreadResponderClaims = () => {
317
+ // No session filter: this helper runs in the outer turn scope, before the
318
+ // session policy exists, and every id here is already known to belong to
319
+ // this turn (notification ids are globally unique).
320
+ const pending = new Map(jobManager
321
+ .getPendingNotifications()
322
+ .map((notification) => [notification.id, notification]));
317
323
  for (const notificationId of unreadResponderNotificationIds) {
324
+ const notification = pending.get(notificationId);
325
+ if (notification?.deliveryStartedAt &&
326
+ !notification.readAt &&
327
+ !notification.analyzedAt &&
328
+ !notification.acknowledgedAt) {
329
+ continue;
330
+ }
318
331
  jobManager.releaseResponderNotificationClaim(notificationId);
319
332
  }
320
333
  };
@@ -356,7 +369,7 @@ export async function runAgentTurn(prompt, options = {}) {
356
369
  const confirmPort = options.confirm ?? inquirerConfirmPort;
357
370
  const projectContext = await loadProjectContext();
358
371
  const hasAttachedImages = Boolean(options.images?.length);
359
- const imageOcrEnabled = shouldEnableImageOcr(prompt, hasAttachedImages);
372
+ const imageOcrEnabled = shouldEnableImageOcr(prompt, hasAttachedImages, options.visionProven !== false);
360
373
  // A vision-capable request already carries the actual image bytes. Hiding
361
374
  // image.ocr from the model prevents it from replacing visual inspection
362
375
  // with a lossy Tesseract pass (which produced fabricated screenshot text).
@@ -470,67 +483,68 @@ export async function runAgentTurn(prompt, options = {}) {
470
483
  if (!pinnedProject && discoveredProjects.length === 1) {
471
484
  setActiveProjectRootIfValid(discoveredProjects[0]);
472
485
  }
473
- const buildSystemContent = (native) => {
486
+ // Only long-lived instructions belong in the provider-cached system prefix.
487
+ // Request, project, workspace, recovery, scope, and plan state are appended
488
+ // later as system-marked turns so a changing byte cannot invalidate the
489
+ // constitution (and, on Anthropic, the native tool schemas before it).
490
+ const buildStableSystemContent = (native) => {
474
491
  const reliability = getReliabilityPolicy();
475
- const sections = [
476
- (useCompactSystemPrompt
477
- ? renderCompactAgentSystemPrompt
478
- : renderAgentSystemPrompt)(toolNames.join(", "), {
479
- nativeTools: native,
480
- // E6: slim native constitution when API tool schemas are attached.
481
- ...(native
482
- ? { slimNative: reliability.slimNativePrompt }
483
- : {}),
484
- }),
485
- ];
486
- if (projectContext) {
487
- sections.push(`Project context from .clai/context.md:\n${projectContext}`);
488
- }
489
- const projectRoot = getActiveProjectRoot();
490
- if (projectRoot) {
491
- sections.push(`ACTIVE PROJECT ROOT: ${projectRoot}\n` +
492
- `All relative paths (./src/…, manifests, configs) resolve under this directory — NOT the agent process cwd. ` +
493
- `Prefer absolute paths under this root. shell cwd for install / run / build must be this root ` +
494
- `(or its parent when creating a NEW named subfolder with a scaffolder). ` +
495
- `Never write user app source into the agent package tree.`);
496
- }
497
- else if (destinationHint) {
498
- sections.push(`USER DESTINATION: create or continue work under "${destinationHint}" (parent folder). ` +
499
- `Pick or detect a project subfolder; do not scaffold into the agent working tree unless the user asked for that.`);
500
- }
501
- // Stack-agnostic PWD / existing-project snapshot so weak models cannot
502
- // skip explore and re-scaffold into non-empty dirs.
503
- if (buildLikeTurn &&
504
- !informationalQuery &&
505
- !idleOrSocialPrompt) {
506
- const guessedName = guessProjectFolderName([prompt, activePlan?.goal, activePlan?.detail, activePlan?.tasks.map((t) => t.title).join(" ")]
507
- .filter(Boolean)
508
- .join("\n"));
509
- const extraPaths = [];
510
- if (destinationHint && guessedName) {
511
- extraPaths.push(join(destinationHint, guessedName));
512
- }
513
- const fromText = extractProjectRootFromPlan(activePlan) ??
514
- extractProjectRootFromText(prompt);
515
- if (fromText)
516
- extraPaths.push(fromText);
517
- const orientInput = {
518
- cwd: safeCwd(),
519
- extraPaths,
520
- };
521
- if (destinationHint)
522
- orientInput.destinationHint = destinationHint;
523
- const candidate = getActiveProjectRoot() ?? fromText;
524
- if (candidate)
525
- orientInput.candidateProject = candidate;
526
- sections.push(buildWorkspaceOrientation(orientInput));
527
- }
528
- if (freshWebSearchRequired) {
529
- sections.push(freshnessGuardMessage());
530
- }
531
- return sections.join("\n\n");
492
+ return (useCompactSystemPrompt
493
+ ? renderCompactAgentSystemPrompt
494
+ : renderAgentSystemPrompt)(toolNames.join(", "), {
495
+ nativeTools: native,
496
+ stableEnvironment: true,
497
+ // E6: slim native constitution when API tool schemas are attached.
498
+ ...(native ? { slimNative: reliability.slimNativePrompt } : {}),
499
+ });
532
500
  };
533
- const systemSections = [buildSystemContent(nativeToolsActive)];
501
+ const systemSections = [renderRequestEnvironmentContext()];
502
+ if (projectContext) {
503
+ systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
504
+ }
505
+ const projectRoot = getActiveProjectRoot();
506
+ if (projectRoot) {
507
+ systemSections.push(`ACTIVE PROJECT ROOT: ${projectRoot}\n` +
508
+ `All relative paths (./src/…, manifests, configs) resolve under this directory — NOT the agent process cwd. ` +
509
+ `Prefer absolute paths under this root. shell cwd for install / run / build must be this root ` +
510
+ `(or its parent when creating a NEW named subfolder with a scaffolder). ` +
511
+ `Never write user app source into the agent package tree.`);
512
+ }
513
+ else if (destinationHint) {
514
+ systemSections.push(`USER DESTINATION: create or continue work under "${destinationHint}" (parent folder). ` +
515
+ `Pick or detect a project subfolder; do not scaffold into the agent working tree unless the user asked for that.`);
516
+ }
517
+ // Stack-agnostic PWD / existing-project snapshot so weak models cannot
518
+ // skip explore and re-scaffold into non-empty dirs. This is live filesystem
519
+ // data and therefore must remain outside the cached constitution.
520
+ if (buildLikeTurn &&
521
+ !informationalQuery &&
522
+ !idleOrSocialPrompt) {
523
+ const guessedName = guessProjectFolderName([prompt, activePlan?.goal, activePlan?.detail, activePlan?.tasks.map((t) => t.title).join(" ")]
524
+ .filter(Boolean)
525
+ .join("\n"));
526
+ const extraPaths = [];
527
+ if (destinationHint && guessedName) {
528
+ extraPaths.push(join(destinationHint, guessedName));
529
+ }
530
+ const fromText = extractProjectRootFromPlan(activePlan) ??
531
+ extractProjectRootFromText(prompt);
532
+ if (fromText)
533
+ extraPaths.push(fromText);
534
+ const orientInput = {
535
+ cwd: safeCwd(),
536
+ extraPaths,
537
+ };
538
+ if (destinationHint)
539
+ orientInput.destinationHint = destinationHint;
540
+ const candidate = getActiveProjectRoot() ?? fromText;
541
+ if (candidate)
542
+ orientInput.candidateProject = candidate;
543
+ systemSections.push(buildWorkspaceOrientation(orientInput));
544
+ }
545
+ if (freshWebSearchRequired) {
546
+ systemSections.push(freshnessGuardMessage());
547
+ }
534
548
  // The live plan is mutable state: it is injected once as a keyed request
535
549
  // suffix (upsertPlanContextMessage) instead of being frozen into the stable
536
550
  // system prefix, so the model never sees a stale and a fresh plan together.
@@ -619,7 +633,7 @@ export async function runAgentTurn(prompt, options = {}) {
619
633
  }
620
634
  }
621
635
  const promptSections = () => {
622
- const sections = systemSections.slice(1).map((content) => ({
636
+ const sections = systemSections.map((content) => ({
623
637
  kind: content.startsWith("ACTIVE PLAN")
624
638
  ? "plan"
625
639
  : content.startsWith("ENGAGEMENT SCOPE")
@@ -634,6 +648,12 @@ export async function runAgentTurn(prompt, options = {}) {
634
648
  content,
635
649
  mandatory: content.startsWith("ACTIVE PLAN") ||
636
650
  content.startsWith("ENGAGEMENT SCOPE") ||
651
+ content.startsWith("REQUEST ENVIRONMENT") ||
652
+ content.startsWith("Project context from .clai/context.md:") ||
653
+ content.startsWith("ACTIVE PROJECT ROOT:") ||
654
+ content.startsWith("USER DESTINATION:") ||
655
+ content.startsWith("WORKSPACE STATUS") ||
656
+ content.startsWith("Freshness guard for this turn:") ||
637
657
  content.includes("MODE") ||
638
658
  content.includes("OUTCOME"),
639
659
  }));
@@ -666,20 +686,14 @@ export async function runAgentTurn(prompt, options = {}) {
666
686
  });
667
687
  return sections;
668
688
  };
669
- const composeCurrentSystemPrompt = (native) => composeAgentSystemPrompt({
689
+ const composeCurrentSystemPrompt = (native) => buildStableSystemContent(native);
690
+ const requestContext = composeAgentSystemPrompt({
670
691
  mode: agentMode,
671
- nativeToolsActive: native,
692
+ nativeToolsActive,
672
693
  maxTokens: inputTokenBudget
673
694
  ? Math.min(2_000, Math.floor(inputTokenBudget * 0.4))
674
695
  : undefined,
675
- sections: [
676
- {
677
- kind: "constitution",
678
- content: buildSystemContent(native),
679
- mandatory: true,
680
- },
681
- ...promptSections(),
682
- ],
696
+ sections: promptSections(),
683
697
  }).content;
684
698
  const fullSystemPrompt = composeCurrentSystemPrompt(nativeToolsActive);
685
699
  // Backend-only directives (implement, displayPrompt=null) stay in model
@@ -696,6 +710,10 @@ export async function runAgentTurn(prompt, options = {}) {
696
710
  const messages = [
697
711
  { role: "system", content: fullSystemPrompt },
698
712
  ...(options.history ?? []),
713
+ // Keep all per-request authority after prior history. This preserves the
714
+ // longest shared prefix for APC providers while single-system dialects
715
+ // retain it in place as a marked user turn.
716
+ { role: "system", content: `REQUEST CONTEXT\n${requestContext}` },
699
717
  userMessage,
700
718
  ];
701
719
  liveMessages = messages;
@@ -1062,6 +1080,13 @@ export async function runAgentTurn(prompt, options = {}) {
1062
1080
  let batchRemindCalls = new Set();
1063
1081
  let batchReminderNote = "";
1064
1082
  const deferredPostToolMessages = [];
1083
+ /**
1084
+ * Successful job.read receipts update the durable ledger only after the
1085
+ * assistant→tool group closes. Inserting the ledger system row inside
1086
+ * executeSingleTool would split native tool protocol and cause repair to
1087
+ * replace the real acknowledgement body with a "No stored body" stub.
1088
+ */
1089
+ const deferredResponderLedgerNotifications = [];
1065
1090
  /**
1066
1091
  * Latest plan seen during tool execution. SESSION STATE is refreshed once
1067
1092
  * after the full tool batch is recorded — never mid-group (see
@@ -1193,6 +1218,20 @@ export async function runAgentTurn(prompt, options = {}) {
1193
1218
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
1194
1219
  const scratchDir = scratchDirFor(safeCwd());
1195
1220
  let call = normalizeToolCall(rawCall);
1221
+ const emitVisibleSyntheticReceipt = (result, summary) => {
1222
+ if (!alreadyPrintedIds.has(toolEventId)) {
1223
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
1224
+ chalk.gray(` ${formatToolArgs(call)}`);
1225
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1226
+ alreadyPrintedIds.add(toolEventId);
1227
+ }
1228
+ emit({ type: "tool-start", id: toolEventId });
1229
+ const output = result.output.endsWith("\n")
1230
+ ? result.output
1231
+ : `${result.output}\n`;
1232
+ writeToolOutput(toolEventId, output, chalk.dim(` ${output}`));
1233
+ emitToolResult(toolEventId, result, summary);
1234
+ };
1196
1235
  let dispatchedTaskId;
1197
1236
  let delegation;
1198
1237
  let engagementLease;
@@ -1273,7 +1312,7 @@ export async function runAgentTurn(prompt, options = {}) {
1273
1312
  `${call.name} previously failed with identical arguments. Change the command/args and retry.`;
1274
1313
  if (loopCheck.kind === "unchanged-success") {
1275
1314
  const result = { ok: true, output: reason, exitCode: 0 };
1276
- emitToolResult(toolEventId, result, reason);
1315
+ emitVisibleSyntheticReceipt(result, reason);
1277
1316
  return {
1278
1317
  ok: true,
1279
1318
  call,
@@ -1347,7 +1386,7 @@ export async function runAgentTurn(prompt, options = {}) {
1347
1386
  ? `${call.name} failed: Responder result ${identifier} was not delivered to this model turn. Analyze a delivered result before marking it read.`
1348
1387
  : `${call.name} failed: read state for Responder result ${identifier} could not be persisted.`;
1349
1388
  if (persistedRead && notification) {
1350
- upsertResponderResultLedger(messages, notification);
1389
+ deferredResponderLedgerNotifications.push(notification);
1351
1390
  unreadResponderNotificationIds.delete(notification.id);
1352
1391
  }
1353
1392
  else if (staleWakeSettled && responderWakeNotificationId) {
@@ -2164,6 +2203,34 @@ export async function runAgentTurn(prompt, options = {}) {
2164
2203
  engagementLease?.release();
2165
2204
  parentSignal.removeEventListener("abort", onParentAbort);
2166
2205
  }
2206
+ if (result.suppressedRepeat) {
2207
+ const contextOutput = result.output;
2208
+ const output = result.output.endsWith("\n")
2209
+ ? result.output
2210
+ : `${result.output}\n`;
2211
+ writeToolOutput(toolEventId, output, "", { replace: true });
2212
+ emitToolResult(toolEventId, result, contextOutput);
2213
+ options.onToolResult?.(call, result);
2214
+ // Record it as an observation-free success so the per-call guard can
2215
+ // stop an identical replay, while a real state change (new job status
2216
+ // or bytes) still produces a fresh stateKey and is allowed through.
2217
+ const suppressedProbeState = probeStateKey(call);
2218
+ loopGuard.recordAttempt(step, call.name, call.args, true, result.exitCode, "", suppressedProbeState ? { stateKey: suppressedProbeState } : undefined);
2219
+ await auditLog("tool.result", {
2220
+ call,
2221
+ ok: result.ok,
2222
+ exitCode: result.exitCode,
2223
+ output: result.output.slice(0, 4_000),
2224
+ suppressedRepeat: true,
2225
+ });
2226
+ return {
2227
+ ok: result.ok,
2228
+ call,
2229
+ result,
2230
+ contextOutput,
2231
+ suppressedRepeat: true,
2232
+ };
2233
+ }
2167
2234
  if ((call.name === "shell.exec" || call.name === "shell.start") &&
2168
2235
  typeof call.args.command === "string" &&
2169
2236
  isScaffoldCreateCommand(call.args.command)) {
@@ -4034,9 +4101,79 @@ export async function runAgentTurn(prompt, options = {}) {
4034
4101
  // Re-index toRun positions for UI callIds[] (0..n-1 this turn).
4035
4102
  toRun = toRun.map((b, index) => ({ ...b, index }));
4036
4103
  const allCalls = toRun.map((b) => b.call);
4104
+ const actionSequenceCalls = bound.map((entry) => {
4105
+ const candidate = entry.call;
4106
+ const stateKey = probeStateKey(candidate);
4107
+ return {
4108
+ name: candidate.name,
4109
+ args: candidate.args,
4110
+ ...(stateKey ? { stateKey } : {}),
4111
+ };
4112
+ });
4037
4113
  /** Stable call→Bound map (object identity; no indexOf for result ids). */
4038
4114
  const callToBound = new Map(toRun.map((b) => [b.call, b]));
4039
4115
  const runIds = new Set(toRun.map((b) => b.id));
4116
+ const sequenceDecision = loopGuard.observeActionSequence(actionSequenceCalls);
4117
+ if (sequenceDecision.suppress) {
4118
+ const reason = sequenceDecision.terminal
4119
+ ? "The model repeated the same action sequence after it was already suppressed. No commands were run again."
4120
+ : "The same action sequence already ran in the previous model round. No commands were run again; reuse the existing results and choose a materially different next action or finish.";
4121
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
4122
+ const suppressedResults = bound.map((b) => {
4123
+ const duplicate = runIds.has(b.id);
4124
+ const resultReason = duplicate ? reason : deferReason;
4125
+ const result = {
4126
+ ok: false,
4127
+ output: resultReason,
4128
+ exitCode: duplicate ? 409 : 130,
4129
+ };
4130
+ return { b, resultReason, result };
4131
+ });
4132
+ // Sequence suppression happens before the normal queued-card flush.
4133
+ // Complete every card explicitly so streamed queued calls never turn
4134
+ // into empty "done" rows when the repeated sequence is skipped.
4135
+ for (const { b, resultReason, result } of suppressedResults) {
4136
+ const queued = deferredToolCalls[b.index];
4137
+ const eventId = queued?.eventId ?? `tool-${++nextToolEventId}`;
4138
+ const toolCallLine = chalk.cyan(` ▶ ${b.call.name}`) +
4139
+ chalk.gray(` ${formatToolArgs(b.call)}`);
4140
+ writeToolCall(eventId, b.call, styleToolChatter(b.call, toolCallLine) + "\n");
4141
+ alreadyPrintedIds.add(eventId);
4142
+ emit({ type: "tool-start", id: eventId });
4143
+ const output = resultReason.endsWith("\n")
4144
+ ? resultReason
4145
+ : `${resultReason}\n`;
4146
+ writeToolOutput(eventId, output, chalk.dim(` ${output}`));
4147
+ emitToolResult(eventId, result, resultReason);
4148
+ }
4149
+ if (historyNativeCalls.length) {
4150
+ appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock);
4151
+ for (const { b, resultReason, result } of suppressedResults) {
4152
+ appendToolResult(messages, b.id, `Tool ${b.call.name} result (exit=${result.exitCode}, ok=false):\n${resultReason}`, b.call.name, false);
4153
+ }
4154
+ }
4155
+ else {
4156
+ const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
4157
+ allCalls
4158
+ .map((candidate) => `\`\`\`tool\n${JSON.stringify(candidate)}\n\`\`\``)
4159
+ .join("\n\n");
4160
+ pushAssistantHistory(standardizedContent);
4161
+ }
4162
+ if (sequenceDecision.terminal) {
4163
+ const remainingCriteria = unreadResponderNotificationIds.size > 0
4164
+ ? ["Analyze and acknowledge the delivered Responder result without repeating completed foreground work."]
4165
+ : ["Continue with a materially different action that can produce new evidence."];
4166
+ outcomeState.outcome.status = "partial";
4167
+ await saveOutcomeState(outcomeState);
4168
+ moveTurn("partial", "repeated identical action sequence");
4169
+ return finishTurn("Stopped an identical action cycle before it could execute again.", productiveSteps, "partial", remainingCriteria, "The model repeated an identical action sequence without a new premise or state change.");
4170
+ }
4171
+ messages.push(recoveryUserMessage(reason +
4172
+ (unreadResponderNotificationIds.size > 0
4173
+ ? " A delivered Responder result is still unread: analyze the available result, gather only genuinely necessary bounded evidence, then call job.read before returning to foreground work."
4174
+ : " Reassess the evidence and select the next action yourself; do not replay completed work.")));
4175
+ continue;
4176
+ }
4040
4177
  // Notice BEFORE tool cards so the transcript reads:
4041
4178
  // thinking → response → "N tool calls…" → tool cards (not tools then info).
4042
4179
  if (allCalls.length > 1) {
@@ -4086,6 +4223,8 @@ export async function runAgentTurn(prompt, options = {}) {
4086
4223
  const planRemindedAt = new Set();
4087
4224
  /** True after a successful plan.create this turn (activePlan is turn-start snapshot). */
4088
4225
  let planCreatedThisTurn = Boolean(activePlan && activePlan.tasks.length > 0);
4226
+ let actionSequenceExecuted = 0;
4227
+ let actionSequenceEligible = allCalls.length > 0;
4089
4228
  /**
4090
4229
  * Record a tool result into history. Failures / user declines are
4091
4230
  * always returned to the model — we never cancel later siblings or
@@ -4094,6 +4233,15 @@ export async function runAgentTurn(prompt, options = {}) {
4094
4233
  */
4095
4234
  const recordResult = (boundCall, res) => {
4096
4235
  recordedNativeIds.add(boundCall.id);
4236
+ actionSequenceExecuted += 1;
4237
+ // A policy-suppressed call is deterministic: replaying it verbatim
4238
+ // returns the identical receipt. It must therefore keep the sequence
4239
+ // eligible, otherwise the tool-level suppression and the sequence
4240
+ // guard cancel each other out and the round can repeat forever.
4241
+ actionSequenceEligible &&=
4242
+ (res.ok || Boolean(res.suppressedRepeat)) &&
4243
+ !res.blockOrCancel &&
4244
+ !res.aborted;
4097
4245
  if (res.ok && res.call.name === "plan.create") {
4098
4246
  planCreatedThisTurn = true;
4099
4247
  }
@@ -4408,9 +4556,25 @@ export async function runAgentTurn(prompt, options = {}) {
4408
4556
  }
4409
4557
  fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
4410
4558
  }
4559
+ loopGuard.completeActionSequence(actionSequenceCalls, actionSequenceEligible &&
4560
+ toRun.length === bound.length &&
4561
+ actionSequenceExecuted === allCalls.length &&
4562
+ !aborted &&
4563
+ !awaitingPlanApproval &&
4564
+ !governorPauseReason);
4565
+ // Keep ledger system rows outside the native assistant→tool group so
4566
+ // protocol repair preserves the real successful job.read body.
4567
+ for (const notification of deferredResponderLedgerNotifications.splice(0)) {
4568
+ upsertResponderResultLedger(messages, notification);
4569
+ }
4411
4570
  // SESSION STATE only after the assistant→tool group is closed.
4412
4571
  // Mid-group upserts were the root cause of "No stored body" thrash.
4413
4572
  refreshSessionState(pendingSessionStatePlan);
4573
+ if (responderWakeTurn &&
4574
+ unreadResponderNotificationIds.size > 0 &&
4575
+ !allCalls.some((candidate) => candidate.name === "job.read" || candidate.name === "task.read")) {
4576
+ messages.push(recoveryUserMessage("The delivered Responder result is still unread. Decide from the evidence already available whether it is understood. If it is, call job.read now; if not, gather only the smallest bounded evidence needed. Do not resume or repeat unrelated foreground work before resolving this receipt."));
4577
+ }
4414
4578
  if (deferredPostToolMessages.length > 0) {
4415
4579
  messages.push(...deferredPostToolMessages.splice(0));
4416
4580
  }