@superblocksteam/vite-plugin-file-sync 2.0.166 → 2.0.168-next.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 (251) hide show
  1. package/dist/ai-service/agent/middleware.d.ts.map +1 -1
  2. package/dist/ai-service/agent/middleware.js +23 -0
  3. package/dist/ai-service/agent/middleware.js.map +1 -1
  4. package/dist/ai-service/agent/tool-message-utils.d.ts +1 -1
  5. package/dist/ai-service/agent/tool-message-utils.d.ts.map +1 -1
  6. package/dist/ai-service/agent/tool-message-utils.js.map +1 -1
  7. package/dist/ai-service/agent/tool-permission-delivery.d.ts +5 -4
  8. package/dist/ai-service/agent/tool-permission-delivery.d.ts.map +1 -1
  9. package/dist/ai-service/agent/tool-permission-delivery.js +22 -28
  10. package/dist/ai-service/agent/tool-permission-delivery.js.map +1 -1
  11. package/dist/ai-service/agent/tool-permission-outcome-metrics.d.ts +9 -0
  12. package/dist/ai-service/agent/tool-permission-outcome-metrics.d.ts.map +1 -0
  13. package/dist/ai-service/agent/tool-permission-outcome-metrics.js +23 -0
  14. package/dist/ai-service/agent/tool-permission-outcome-metrics.js.map +1 -0
  15. package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts +44 -0
  16. package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts.map +1 -1
  17. package/dist/ai-service/agent/tools/build-capture-screenshot.js +198 -18
  18. package/dist/ai-service/agent/tools/build-capture-screenshot.js.map +1 -1
  19. package/dist/ai-service/agent/tools/build-manage-checklist.d.ts +2 -0
  20. package/dist/ai-service/agent/tools/build-manage-checklist.d.ts.map +1 -1
  21. package/dist/ai-service/agent/tools/build-manage-checklist.js.map +1 -1
  22. package/dist/ai-service/agent/tools/build-reload-file.d.ts.map +1 -1
  23. package/dist/ai-service/agent/tools/build-reload-file.js +32 -19
  24. package/dist/ai-service/agent/tools/build-reload-file.js.map +1 -1
  25. package/dist/ai-service/agent/tools/get-security-findings.d.ts +45 -0
  26. package/dist/ai-service/agent/tools/get-security-findings.d.ts.map +1 -0
  27. package/dist/ai-service/agent/tools/get-security-findings.js +74 -0
  28. package/dist/ai-service/agent/tools/get-security-findings.js.map +1 -0
  29. package/dist/ai-service/agent/tools/integrations/integration-setup.d.ts +2 -2
  30. package/dist/ai-service/agent/tools/integrations/integration-setup.d.ts.map +1 -1
  31. package/dist/ai-service/agent/tools/integrations/integration-setup.js +24 -2
  32. package/dist/ai-service/agent/tools/integrations/integration-setup.js.map +1 -1
  33. package/dist/ai-service/agent/tools/report-security-findings.d.ts +20 -20
  34. package/dist/ai-service/agent/tools.d.ts.map +1 -1
  35. package/dist/ai-service/agent/tools.js +13 -2
  36. package/dist/ai-service/agent/tools.js.map +1 -1
  37. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.d.ts.map +1 -1
  38. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js +14 -0
  39. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js.map +1 -1
  40. package/dist/ai-service/agent/tools2/tools/git.d.ts +2 -1
  41. package/dist/ai-service/agent/tools2/tools/git.d.ts.map +1 -1
  42. package/dist/ai-service/agent/tools2/tools/git.js +1 -1
  43. package/dist/ai-service/agent/tools2/tools/git.js.map +1 -1
  44. package/dist/ai-service/agent/utils.d.ts.map +1 -1
  45. package/dist/ai-service/agent/utils.js +5 -0
  46. package/dist/ai-service/agent/utils.js.map +1 -1
  47. package/dist/ai-service/app-interface/app-package-manager.d.ts +43 -0
  48. package/dist/ai-service/app-interface/app-package-manager.d.ts.map +1 -0
  49. package/dist/ai-service/app-interface/app-package-manager.js +82 -0
  50. package/dist/ai-service/app-interface/app-package-manager.js.map +1 -0
  51. package/dist/ai-service/app-interface/constants.d.ts +2 -2
  52. package/dist/ai-service/app-interface/constants.d.ts.map +1 -1
  53. package/dist/ai-service/app-interface/constants.js +5 -0
  54. package/dist/ai-service/app-interface/constants.js.map +1 -1
  55. package/dist/ai-service/app-interface/file-system-interface.d.ts.map +1 -1
  56. package/dist/ai-service/app-interface/file-system-interface.js +0 -3
  57. package/dist/ai-service/app-interface/file-system-interface.js.map +1 -1
  58. package/dist/ai-service/app-interface/npm-error-parser.d.ts +1 -1
  59. package/dist/ai-service/app-interface/npm-error-parser.d.ts.map +1 -1
  60. package/dist/ai-service/app-interface/npm-error-parser.js +42 -8
  61. package/dist/ai-service/app-interface/npm-error-parser.js.map +1 -1
  62. package/dist/ai-service/app-interface/npm-registry.d.ts +30 -0
  63. package/dist/ai-service/app-interface/npm-registry.d.ts.map +1 -1
  64. package/dist/ai-service/app-interface/npm-registry.js +108 -1
  65. package/dist/ai-service/app-interface/npm-registry.js.map +1 -1
  66. package/dist/ai-service/app-interface/pnpm-lockfile.d.ts +28 -0
  67. package/dist/ai-service/app-interface/pnpm-lockfile.d.ts.map +1 -0
  68. package/dist/ai-service/app-interface/pnpm-lockfile.js +89 -0
  69. package/dist/ai-service/app-interface/pnpm-lockfile.js.map +1 -0
  70. package/dist/ai-service/app-interface/shell.d.ts +43 -7
  71. package/dist/ai-service/app-interface/shell.d.ts.map +1 -1
  72. package/dist/ai-service/app-interface/shell.js +252 -119
  73. package/dist/ai-service/app-interface/shell.js.map +1 -1
  74. package/dist/ai-service/app-interface/vulnerability-gate.d.ts +5 -0
  75. package/dist/ai-service/app-interface/vulnerability-gate.d.ts.map +1 -1
  76. package/dist/ai-service/app-interface/vulnerability-gate.js +3 -0
  77. package/dist/ai-service/app-interface/vulnerability-gate.js.map +1 -1
  78. package/dist/ai-service/app-skills/helpers.d.ts.map +1 -1
  79. package/dist/ai-service/app-skills/helpers.js +9 -9
  80. package/dist/ai-service/app-skills/helpers.js.map +1 -1
  81. package/dist/ai-service/chat/chat-session-store.d.ts +4 -1
  82. package/dist/ai-service/chat/chat-session-store.d.ts.map +1 -1
  83. package/dist/ai-service/chat/chat-session-store.js +4 -0
  84. package/dist/ai-service/chat/chat-session-store.js.map +1 -1
  85. package/dist/ai-service/checklist/persisted-checklist-store.d.ts +66 -1
  86. package/dist/ai-service/checklist/persisted-checklist-store.d.ts.map +1 -1
  87. package/dist/ai-service/checklist/persisted-checklist-store.js +274 -4
  88. package/dist/ai-service/checklist/persisted-checklist-store.js.map +1 -1
  89. package/dist/ai-service/checklist/policy-remediation.d.ts +8 -0
  90. package/dist/ai-service/checklist/policy-remediation.d.ts.map +1 -0
  91. package/dist/ai-service/checklist/policy-remediation.js +46 -0
  92. package/dist/ai-service/checklist/policy-remediation.js.map +1 -0
  93. package/dist/ai-service/checklist/remediation-findings-client.d.ts +31 -0
  94. package/dist/ai-service/checklist/remediation-findings-client.d.ts.map +1 -0
  95. package/dist/ai-service/checklist/remediation-findings-client.js +117 -0
  96. package/dist/ai-service/checklist/remediation-findings-client.js.map +1 -0
  97. package/dist/ai-service/context-download.d.ts.map +1 -1
  98. package/dist/ai-service/context-download.js +76 -37
  99. package/dist/ai-service/context-download.js.map +1 -1
  100. package/dist/ai-service/dev-database-file-sync.d.ts +2 -1
  101. package/dist/ai-service/dev-database-file-sync.d.ts.map +1 -1
  102. package/dist/ai-service/dev-database-file-sync.js.map +1 -1
  103. package/dist/ai-service/index.d.ts +59 -9
  104. package/dist/ai-service/index.d.ts.map +1 -1
  105. package/dist/ai-service/index.js +1191 -479
  106. package/dist/ai-service/index.js.map +1 -1
  107. package/dist/ai-service/integrations/store.d.ts.map +1 -1
  108. package/dist/ai-service/integrations/store.js +11 -2
  109. package/dist/ai-service/integrations/store.js.map +1 -1
  110. package/dist/ai-service/llm/context-v2/adapter.d.ts +3 -0
  111. package/dist/ai-service/llm/context-v2/adapter.d.ts.map +1 -1
  112. package/dist/ai-service/llm/context-v2/adapter.js +6 -0
  113. package/dist/ai-service/llm/context-v2/adapter.js.map +1 -1
  114. package/dist/ai-service/llm/context-v2/context.d.ts +22 -1
  115. package/dist/ai-service/llm/context-v2/context.d.ts.map +1 -1
  116. package/dist/ai-service/llm/context-v2/context.js +185 -30
  117. package/dist/ai-service/llm/context-v2/context.js.map +1 -1
  118. package/dist/ai-service/llm/context-v2/conversation-context.d.ts +7 -0
  119. package/dist/ai-service/llm/context-v2/conversation-context.d.ts.map +1 -1
  120. package/dist/ai-service/llm/context-v2/manager.d.ts +11 -10
  121. package/dist/ai-service/llm/context-v2/manager.d.ts.map +1 -1
  122. package/dist/ai-service/llm/context-v2/manager.js +128 -85
  123. package/dist/ai-service/llm/context-v2/manager.js.map +1 -1
  124. package/dist/ai-service/llm/context-v2/storage/event-store.d.ts +73 -1
  125. package/dist/ai-service/llm/context-v2/storage/event-store.d.ts.map +1 -1
  126. package/dist/ai-service/llm/context-v2/storage/event-types.d.ts +152 -24
  127. package/dist/ai-service/llm/context-v2/storage/event-types.d.ts.map +1 -1
  128. package/dist/ai-service/llm/context-v2/storage/event-types.js +141 -0
  129. package/dist/ai-service/llm/context-v2/storage/event-types.js.map +1 -1
  130. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts +20 -2
  131. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts.map +1 -1
  132. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js +111 -49
  133. package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js.map +1 -1
  134. package/dist/ai-service/llm/context-v2/storage/migration.d.ts.map +1 -1
  135. package/dist/ai-service/llm/context-v2/storage/migration.js +31 -4
  136. package/dist/ai-service/llm/context-v2/storage/migration.js.map +1 -1
  137. package/dist/ai-service/llm/context-v2/token-tracker.d.ts +34 -0
  138. package/dist/ai-service/llm/context-v2/token-tracker.d.ts.map +1 -1
  139. package/dist/ai-service/llm/context-v2/token-tracker.js +164 -0
  140. package/dist/ai-service/llm/context-v2/token-tracker.js.map +1 -1
  141. package/dist/ai-service/llm/context-v2/types.d.ts +5 -2
  142. package/dist/ai-service/llm/context-v2/types.d.ts.map +1 -1
  143. package/dist/ai-service/llm/context-v2/types.js +1 -1
  144. package/dist/ai-service/llm/context-v2/types.js.map +1 -1
  145. package/dist/ai-service/llm/stream/config.d.ts +7 -0
  146. package/dist/ai-service/llm/stream/config.d.ts.map +1 -1
  147. package/dist/ai-service/llm/stream/config.js +20 -2
  148. package/dist/ai-service/llm/stream/config.js.map +1 -1
  149. package/dist/ai-service/llm/stream/orchestrator.d.ts.map +1 -1
  150. package/dist/ai-service/llm/stream/orchestrator.js +30 -14
  151. package/dist/ai-service/llm/stream/orchestrator.js.map +1 -1
  152. package/dist/ai-service/llm/stream/session.d.ts +1 -1
  153. package/dist/ai-service/llm/stream/session.d.ts.map +1 -1
  154. package/dist/ai-service/llm/stream/session.js +1 -1
  155. package/dist/ai-service/llm/stream/session.js.map +1 -1
  156. package/dist/ai-service/security/secret-scanner-service.d.ts.map +1 -1
  157. package/dist/ai-service/security/secret-scanner-service.js +12 -2
  158. package/dist/ai-service/security/secret-scanner-service.js.map +1 -1
  159. package/dist/ai-service/security/secret-scanner.d.ts +1 -1
  160. package/dist/ai-service/security/secret-scanner.d.ts.map +1 -1
  161. package/dist/ai-service/security/secret-scanner.js +4 -1
  162. package/dist/ai-service/security/secret-scanner.js.map +1 -1
  163. package/dist/ai-service/security/vulnerability-triage.d.ts +2 -2
  164. package/dist/ai-service/state-machine/clark-fsm.d.ts +17 -1
  165. package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
  166. package/dist/ai-service/state-machine/clark-fsm.js +85 -8
  167. package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
  168. package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
  169. package/dist/ai-service/state-machine/handlers/agent-planning.js +97 -47
  170. package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
  171. package/dist/ai-service/state-machine/handlers/awaiting-user.d.ts.map +1 -1
  172. package/dist/ai-service/state-machine/handlers/awaiting-user.js +68 -50
  173. package/dist/ai-service/state-machine/handlers/awaiting-user.js.map +1 -1
  174. package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
  175. package/dist/ai-service/state-machine/handlers/llm-generating.js +91 -73
  176. package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
  177. package/dist/ai-service/state-machine/helpers/prompt-failure.d.ts +1 -1
  178. package/dist/ai-service/state-machine/helpers/prompt-failure.d.ts.map +1 -1
  179. package/dist/ai-service/state-machine/helpers/prompt-failure.js +1 -0
  180. package/dist/ai-service/state-machine/helpers/prompt-failure.js.map +1 -1
  181. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts +2 -1
  182. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts.map +1 -1
  183. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js +1 -0
  184. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js.map +1 -1
  185. package/dist/ai-service/state-machine/helpers/user-response-message.d.ts +4 -0
  186. package/dist/ai-service/state-machine/helpers/user-response-message.d.ts.map +1 -0
  187. package/dist/ai-service/state-machine/helpers/user-response-message.js +37 -0
  188. package/dist/ai-service/state-machine/helpers/user-response-message.js.map +1 -0
  189. package/dist/ai-service/template-renderer.d.ts.map +1 -1
  190. package/dist/ai-service/template-renderer.js +22 -9
  191. package/dist/ai-service/template-renderer.js.map +1 -1
  192. package/dist/ai-service/types.d.ts +2 -1
  193. package/dist/ai-service/types.d.ts.map +1 -1
  194. package/dist/ai-service/types.js.map +1 -1
  195. package/dist/components-manager.d.ts +9 -2
  196. package/dist/components-manager.d.ts.map +1 -1
  197. package/dist/components-manager.js +0 -4
  198. package/dist/components-manager.js.map +1 -1
  199. package/dist/file-sync-vite-plugin.d.ts.map +1 -1
  200. package/dist/file-sync-vite-plugin.js +2 -1
  201. package/dist/file-sync-vite-plugin.js.map +1 -1
  202. package/dist/file-system-helpers.d.ts +7 -0
  203. package/dist/file-system-helpers.d.ts.map +1 -1
  204. package/dist/file-system-helpers.js +9 -1
  205. package/dist/file-system-helpers.js.map +1 -1
  206. package/dist/file-system-manager.d.ts +12 -1
  207. package/dist/file-system-manager.d.ts.map +1 -1
  208. package/dist/file-system-manager.js +37 -22
  209. package/dist/file-system-manager.js.map +1 -1
  210. package/dist/index.d.ts +7 -0
  211. package/dist/index.d.ts.map +1 -1
  212. package/dist/index.js +7 -0
  213. package/dist/index.js.map +1 -1
  214. package/dist/lock-service/index.d.ts +20 -0
  215. package/dist/lock-service/index.d.ts.map +1 -1
  216. package/dist/lock-service/index.js +53 -5
  217. package/dist/lock-service/index.js.map +1 -1
  218. package/dist/migration/get-fullstack-template-dir.js +2 -2
  219. package/dist/migration/get-fullstack-template-dir.js.map +1 -1
  220. package/dist/migration/migration-routes.d.ts.map +1 -1
  221. package/dist/migration/migration-routes.js +8 -2
  222. package/dist/migration/migration-routes.js.map +1 -1
  223. package/dist/plugin-options.d.ts +1 -0
  224. package/dist/plugin-options.d.ts.map +1 -1
  225. package/dist/plugin-options.js.map +1 -1
  226. package/dist/router-parser.d.ts.map +1 -1
  227. package/dist/router-parser.js +336 -65
  228. package/dist/router-parser.js.map +1 -1
  229. package/dist/socket-manager.d.ts +25 -2
  230. package/dist/socket-manager.d.ts.map +1 -1
  231. package/dist/socket-manager.js +270 -55
  232. package/dist/socket-manager.js.map +1 -1
  233. package/dist/split-edit-after-clark.d.ts +14 -0
  234. package/dist/split-edit-after-clark.d.ts.map +1 -0
  235. package/dist/split-edit-after-clark.js +40 -0
  236. package/dist/split-edit-after-clark.js.map +1 -0
  237. package/dist/sync-service/index.d.ts +2 -1
  238. package/dist/sync-service/index.d.ts.map +1 -1
  239. package/dist/sync-service/index.js +5 -0
  240. package/dist/sync-service/index.js.map +1 -1
  241. package/dist/sync-service/list-dir.d.ts.map +1 -1
  242. package/dist/sync-service/list-dir.js +9 -4
  243. package/dist/sync-service/list-dir.js.map +1 -1
  244. package/dist/util/log-sanitizer.d.ts.map +1 -1
  245. package/dist/util/log-sanitizer.js +4 -0
  246. package/dist/util/log-sanitizer.js.map +1 -1
  247. package/dist/vite-module-reload.d.ts +34 -0
  248. package/dist/vite-module-reload.d.ts.map +1 -0
  249. package/dist/vite-module-reload.js +121 -0
  250. package/dist/vite-module-reload.js.map +1 -0
  251. package/package.json +9 -8
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { context as otelContext, propagation, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
4
+ import { context as otelContext, metrics, propagation, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
5
5
  import { hasToolCall, stepCountIs, } from "ai";
6
6
  import { z } from "zod";
7
7
  import { AiGenerationState, AiMode, resolveAttachmentFileName, } from "@superblocksteam/library-shared/types";
@@ -12,10 +12,12 @@ import { applyTestApiDenialSideEffects, buildMigrationTestApiDenialContinuationP
12
12
  import { buildTranslationPrompt } from "../migration/translation-prompt.js";
13
13
  import { POLICY_GATE_RUN_IN_PROGRESS_ERROR, policyGateRunRegistry, } from "../policy-gate-run-registry.js";
14
14
  import { getErrorMeta, getLogger, setDefaultLogger } from "../util/logger.js";
15
+ import { OperationQueue } from "../util/operation-queue.js";
15
16
  import { composeSecurityAgentPrompt, } from "./agent/prompts/build-security-scan-prompt.js";
16
17
  import { getToolCallArguments, getToolErrorCode, getToolErrorMessage, getToolOutput, } from "./agent/tool-message-utils.js";
17
18
  import { recordToolPermissionDeliveryOutcome } from "./agent/tool-permission-delivery-metrics.js";
18
19
  import { createExpiredPermissionPromptDeliveryError, flushPendingToolPermissionPrompt, inFlightToolPermissionReplay, } from "./agent/tool-permission-delivery.js";
20
+ import { recordToolPermissionLateOutcome } from "./agent/tool-permission-outcome-metrics.js";
19
21
  import { buildSecurityScanTools, isBackgroundTaskFrameworkEnabled, } from "./agent/tools.js";
20
22
  import { hasPendingEscalations, flushPendingEscalations, } from "./agent/tools/apis/api-validation-orchestrator.js";
21
23
  import { safeSampleJson } from "./agent/tools/apis/sample-json.js";
@@ -37,7 +39,8 @@ import { isUploadedAttachment, toUploadedAttachmentContentPart, } from "./attach
37
39
  import { recordChatSessionIdLookup } from "./chat/chat-session-store-metrics.js";
38
40
  import { ChatSessionStore } from "./chat/chat-session-store.js";
39
41
  import { normalizeTextAttachmentForModel } from "./chat/utils.js";
40
- import { API_MIGRATION_ORIGINS, isApiMigrationOrigin, readPersistedChecklist, updateChecklistItem, } from "./checklist/persisted-checklist-store.js";
42
+ import { API_MIGRATION_ORIGINS, cancelPolicyRemediation, seedPolicyRemediationChecklist, isApiMigrationOrigin, readPersistedChecklist, readRemediationRun, updateChecklistItem, advancePolicyRemediationTurn, MAX_REMEDIATION_TURNS, failPolicyRemediationDispatch, } from "./checklist/persisted-checklist-store.js";
43
+ import { fetchRemediationFindings } from "./checklist/remediation-findings-client.js";
41
44
  import { downloadContextFromBucketeer, downloadLatestContextFromBucketeer, restoreContextFromCheckpoint, } from "./context-download.js";
42
45
  import { restoreConversationToCheckpoint, } from "./context-restore.js";
43
46
  import { uploadContextToBucketeer } from "./context-upload.js";
@@ -63,6 +66,8 @@ import { ContextManagerV2 } from "./llm/context-v2/manager.js";
63
66
  import { JsonlEventStore } from "./llm/context-v2/storage/jsonl-event-store.js";
64
67
  import { adoptLegacyPerUserContextDirs } from "./llm/context-v2/storage/legacy-context-adoption.js";
65
68
  import { LocalContextStorageV2 } from "./llm/context-v2/storage/local.js";
69
+ import { buildRestoredContextUsageSnapshot } from "./llm/context-v2/token-tracker.js";
70
+ import { toJsonValue, } from "./llm/context-v2/types.js";
66
71
  import { createLLMProvider, setSpanError } from "./llm/provider.js";
67
72
  import { createAbortReason } from "./llm/stream/abort-reason.js";
68
73
  import { getJwtTraceTags, traceLLM } from "./llmobs/helpers.js";
@@ -86,7 +91,7 @@ import { RequestDeduplicator } from "./request-deduplicator.js";
86
91
  import { resolveGenerationStateForUi } from "./resolve-generation-state-for-ui.js";
87
92
  import { scanContentForSecrets } from "./security/secret-scanner-service.js";
88
93
  import { SecretRedactor } from "./security/secret-scanner.js";
89
- import { ClarkStateNames, Clark, SERVICE_STARTED_WITH_DRAFT, USER_ACCEPTED_DRAFT, USER_REJECTED_DRAFT, USER_SENT_PROMPT, USER_CANCELED, AGENT_CANCELED, } from "./state-machine/clark-fsm.js";
94
+ import { ClarkStateNames, Clark, SERVICE_STARTED_WITH_DRAFT, USER_ACCEPTED_DRAFT, USER_REJECTED_DRAFT, USER_SENT_PROMPT, USER_CANCELED, AGENT_CANCELED, APP_RUNTIME_UPDATED_WITHOUT_EDITS, RUNTIME_REVIEW_FINISHED, V3_AGENT_FINISHED, } from "./state-machine/clark-fsm.js";
90
95
  import { doAgentPlanning } from "./state-machine/handlers/agent-planning.js";
91
96
  import { doAwaitingUser } from "./state-machine/handlers/awaiting-user.js";
92
97
  import { doIdle } from "./state-machine/handlers/idle.js";
@@ -106,6 +111,8 @@ import { paywallStopAdvice } from "./state-machine/helpers/prompt-failure.js";
106
111
  import { CLARK_USAGE_ORIGIN, promptFailureFromStreamAbort, promptFailurePaywall, promptFailurePlatform, promptFailureUserCanceled, PROMPT_ATTEMPT_ENTRYPOINT, recordBaseCommitResolutionFailed, recordPromptAttempt, recordPromptEntryRejection, recordPromptOutcome, recordAwaitingUserHandoffDismissed, resolvePromptMetricLabels, resolvePromptOutcomeDedupeKey, resolvePromptRequestDedupeKey, setPromptRequestMetricIdentity, } from "./state-machine/helpers/prompt-request-metrics.js";
107
112
  import { catchBackgroundPeerDisconnect, StablePeer, } from "./state-machine/helpers/stable-peer.js";
108
113
  import { transitionFrom } from "./state-machine/helpers/transition.js";
114
+ import { resolveUserPreferences } from "./state-machine/helpers/user-preferences.js";
115
+ import { createUserResponseMessage } from "./state-machine/helpers/user-response-message.js";
109
116
  import { isTaskAccessibleTo, registerBackgroundTaskTypes, resyncActiveTaskStatusesToPeer, TaskStore, wireTaskStatusPushToPeer, } from "./tasks/index.js";
110
117
  import { TemplateRenderer } from "./template-renderer.js";
111
118
  import { describeMigrationInferenceJwt, isUsableMigrationInferenceJwt, } from "./util/inference-jwt.js";
@@ -439,11 +446,46 @@ const POLICY_GATE_USAGE_METADATA = {
439
446
  const ERROR_JSON_OUTPUT_TYPE = "error-json";
440
447
  const TOOL_MESSAGE_ROLE = "tool";
441
448
  const TOOL_RESULT_CONTENT_TYPE = "tool-result";
449
+ const TOOL_PERMISSION_CONTEXT_ATTEMPTS = 3;
450
+ const TOOL_PERMISSION_CONTEXT_RETRY_MS = 200;
451
+ const CLARIFICATION_DENY_REASON = "The user answered with the message that follows instead of approving.";
452
+ const TOOL_PERMISSION_PLACEHOLDER_USAGE = {
453
+ inputTokens: 0,
454
+ outputTokens: 0,
455
+ totalTokens: 0,
456
+ inputTokenDetails: {
457
+ noCacheTokens: 0,
458
+ cacheReadTokens: 0,
459
+ cacheWriteTokens: 0,
460
+ },
461
+ outputTokenDetails: {
462
+ textTokens: 0,
463
+ reasoningTokens: 0,
464
+ },
465
+ };
466
+ /**
467
+ * Provider-facing context expects tool failures to be encoded as errored tool
468
+ * results so they still satisfy the preceding tool call.
469
+ */
470
+ function toolErrorResult(toolCallId, toolName, error) {
471
+ return {
472
+ role: TOOL_MESSAGE_ROLE,
473
+ content: [
474
+ {
475
+ type: TOOL_RESULT_CONTENT_TYPE,
476
+ toolCallId,
477
+ toolName,
478
+ output: { type: ERROR_JSON_OUTPUT_TYPE, value: { error } },
479
+ },
480
+ ],
481
+ };
482
+ }
442
483
  export class AiService extends TracedEventEmitter {
443
484
  config;
444
485
  templateRenderer;
445
486
  appShell;
446
487
  fileSystemInterface;
488
+ fileSystemManager;
447
489
  integrationStore;
448
490
  knowledgeManager;
449
491
  _appContext;
@@ -468,6 +510,8 @@ export class AiService extends TracedEventEmitter {
468
510
  entityPermissionStore = new SessionEntityPermissionStore();
469
511
  generationCompleteWaiters = new Set();
470
512
  activeGenerationTrace;
513
+ currentGenerationId;
514
+ generationStarting = false;
471
515
  /** JWT-derived userId of the currently-authenticated peer, set by
472
516
  * `notifyAuthenticatedRpc` and cleared on disconnect / peer swap.
473
517
  * Only trusted while `authenticatedPeerId` still matches the bound
@@ -656,7 +700,11 @@ export class AiService extends TracedEventEmitter {
656
700
  return false;
657
701
  }
658
702
  getLogger().info("[ai-service] Accepted a new inference JWT");
703
+ const hadUser = this.getContextId() !== null;
659
704
  this.clark.updateContext({ jwt });
705
+ if (!hadUser) {
706
+ void this.pushRestoredContextUsage();
707
+ }
660
708
  const llmConfig = this.lastMigrationTurnCredentials?.llmConfig ??
661
709
  this.clark.context.llmConfig ??
662
710
  buildServerMigrationLlmConfig(this.config.flagBootstrap);
@@ -1066,7 +1114,9 @@ export class AiService extends TracedEventEmitter {
1066
1114
  this.config.draftInterface
1067
1115
  .hasLocalDraftChanges()
1068
1116
  .then((hasDraft) => {
1069
- if (hasDraft) {
1117
+ if (hasDraft &&
1118
+ this.currentGenerationId === undefined &&
1119
+ this.clark.state === ClarkStateNames.Idle) {
1070
1120
  this.clark.updateContext({
1071
1121
  hasSuggestions: true,
1072
1122
  });
@@ -1123,14 +1173,17 @@ export class AiService extends TracedEventEmitter {
1123
1173
  this.getLogger().info("[ai-service] Initialized");
1124
1174
  }
1125
1175
  async hydrateChecklistFromDisk() {
1126
- const checklist = await readPersistedChecklist(this.appRootDirPath);
1176
+ let checklist = await readPersistedChecklist(this.appRootDirPath);
1177
+ this.remediationRequestId = (await readRemediationRun(this.appRootDirPath))?.requestId;
1127
1178
  if (!checklist) {
1128
1179
  return;
1129
1180
  }
1130
1181
  // Reset stale in_progress items to pending. After a dev-server restart,
1131
1182
  // items left in_progress are not truly running — resetting them allows
1132
1183
  // the UI polling trigger and backend handler to re-dispatch Clark.
1133
- const staleInProgressItems = checklist.items.filter((item) => API_MIGRATION_ORIGINS.has(item.origin) && item.status === "in_progress");
1184
+ const staleInProgressItems = checklist.items.filter((item) => (API_MIGRATION_ORIGINS.has(item.origin) ||
1185
+ item.source === "policy_remediation") &&
1186
+ item.status === "in_progress");
1134
1187
  for (const item of staleInProgressItems) {
1135
1188
  await updateChecklistItem({
1136
1189
  appRootDirPath: this.appRootDirPath,
@@ -1139,6 +1192,8 @@ export class AiService extends TracedEventEmitter {
1139
1192
  });
1140
1193
  this.getLogger().info(`[ai-service] Reset stale in_progress item "${item.id}" to pending after restart`);
1141
1194
  }
1195
+ checklist =
1196
+ (await readPersistedChecklist(this.appRootDirPath)) ?? checklist;
1142
1197
  const hasPendingSeedApi = checklist.items.some((item) => API_MIGRATION_ORIGINS.has(item.origin) &&
1143
1198
  (item.status === "pending" || item.status === "in_progress"));
1144
1199
  if (hasPendingSeedApi) {
@@ -1395,6 +1450,7 @@ export class AiService extends TracedEventEmitter {
1395
1450
  return (fsm) => {
1396
1451
  const clark = fsm;
1397
1452
  return async (transition) => {
1453
+ const generationId = this.currentGenerationId;
1398
1454
  const params = {
1399
1455
  ...this.config,
1400
1456
  logger: this.getLogger(),
@@ -1427,6 +1483,7 @@ export class AiService extends TracedEventEmitter {
1427
1483
  toolRegistry: this.toolRegistry,
1428
1484
  entityPermissionStore: this.entityPermissionStore,
1429
1485
  onToolPermissionParked: (messageId) => this.scheduleToolPermissionReplayWindow(messageId),
1486
+ resolveToolPermissionResponse: (request) => this.resolveToolPermissionResponse(request),
1430
1487
  mcpServerManager: this.mcpServerManager,
1431
1488
  viteServer: this.viteServer,
1432
1489
  recordingManager: this.recordingManager,
@@ -1449,6 +1506,9 @@ export class AiService extends TracedEventEmitter {
1449
1506
  };
1450
1507
  const clarkStateHandlers = Object.fromEntries(Object.entries(clarkStateHandlerFactories).map(([state, handlerFactory]) => [state, handlerFactory(clark, params)]));
1451
1508
  const { from, to, event } = transition;
1509
+ const remediationTurnId = clark.context.promptOutcomeDedupeKey ?? clark.context.promptId;
1510
+ const remediationLlmConfig = clark.context.llmConfig;
1511
+ const remediationRequestId = this.remediationRequestId;
1452
1512
  this.clarkProfiler.onStateTransition(from, to, event);
1453
1513
  if (clark.context.runTimestamp) {
1454
1514
  this.clarkProfiler.initialize(this.appShell, clark.context.runTimestamp);
@@ -1459,12 +1519,74 @@ export class AiService extends TracedEventEmitter {
1459
1519
  throw new Error(`No handler for state ${to}`);
1460
1520
  }
1461
1521
  this.getLogger().info(`[ai-service] Transitioned from ${from} to ${to} with event ${event.type}`);
1522
+ const { promptId, promptOutcomeDedupeKey } = clark.context;
1523
+ let handlerCompleted = false;
1462
1524
  try {
1525
+ if (event.type === USER_SENT_PROMPT &&
1526
+ remediationRequestId &&
1527
+ (event.request.policyGateRemediation ||
1528
+ event.request.responseMetadata)) {
1529
+ const turnId = event.request.requestId ?? randomUUID();
1530
+ clark.updateContext({ promptOutcomeDedupeKey: turnId });
1531
+ const result = await this.remediationChecklistQueue.enqueue(async () => {
1532
+ const result = await advancePolicyRemediationTurn({
1533
+ appRootDirPath: this.appRootDirPath,
1534
+ applicationId: this.config.applicationId,
1535
+ turnId,
1536
+ phase: event.request.responseMetadata ? "resume" : "start",
1537
+ requestId: remediationRequestId,
1538
+ });
1539
+ await this.publishRemediationTurnResult(result);
1540
+ return result;
1541
+ });
1542
+ if (result.outcome === "turn_limit") {
1543
+ if (!event.internal)
1544
+ await this.persistRejectedUserInput(event.request, event.inputSource);
1545
+ await clark.send({
1546
+ type: AGENT_CANCELED,
1547
+ advice: `Security remediation stopped after ${MAX_REMEDIATION_TURNS} turns. Retry to continue the remaining groups.`,
1548
+ failure: promptFailurePlatform("retries_exhausted"),
1549
+ });
1550
+ return;
1551
+ }
1552
+ if (clark.state !== to || clark.context.userInitiatedPromptAbort)
1553
+ return;
1554
+ clark.updateContext({
1555
+ usageMetadata: buildPolicyGateRemediationUsageMetadata(result.state?.policyGateRemediation),
1556
+ });
1557
+ }
1463
1558
  await handler(transition);
1559
+ handlerCompleted = true;
1560
+ if (event.type === USER_SENT_PROMPT &&
1561
+ event.internal &&
1562
+ event.request.policyGateRemediation &&
1563
+ remediationRequestId &&
1564
+ this.ownsRemediationRun(remediationRequestId)) {
1565
+ this.recordRemediationDispatch("started", "dispatched");
1566
+ }
1464
1567
  }
1465
1568
  catch (error) {
1569
+ if (event.type === USER_SENT_PROMPT &&
1570
+ event.executeToolPermissionResponse &&
1571
+ (clark.context.promptId !== promptId ||
1572
+ clark.context.promptOutcomeDedupeKey !== promptOutcomeDedupeKey)) {
1573
+ // A newer turn owns Clark now, so this failure must not cancel it.
1574
+ this.getLogger().warn("[ai-service] A superseded tool permission turn failed after a newer turn took over", {
1575
+ ...getErrorMeta(error),
1576
+ currentPromptId: clark.context.promptId,
1577
+ supersededPromptId: promptId,
1578
+ });
1579
+ return;
1580
+ }
1466
1581
  const errorMeta = getErrorMeta(error);
1467
1582
  this.getLogger().error(`[ai-service] Error in handler for state ${to} with event ${event.type}: ${errorMeta.error.message}`, errorMeta);
1583
+ if (event.type === USER_SENT_PROMPT &&
1584
+ remediationRequestId &&
1585
+ (event.request.policyGateRemediation ||
1586
+ event.request.responseMetadata) &&
1587
+ !clark.context.abortController?.signal.aborted) {
1588
+ await this.failRemediationDispatch(remediationRequestId, event.request.requestId ?? remediationTurnId ?? "", "dispatch_failed", undefined, error);
1589
+ }
1468
1590
  if (clark.context.abortController?.signal.aborted &&
1469
1591
  event.type !== USER_CANCELED &&
1470
1592
  event.type !== AGENT_CANCELED) {
@@ -1488,14 +1610,26 @@ export class AiService extends TracedEventEmitter {
1488
1610
  to === ClarkStateNames.Dead) {
1489
1611
  // The turn is over: drop per-turn billing attribution so stray
1490
1612
  // LLM calls before the next prompt bill as default generation.
1491
- if (clark.context.usageMetadata || clark.context.baseCommitId) {
1613
+ if (generationId === this.currentGenerationId &&
1614
+ (clark.context.usageMetadata || clark.context.baseCommitId)) {
1492
1615
  clark.updateContext((context) => ({
1493
1616
  ...context,
1494
1617
  usageMetadata: undefined,
1495
1618
  baseCommitId: undefined,
1496
1619
  }));
1497
1620
  }
1498
- this.triggerGenerationComplete();
1621
+ this.triggerGenerationComplete(generationId);
1622
+ if (handlerCompleted &&
1623
+ remediationRequestId &&
1624
+ this.ownsRemediationRun(remediationRequestId) &&
1625
+ remediationTurnId &&
1626
+ remediationLlmConfig &&
1627
+ (event.type === APP_RUNTIME_UPDATED_WITHOUT_EDITS ||
1628
+ event.type === V3_AGENT_FINISHED ||
1629
+ event.type === RUNTIME_REVIEW_FINISHED ||
1630
+ event.type === USER_ACCEPTED_DRAFT)) {
1631
+ void this.tryContinuePolicyRemediation(remediationTurnId, remediationLlmConfig);
1632
+ }
1499
1633
  }
1500
1634
  }
1501
1635
  };
@@ -1540,7 +1674,9 @@ export class AiService extends TracedEventEmitter {
1540
1674
  */
1541
1675
  async remoteRestoreFallback(id) {
1542
1676
  const jwt = this.getInferenceJwt();
1543
- const branchName = this._syncContextProvider?.getBranchName();
1677
+ const branchName = jwt
1678
+ ? await this._syncContextProvider?.getBranchName()
1679
+ : undefined;
1544
1680
  if (!jwt || !branchName) {
1545
1681
  // A missing JWT is expected startup churn on a fresh pod (this runs
1546
1682
  // before auth is ready), so stay quiet there. Warn on the authed case
@@ -1865,132 +2001,147 @@ export class AiService extends TracedEventEmitter {
1865
2001
  recordAutoDispatchOutcome("dead");
1866
2002
  return { dispatched: false, reason: "dead" };
1867
2003
  }
1868
- if (this.clark.state !== ClarkStateNames.Idle &&
1869
- this.clark.state !== ClarkStateNames.AwaitingUser) {
1870
- const reason = `busy:${this.clark.state}`;
1871
- recordAutoDispatchOutcome(reason);
1872
- return { dispatched: false, reason };
1873
- }
1874
- if (this.isAwaitingInteractiveResponse()) {
1875
- const reason = this.clark.context.pendingToolPermissionRequest
1876
- ? "pending-tool-permission"
1877
- : "awaiting-interactive-response";
2004
+ const blocked = this.autoDispatchBlockReason();
2005
+ if (blocked) {
2006
+ const reason = blocked === "busy"
2007
+ ? `busy:${this.clark.state}`
2008
+ : blocked === "tool_permission"
2009
+ ? "pending-tool-permission"
2010
+ : "awaiting-interactive-response";
1878
2011
  recordAutoDispatchOutcome(reason);
1879
2012
  return { dispatched: false, reason };
1880
2013
  }
1881
2014
  this.migrationTurnTriggered = true;
1882
- const checklist = await readPersistedChecklist(this.appRootDirPath);
1883
- if (!checklist) {
1884
- this.migrationTurnTriggered = false;
1885
- this.getLogger().warn("[ai-service] Migration checklist not found or unreadable; skipping translation turn");
1886
- recordAutoDispatchOutcome("checklist-unavailable");
1887
- return { dispatched: false, reason: "checklist-unavailable" };
1888
- }
1889
- const pendingSeedApiItems = checklist.items.filter((item) => API_MIGRATION_ORIGINS.has(item.origin) &&
1890
- (item.status === "pending" || item.status === "in_progress"));
1891
- const pendingSeedApiCount = pendingSeedApiItems.length;
1892
- if (pendingSeedApiCount === 0) {
1893
- this.migrationTurnTriggered = false;
1894
- recordAutoDispatchOutcome("no-pending-items");
1895
- return { dispatched: false, reason: "no-pending-items" };
1896
- }
1897
- // Pre-flight quota gate. The migration turn drives LLM inference (which
1898
- // doesn't enforce quota) followed by tool calls that hit quota-protected
1899
- // endpoints (e.g. attachment uploads via /api/v1/files). Without this gate
1900
- // an over-limit org burns more credits on the migration turn, then fails
1901
- // mid-turn with a generic "inference error" when a downstream tool call
1902
- // 429s. Fail fast with a clear reason before consuming any credits, the
1903
- // same pre-flight check handleAiGenerate already runs.
1904
- const quota = await this.checkAiQuota(this.config.inferenceAuthToken ?? request.jwt);
1905
- if (!quota.allowed) {
1906
- this.migrationTurnTriggered = false;
1907
- this.getLogger().warn("[ai-service] Skipping v2→v3 migration turn — AI quota exceeded", { reason: quota.reason });
1908
- recordAutoDispatchOutcome("quota-exceeded");
1909
- return { dispatched: false, reason: "quota-exceeded" };
1910
- }
1911
- this.appShell.setApiHandlerEnabled(false);
1912
- this.getLogger().info(`[ai-service] Dispatching internal v2→v3 migration turn for ${pendingSeedApiCount} API(s) (API VFS projection disabled, retryMode=${request.retryMode ?? "none"})`);
1913
- const prompt = buildTranslationPrompt({
1914
- focusedDebug: request.retryMode === "focused-debug",
1915
- });
1916
- const migrationLlmConfig = this.getMigrationSafeLlmConfig(request.llmConfig);
1917
- const generateRequest = {
1918
- prompt,
1919
- jwt: request.jwt,
1920
- llmConfig: migrationLlmConfig,
1921
- mode: AiMode.BUILD,
1922
- };
2015
+ this.generationStarting = true;
1923
2016
  const promptId = randomUUID();
1924
- const retryMode = request.retryMode === "focused-debug" ? "focused-debug" : "none";
1925
- const triggeredBy = request.triggeredBy ??
1926
- (request.retryMode === "focused-debug" ? "focused-debug" : "ui-rpc");
1927
- const branchName = this._syncContextProvider?.getBranchName();
1928
- const migrationUsageMetadata = {
1929
- applicationId: this.config.applicationId,
1930
- branchName,
1931
- migrationSource: "superblocks_2_0",
1932
- origin: CLARK_USAGE_ORIGIN.app_migration,
1933
- pendingSeedApiCount,
1934
- retryMode,
1935
- surface: "app",
1936
- triggeredBy,
1937
- };
1938
- const [baseCommitId] = await Promise.all([
1939
- this.resolveBaseCommitId(promptId),
1940
- this.resolveChatSessionId(),
1941
- ]);
1942
- this.clark.updateContext((context) => ({
1943
- ...context,
1944
- promptId,
1945
- promptOutcomeDedupeKey: promptId,
1946
- baseCommitId,
1947
- usageMetadata: migrationUsageMetadata,
1948
- llmConfig: migrationLlmConfig,
1949
- }));
1950
- const transitionTo = transitionFrom(this.clark);
1951
- // Promise is intentionally not awaited — the translation turn runs in the
1952
- // background. We reset the guard + re-enable the API handler when the
1953
- // turn *settles* (success or failure) so any remaining pending items
1954
- // (from auto-retry budget flips or genuine agent exits) can trigger
1955
- // another sweep without a dev-server restart.
1956
- let settleOutcome = "success";
1957
- transitionTo({
1958
- type: USER_SENT_PROMPT,
1959
- request: generateRequest,
1960
- internal: true,
1961
- visibleInChatHistory: true,
1962
- })
1963
- .catch((error) => {
1964
- settleOutcome = "error";
1965
- this.getLogger().error("[ai-service] Migration translation turn failed", getErrorMeta(error));
1966
- })
1967
- .finally(async () => {
2017
+ let generationStarted = false;
2018
+ try {
2019
+ const checklist = await readPersistedChecklist(this.appRootDirPath);
2020
+ if (!checklist) {
2021
+ this.migrationTurnTriggered = false;
2022
+ this.getLogger().warn("[ai-service] Migration checklist not found or unreadable; skipping translation turn");
2023
+ recordAutoDispatchOutcome("checklist-unavailable");
2024
+ return { dispatched: false, reason: "checklist-unavailable" };
2025
+ }
2026
+ const pendingSeedApiItems = checklist.items.filter((item) => API_MIGRATION_ORIGINS.has(item.origin) &&
2027
+ (item.status === "pending" || item.status === "in_progress"));
2028
+ const pendingSeedApiCount = pendingSeedApiItems.length;
2029
+ if (pendingSeedApiCount === 0) {
2030
+ this.migrationTurnTriggered = false;
2031
+ recordAutoDispatchOutcome("no-pending-items");
2032
+ return { dispatched: false, reason: "no-pending-items" };
2033
+ }
2034
+ // Pre-flight quota gate. The migration turn drives LLM inference (which
2035
+ // doesn't enforce quota) followed by tool calls that hit quota-protected
2036
+ // endpoints (e.g. attachment uploads via /api/v1/files). Without this gate
2037
+ // an over-limit org burns more credits on the migration turn, then fails
2038
+ // mid-turn with a generic "inference error" when a downstream tool call
2039
+ // 429s. Fail fast with a clear reason before consuming any credits, the
2040
+ // same pre-flight check handleAiGenerate already runs.
2041
+ const quota = await this.checkAiQuota(this.config.inferenceAuthToken ?? request.jwt);
2042
+ if (!quota.allowed) {
2043
+ this.migrationTurnTriggered = false;
2044
+ this.getLogger().warn("[ai-service] Skipping v2→v3 migration turn — AI quota exceeded", { reason: quota.reason });
2045
+ recordAutoDispatchOutcome("quota-exceeded");
2046
+ return { dispatched: false, reason: "quota-exceeded" };
2047
+ }
2048
+ await this.config.waitForAppFiles?.(promptId);
2049
+ this.currentGenerationId = promptId;
2050
+ this.appShell.setApiHandlerEnabled(false);
2051
+ this.getLogger().info(`[ai-service] Dispatching internal v2→v3 migration turn for ${pendingSeedApiCount} API(s) (API VFS projection disabled, retryMode=${request.retryMode ?? "none"})`);
2052
+ const prompt = buildTranslationPrompt({
2053
+ focusedDebug: request.retryMode === "focused-debug",
2054
+ });
2055
+ const migrationLlmConfig = this.getMigrationSafeLlmConfig(request.llmConfig);
2056
+ const generateRequest = {
2057
+ prompt,
2058
+ jwt: request.jwt,
2059
+ llmConfig: migrationLlmConfig,
2060
+ mode: AiMode.BUILD,
2061
+ };
2062
+ const retryMode = request.retryMode === "focused-debug" ? "focused-debug" : "none";
2063
+ const triggeredBy = request.triggeredBy ??
2064
+ (request.retryMode === "focused-debug" ? "focused-debug" : "ui-rpc");
2065
+ const branchName = await this._syncContextProvider?.getBranchName();
2066
+ const migrationUsageMetadata = {
2067
+ applicationId: this.config.applicationId,
2068
+ branchName,
2069
+ migrationSource: "superblocks_2_0",
2070
+ origin: CLARK_USAGE_ORIGIN.app_migration,
2071
+ pendingSeedApiCount,
2072
+ retryMode,
2073
+ surface: "app",
2074
+ triggeredBy,
2075
+ };
2076
+ const [baseCommitId] = await Promise.all([
2077
+ this.resolveBaseCommitId(promptId),
2078
+ this.resolveChatSessionId(),
2079
+ ]);
2080
+ this.clark.updateContext((context) => ({
2081
+ ...context,
2082
+ promptId,
2083
+ promptOutcomeDedupeKey: promptId,
2084
+ baseCommitId,
2085
+ usageMetadata: migrationUsageMetadata,
2086
+ llmConfig: migrationLlmConfig,
2087
+ }));
2088
+ const transitionTo = transitionFrom(this.clark);
2089
+ // Promise is intentionally not awaited — the translation turn runs in the
2090
+ // background. We reset the guard + re-enable the API handler when the
2091
+ // turn *settles* (success or failure) so any remaining pending items
2092
+ // (from auto-retry budget flips or genuine agent exits) can trigger
2093
+ // another sweep without a dev-server restart.
2094
+ let settleOutcome = "success";
2095
+ transitionTo({
2096
+ type: USER_SENT_PROMPT,
2097
+ request: generateRequest,
2098
+ internal: true,
2099
+ visibleInChatHistory: true,
2100
+ })
2101
+ .catch((error) => {
2102
+ settleOutcome = "error";
2103
+ this.getLogger().error("[ai-service] Migration translation turn failed", getErrorMeta(error));
2104
+ })
2105
+ .finally(async () => {
2106
+ this.emit("generationSettled", promptId);
2107
+ try {
2108
+ await recordMigrationTurnSettled(this.appRootDirPath, {
2109
+ outcome: settleOutcome,
2110
+ promptId,
2111
+ retryMode,
2112
+ });
2113
+ }
2114
+ catch (error) {
2115
+ this.getLogger().error(`[ai-service] Failed to record migration turn settlement (promptId=${promptId})`, getErrorMeta(error));
2116
+ }
2117
+ this.migrationTurnTriggered = false;
2118
+ this.appShell.setApiHandlerEnabled(true);
2119
+ });
2120
+ generationStarted = true;
1968
2121
  try {
1969
- await recordMigrationTurnSettled(this.appRootDirPath, {
1970
- outcome: settleOutcome,
2122
+ await recordMigrationTurnDispatched(this.appRootDirPath, {
2123
+ pendingSeedApiCount,
1971
2124
  promptId,
1972
2125
  retryMode,
2126
+ triggeredBy,
1973
2127
  });
1974
2128
  }
1975
2129
  catch (error) {
1976
- this.getLogger().error(`[ai-service] Failed to record migration turn settlement (promptId=${promptId})`, getErrorMeta(error));
2130
+ this.getLogger().error(`[ai-service] Failed to record migration turn dispatch (promptId=${promptId})`, getErrorMeta(error));
1977
2131
  }
1978
- this.migrationTurnTriggered = false;
1979
- this.appShell.setApiHandlerEnabled(true);
1980
- });
1981
- try {
1982
- await recordMigrationTurnDispatched(this.appRootDirPath, {
1983
- pendingSeedApiCount,
1984
- promptId,
1985
- retryMode,
1986
- triggeredBy,
1987
- });
2132
+ recordAutoDispatchOutcome("dispatched");
2133
+ return { dispatched: true };
1988
2134
  }
1989
- catch (error) {
1990
- this.getLogger().error(`[ai-service] Failed to record migration turn dispatch (promptId=${promptId})`, getErrorMeta(error));
2135
+ finally {
2136
+ this.generationStarting = false;
2137
+ if (!generationStarted) {
2138
+ this.migrationTurnTriggered = false;
2139
+ if (this.currentGenerationId === promptId) {
2140
+ this.appShell.setApiHandlerEnabled(true);
2141
+ }
2142
+ this.emit("generationSettled", promptId);
2143
+ }
1991
2144
  }
1992
- recordAutoDispatchOutcome("dispatched");
1993
- return { dispatched: true };
1994
2145
  }
1995
2146
  getMigrationSafeLlmConfig(llmConfig) {
1996
2147
  const result = filterDisabledToolsForMigration(llmConfig);
@@ -2024,16 +2175,17 @@ export class AiService extends TracedEventEmitter {
2024
2175
  * chat record + modal — instead of the interrupt path silently rejecting
2025
2176
  * with no UI state. (APPS-4080)
2026
2177
  */
2027
- async tryHandlePaywallForRequest(request, inputSource) {
2178
+ async tryHandlePaywallForRequest(request, inputSource, internalSignal) {
2028
2179
  try {
2029
2180
  await this.ensureAiQuotaAvailable(this.config.inferenceAuthToken ?? request.jwt);
2030
2181
  return undefined;
2031
2182
  }
2032
2183
  catch (error) {
2184
+ internalSignal?.throwIfAborted();
2033
2185
  if (error instanceof Error) {
2034
2186
  const reason = getAiQuotaPaywallReasonFromMessage(error.message);
2035
2187
  if (reason) {
2036
- await this.handlePreCallPaywall(request, reason, inputSource);
2188
+ await this.handlePreCallPaywall(request, reason, inputSource, !!internalSignal);
2037
2189
  return reason;
2038
2190
  }
2039
2191
  }
@@ -2055,48 +2207,8 @@ export class AiService extends TracedEventEmitter {
2055
2207
  this.getLogger().warn("[ai-service] Failed to publish user input", getErrorMeta(err));
2056
2208
  });
2057
2209
  }
2058
- /**
2059
- * Persist the user's prompt + the "credits ran out" advice and open the
2060
- * paywall modal when the pre-call quota check denies the request. Mirrors
2061
- * what `idle.ts:AGENT_CANCELED` does for the mid-stream paywall path so
2062
- * both paths leave an identical chat record. Used only when
2063
- * `ensureAiQuotaAvailable` throws a paywall error in `handleAiGenerate`.
2064
- *
2065
- * - For organic user prompts (no `responseMetadata`), persists via
2066
- * `recordUser`.
2067
- * - For widget responses (`multi_choice_response`, `plan_response`,
2068
- * `confirm_response`, `searchable_dropdown_response`,
2069
- * `remember_knowledge_draft_response`), persists via
2070
- * `recordUserResponse` with the full metadata — without this, the
2071
- * user's exact choice is silently dropped (they lose their selection
2072
- * from chat history AND the resume turn's LLM has no record of what
2073
- * they picked). Mirrors `agent-planning.ts:USER_SENT_PROMPT`.
2074
- * - `tool_permission_response` is intentionally NOT persisted — those
2075
- * responses don't have a chat representation; the approval is just
2076
- * side-effect state. On resume the user will see the permission
2077
- * prompt again, which is acceptable UX.
2078
- * - Uses fire-and-forget peer pushes after each `await` on the store so
2079
- * the chat is durable across refresh even if the live peer push drops.
2080
- */
2081
- async handlePreCallPaywall(request, paywallReason, inputSource) {
2082
- this.getLogger().info(`[ai-service] Pre-call paywall denial; persisting prompt + advice for chat history`, { paywallReason });
2083
- // APPS-4080: pre-cache the CANCELLED + paywallReason snapshot into
2084
- // `lastGenerationState` BEFORE any chat-persistence await. Without
2085
- // this, a socket reconnect during the awaits in steps 1-2 below
2086
- // would replay the stale IDLE snapshot held over from before the
2087
- // request, and the UI saga (`paywallReason && state === CANCELLED`
2088
- // -> openAiQuotaPaywallModal) would never see the paywall payload —
2089
- // so the modal would not reopen on refresh. The full
2090
- // `sendUserGenerationStateChannel({...})` in step 3 below re-caches
2091
- // and pushes to the peer so the LIVE modal opens AFTER the advice
2092
- // is in chat. (Cursor PR-19341 review.)
2093
- this.clark.updateContext({
2094
- lastGenerationState: {
2095
- state: AiGenerationState.CANCELLED,
2096
- hasError: true,
2097
- paywallReason,
2098
- },
2099
- });
2210
+ /** Record a rejected request before emitting a terminal response, without starting generation. */
2211
+ async persistRejectedUserInput(request, inputSource) {
2100
2212
  // 1. Persist + echo the user's prompt so the chat doesn't lose it.
2101
2213
  const responseMetadata = request.responseMetadata;
2102
2214
  if (!responseMetadata) {
@@ -2116,7 +2228,7 @@ export class AiService extends TracedEventEmitter {
2116
2228
  });
2117
2229
  }
2118
2230
  catch (err) {
2119
- this.getLogger().warn("[ai-service] Failed to persist pre-call paywall user prompt", getErrorMeta(err));
2231
+ this.getLogger().warn("[ai-service] Failed to persist rejected user prompt", getErrorMeta(err));
2120
2232
  }
2121
2233
  }
2122
2234
  else if (responseMetadata.type !== "tool_permission_response") {
@@ -2134,27 +2246,7 @@ export class AiService extends TracedEventEmitter {
2134
2246
  responseMetadata.selectedChoiceIndices.length === 0) ||
2135
2247
  (responseMetadata.type === "plan_response" &&
2136
2248
  !responseMetadata.approved);
2137
- const responseMessage = responseMetadata.type === "multi_choice_response" &&
2138
- responseMetadata.selectedChoiceIndices.length > 0
2139
- ? {
2140
- content: request.prompt,
2141
- id: randomUUID(),
2142
- responseToMessageId: responseMetadata.responseToMessageId,
2143
- role: "user",
2144
- selectedChoiceIndices: responseMetadata.selectedChoiceIndices,
2145
- selectedScopes: responseMetadata.selectedScopes,
2146
- timestamp: Date.now(),
2147
- type: "multi_choice_response",
2148
- }
2149
- : isTypedOverride
2150
- ? {
2151
- content: request.prompt,
2152
- id: randomUUID(),
2153
- role: "user",
2154
- timestamp: Date.now(),
2155
- type: "text",
2156
- }
2157
- : undefined;
2249
+ const responseMessage = createUserResponseMessage(request.prompt, responseMetadata, inputSource);
2158
2250
  if (responseMessage) {
2159
2251
  this.publishUserInput(responseMessage, inputSource, isTypedOverride);
2160
2252
  }
@@ -2207,14 +2299,59 @@ export class AiService extends TracedEventEmitter {
2207
2299
  responseToMessageId: responseMetadata.responseToMessageId,
2208
2300
  })
2209
2301
  .catch((err) => {
2210
- this.getLogger().warn("[ai-service] Failed to echo pre-call paywall widget response", getErrorMeta(err));
2302
+ this.getLogger().warn("[ai-service] Failed to echo rejected widget response", getErrorMeta(err));
2211
2303
  });
2212
2304
  }
2213
2305
  }
2214
2306
  catch (err) {
2215
- this.getLogger().warn("[ai-service] Failed to persist pre-call paywall widget response", getErrorMeta(err));
2307
+ this.getLogger().warn("[ai-service] Failed to persist rejected widget response", getErrorMeta(err));
2216
2308
  }
2217
2309
  }
2310
+ }
2311
+ /**
2312
+ * Persist the user's prompt + the "credits ran out" advice and open the
2313
+ * paywall modal when the pre-call quota check denies the request. Mirrors
2314
+ * what `idle.ts:AGENT_CANCELED` does for the mid-stream paywall path so
2315
+ * both paths leave an identical chat record. Used only when
2316
+ * `ensureAiQuotaAvailable` throws a paywall error in `handleAiGenerate`.
2317
+ *
2318
+ * - For organic user prompts (no `responseMetadata`), persists via
2319
+ * `recordUser`.
2320
+ * - For widget responses (`multi_choice_response`, `plan_response`,
2321
+ * `confirm_response`, `searchable_dropdown_response`,
2322
+ * `remember_knowledge_draft_response`), persists via
2323
+ * `recordUserResponse` with the full metadata — without this, the
2324
+ * user's exact choice is silently dropped (they lose their selection
2325
+ * from chat history AND the resume turn's LLM has no record of what
2326
+ * they picked). Mirrors `agent-planning.ts:USER_SENT_PROMPT`.
2327
+ * - `tool_permission_response` is intentionally NOT persisted — those
2328
+ * responses don't have a chat representation; the approval is just
2329
+ * side-effect state. On resume the user will see the permission
2330
+ * prompt again, which is acceptable UX.
2331
+ * - Uses fire-and-forget peer pushes after each `await` on the store so
2332
+ * the chat is durable across refresh even if the live peer push drops.
2333
+ */
2334
+ async handlePreCallPaywall(request, paywallReason, inputSource, internal = false) {
2335
+ this.getLogger().info(`[ai-service] Pre-call paywall denial; persisting prompt + advice for chat history`, { paywallReason });
2336
+ // APPS-4080: pre-cache the CANCELLED + paywallReason snapshot into
2337
+ // `lastGenerationState` BEFORE any chat-persistence await. Without
2338
+ // this, a socket reconnect during the awaits in steps 1-2 below
2339
+ // would replay the stale IDLE snapshot held over from before the
2340
+ // request, and the UI saga (`paywallReason && state === CANCELLED`
2341
+ // -> openAiQuotaPaywallModal) would never see the paywall payload —
2342
+ // so the modal would not reopen on refresh. The full
2343
+ // `sendUserGenerationStateChannel({...})` in step 3 below re-caches
2344
+ // and pushes to the peer so the LIVE modal opens AFTER the advice
2345
+ // is in chat. (Cursor PR-19341 review.)
2346
+ this.clark.updateContext({
2347
+ lastGenerationState: {
2348
+ state: AiGenerationState.CANCELLED,
2349
+ hasError: true,
2350
+ paywallReason,
2351
+ },
2352
+ });
2353
+ if (!internal)
2354
+ await this.persistRejectedUserInput(request, inputSource);
2218
2355
  // 2. Persist + echo the credit advice. Same reason-aware copy as
2219
2356
  // `agent-planning.ts:LLM_ERRORED` so users see consistent guidance
2220
2357
  // across pre-call and mid-stream paywall paths.
@@ -2284,7 +2421,8 @@ export class AiService extends TracedEventEmitter {
2284
2421
  // tool_permission_response — those have no persisted chat
2285
2422
  // representation; the approval is just side-effect state that gets
2286
2423
  // re-prompted on resume.
2287
- if (request.responseMetadata?.type !== "tool_permission_response") {
2424
+ if (!internal &&
2425
+ request.responseMetadata?.type !== "tool_permission_response") {
2288
2426
  try {
2289
2427
  // `getContextId` reads `clark.context.jwt` for the userId, but the
2290
2428
  // JWT is normally only stored into clark context by the
@@ -2347,7 +2485,198 @@ export class AiService extends TracedEventEmitter {
2347
2485
  }
2348
2486
  }
2349
2487
  }
2350
- async handleAiGenerate(request, inputSource) {
2488
+ remediationPreparation;
2489
+ remediationAutoDispatch;
2490
+ // Identifies the run owned by the live turn; run data stays on disk.
2491
+ remediationRequestId;
2492
+ ownsRemediationRun(requestId, signal) {
2493
+ return (!signal?.aborted &&
2494
+ !this.clark.context.userInitiatedPromptAbort &&
2495
+ this.remediationRequestId === requestId);
2496
+ }
2497
+ remediationCancellation;
2498
+ remediationChecklistQueue = new OperationQueue();
2499
+ async publishRemediationTurnResult(result) {
2500
+ this.remediationRequestId = result.state?.policyGateRemediation?.requestId;
2501
+ for (const item of result.failedItems) {
2502
+ await this.appShell.sendMessage({
2503
+ type: "checklist_item_updated",
2504
+ payload: { item, totalItems: result.state?.items.length },
2505
+ });
2506
+ }
2507
+ }
2508
+ recordRemediationDispatch(outcome, reason) {
2509
+ metrics
2510
+ .getMeter("superblocks.clark")
2511
+ .createCounter("policy_remediation_dispatch_total", {
2512
+ description: "Automatic remediation dispatch decisions",
2513
+ unit: "1",
2514
+ })
2515
+ .add(1, { outcome, "remediation.reason": reason });
2516
+ }
2517
+ async failRemediationDispatch(requestId, turnId, failureReason = "dispatch_failed", signal, error) {
2518
+ if (!this.ownsRemediationRun(requestId, signal))
2519
+ return;
2520
+ this.recordRemediationDispatch("failed", failureReason);
2521
+ this.getLogger().warnStructured?.("Policy remediation continuation failed", {
2522
+ "application.id": this.config.applicationId,
2523
+ "remediation.request_id": requestId,
2524
+ "remediation.turn_id": turnId,
2525
+ "remediation.failure_reason": failureReason,
2526
+ "error.type": getErrorMeta(error).error.kind,
2527
+ "error.message": getErrorMeta(error).error.message,
2528
+ });
2529
+ const maxAttempts = 3;
2530
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2531
+ if (attempt > 1)
2532
+ await new Promise((resolve) => setTimeout(resolve, 100 * (attempt - 1)));
2533
+ if (!this.ownsRemediationRun(requestId, signal))
2534
+ return;
2535
+ try {
2536
+ await this.remediationChecklistQueue.enqueue(async () => {
2537
+ if (!this.ownsRemediationRun(requestId, signal))
2538
+ return;
2539
+ const failed = await failPolicyRemediationDispatch({
2540
+ appRootDirPath: this.appRootDirPath,
2541
+ requestId,
2542
+ });
2543
+ await this.publishRemediationTurnResult(failed);
2544
+ });
2545
+ return;
2546
+ }
2547
+ catch (persistenceError) {
2548
+ this.recordRemediationDispatch("failed", "failure_persistence_failed");
2549
+ this.getLogger().warnStructured?.("Could not persist remediation dispatch failure", {
2550
+ "application.id": this.config.applicationId,
2551
+ "remediation.request_id": requestId,
2552
+ "remediation.turn_id": turnId,
2553
+ "remediation.failure_reason": "failure_persistence_failed",
2554
+ "remediation.persistence_attempt": attempt,
2555
+ "error.type": getErrorMeta(persistenceError).error.kind,
2556
+ "error.message": getErrorMeta(persistenceError).error.message,
2557
+ });
2558
+ }
2559
+ }
2560
+ if (!this.ownsRemediationRun(requestId, signal))
2561
+ return;
2562
+ // Keep the durable owner available for Stop/retry. Never pretend a failed
2563
+ // write committed; notify the live peer even if chat persistence also fails.
2564
+ const message = {
2565
+ id: randomUUID(),
2566
+ role: "assistant",
2567
+ type: "error",
2568
+ timestamp: Date.now(),
2569
+ content: "Security remediation stopped because its progress could not be saved. Restore access to project storage, then stop and retry security remediation.",
2570
+ };
2571
+ const notifications = await Promise.allSettled([
2572
+ this.chatSessionStore.recordAssistant(message),
2573
+ this.clark.context.peer?.call.aiPushMessage(message),
2574
+ ]);
2575
+ for (const notification of notifications) {
2576
+ if (notification.status === "rejected")
2577
+ this.getLogger().warn("Could not publish remediation persistence failure", getErrorMeta(notification.reason));
2578
+ }
2579
+ }
2580
+ async tryContinuePolicyRemediation(turnId, llmConfig) {
2581
+ if (this.remediationAutoDispatch &&
2582
+ !this.remediationAutoDispatch.signal.aborted) {
2583
+ this.recordRemediationDispatch("skipped", "already_dispatching");
2584
+ return;
2585
+ }
2586
+ const requestId = this.remediationRequestId;
2587
+ if (!requestId) {
2588
+ this.recordRemediationDispatch("skipped", "unowned_turn");
2589
+ return;
2590
+ }
2591
+ const controller = new AbortController();
2592
+ this.remediationAutoDispatch = controller;
2593
+ const skipReason = () => {
2594
+ if (controller.signal.aborted ||
2595
+ this.clark.context.userInitiatedPromptAbort)
2596
+ return "cancelled";
2597
+ // Terminal settlement clears the owner; only a replacement invalidates it.
2598
+ if (this.remediationRequestId && !this.ownsRemediationRun(requestId))
2599
+ return "turn_changed";
2600
+ return this.autoDispatchBlockReason();
2601
+ };
2602
+ const shouldSkip = () => {
2603
+ const reason = skipReason();
2604
+ if (reason)
2605
+ this.recordRemediationDispatch("skipped", reason);
2606
+ return reason !== undefined;
2607
+ };
2608
+ let failureReason = "dispatch_failed";
2609
+ try {
2610
+ if (shouldSkip())
2611
+ return;
2612
+ const result = await this.remediationChecklistQueue.enqueue(async () => {
2613
+ if (shouldSkip())
2614
+ return undefined;
2615
+ const result = await advancePolicyRemediationTurn({
2616
+ appRootDirPath: this.appRootDirPath,
2617
+ applicationId: this.config.applicationId,
2618
+ turnId,
2619
+ phase: "settle",
2620
+ requestId,
2621
+ });
2622
+ await this.publishRemediationTurnResult(result);
2623
+ return result;
2624
+ });
2625
+ if (!result || shouldSkip())
2626
+ return;
2627
+ if (result.outcome !== "continue") {
2628
+ // started cannot result from settle, but keep the signal bounded.
2629
+ this.recordRemediationDispatch("skipped", result.outcome === "started" ? "duplicate" : result.outcome);
2630
+ return;
2631
+ }
2632
+ const run = result.state?.policyGateRemediation;
2633
+ if (!run || run.requestId !== requestId) {
2634
+ this.recordRemediationDispatch("skipped", "inactive");
2635
+ return;
2636
+ }
2637
+ if (!this.clark.context.jwt) {
2638
+ failureReason = "missing_auth";
2639
+ throw new Error("Authentication is required to continue remediation");
2640
+ }
2641
+ const nextTurnId = randomUUID();
2642
+ await this.handleAiGenerate({
2643
+ prompt: "Continue the security fixes. Call getSecurityFindings for the next group.",
2644
+ requestId: nextTurnId,
2645
+ jwt: this.clark.context.jwt,
2646
+ llmConfig,
2647
+ mode: this.clark.context.currentMode,
2648
+ userPreferences: resolveUserPreferences(this.clark.context.userPreferences),
2649
+ policyGateRemediation: {
2650
+ batched: true,
2651
+ reviewFindingIds: run.reviewFindingIds,
2652
+ reviewRunIds: run.reviewRunIds,
2653
+ },
2654
+ }, undefined, controller.signal);
2655
+ if (!this.ownsRemediationRun(requestId, controller.signal)) {
2656
+ this.recordRemediationDispatch("skipped", "cancelled");
2657
+ }
2658
+ else if (this.clark.context.promptOutcomeDedupeKey !== nextTurnId) {
2659
+ if (this.clark.context.pausedForCreditsReason) {
2660
+ this.recordRemediationDispatch("skipped", "paywall");
2661
+ }
2662
+ else {
2663
+ throw new Error("Automatic remediation dispatch was not accepted");
2664
+ }
2665
+ }
2666
+ }
2667
+ catch (error) {
2668
+ if (!this.ownsRemediationRun(requestId, controller.signal)) {
2669
+ this.recordRemediationDispatch("skipped", "cancelled");
2670
+ return;
2671
+ }
2672
+ await this.failRemediationDispatch(requestId, turnId, failureReason, controller.signal, error);
2673
+ }
2674
+ finally {
2675
+ if (this.remediationAutoDispatch === controller)
2676
+ this.remediationAutoDispatch = undefined;
2677
+ }
2678
+ }
2679
+ async handleAiGenerate(request, inputSource, remediationContinuationSignal) {
2351
2680
  this.getLogger().info(`[ai-service] handleAiGenerate: peerId=${this.stablePeer.peerId ?? "unknown"}`);
2352
2681
  this.storeMigrationTurnCredentials(request.jwt, request.llmConfig);
2353
2682
  if (this.clark.state === ClarkStateNames.Dead) {
@@ -2364,7 +2693,9 @@ export class AiService extends TracedEventEmitter {
2364
2693
  return;
2365
2694
  }
2366
2695
  this.assertNoActivePolicyGateRun();
2367
- if (this.isBusy()) {
2696
+ if (this.isBusy() ||
2697
+ this.remediationPreparation ||
2698
+ this.remediationCancellation) {
2368
2699
  this.getLogger().warn("[ai-service] Service is busy. State:", this.clark.state);
2369
2700
  recordPromptEntryRejection({
2370
2701
  dedupeKey: resolvePromptRequestDedupeKey(request),
@@ -2373,34 +2704,19 @@ export class AiService extends TracedEventEmitter {
2373
2704
  });
2374
2705
  throw new Error("Service is busy");
2375
2706
  }
2376
- // Pre-call quota check. When denied, `ensureAiQuotaAvailable` throws so
2377
- // the UI gets a paywall reason and opens the modal. Without intervention
2378
- // the throw means we never reach `transitionTo<$AgentPlanning>` below,
2379
- // so `agent-planning.ts:USER_SENT_PROMPT` never records the user prompt
2380
- // and `idle.ts:AGENT_CANCELED` never persists the credit advice — the UI
2381
- // ends up with the optimistic user message removed (thunks.ts catch path)
2382
- // and no record of what the user tried. Catch it here, persist prompt +
2383
- // advice + open modal via the same `aiSetGenerationState(CANCELLED,
2384
- // paywallReason)` saga path the mid-stream paywall uses, then resolve
2385
- // the socket call so the UI's catch doesn't run. (APPS-4080)
2386
- const paywallReason = await this.tryHandlePaywallForRequest(request, inputSource);
2387
- if (paywallReason) {
2388
- recordPromptEntryRejection({
2389
- dedupeKey: resolvePromptRequestDedupeKey(request),
2390
- failure: promptFailurePaywall(paywallReason),
2391
- coreModel: request.llmConfig?.coreModel,
2392
- });
2393
- return;
2394
- }
2395
- if (request.requestId &&
2396
- !this.requestDeduplicator.markIfNew(request.requestId)) {
2397
- this.getLogger().warn("[ai-service] Ignoring duplicate aiGenerate re-send (lost dedup race)", { requestId: request.requestId });
2398
- return;
2399
- }
2400
- recordPromptAttempt({
2401
- outcomeDedupeKey: resolvePromptRequestDedupeKey(request),
2402
- coreModel: request.llmConfig?.coreModel,
2403
- });
2707
+ if (request.responseMetadata?.type === "tool_permission_response") {
2708
+ this.getPendingToolPermissionRequest(request.responseMetadata.responseToMessageId);
2709
+ }
2710
+ remediationContinuationSignal?.throwIfAborted();
2711
+ if (!remediationContinuationSignal)
2712
+ this.remediationAutoDispatch?.abort();
2713
+ // Remediation needs a stable identity for its durable owner and budget.
2714
+ // Preserve the existing identity behavior for unrelated legacy requests.
2715
+ if (!request.requestId &&
2716
+ (request.policyGateRemediation ||
2717
+ (request.responseMetadata && this.remediationRequestId)))
2718
+ request = { ...request, requestId: randomUUID() };
2719
+ const promptId = randomUUID();
2404
2720
  const requestUsageMetadata = request.importSource
2405
2721
  ? {
2406
2722
  origin: CLARK_USAGE_ORIGIN.app_migration,
@@ -2411,15 +2727,127 @@ export class AiService extends TracedEventEmitter {
2411
2727
  const generateStreakEligible = resolvePromptMetricLabels({
2412
2728
  usageMetadata: requestUsageMetadata,
2413
2729
  }).streakEligible;
2414
- // The id is now recorded. If anything below throws before generation is
2415
- // actually kicked off (the `transitionTo` USER_SENT_PROMPT), evict it so a
2416
- // legitimate reconnect re-send of this same prompt is not swallowed as a
2417
- // duplicate while nothing is running. (APPS-4663)
2418
2730
  let generationTrace;
2731
+ let remediationPreparation;
2732
+ let generationStarted = false;
2733
+ let requestMarked = false;
2734
+ this.generationStarting = true;
2419
2735
  try {
2420
- // Generate prompt_id for this user interaction. It doubles as the
2421
- // generation id on terminal events, so the trace needs it up front.
2422
- const promptId = randomUUID();
2736
+ if (request.requestId) {
2737
+ requestMarked = this.requestDeduplicator.markIfNew(request.requestId);
2738
+ if (!requestMarked) {
2739
+ this.getLogger().warn("[ai-service] Ignoring duplicate aiGenerate re-send (lost dedup race)", { requestId: request.requestId });
2740
+ return;
2741
+ }
2742
+ }
2743
+ const remediation = request.policyGateRemediation?.batched === true
2744
+ ? request.policyGateRemediation
2745
+ : undefined;
2746
+ if (!remediation &&
2747
+ !request.responseMetadata &&
2748
+ !remediationContinuationSignal) {
2749
+ this.remediationRequestId = undefined;
2750
+ }
2751
+ if (remediation && !remediationContinuationSignal) {
2752
+ // Stop must also cancel preparation while file sync or quota is pending.
2753
+ remediationPreparation = new AbortController();
2754
+ this.remediationPreparation = remediationPreparation;
2755
+ }
2756
+ await this.config.waitForAppFiles?.(promptId);
2757
+ remediationPreparation?.signal.throwIfAborted();
2758
+ remediationContinuationSignal?.throwIfAborted();
2759
+ // Pre-call quota check. When denied, `ensureAiQuotaAvailable` throws so
2760
+ // the UI gets a paywall reason and opens the modal. Without intervention
2761
+ // the throw means we never reach `transitionTo<$AgentPlanning>` below,
2762
+ // so `agent-planning.ts:USER_SENT_PROMPT` never records the user prompt
2763
+ // and `idle.ts:AGENT_CANCELED` never persists the credit advice — the UI
2764
+ // ends up with the optimistic user message removed (thunks.ts catch path)
2765
+ // and no record of what the user tried. Catch it here, persist prompt +
2766
+ // advice + open modal via the same `aiSetGenerationState(CANCELLED,
2767
+ // paywallReason)` saga path the mid-stream paywall uses, then resolve
2768
+ // the socket call so the UI's catch doesn't run. (APPS-4080)
2769
+ const paywallReason = await this.tryHandlePaywallForRequest(request, inputSource, remediationContinuationSignal);
2770
+ remediationPreparation?.signal.throwIfAborted();
2771
+ remediationContinuationSignal?.throwIfAborted();
2772
+ if (paywallReason) {
2773
+ recordPromptEntryRejection({
2774
+ dedupeKey: resolvePromptRequestDedupeKey(request),
2775
+ failure: promptFailurePaywall(paywallReason),
2776
+ coreModel: request.llmConfig?.coreModel,
2777
+ });
2778
+ if (request.responseMetadata?.type === "tool_permission_response") {
2779
+ throw new Error(`${paywallStopAdvice(paywallReason)} [reason:${paywallReason}]`);
2780
+ }
2781
+ return;
2782
+ }
2783
+ recordPromptAttempt({
2784
+ outcomeDedupeKey: resolvePromptRequestDedupeKey(request),
2785
+ coreModel: request.llmConfig?.coreModel,
2786
+ });
2787
+ if (remediation && remediationPreparation) {
2788
+ const preparation = remediationPreparation;
2789
+ const existing = await readPersistedChecklist(this.appRootDirPath);
2790
+ if (request.requestId &&
2791
+ existing?.items.some((item) => item.remediationRequestId === request.requestId)) {
2792
+ // A restart or failed generation start may leave a persisted run.
2793
+ // Resume pending work without reseeding or cancelling its groups.
2794
+ if (!existing.items.some((item) => item.remediationRequestId === request.requestId &&
2795
+ (item.status === "pending" || item.status === "in_progress"))) {
2796
+ this.getLogger().info("[ai-service] Ignoring finished remediation request replay", { requestId: request.requestId });
2797
+ recordPromptEntryRejection({
2798
+ dedupeKey: resolvePromptRequestDedupeKey(request),
2799
+ failure: promptFailurePlatform("request_already_finished"),
2800
+ coreModel: request.llmConfig?.coreModel,
2801
+ });
2802
+ await sendUserGenerationStateChannel(this.clark)({
2803
+ state: AiGenerationState.CANCELLED,
2804
+ });
2805
+ return;
2806
+ }
2807
+ preparation.signal.throwIfAborted();
2808
+ this.remediationRequestId = existing.policyGateRemediation?.requestId;
2809
+ }
2810
+ else {
2811
+ preparation.signal.throwIfAborted();
2812
+ const findings = await fetchRemediationFindings({
2813
+ rpcClient: this.config.rpcClient,
2814
+ applicationId: this.config.applicationId,
2815
+ reviewFindingIds: remediation.reviewFindingIds,
2816
+ signal: preparation.signal,
2817
+ });
2818
+ preparation.signal.throwIfAborted();
2819
+ await this.remediationChecklistQueue.enqueue(async () => {
2820
+ preparation.signal.throwIfAborted();
2821
+ const seeded = await seedPolicyRemediationChecklist({
2822
+ appRootDirPath: this.appRootDirPath,
2823
+ requestId: request.requestId ?? promptId,
2824
+ remediation,
2825
+ findings,
2826
+ });
2827
+ // Transfer ownership only after the replacement is durable. Stop
2828
+ // reads it from this same queue, including during the seed write.
2829
+ this.remediationRequestId = request.requestId ?? promptId;
2830
+ for (const item of seeded.cancelledItems) {
2831
+ await this.appShell.sendMessage({
2832
+ type: "checklist_item_updated",
2833
+ payload: { item, totalItems: seeded.state.items.length },
2834
+ });
2835
+ }
2836
+ for (const item of seeded.addedItems) {
2837
+ // Stop publishes these rows as cancelled from the same queue.
2838
+ // Still publish the prior run's cancelledItems above.
2839
+ if (preparation.signal.aborted)
2840
+ break;
2841
+ await this.appShell.sendMessage({
2842
+ type: "checklist_item_added",
2843
+ payload: { item, totalItems: seeded.state.items.length },
2844
+ });
2845
+ }
2846
+ });
2847
+ }
2848
+ preparation.signal.throwIfAborted();
2849
+ }
2850
+ this.currentGenerationId = promptId;
2423
2851
  generationTrace = this.startGenerationTrace(request, promptId);
2424
2852
  const activeGenerationTrace = generationTrace;
2425
2853
  this.clarkProfiler.startSession();
@@ -2442,6 +2870,14 @@ export class AiService extends TracedEventEmitter {
2442
2870
  this.resolveBaseCommitId(promptId),
2443
2871
  this.resolveChatSessionId(),
2444
2872
  ]);
2873
+ if (request.responseMetadata?.type === "tool_permission_response") {
2874
+ if (this.isClarkMidTurn()) {
2875
+ throw new Error("Service is busy");
2876
+ }
2877
+ this.getPendingToolPermissionRequest(request.responseMetadata.responseToMessageId);
2878
+ }
2879
+ this.remediationPreparation?.signal.throwIfAborted();
2880
+ remediationContinuationSignal?.throwIfAborted();
2445
2881
  this.clark.updateContext((context) => {
2446
2882
  return {
2447
2883
  ...context,
@@ -2473,15 +2909,19 @@ export class AiService extends TracedEventEmitter {
2473
2909
  userPreferences: request.userPreferences,
2474
2910
  };
2475
2911
  });
2912
+ const generationRemediationRequestId = this.remediationRequestId;
2476
2913
  const transitionTo = transitionFrom(this.clark);
2477
2914
  void otelContext
2478
2915
  .with(activeGenerationTrace.context, () => Promise.resolve(transitionTo({
2479
2916
  type: USER_SENT_PROMPT,
2480
2917
  request,
2481
2918
  inputSource,
2919
+ executeToolPermissionResponse: request.responseMetadata?.type === "tool_permission_response",
2920
+ internal: remediationContinuationSignal ? true : undefined,
2482
2921
  })))
2483
- .catch((error) => {
2922
+ .catch(async (error) => {
2484
2923
  activeGenerationTrace.finish(error);
2924
+ this.emit("generationSettled", promptId);
2485
2925
  if (request.requestId) {
2486
2926
  this.requestDeduplicator.forget(request.requestId);
2487
2927
  }
@@ -2496,23 +2936,35 @@ export class AiService extends TracedEventEmitter {
2496
2936
  this.getLogger().error("[ai-service] Failed to start generation", {
2497
2937
  error,
2498
2938
  });
2939
+ if (generationRemediationRequestId) {
2940
+ await this.failRemediationDispatch(generationRemediationRequestId, promptOutcomeDedupeKey, "dispatch_failed", undefined, error);
2941
+ }
2499
2942
  });
2943
+ generationStarted = true;
2500
2944
  }
2501
2945
  catch (error) {
2502
2946
  generationTrace?.finish(error);
2503
- if (request.requestId) {
2504
- this.requestDeduplicator.forget(request.requestId);
2505
- }
2506
2947
  recordPromptOutcome({
2507
2948
  dedupeKey: resolvePromptRequestDedupeKey(request),
2508
2949
  outcome: "failure",
2509
2950
  failure: promptFailurePlatform("handler_error"),
2510
2951
  coreModel: request.llmConfig?.coreModel,
2511
- promptId: this.clark.context.promptId,
2952
+ promptId,
2512
2953
  streakEligible: generateStreakEligible,
2513
2954
  });
2514
2955
  throw error;
2515
2956
  }
2957
+ finally {
2958
+ if (this.remediationPreparation === remediationPreparation)
2959
+ this.remediationPreparation = undefined;
2960
+ this.generationStarting = false;
2961
+ if (!generationStarted) {
2962
+ if (requestMarked && request.requestId) {
2963
+ this.requestDeduplicator.forget(request.requestId);
2964
+ }
2965
+ this.emit("generationSettled", promptId);
2966
+ }
2967
+ }
2516
2968
  }
2517
2969
  async handleAiSummarizeApiUsage(request) {
2518
2970
  if (this.clark.state === ClarkStateNames.Dead) {
@@ -3066,67 +3518,103 @@ export class AiService extends TracedEventEmitter {
3066
3518
  : "User closed the integration setup form. The integration setup might not have been completed successfully.");
3067
3519
  }
3068
3520
  async handleUserCanceled(reason) {
3521
+ if (this.remediationCancellation)
3522
+ return this.remediationCancellation;
3523
+ const busy = this.isBusy();
3524
+ const awaitingUser = this.isAwaitingUser();
3525
+ const cancelledContext = { ...this.clark.context };
3526
+ const cancelRemediation = this.remediationRequestId || this.remediationPreparation;
3527
+ this.remediationAutoDispatch?.abort(createAbortReason("user-cancel"));
3069
3528
  if (reason === "stop")
3070
3529
  this.reportAbandonTurn(reason);
3071
3530
  // Drop parked/in-flight permission prompts first so interval replay and
3072
3531
  // grace expiry cannot keep pushing the dialog or inject a continuation
3073
3532
  // after the user has already stopped the turn.
3074
3533
  clearPendingToolPermissionRequest(this.clark, "user-canceled");
3075
- const awaitingUser = this.isAwaitingUser();
3076
- if (!this.isBusy() && !awaitingUser) {
3077
- return;
3078
- }
3079
- this.clark.updateContext((context) => ({
3080
- ...context,
3081
- userInitiatedPromptAbort: true,
3082
- }));
3083
- this.clark.context.abortController?.abort(createAbortReason("user-cancel"));
3084
- if (this.isBusy()) {
3085
- const { promptId, workflowMetrics } = this.clark.context;
3086
- const labels = resolvePromptMetricLabels(this.clark.context);
3087
- // Fire-and-forget: calculatePromptCost has no timeout and reconnect can
3088
- // sleep for seconds. Cancel teardown must not wait on a billing RPC —
3089
- // the in-flight map already dedupes a racing Idle handler, and locals
3090
- // above survive the context clear below.
3091
- void finalizePromptBillingMetrics({
3092
- chatSessionStore: this.chatSessionStore,
3093
- labels,
3094
- logger: this.getLogger(),
3095
- promptId,
3096
- promptUsageAlarmThreshold: this.config.features?.promptUsageAlarmThreshold,
3097
- workflowMetrics,
3534
+ this.remediationPreparation?.abort(createAbortReason("user-cancel"));
3535
+ if (busy || awaitingUser) {
3536
+ this.clark.updateContext({ userInitiatedPromptAbort: true });
3537
+ this.clark.context.abortController?.abort(createAbortReason("user-cancel"));
3538
+ }
3539
+ if (cancelRemediation) {
3540
+ this.remediationCancellation = this.remediationChecklistQueue.enqueue(async () => {
3541
+ const remediationRequestId = this.remediationRequestId;
3542
+ if (!remediationRequestId)
3543
+ return;
3544
+ const cancelledItems = await cancelPolicyRemediation(this.appRootDirPath, remediationRequestId);
3545
+ this.remediationRequestId = undefined;
3546
+ for (const item of cancelledItems) {
3547
+ try {
3548
+ await this.appShell.sendMessage({
3549
+ type: "checklist_item_updated",
3550
+ payload: { item },
3551
+ });
3552
+ }
3553
+ catch (error) {
3554
+ this.getLogger().error("[ai-service] Failed to publish remediation cancellation", getErrorMeta(error));
3555
+ }
3556
+ }
3098
3557
  });
3099
- recordPromptOutcome({
3100
- dedupeKey: resolvePromptOutcomeDedupeKey(this.clark.context),
3101
- outcome: "failure",
3558
+ }
3559
+ try {
3560
+ if (!busy && !awaitingUser) {
3561
+ return;
3562
+ }
3563
+ if (busy) {
3564
+ const { promptId, workflowMetrics } = cancelledContext;
3565
+ const labels = resolvePromptMetricLabels(cancelledContext);
3566
+ // Fire-and-forget: calculatePromptCost has no timeout and reconnect can
3567
+ // sleep for seconds. Cancel teardown must not wait on a billing RPC —
3568
+ // the in-flight map already dedupes a racing Idle handler, and locals
3569
+ // above survive the context clear below.
3570
+ void finalizePromptBillingMetrics({
3571
+ chatSessionStore: this.chatSessionStore,
3572
+ labels,
3573
+ logger: this.getLogger(),
3574
+ promptId,
3575
+ promptUsageAlarmThreshold: this.config.features?.promptUsageAlarmThreshold,
3576
+ workflowMetrics,
3577
+ });
3578
+ recordPromptOutcome({
3579
+ dedupeKey: resolvePromptOutcomeDedupeKey(cancelledContext),
3580
+ outcome: "failure",
3581
+ failure: promptFailureUserCanceled(),
3582
+ promptId: cancelledContext.promptId,
3583
+ ...labels,
3584
+ });
3585
+ }
3586
+ if (awaitingUser && cancelledContext.awaitingUserHandoffRecorded) {
3587
+ recordAwaitingUserHandoffDismissed({
3588
+ awaitingUserHandoffPromptId: cancelledContext.awaitingUserHandoffPromptId,
3589
+ awaitingUserHandoffRecorded: cancelledContext.awaitingUserHandoffRecorded,
3590
+ promptId: cancelledContext.promptId,
3591
+ promptOutcomeDedupeKey: cancelledContext.promptOutcomeDedupeKey,
3592
+ reason: "cancel",
3593
+ });
3594
+ }
3595
+ // Clear the prompt_id since this prompt was abandoned
3596
+ this.clark.updateContext((context) => ({
3597
+ ...context,
3598
+ promptId: undefined,
3599
+ promptOutcomeDedupeKey: undefined,
3600
+ usageMetadata: undefined,
3601
+ baseCommitId: undefined,
3602
+ awaitingUserHandoffPromptId: undefined,
3603
+ awaitingUserHandoffRecorded: undefined,
3604
+ }));
3605
+ void this.clark.send({
3606
+ type: USER_CANCELED,
3102
3607
  failure: promptFailureUserCanceled(),
3103
- promptId: this.clark.context.promptId,
3104
- ...labels,
3105
3608
  });
3106
3609
  }
3107
- if (awaitingUser && this.clark.context.awaitingUserHandoffRecorded) {
3108
- recordAwaitingUserHandoffDismissed({
3109
- awaitingUserHandoffPromptId: this.clark.context.awaitingUserHandoffPromptId,
3110
- awaitingUserHandoffRecorded: this.clark.context.awaitingUserHandoffRecorded,
3111
- promptId: this.clark.context.promptId,
3112
- promptOutcomeDedupeKey: this.clark.context.promptOutcomeDedupeKey,
3113
- reason: "cancel",
3114
- });
3610
+ finally {
3611
+ try {
3612
+ await this.remediationCancellation;
3613
+ }
3614
+ finally {
3615
+ this.remediationCancellation = undefined;
3616
+ }
3115
3617
  }
3116
- // Clear the prompt_id since this prompt was abandoned
3117
- this.clark.updateContext((context) => ({
3118
- ...context,
3119
- promptId: undefined,
3120
- promptOutcomeDedupeKey: undefined,
3121
- usageMetadata: undefined,
3122
- baseCommitId: undefined,
3123
- awaitingUserHandoffPromptId: undefined,
3124
- awaitingUserHandoffRecorded: undefined,
3125
- }));
3126
- void this.clark.send({
3127
- type: USER_CANCELED,
3128
- failure: promptFailureUserCanceled(),
3129
- });
3130
3618
  }
3131
3619
  /**
3132
3620
  * UI-initiated cancellation of a background task. Delegates to
@@ -3201,6 +3689,9 @@ export class AiService extends TracedEventEmitter {
3201
3689
  await this.handleAiGenerate(request, inputSource);
3202
3690
  return { generationId: this.clark.context.promptId, status: "started" };
3203
3691
  }
3692
+ if (request.responseMetadata?.type === "tool_permission_response") {
3693
+ throw new Error("Service is busy");
3694
+ }
3204
3695
  await this.interruptAndContinue(request, inputSource);
3205
3696
  return {
3206
3697
  generationId: this.clark.context.promptId,
@@ -3392,11 +3883,19 @@ export class AiService extends TracedEventEmitter {
3392
3883
  if (!(await this.waitUntilToolPermissionContinuationAllowed(messageId))) {
3393
3884
  return;
3394
3885
  }
3886
+ // A reconnect during the wait above moves the deadline and may still
3887
+ // be replaying this prompt. Expiring from the old timer would write a
3888
+ // delivery error for the message id that replay is delivering.
3889
+ await inFlightToolPermissionReplay(this.clark);
3395
3890
  const expired = this.clark.context.pendingToolPermissionRequest;
3396
3891
  if (expired?.messageId !== messageId ||
3397
3892
  expired.delivery.status !== "parked") {
3398
3893
  return;
3399
3894
  }
3895
+ if (Date.now() < expired.delivery.expiresAtMs) {
3896
+ this.scheduleToolPermissionExpiry(messageId);
3897
+ return;
3898
+ }
3400
3899
  const error = createExpiredPermissionPromptDeliveryError(expired.toolName, expired.delivery);
3401
3900
  recordToolPermissionDeliveryOutcome("expired");
3402
3901
  await this.handleToolPermissionOutcome({
@@ -3409,17 +3908,12 @@ export class AiService extends TracedEventEmitter {
3409
3908
  });
3410
3909
  }, expiresInMs);
3411
3910
  }
3412
- /**
3413
- * `userSentPrompt` is only valid from Idle or AwaitingUser. A parked
3414
- * permission can expire while a sibling tool is still generating; wait
3415
- * until the continuation can actually run, or abort if the request is gone.
3416
- */
3911
+ /** Wait for a pending permission decision to become a valid continuation. */
3417
3912
  async waitUntilToolPermissionContinuationAllowed(messageId) {
3418
3913
  const TOOL_PERMISSION_CONTINUATION_POLL_MS = 250;
3419
3914
  for (;;) {
3420
3915
  const current = this.clark.context.pendingToolPermissionRequest;
3421
- if (current?.messageId !== messageId ||
3422
- current.delivery.status !== "parked") {
3916
+ if (current?.messageId !== messageId) {
3423
3917
  return false;
3424
3918
  }
3425
3919
  switch (this.clark.state) {
@@ -3449,12 +3943,18 @@ export class AiService extends TracedEventEmitter {
3449
3943
  const pending = this.clark.context.pendingToolPermissionRequest;
3450
3944
  if (pending?.messageId !== messageId ||
3451
3945
  pending.delivery.status !== "parked" ||
3452
- Date.now() >= pending.delivery.expiresAtMs) {
3946
+ this.clark.state === ClarkStateNames.Dead) {
3947
+ return;
3948
+ }
3949
+ if (Date.now() >= pending.delivery.expiresAtMs) {
3950
+ // Expiry can wait for Clark to become idle. Keep this retry loop armed
3951
+ // so a reconnect that renews the deadline can resume delivery.
3952
+ this.scheduleToolPermissionReplay(messageId);
3453
3953
  return;
3454
3954
  }
3455
- const replay = this.stablePeer.isConnected
3456
- ? flushPendingToolPermissionPrompt(this.clark, { source: "retry" })
3457
- : Promise.resolve();
3955
+ const replay = flushPendingToolPermissionPrompt(this.clark, {
3956
+ source: "retry",
3957
+ });
3458
3958
  void replay.finally(() => {
3459
3959
  const current = this.clark.context.pendingToolPermissionRequest;
3460
3960
  if (current?.messageId === messageId &&
@@ -3487,26 +3987,97 @@ export class AiService extends TracedEventEmitter {
3487
3987
  });
3488
3988
  return;
3489
3989
  }
3990
+ const pendingRequest = this.getPendingToolPermissionRequest(request.messageId);
3991
+ if (this.clark.state === ClarkStateNames.Dead) {
3992
+ throw new Error("Service is unavailable");
3993
+ }
3994
+ // Only yield while Clark is mid-generation: awaiting in the common case
3995
+ // would let a concurrent continuation pass the busy check below before
3996
+ // this one claims `generationStarting`.
3997
+ if (this.clark.state !== ClarkStateNames.Idle &&
3998
+ this.clark.state !== ClarkStateNames.AwaitingUser &&
3999
+ !(await this.waitUntilToolPermissionContinuationAllowed(request.messageId))) {
4000
+ return;
4001
+ }
4002
+ if (this.isBusy()) {
4003
+ throw new Error("Service is busy");
4004
+ }
4005
+ const promptOutcomeDedupeKey = randomUUID();
4006
+ let generationStarted = false;
4007
+ this.generationStarting = true;
4008
+ try {
4009
+ await this.config.waitForAppFiles?.(promptOutcomeDedupeKey);
4010
+ this.currentGenerationId = promptOutcomeDedupeKey;
4011
+ // The response is now accepted (matches the pending request). Record its id
4012
+ // and honor the result so a re-send dropped here can't be applied twice. A
4013
+ // duplicate that loses the atomic `markIfNew` bails before the tool runs.
4014
+ // (APPS-4663)
4015
+ if (request.kind === "response" &&
4016
+ request.requestId &&
4017
+ !this.requestDeduplicator.markIfNew(request.requestId)) {
4018
+ logger.warn("[ai-service] Ignoring duplicate tool-permission re-send (lost dedup race)", { requestId: request.requestId });
4019
+ return;
4020
+ }
4021
+ const continueRequest = await this.resolveToolPermissionOutcome(request, pendingRequest);
4022
+ if (!continueRequest) {
4023
+ return;
4024
+ }
4025
+ this.clark.updateContext({ promptOutcomeDedupeKey });
4026
+ recordPromptAttempt({
4027
+ coreModel: this.clark.context.llmConfig?.coreModel,
4028
+ entrypoint: PROMPT_ATTEMPT_ENTRYPOINT.tool_permission_continuation,
4029
+ outcomeDedupeKey: promptOutcomeDedupeKey,
4030
+ });
4031
+ const transitionTo = transitionFrom(this.clark);
4032
+ void Promise.resolve(transitionTo({
4033
+ type: USER_SENT_PROMPT,
4034
+ request: { ...continueRequest, requestId: promptOutcomeDedupeKey },
4035
+ })).catch((error) => {
4036
+ this.emit("generationSettled", promptOutcomeDedupeKey);
4037
+ logger.error("[ai-service] Failed to continue after tool permission", {
4038
+ error,
4039
+ });
4040
+ });
4041
+ generationStarted = true;
4042
+ }
4043
+ finally {
4044
+ this.generationStarting = false;
4045
+ if (!generationStarted) {
4046
+ this.emit("generationSettled", promptOutcomeDedupeKey);
4047
+ }
4048
+ }
4049
+ }
4050
+ getPendingToolPermissionRequest(messageId) {
3490
4051
  const pendingRequest = this.clark.context.pendingToolPermissionRequest;
3491
- logger.info(`[ai-service] Pending request: ${JSON.stringify(pendingRequest)}`);
3492
4052
  if (!pendingRequest) {
3493
- logger.warn(`[ai-service] No pending tool permission request found for messageId=${request.messageId}`);
3494
4053
  throw new Error("No pending tool permission request");
3495
4054
  }
3496
- if (pendingRequest.messageId !== request.messageId) {
3497
- logger.warn(`[ai-service] Message ID mismatch: expected=${pendingRequest.messageId}, got=${request.messageId}`);
4055
+ if (pendingRequest.messageId !== messageId) {
3498
4056
  throw new Error("Message ID mismatch");
3499
4057
  }
3500
- // The response is now accepted (matches the pending request). Record its id
3501
- // and honor the result so a re-send dropped here can't be applied twice. A
3502
- // duplicate that loses the atomic `markIfNew` bails before the tool runs.
3503
- // (APPS-4663)
3504
- if (request.kind === "response" &&
3505
- request.requestId &&
3506
- !this.requestDeduplicator.markIfNew(request.requestId)) {
3507
- logger.warn("[ai-service] Ignoring duplicate tool-permission re-send (lost dedup race)", { requestId: request.requestId });
3508
- return;
4058
+ return pendingRequest;
4059
+ }
4060
+ async resolveToolPermissionResponse(request) {
4061
+ const metadata = request.responseMetadata;
4062
+ if (metadata?.type !== "tool_permission_response") {
4063
+ throw new Error("Expected a tool permission response");
3509
4064
  }
4065
+ const pendingRequest = this.getPendingToolPermissionRequest(metadata.responseToMessageId);
4066
+ return this.resolveToolPermissionOutcome({
4067
+ kind: "response",
4068
+ messageId: metadata.responseToMessageId,
4069
+ approved: metadata.approved === true,
4070
+ alwaysAllowPermissions: metadata.alwaysAllowPermissions,
4071
+ denyMessage: metadata.approved ? undefined : request.prompt,
4072
+ }, pendingRequest, metadata.clarification === true);
4073
+ }
4074
+ async resolveToolPermissionOutcome(request, pendingRequest, clarification) {
4075
+ const logger = this.getLogger();
4076
+ const approved = request.kind === "response" && request.approved;
4077
+ const { promptId, promptOutcomeDedupeKey, abortController } = this.clark.context;
4078
+ const isCurrentResponse = () => !abortController?.signal.aborted &&
4079
+ this.clark.context.promptId === promptId &&
4080
+ this.clark.context.promptOutcomeDedupeKey === promptOutcomeDedupeKey;
3510
4081
  logger.info(`[ai-service] Clearing pending request`);
3511
4082
  // Clear the pending request and notify UI
3512
4083
  clearPendingToolPermissionRequest(this.clark, request.kind === "delivery_error"
@@ -3530,12 +4101,21 @@ export class AiService extends TracedEventEmitter {
3530
4101
  },
3531
4102
  ],
3532
4103
  };
3533
- let toolResultMessage;
4104
+ const chatSessionId = this.chatSessionStore.getChatSessionId();
4105
+ const isClarification = clarification ??
4106
+ isToolPermissionClarification(approved, request.kind === "response" ? request.denyMessage : undefined);
4107
+ let outcome;
3534
4108
  if (approved) {
3535
- // Approve in entity permission store
3536
- this.entityPermissionStore.approveToolCall(toolName, rawInput);
3537
4109
  const signature = getToolCallSignature(toolName, rawInput);
3538
4110
  const formattedArgs = await getToolCallArguments(toolName, rawInput, this.clark);
4111
+ // The answer stays consumed: whatever superseded it (a cancel or a
4112
+ // newer prompt) also retires the request it answered.
4113
+ if (!isCurrentResponse()) {
4114
+ recordToolPermissionLateOutcome("skipped");
4115
+ logger.warn("[ai-service] An approved tool permission was superseded before its tool ran", { toolCallId, toolName });
4116
+ return;
4117
+ }
4118
+ this.entityPermissionStore.approveToolCall(toolName, rawInput);
3539
4119
  logger.info(`[ai-service] Sending tool call message: tool=${toolName}, toolCallId=${toolCallId}, signature=${signature}`);
3540
4120
  void sendUserMessage({
3541
4121
  role: "assistant",
@@ -3552,116 +4132,19 @@ export class AiService extends TracedEventEmitter {
3552
4132
  void this.entityPermissionStore.approve(permission.entityType, permission.entityId, permission.permissionType);
3553
4133
  });
3554
4134
  }
3555
- // Execute the tool with proper access control context from Clark
3556
- const accessControlContext = this.clark.getAccessControl(this.config.features);
3557
- logger.info(`[ai-service] Executing approved tool: ${toolName}`);
3558
- logger.info(`[ai-service] Access control context: mode=${accessControlContext.mode}`);
3559
- try {
3560
- const result = await this.toolRegistry.execute(toolName, rawInput, accessControlContext, { toolCallId, messages: [] });
3561
- if (result.type === "success") {
3562
- toolResultMessage = {
3563
- role: "tool",
3564
- content: [
3565
- {
3566
- type: "tool-result",
3567
- toolCallId,
3568
- toolName,
3569
- output: { type: "json", value: result.data },
3570
- },
3571
- ],
3572
- };
3573
- // Use getToolOutput for consistent formatting with processStreamChunk
3574
- const output = getToolOutput(toolName, result.data);
3575
- void sendUserMessage({
3576
- role: "assistant",
3577
- content: "",
3578
- type: "tool-result",
3579
- toolCallId,
3580
- toolName,
3581
- output,
3582
- });
3583
- }
3584
- else {
3585
- logger.warn(`[ai-service] Tool ${toolName} execution failed: ${result.error.message}`);
3586
- toolResultMessage = {
3587
- role: "tool",
3588
- content: [
3589
- {
3590
- type: "tool-result",
3591
- toolCallId,
3592
- toolName,
3593
- output: {
3594
- type: "error-json",
3595
- value: { error: result.error.message },
3596
- },
3597
- isError: true,
3598
- },
3599
- ],
3600
- };
3601
- // Use getToolErrorCode for consistent formatting with processStreamChunk
3602
- const errorCode = getToolErrorCode(result.error);
3603
- void sendUserMessage({
3604
- role: "assistant",
3605
- content: "",
3606
- type: "tool-error",
3607
- toolCallId,
3608
- toolName,
3609
- error: getToolErrorMessage(result.error.cause ?? result.error),
3610
- errorCode,
3611
- });
3612
- }
3613
- }
3614
- catch (error) {
3615
- const errorMessage = error instanceof Error ? error.message : String(error);
3616
- // Provider-facing context expects tool failures to be encoded as
3617
- // errored tool results so they still satisfy the preceding tool call.
3618
- toolResultMessage = {
3619
- role: "tool",
3620
- content: [
3621
- {
3622
- type: "tool-result",
3623
- toolCallId,
3624
- toolName,
3625
- output: {
3626
- type: "error-json",
3627
- value: { error: errorMessage },
3628
- },
3629
- isError: true,
3630
- },
3631
- ],
3632
- };
3633
- // Use getToolErrorCode for consistent formatting with processStreamChunk
3634
- const errorCode = getToolErrorCode(error);
3635
- void sendUserMessage({
3636
- role: "assistant",
3637
- content: "",
3638
- type: "tool-error",
3639
- toolCallId,
3640
- toolName,
3641
- error: errorMessage,
3642
- errorCode,
3643
- });
3644
- }
4135
+ outcome = await this.executeApprovedTool(toolName, rawInput, toolCallId, abortController?.signal);
3645
4136
  }
3646
4137
  else if (request.kind === "delivery_error") {
3647
- toolResultMessage = {
3648
- role: TOOL_MESSAGE_ROLE,
3649
- content: [
3650
- {
3651
- type: TOOL_RESULT_CONTENT_TYPE,
3652
- toolCallId,
3653
- toolName,
3654
- output: {
3655
- type: ERROR_JSON_OUTPUT_TYPE,
3656
- value: { error: request.error.message },
3657
- },
3658
- isError: true,
3659
- },
3660
- ],
4138
+ outcome = {
4139
+ toolResultMessage: toolErrorResult(toolCallId, toolName, request.error.message),
3661
4140
  };
3662
4141
  }
3663
4142
  else {
3664
- const denyReason = request.denyMessage || "User denied permission for this tool call";
4143
+ // A clarification reaches Clark as the user's next message, where the
4144
+ // prompt safety checks see it. Repeating it here would leak it past them.
4145
+ const denyReason = isClarification
4146
+ ? CLARIFICATION_DENY_REASON
4147
+ : request.denyMessage || "User denied permission for this tool call";
3665
4148
  if (toolName === "testApi") {
3666
4149
  const apiName = rawInput?.apiName;
3667
4150
  if (apiName) {
@@ -3675,56 +4158,51 @@ export class AiService extends TracedEventEmitter {
3675
4158
  });
3676
4159
  }
3677
4160
  }
3678
- toolResultMessage = {
3679
- role: "tool",
3680
- content: [
3681
- {
3682
- type: "tool-result",
3683
- toolCallId,
3684
- toolName,
3685
- output: {
3686
- type: "error-json",
3687
- value: { error: `Permission denied: ${denyReason}` },
3688
- },
3689
- isError: true,
3690
- },
3691
- ],
4161
+ outcome = {
4162
+ toolResultMessage: toolErrorResult(toolCallId, toolName, `Permission denied: ${denyReason}`),
3692
4163
  };
3693
4164
  logger.info(`[ai-service] Tool ${toolName} permission denied, error injected into context`);
3694
4165
  }
3695
- const contextManagement = this.buildContextManagementForCurrentClark(llmConfig);
3696
- const context = await this.contextManagerV2.getOrCreateContext(contextId, llmConfig?.contextOptionsV2, undefined, contextManagement);
3697
- const placeholderUsage = {
3698
- inputTokens: 0,
3699
- outputTokens: 0,
3700
- totalTokens: 0,
3701
- inputTokenDetails: {
3702
- noCacheTokens: 0,
3703
- cacheReadTokens: 0,
3704
- cacheWriteTokens: 0,
3705
- },
3706
- outputTokenDetails: {
3707
- textTokens: 0,
3708
- reasoningTokens: 0,
3709
- },
3710
- };
3711
- await context.addResponse(assistantMessage, placeholderUsage);
3712
- await context.addResponse(toolResultMessage, placeholderUsage);
3713
- // Check for pending escalations that were deferred during tool execution
3714
- // (because Clark was in AwaitingUser state for the permission request)
4166
+ // The tool or its denial has already taken effect, so the outcome is
4167
+ // recorded even when a cancel or a newer prompt superseded this response.
4168
+ // Otherwise Clark cannot know the tool ran and may run it again. Only a
4169
+ // chat reset discards it: the conversation it belonged to is gone.
4170
+ if (this.chatSessionStore.getChatSessionId() !== chatSessionId) {
4171
+ recordToolPermissionLateOutcome("discarded");
4172
+ logger.warn("[ai-service] Discarded a tool permission outcome from a reset chat", { toolCallId, toolName });
4173
+ return;
4174
+ }
4175
+ const currentBeforeRecording = isCurrentResponse();
4176
+ try {
4177
+ await this.recordToolPermissionOutcome(contextId, llmConfig, assistantMessage, outcome, sendUserMessage);
4178
+ }
4179
+ catch (error) {
4180
+ if (currentBeforeRecording) {
4181
+ throw error;
4182
+ }
4183
+ recordToolPermissionLateOutcome("discarded");
4184
+ logger.warn("[ai-service] Could not record a superseded tool permission outcome", { toolCallId, toolName, ...getErrorMeta(error) });
4185
+ return;
4186
+ }
4187
+ if (!isCurrentResponse()) {
4188
+ recordToolPermissionLateOutcome("recorded");
4189
+ logger.info("[ai-service] Recorded a superseded tool permission outcome without continuing", { toolCallId, toolName });
4190
+ return;
4191
+ }
3715
4192
  if (hasPendingEscalations()) {
3716
4193
  logger.info(`[ai-service] Found pending escalation(s) after tool execution, flushing...`);
3717
4194
  const escalationSent = await flushPendingEscalations({
3718
4195
  clark: this.clark,
3719
4196
  services: { chatSessionStore: this.chatSessionStore },
3720
4197
  logger,
3721
- force: true, // Bypass the AwaitingUser check since we're handling it here
3722
- skipStateTransition: true, // Clark is already in AwaitingUser
4198
+ force: true,
4199
+ skipStateTransition: true,
3723
4200
  });
4201
+ if (!isCurrentResponse()) {
4202
+ return;
4203
+ }
3724
4204
  if (escalationSent) {
3725
- logger.info(`[ai-service] Sent escalation multi_choice to user, staying in AwaitingUser`);
3726
- // Stay in AwaitingUser state - don't transition to AgentPlanning
3727
- // The user needs to respond to the escalation
4205
+ logger.info(`[ai-service] Sent escalation multi_choice to user`);
3728
4206
  return;
3729
4207
  }
3730
4208
  }
@@ -3734,7 +4212,6 @@ export class AiService extends TracedEventEmitter {
3734
4212
  if (!this.clark.context.llmConfig) {
3735
4213
  throw new Error("Cannot continue after tool permission: llmConfig is missing from context");
3736
4214
  }
3737
- const isClarification = isToolPermissionClarification(approved, request.kind === "response" ? request.denyMessage : undefined);
3738
4215
  logger.info("[ai-service] tool_permission_response continuation", {
3739
4216
  approved,
3740
4217
  clarification: isClarification,
@@ -3743,21 +4220,6 @@ export class AiService extends TracedEventEmitter {
3743
4220
  trace
3744
4221
  .getActiveSpan()
3745
4222
  ?.setAttribute("clark.tool_permission.clarification", isClarification);
3746
- const promptOutcomeDedupeKey = randomUUID();
3747
- this.clark.updateContext((context) => ({
3748
- ...context,
3749
- promptOutcomeDedupeKey,
3750
- }));
3751
- // The continuation is a genuine new turn on a fresh key (the handoff
3752
- // already recorded an outcome on the original). Pair it with an attempt
3753
- // so attempts_total and outcomes_total stay aligned (ENG-5477 review).
3754
- // Tag the entrypoint so dashboards can separate ~server-driven volume
3755
- // from user aiGenerate (ENG-5477 scout review).
3756
- recordPromptAttempt({
3757
- coreModel: this.clark.context.llmConfig?.coreModel,
3758
- entrypoint: PROMPT_ATTEMPT_ENTRYPOINT.tool_permission_continuation,
3759
- outcomeDedupeKey: promptOutcomeDedupeKey,
3760
- });
3761
4223
  const continueRequest = {
3762
4224
  prompt: approved || request.kind === "delivery_error"
3763
4225
  ? ``
@@ -3772,7 +4234,7 @@ export class AiService extends TracedEventEmitter {
3772
4234
  llmConfig: this.clark.context.llmConfig,
3773
4235
  mode: this.clark.context.currentMode,
3774
4236
  browserContext: this.clark.context.browserContext,
3775
- requestId: promptOutcomeDedupeKey,
4237
+ requestId: this.clark.context.promptOutcomeDedupeKey,
3776
4238
  responseMetadata: {
3777
4239
  type: "tool_permission_response",
3778
4240
  responseToMessageId: request.messageId,
@@ -3786,11 +4248,101 @@ export class AiService extends TracedEventEmitter {
3786
4248
  ...(isClarification ? { clarification: true } : {}),
3787
4249
  },
3788
4250
  };
3789
- const transitionTo = transitionFrom(this.clark);
3790
- void transitionTo({
3791
- type: USER_SENT_PROMPT,
3792
- request: continueRequest,
3793
- });
4251
+ return continueRequest;
4252
+ }
4253
+ /**
4254
+ * Run a user-approved tool with the turn's abort signal, so a cancel stops
4255
+ * it instead of leaving it running unobserved.
4256
+ */
4257
+ async executeApprovedTool(toolName, rawInput, toolCallId, abortSignal) {
4258
+ const logger = this.getLogger();
4259
+ const accessControlContext = this.clark.getAccessControl(this.config.features);
4260
+ logger.info(`[ai-service] Executing approved tool: ${toolName}`);
4261
+ logger.info(`[ai-service] Access control context: mode=${accessControlContext.mode}`);
4262
+ try {
4263
+ const result = await this.toolRegistry.execute(toolName, rawInput, accessControlContext, { abortSignal, messages: [], toolCallId });
4264
+ if (result.type === "success") {
4265
+ return {
4266
+ chatMessage: {
4267
+ role: "assistant",
4268
+ content: "",
4269
+ type: "tool-result",
4270
+ toolCallId,
4271
+ toolName,
4272
+ // Use getToolOutput for consistent formatting with processStreamChunk
4273
+ output: getToolOutput(toolName, result.data),
4274
+ },
4275
+ toolResultMessage: {
4276
+ role: TOOL_MESSAGE_ROLE,
4277
+ content: [
4278
+ {
4279
+ type: TOOL_RESULT_CONTENT_TYPE,
4280
+ toolCallId,
4281
+ toolName,
4282
+ output: { type: "json", value: toJsonValue(result.data) },
4283
+ },
4284
+ ],
4285
+ },
4286
+ };
4287
+ }
4288
+ logger.warn(`[ai-service] Tool ${toolName} execution failed: ${result.error.message}`);
4289
+ return {
4290
+ chatMessage: {
4291
+ role: "assistant",
4292
+ content: "",
4293
+ type: "tool-error",
4294
+ toolCallId,
4295
+ toolName,
4296
+ error: getToolErrorMessage(result.error.cause ?? result.error),
4297
+ // Use getToolErrorCode for consistent formatting with processStreamChunk
4298
+ errorCode: getToolErrorCode(result.error),
4299
+ },
4300
+ toolResultMessage: toolErrorResult(toolCallId, toolName, result.error.message),
4301
+ };
4302
+ }
4303
+ catch (error) {
4304
+ const errorMessage = error instanceof Error ? error.message : String(error);
4305
+ return {
4306
+ chatMessage: {
4307
+ role: "assistant",
4308
+ content: "",
4309
+ type: "tool-error",
4310
+ toolCallId,
4311
+ toolName,
4312
+ error: errorMessage,
4313
+ errorCode: getToolErrorCode(error),
4314
+ },
4315
+ toolResultMessage: toolErrorResult(toolCallId, toolName, errorMessage),
4316
+ };
4317
+ }
4318
+ }
4319
+ async recordToolPermissionOutcome(contextId, llmConfig, toolCall, outcome, sendUserMessage) {
4320
+ if (outcome.chatMessage) {
4321
+ void sendUserMessage(outcome.chatMessage);
4322
+ }
4323
+ const context = await this.toolPermissionOutcomeContext(contextId, llmConfig);
4324
+ await context.addResponses([toolCall, outcome.toolResultMessage], TOOL_PERMISSION_PLACEHOLDER_USAGE);
4325
+ }
4326
+ /**
4327
+ * The context a tool permission outcome is written to, retried a bounded
4328
+ * number of times so a transient store failure does not drop the record of a
4329
+ * tool that already ran. Only acquisition is retried: addResponses ingests
4330
+ * each message before persisting it, so repeating it could duplicate the
4331
+ * tool call.
4332
+ */
4333
+ async toolPermissionOutcomeContext(contextId, llmConfig) {
4334
+ for (let attempt = 1;; attempt += 1) {
4335
+ try {
4336
+ const contextManagement = this.buildContextManagementForCurrentClark(llmConfig);
4337
+ return await this.contextManagerV2.getOrCreateContext(contextId, llmConfig?.contextOptionsV2, undefined, contextManagement);
4338
+ }
4339
+ catch (error) {
4340
+ if (attempt >= TOOL_PERMISSION_CONTEXT_ATTEMPTS) {
4341
+ throw error;
4342
+ }
4343
+ await new Promise((resolve) => setTimeout(resolve, TOOL_PERMISSION_CONTEXT_RETRY_MS * attempt));
4344
+ }
4345
+ }
3794
4346
  }
3795
4347
  /**
3796
4348
  * Builds a continuation prompt when a tool permission is denied.
@@ -3849,6 +4401,15 @@ export class AiService extends TracedEventEmitter {
3849
4401
  lastGenerationState: this.clark.context.lastGenerationState,
3850
4402
  });
3851
4403
  }
4404
+ getChatStateSnapshot() {
4405
+ const generationState = this.resolveGenerationStateForUi();
4406
+ return {
4407
+ generationState,
4408
+ activeInteractiveMessage: generationState?.state === AiGenerationState.AWAITING_USER_INPUT
4409
+ ? this.clark.context.activeInteractiveMessage?.payload
4410
+ : undefined,
4411
+ };
4412
+ }
3852
4413
  /**
3853
4414
  * A gateway peer asked for a turn. Clark's pushes and tool callbacks for it
3854
4415
  * are routed to that peer for as long as it is attached, without giving it
@@ -3995,6 +4556,103 @@ export class AiService extends TracedEventEmitter {
3995
4556
  // push retries on a later bind.
3996
4557
  void this.chatSessionStore.reconcileAbandonedToolCalls();
3997
4558
  this.tryAutoDispatchMigrationTurnIfReady();
4559
+ void this.pushRestoredContextUsage();
4560
+ }
4561
+ /** Occupancy-ring flags only. Do not pass into getOrCreateContext. */
4562
+ editorContextOptionsV2;
4563
+ applyEditorContextOptions(contextOptionsV2) {
4564
+ // Assigned even when empty. The editor is authoritative for the ring, so
4565
+ // keeping the previous value here would outlive an override it cleared.
4566
+ this.editorContextOptionsV2 = contextOptionsV2;
4567
+ if (this.isBusy()) {
4568
+ return;
4569
+ }
4570
+ void this.pushRestoredContextUsageFromDisk();
4571
+ }
4572
+ /**
4573
+ * Fill the chat occupancy ring from the last completed step, or from the
4574
+ * restored conversation on disk when this process has not streamed yet.
4575
+ */
4576
+ async pushRestoredContextUsage() {
4577
+ if (this.clark.replayLastContextUsage?.()) {
4578
+ return;
4579
+ }
4580
+ if (this.isBusy()) {
4581
+ return;
4582
+ }
4583
+ await this.pushRestoredContextUsageFromDisk();
4584
+ }
4585
+ restoredContextUsageRead;
4586
+ restoredContextUsageRerun = false;
4587
+ /**
4588
+ * One read at a time: parallel getOrCreateContext calls on a cold cache each
4589
+ * build their own ContextV2. A caller that arrives mid-read reruns it once.
4590
+ */
4591
+ pushRestoredContextUsageFromDisk() {
4592
+ if (this.restoredContextUsageRead) {
4593
+ this.restoredContextUsageRerun = true;
4594
+ return this.restoredContextUsageRead;
4595
+ }
4596
+ this.restoredContextUsageRead = (async () => {
4597
+ try {
4598
+ do {
4599
+ this.restoredContextUsageRerun = false;
4600
+ await this.readRestoredContextUsageFromDisk();
4601
+ } while (this.restoredContextUsageRerun);
4602
+ }
4603
+ finally {
4604
+ this.restoredContextUsageRead = undefined;
4605
+ }
4606
+ })();
4607
+ return this.restoredContextUsageRead;
4608
+ }
4609
+ async readRestoredContextUsageFromDisk() {
4610
+ const generation = this.clark.getContextUsageGeneration?.();
4611
+ // No JWT yet means no user; updateClarkJwt retries once it arrives.
4612
+ const contextId = this.getContextId();
4613
+ if (!contextId) {
4614
+ return;
4615
+ }
4616
+ try {
4617
+ const displayOptions = this.editorContextOptionsV2 ??
4618
+ this.clark.context.llmConfig?.contextOptionsV2;
4619
+ if (typeof displayOptions?.contextLimit !== "number") {
4620
+ return;
4621
+ }
4622
+ const context = await this.contextManagerV2.getOrCreateContext(contextId);
4623
+ const lastKnownInputTokens = context.getLastKnownInputTokens();
4624
+ const messages = await context.getMessages();
4625
+ const storedBreakdown = context.getLastContextUsage()?.breakdown;
4626
+ if (lastKnownInputTokens <= 0 &&
4627
+ messages.length === 0 &&
4628
+ !storedBreakdown) {
4629
+ return;
4630
+ }
4631
+ const snapshot = buildRestoredContextUsageSnapshot({
4632
+ contextOptions: displayOptions,
4633
+ lastKnownInputTokens,
4634
+ messages,
4635
+ storedBreakdown,
4636
+ });
4637
+ if (snapshot.contextUsed <= 0) {
4638
+ return;
4639
+ }
4640
+ // A New Chat, restore or live step during the reads above makes this stale.
4641
+ if (this.isBusy() ||
4642
+ this.clark.getContextUsageGeneration?.() !== generation) {
4643
+ return;
4644
+ }
4645
+ const { breakdown } = snapshot;
4646
+ if (breakdown.systemPrompt > 0 ||
4647
+ breakdown.tools > 0 ||
4648
+ breakdown.summarizedConversation > 0) {
4649
+ this.clark.rememberContextUsage(snapshot);
4650
+ }
4651
+ catchBackgroundPeerDisconnect(this.stablePeer.call.aiPushContextUsed(snapshot), "restored context usage push");
4652
+ }
4653
+ catch (error) {
4654
+ this.getLogger().warn("[ai-service] Failed to restore context usage before the next step", getErrorMeta(error));
4655
+ }
3998
4656
  }
3999
4657
  /**
4000
4658
  * Per-session set of peer IDs that connected since their last task
@@ -4062,6 +4720,9 @@ export class AiService extends TracedEventEmitter {
4062
4720
  logger: this.getLogger(),
4063
4721
  }, callerUserId);
4064
4722
  }
4723
+ if (this.stablePeer.peerId === peerId) {
4724
+ void this.pushRestoredContextUsage();
4725
+ }
4065
4726
  }
4066
4727
  /**
4067
4728
  * @param replacement An editor socket that is still attached. Clark's RPCs
@@ -4928,6 +5589,13 @@ export class AiService extends TracedEventEmitter {
4928
5589
  pendingToolPermissionRequest: undefined,
4929
5590
  resumeAfterAuthFailure: undefined,
4930
5591
  });
5592
+ this.clark.clearLastContextUsage?.();
5593
+ if (!this.isBusy()) {
5594
+ catchBackgroundPeerDisconnect(sendUserGenerationStateChannel(this.clark)({
5595
+ state: AiGenerationState.IDLE,
5596
+ chatSessionReset: true,
5597
+ }), "new chat generation state push");
5598
+ }
4931
5599
  }
4932
5600
  reportAbandonTurn(action) {
4933
5601
  if (!isAiAbandonTurnState(this.clark.state))
@@ -4963,6 +5631,7 @@ export class AiService extends TracedEventEmitter {
4963
5631
  /** Clears local LLM context so migration-era memory does not survive abort. */
4964
5632
  async clearMigrationAbortLocalContext() {
4965
5633
  this.clark.updateContext({ resumeAfterAuthFailure: undefined });
5634
+ this.clark.clearLastContextUsage?.();
4966
5635
  const contextId = getContextId(this.clark, this.config);
4967
5636
  await this.contextManagerV2.clearAllContexts();
4968
5637
  if (contextId) {
@@ -4976,11 +5645,13 @@ export class AiService extends TracedEventEmitter {
4976
5645
  */
4977
5646
  async uploadEmptyContextToBucketeer(commitId) {
4978
5647
  const jwt = this.getInferenceJwt();
4979
- const branchName = this._syncContextProvider?.getBranchName();
4980
- if (!jwt || !commitId || !branchName) {
5648
+ if (!jwt || !commitId) {
4981
5649
  return;
4982
5650
  }
4983
5651
  try {
5652
+ const branchName = await this._syncContextProvider?.getBranchName();
5653
+ if (!branchName)
5654
+ return;
4984
5655
  await uploadContextToBucketeer({
4985
5656
  applicationId: this.config.applicationId,
4986
5657
  commitId,
@@ -5086,6 +5757,15 @@ export class AiService extends TracedEventEmitter {
5086
5757
  };
5087
5758
  }
5088
5759
  async handleRestoreToCommit(sinceTimestamp, commitId, applicationId, branchName) {
5760
+ try {
5761
+ await this.restoreToCommit(sinceTimestamp, commitId, applicationId, branchName);
5762
+ }
5763
+ finally {
5764
+ // The editor empties its ring when a restore starts; refill it before replying.
5765
+ await this.pushRestoredContextUsage();
5766
+ }
5767
+ }
5768
+ async restoreToCommit(sinceTimestamp, commitId, applicationId, branchName) {
5089
5769
  // Note: clark.context.jwt should be set by the socket handler (socket-manager.ts) before
5090
5770
  // this method is called. The socket handler updates it from peerAuthorization if missing.
5091
5771
  const jwt = this.clark?.context?.jwt;
@@ -5162,6 +5842,7 @@ export class AiService extends TracedEventEmitter {
5162
5842
  this.getLogger().info(`[handleRestoreToCommit] No context restored (${outcome}) for commitId=${commitId}; leaving chat intact`, { applicationId, branchName });
5163
5843
  return;
5164
5844
  }
5845
+ this.clark.clearLastContextUsage?.();
5165
5846
  // 2. Context is rolled back — truncate the server chat to match. This is a
5166
5847
  // cross-service RPC and is NOT transactional with the context rollback
5167
5848
  // above, so a failure here leaves the context rolled back while the chat
@@ -5209,6 +5890,10 @@ export class AiService extends TracedEventEmitter {
5209
5890
  // timestamps and regenerating from YAML on-demand when YAML is newer.
5210
5891
  }
5211
5892
  isBusy() {
5893
+ return this.generationStarting || this.isClarkMidTurn();
5894
+ }
5895
+ /** Whether Clark's own state machine is running a turn, ignoring a turn still being started. */
5896
+ isClarkMidTurn() {
5212
5897
  return (this.clark.state !== ClarkStateNames.Idle &&
5213
5898
  this.clark.state !== ClarkStateNames.AwaitingUser &&
5214
5899
  this.clark.state !== ClarkStateNames.Dead);
@@ -5216,6 +5901,17 @@ export class AiService extends TracedEventEmitter {
5216
5901
  isAwaitingUser() {
5217
5902
  return this.clark.state === ClarkStateNames.AwaitingUser;
5218
5903
  }
5904
+ autoDispatchBlockReason() {
5905
+ if (this.generationStarting ||
5906
+ (this.clark.state !== ClarkStateNames.Idle &&
5907
+ this.clark.state !== ClarkStateNames.AwaitingUser))
5908
+ return "busy";
5909
+ if (this.clark.context.pendingToolPermissionRequest)
5910
+ return "tool_permission";
5911
+ if (this.isAwaitingInteractiveResponse())
5912
+ return "interactive";
5913
+ return undefined;
5914
+ }
5219
5915
  /**
5220
5916
  * True when finalize (or similar) must wait for the user to resolve an
5221
5917
  * interactive prompt or tool-permission widget. Unlike {@link isAwaitingUser},
@@ -5280,13 +5976,21 @@ export class AiService extends TracedEventEmitter {
5280
5976
  /**
5281
5977
  * Resolve generation waiters when Clark reaches a terminal state.
5282
5978
  */
5283
- triggerGenerationComplete() {
5979
+ triggerGenerationComplete(generationId) {
5980
+ if (generationId !== this.currentGenerationId) {
5981
+ if (generationId !== undefined) {
5982
+ this.emit("generationSettled", generationId);
5983
+ }
5984
+ return;
5985
+ }
5284
5986
  // The FSM parked, so any still-open generation span is over. Runs that
5285
5987
  // completed or failed already ended it via the generation events; this
5286
5988
  // ends it on the paths that emit neither (user/agent cancel,
5287
5989
  // runtime-updated-without-edits, planning errors that park in Idle).
5288
5990
  this.activeGenerationTrace?.finish();
5289
- this.emit("generationSettled");
5991
+ if (generationId !== undefined) {
5992
+ this.emit("generationSettled", generationId);
5993
+ }
5290
5994
  const waiters = Array.from(this.generationCompleteWaiters);
5291
5995
  this.generationCompleteWaiters.clear();
5292
5996
  for (const resolve of waiters) {
@@ -5307,10 +6011,18 @@ export class AiService extends TracedEventEmitter {
5307
6011
  this.fileSystemInterface.setSourceTracker(sourceTracker);
5308
6012
  }
5309
6013
  setFileSystemManager(fileSystemManager) {
6014
+ this.fileSystemManager = fileSystemManager;
5310
6015
  this.fileSystemInterface.setFileSystemManager(fileSystemManager);
5311
6016
  // Also set the template renderer for YAML→TS sync
5312
6017
  fileSystemManager.setTemplateRenderer(this.templateRenderer);
5313
6018
  }
6019
+ async refreshFileModel() {
6020
+ if (!this.fileSystemManager) {
6021
+ throw new Error("File model is not available");
6022
+ }
6023
+ await this.fileSystemManager.initialize(this.config.appRootDirPath);
6024
+ this.setSourceTracker(this.fileSystemManager.getSourceTracker());
6025
+ }
5314
6026
  setViteServer(server) {
5315
6027
  this.viteServer = server;
5316
6028
  }