@stigmer/runner 3.1.1 → 3.1.3

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 (166) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  6. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  7. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  8. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  9. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  10. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +19 -1
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +5 -0
  14. package/dist/activities/execute-cursor/index.js +222 -160
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.js +10 -1
  17. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  19. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  22. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  23. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  24. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  25. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  26. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  27. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  28. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  30. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  31. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  32. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +89 -5
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.js +22 -16
  39. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  41. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  43. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  44. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  45. package/dist/config.d.ts +7 -0
  46. package/dist/config.js +9 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/middleware/approval-gate.js +36 -6
  49. package/dist/middleware/approval-gate.js.map +1 -1
  50. package/dist/runner-manager.d.ts +2 -0
  51. package/dist/runner-manager.js +2 -1
  52. package/dist/runner-manager.js.map +1 -1
  53. package/dist/runner.d.ts +2 -0
  54. package/dist/runner.js +2 -1
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +32 -0
  57. package/dist/shared/artifact-storage.js +74 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/filereview/capture.d.ts +18 -1
  60. package/dist/shared/filereview/capture.js +105 -28
  61. package/dist/shared/filereview/capture.js.map +1 -1
  62. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  63. package/dist/shared/filereview/cas-substrate.js +9 -1
  64. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  65. package/dist/shared/filereview/events.d.ts +16 -0
  66. package/dist/shared/filereview/events.js +37 -0
  67. package/dist/shared/filereview/events.js.map +1 -1
  68. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  69. package/dist/shared/filereview/git-substrate.js +78 -0
  70. package/dist/shared/filereview/git-substrate.js.map +1 -1
  71. package/dist/shared/filereview/index.d.ts +3 -1
  72. package/dist/shared/filereview/index.js +3 -1
  73. package/dist/shared/filereview/index.js.map +1 -1
  74. package/dist/shared/filereview/line-counts.d.ts +48 -0
  75. package/dist/shared/filereview/line-counts.js +72 -0
  76. package/dist/shared/filereview/line-counts.js.map +1 -0
  77. package/dist/shared/filereview/progress.d.ts +96 -0
  78. package/dist/shared/filereview/progress.js +134 -0
  79. package/dist/shared/filereview/progress.js.map +1 -0
  80. package/dist/shared/implement-plan-prompt.d.ts +44 -0
  81. package/dist/shared/implement-plan-prompt.js +98 -0
  82. package/dist/shared/implement-plan-prompt.js.map +1 -0
  83. package/dist/shared/plan-artifact.d.ts +59 -11
  84. package/dist/shared/plan-artifact.js +144 -16
  85. package/dist/shared/plan-artifact.js.map +1 -1
  86. package/dist/shared/plan-mode-prompt.d.ts +32 -0
  87. package/dist/shared/plan-mode-prompt.js +56 -0
  88. package/dist/shared/plan-mode-prompt.js.map +1 -0
  89. package/dist/shared/status.d.ts +11 -0
  90. package/dist/shared/status.js +12 -2
  91. package/dist/shared/status.js.map +1 -1
  92. package/dist/shared/tool-row.d.ts +30 -1
  93. package/dist/shared/tool-row.js +53 -4
  94. package/dist/shared/tool-row.js.map +1 -1
  95. package/dist/shared/workspace/session-root.d.ts +35 -0
  96. package/dist/shared/workspace/session-root.js +53 -0
  97. package/dist/shared/workspace/session-root.js.map +1 -0
  98. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  99. package/dist/shared/workspace/workspace-lock.js +187 -0
  100. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  101. package/package.json +8 -5
  102. package/src/__tests__/config.test.ts +8 -0
  103. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  104. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  105. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  106. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
  107. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  108. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  109. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  110. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  111. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  112. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  113. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  114. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  115. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  116. package/src/activities/execute-cursor/hook-script.ts +19 -1
  117. package/src/activities/execute-cursor/index.ts +243 -178
  118. package/src/activities/execute-cursor/message-translator.ts +9 -1
  119. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  120. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  121. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  122. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  123. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  124. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  125. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  126. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  127. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
  128. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  129. package/src/activities/execute-deep-agent/index.ts +104 -5
  130. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  131. package/src/activities/execute-deep-agent/setup.ts +25 -17
  132. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  133. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  134. package/src/config.ts +17 -0
  135. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  136. package/src/middleware/approval-gate.ts +39 -7
  137. package/src/runner-manager.ts +6 -1
  138. package/src/runner.ts +6 -1
  139. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  140. package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
  141. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  142. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  143. package/src/shared/__tests__/status.test.ts +7 -2
  144. package/src/shared/__tests__/tool-row.test.ts +136 -1
  145. package/src/shared/artifact-storage.ts +83 -1
  146. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  147. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  148. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  149. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  150. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  151. package/src/shared/filereview/capture.ts +134 -30
  152. package/src/shared/filereview/cas-substrate.ts +17 -1
  153. package/src/shared/filereview/events.ts +50 -0
  154. package/src/shared/filereview/git-substrate.ts +108 -0
  155. package/src/shared/filereview/index.ts +22 -1
  156. package/src/shared/filereview/line-counts.ts +83 -0
  157. package/src/shared/filereview/progress.ts +186 -0
  158. package/src/shared/implement-plan-prompt.ts +104 -0
  159. package/src/shared/plan-artifact.ts +152 -16
  160. package/src/shared/plan-mode-prompt.ts +56 -0
  161. package/src/shared/status.ts +11 -1
  162. package/src/shared/tool-row.ts +58 -5
  163. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  164. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  165. package/src/shared/workspace/session-root.ts +60 -0
  166. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -35,11 +35,11 @@ import { StigmerClient } from "../../client/stigmer-client.js";
35
35
  import { resolveAgent } from "./session-lifecycle.js";
36
36
  import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
37
37
  import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
38
- import { MessageAccumulator, reconcileDeniedToolCalls, clearProvisionalPostDenialNarration, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
38
+ import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
39
39
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
40
- import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
40
+ import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
41
41
  import { startStallWatchdog, StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
42
- import { createArtifactStorage, loadArtifactStorageConfig } from "../../shared/artifact-storage.js";
42
+ import { resolveUsableArtifactStorage, loadArtifactStorageConfig } from "../../shared/artifact-storage.js";
43
43
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
44
44
  import { DeltaEnricher } from "./delta-enricher.js";
45
45
  import { TodoTracker } from "./todo-tracker.js";
@@ -53,17 +53,20 @@ import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
53
53
  import { resolveExecutionEnv } from "./env-resolver.js";
54
54
  import { resolveBlueprint } from "./blueprint-resolver.js";
55
55
  import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
56
- import { resolveSkills, removeStigmerSymlink } from "./skill-resolver.js";
56
+ import { resolveSkills } from "./skill-resolver.js";
57
+ import { removeStigmerSymlink } from "./stigmer-link.js";
57
58
  import { resolveAttachments } from "./attachment-resolver.js";
58
- import { buildEnhancedPrompt, buildReinvocationPrompt } from "./prompt-builder.js";
59
+ import { buildEnhancedPrompt, buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
59
60
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
60
61
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
62
+ import { acquireWorkspaceLock, WorkspaceLockCancelledError, WorkspaceLockTimeoutError, } from "../../shared/workspace/workspace-lock.js";
61
63
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
62
- import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, primaryToken, readDenialLedger, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
63
- import { deriveTurnCommandProvenance } from "./command-provenance.js";
64
+ import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, readDenialLedger, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
64
65
  import { applyApprovedWholeFileWrites, excludeAppliedFromGrants } from "./exact-apply.js";
65
66
  import { isGitWorkTree } from "../../shared/filereview/git-substrate.js";
66
- import { captureBaselineToLedger, captureTurnToLedger, applyCaptureDecisions, deriveCaptureMode, } from "./capture-flow.js";
67
+ import { captureBaselineToLedger, captureProgressToStatus, applyCaptureDecisions, deriveCaptureMode, } from "./capture-flow.js";
68
+ import { runTurnBoundary } from "./turn-boundary.js";
69
+ import { newProgressCaptureState, } from "../../shared/filereview/progress.js";
67
70
  import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
68
71
  import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
69
72
  import { provisionCursorWorkspace } from "./workspace-provision.js";
@@ -80,11 +83,6 @@ import { createAgent, createCloudAgent } from "./session-lifecycle.js";
80
83
  import { setMaxListeners } from "node:events";
81
84
  import { startHeartbeat } from "../../shared/heartbeat.js";
82
85
  import { getShutdownSignalForQueue } from "../../runner-manager.js";
83
- // How long Phase 12 waits for the first-denial-stop's run.cancel() to settle
84
- // before reading the final denial ledger and capturing the turn's tree. Long
85
- // enough for the SDK's normal teardown, short enough that a wedged cancel
86
- // cannot noticeably delay the approval pause the user is already waiting on.
87
- const FIRST_DENIAL_CANCEL_TIMEOUT_MS = 5_000;
88
86
  /**
89
87
  * Creates the activity functions bound to the runner config.
90
88
  * Returned object is passed to Temporal Worker.create({ activities }).
@@ -131,25 +129,30 @@ turnSeq) {
131
129
  });
132
130
  // Artifact storage for offloading oversized tool outputs (screenshots, giant
133
131
  // dumps) out of the persisted status, and for publishing the plan artifact.
134
- // Created once here so it is available to EVERY persist below. Best-effort:
135
- // if it can't be built (e.g. proxy mode without a token), offload is disabled
136
- // but persistStatus still enforces the aggregate size cap, so persistence can
137
- // never silently blow past the gRPC limit.
138
- let artifactStorage;
139
- try {
140
- artifactStorage = createArtifactStorage(loadArtifactStorageConfig(config));
141
- }
142
- catch (storageErr) {
143
- console.warn(`ExecuteCursor artifact storage unavailable — tool-output offload disabled ` +
144
- `(aggregate size guard still active): execution=${executionId}, error=${storageErr}`);
145
- }
132
+ // Resolved once here so it is available to EVERY persist below. Best-effort via
133
+ // the shared resolver (identical to the deep-agent harness): `undefined` never
134
+ // a throw when there is no working substrate (proxy misconfig OR an unwritable
135
+ // local path). An absent store disables offload (persistStatus still enforces
136
+ // the aggregate size cap) and flips capture mode off (deny-gate fallback).
137
+ const artifactStorage = await resolveUsableArtifactStorage(loadArtifactStorageConfig(config), { executionId });
146
138
  const statusOffload = artifactStorage
147
139
  ? { artifactStorage, executionId }
148
140
  : undefined;
149
141
  // ALL status persistence in this activity flows through `persist`, so the
150
142
  // single size-bounding guard (offload + aggregate elision) is unforgeable and
151
143
  // a future call site cannot accidentally skip it.
152
- const persist = (s = status) => persistStatus(client, executionId, s, { offload: statusOffload });
144
+ const persist = (s = status) => {
145
+ // Never-persist-secret backstop (DD-26 #2): before EVERY persist, withhold
146
+ // content from any built-in write row targeting a secret-like path (top-level
147
+ // + sub-agent). This is the single airtight choke point for the Cursor harness
148
+ // — the deny-gate analog of capture mode's stamping scrub, and the only
149
+ // guarantee under auto_approve_all (where the hook installs no gate). Safe on
150
+ // every call: Cursor sets tool args atomically from the SDK tool_call event
151
+ // (buildToolCallProto), so there is no mid-stream partial-args hazard, and the
152
+ // pass only ever touches secret-like write rows (idempotent, else a no-op).
153
+ withholdSecretContentFromMessages(s.messages, s.subAgentExecutions);
154
+ return persistStatus(client, executionId, s, { offload: statusOffload });
155
+ };
153
156
  let sessionId;
154
157
  let session;
155
158
  let pauseDetected = false;
@@ -195,6 +198,14 @@ turnSeq) {
195
198
  // (issue #173). Runs in the finally, covering every success/error/approval
196
199
  // exit path. Undefined until the gate is installed.
197
200
  let hitlCleanup;
201
+ // Exclusive turn lock on the primary workspace working tree. Held across the
202
+ // ENTIRE tree-mutating window (decision reconcile, HITL gate install, the
203
+ // agent's own writes, candidate capture) so a concurrent execution sharing
204
+ // this directory can never write between this turn's baseline and candidate
205
+ // snapshots — the misattribution that showed another session's file as this
206
+ // turn's change. Released in the finally AFTER hitlCleanup (which still
207
+ // mutates the tree). See shared/workspace/workspace-lock.ts.
208
+ let releaseWorkspaceLock;
198
209
  // Carries model/mode/agentId out to the outer catch so a thrown CursorSdkError
199
210
  // can be classified with the same context as the run.wait() error path.
200
211
  let errorContext = { model: "default", mode: "local", agentId: "" };
@@ -239,6 +250,9 @@ turnSeq) {
239
250
  // Pre-turn baseline tree, pinned before the agent runs (capture mode only)
240
251
  // so the turn-end capture diffs against it and the tree restores exactly.
241
252
  let baselineTree;
253
+ // Per-turn state for mid-run live capture (DD-32): the last progress tree sha
254
+ // (short-circuit) + last capture time (floor), threaded across persists.
255
+ const progressState = newProgressCaptureState();
242
256
  // Deterministic id of the change set this turn may produce:
243
257
  // `${executionId}:${turnSeq}`. Minted from the workflow-threaded turn index
244
258
  // so it is stable across a Temporal retry (idempotent ledger authoring) and
@@ -247,6 +261,44 @@ turnSeq) {
247
261
  // resume (which never authors a baseline) is harmless.
248
262
  const changeSetId = `${executionId}:${turnSeq}`;
249
263
  heartbeat();
264
+ // Serialize this turn against every other execution sharing this working
265
+ // tree — sessions declaring the same localPath (or the shared runner root)
266
+ // resolve to ONE directory, and an unserialized concurrent write lands
267
+ // inside this turn's baseline→candidate window, misattributing another
268
+ // session's file to this turn's review. Acquired before ANY tree mutation
269
+ // below (decision reconcile, gate install, agent writes, capture). While
270
+ // another turn holds the lock this surfaces a visible waiting state and
271
+ // heartbeats; a cancel aborts the wait immediately.
272
+ if (primaryWorkspaceDir) {
273
+ try {
274
+ releaseWorkspaceLock = await acquireWorkspaceLock(primaryWorkspaceDir, {
275
+ onWaiting: () => reportSetupProgress(client, executionId, "Waiting for workspace — in use by another session"),
276
+ heartbeat,
277
+ signal: Context.current().cancellationSignal,
278
+ timeoutMs: config.workspaceLockTimeoutMs,
279
+ });
280
+ }
281
+ catch (lockErr) {
282
+ if (lockErr instanceof WorkspaceLockCancelledError) {
283
+ throw new CancelledFailure("Activity cancelled while waiting for the workspace lock");
284
+ }
285
+ if (lockErr instanceof WorkspaceLockTimeoutError) {
286
+ status.phase = ExecutionPhase.EXECUTION_FAILED;
287
+ status.error = lockErr.message;
288
+ status.completedAt = utcTimestamp();
289
+ status.messages.push(create(AgentMessageSchema, {
290
+ type: MessageType.MESSAGE_SYSTEM,
291
+ content: `Execution failed: ${lockErr.message}`,
292
+ timestamp: utcTimestamp(),
293
+ }));
294
+ await persist(status);
295
+ console.warn(`ExecuteCursor workspace lock timeout: execution=${executionId}`);
296
+ return slimStatus(status);
297
+ }
298
+ throw lockErr;
299
+ }
300
+ }
301
+ heartbeat();
250
302
  // Set OTel baggage so downstream calls carry execution context.
251
303
  try {
252
304
  const { setBaggage, BAGGAGE_EXECUTION_ID, BAGGAGE_SESSION_ID, BAGGAGE_ORG_ID } = await import("../../otel.js");
@@ -430,8 +482,15 @@ turnSeq) {
430
482
  primaryWorkspaceDir,
431
483
  });
432
484
  heartbeat();
433
- // Phase 5b: Resolve attachments
434
- const attachmentResults = await resolveAttachments(spec.attachments, sessionId, primaryWorkspaceDir, config.mode);
485
+ // Phase 5b: Resolve attachments (fail-hard — explicit user inputs; see
486
+ // attachment-resolver.ts). Downloads by storage key through the same
487
+ // artifactStorage resolved for status offload above.
488
+ const attachmentResults = await resolveAttachments(spec.attachments, {
489
+ sessionId,
490
+ primaryWorkspaceDir,
491
+ mode: config.mode,
492
+ storage: artifactStorage,
493
+ });
435
494
  const attachmentPaths = attachmentResults.map((a) => a.relativePath);
436
495
  // Phase 5b3: Exact-apply approved whole-file writes (HITL "what you approve
437
496
  // is what gets applied"). The Cursor deny-only harness reinvokes the model,
@@ -611,6 +670,7 @@ turnSeq) {
611
670
  // Phase 10: Build the prompt
612
671
  const interactionMode = spec.executionConfig?.interactionMode
613
672
  ?? InteractionMode.UNSPECIFIED;
673
+ const buildFromPlan = spec.executionConfig?.buildFromPlan ?? false;
614
674
  const prompt = buildPrompt({
615
675
  resolution,
616
676
  approvalDecisions,
@@ -624,6 +684,7 @@ turnSeq) {
624
684
  pendingApprovals: adjudicatedApprovals,
625
685
  appliedToolCallIds,
626
686
  interactionMode,
687
+ buildFromPlan,
627
688
  });
628
689
  // Phase 10a: Inject structured output instruction for Cursor harness
629
690
  let effectivePrompt = prompt;
@@ -831,6 +892,22 @@ turnSeq) {
831
892
  // accumulator tracked sub-agents in memory but they only reached the
832
893
  // status (and the subscriber stream) after the loop ended.
833
894
  status.subAgentExecutions = accumulator.subAgentExecutions;
895
+ // Mid-run live capture (DD-32): attach the "N files changed so far"
896
+ // snapshot onto status.file_change_progress, throttled internally by the
897
+ // floor + tree-sha short-circuit. Git capture mode only (a pinned baseline
898
+ // exists); shell + sub-agent + tool edits are all captured for free by the
899
+ // workspace-wide diff. Never authoritative — the turn-boundary candidate
900
+ // remains the reviewed diff.
901
+ if (captureMode && gitWorkspace && baselineTree && primaryWorkspaceDir) {
902
+ await captureProgressToStatus({
903
+ status,
904
+ gitRoot: primaryWorkspaceDir,
905
+ executionId,
906
+ changeSetId,
907
+ baselineTree,
908
+ state: progressState,
909
+ });
910
+ }
834
911
  const signal = await persist(status);
835
912
  deltaEnricher.markPersisted();
836
913
  todoTracker.markPersisted();
@@ -998,134 +1075,80 @@ turnSeq) {
998
1075
  console.log(`ExecuteCursor completed (platform stop): execution=${executionId}`);
999
1076
  return slimStatus(status);
1000
1077
  }
1001
- // Phase 12: Surface tools the preToolUse hook gated (HITL).
1002
- //
1003
- // The hook records each denial to the ledger; we mark the corresponding tool
1004
- // calls WAITING_APPROVAL. The backend projects pending_approvals from that
1005
- // tool-call status (PendingApprovalComputer), so exactly like the native
1006
- // harness the approval surface is driven entirely by tool-call status. We
1007
- // deliberately do NOT set status.pendingApprovals here: any value would be
1008
- // discarded by the backend's recompute on the next updateStatus.
1009
- //
1010
- // Before reading the ledger, wait (timeboxed) for the first-denial-stop's
1011
- // run.cancel() to settle. run.cancel() races the SDK's auto-execution: until
1012
- // it lands, the agent process may still attempt a post-denial workaround
1013
- // whose hook denial would land AFTER a premature ledger read — the row then
1014
- // never collapses and renders as RUNNING forever (production case
1015
- // aex_01kwj07f7g23c3wp9sn8496z5g) — or a late tool could mutate the tree
1016
- // mid-capture. The timebox keeps a wedged cancel from hanging the pause;
1017
- // the Phase 12 trims below remain the backstop for that degraded case.
1018
- if (firstDenialDetected && denialCancelSettled) {
1019
- await Promise.race([
1020
- denialCancelSettled,
1021
- new Promise((resolve) => {
1022
- const timer = setTimeout(resolve, FIRST_DENIAL_CANCEL_TIMEOUT_MS);
1023
- timer.unref();
1024
- }),
1025
- ]);
1026
- }
1027
- const deniedLedger = await readDenialLedger(hitlDir ?? "");
1028
- // Capture mode: author the net change set to the file_review ledger as the
1029
- // CANDIDATE_CAPTURED event (projected server-side to a file_change_set
1030
- // AWAITING_REVIEW — the single review surface). The runner-owned gate files
1031
- // are excluded from the capture. The agent's edits are LEFT applied on the
1032
- // working tree (Cursor parity — the user reviews the real change; nothing is
1033
- // committed and the next turn is blocked until approval, and a reject snaps
1034
- // each file back on resume). Runs BEFORE the denial reconcile so a denied
1035
- // (gitignored) write stays on the deny-gate path while every flowed edit is
1036
- // captured to the ledger.
1037
- let capturedChangeCount = 0;
1038
- // `baselineTree !== undefined` means a baseline was authored this turn — the
1039
- // git tree sha for a git workspace, or "" (empty, but authored) for a non-git
1040
- // one. A plain truthiness check would wrongly skip the non-git capture.
1041
- if (captureMode && baselineTree !== undefined && primaryWorkspaceDir) {
1042
- const deniedTokens = new Set(deniedLedger.map((e) => e.token));
1043
- // Approved-command turn facts (DD-28): when every mutation-capable call
1044
- // this turn was a consented shell command, attach the provenance so the
1045
- // backend can verify the cited consent rows and auto-keep the set instead
1046
- // of arming a second gate. Fail-closed: any non-qualifying turn attaches
1047
- // nothing and reviews manually exactly as before.
1048
- const commandProvenance = deriveTurnCommandProvenance({
1049
- messages: status.messages,
1050
- turnStartIndex: turnStartMessageIndex,
1051
- deniedTokens,
1052
- grantTokenToConsentId: new Map((approvalGrants ?? []).map((g) => [
1053
- primaryToken(g.key, g.salient, g.contentDigest),
1054
- g.sourceToolCallId,
1055
- ])),
1056
- globalBypass,
1057
- });
1058
- if (commandProvenance) {
1059
- console.log(`ExecuteCursor capture: turn qualifies for approved-command auto-keep ` +
1060
- `(consent rows: ${commandProvenance.consentToolCallIds.join(",") || "(auto_approve_all)"}); ` +
1061
- `attaching provenance to candidate (execution=${executionId})`);
1062
- }
1063
- const captured = await captureTurnToLedger({
1064
- status,
1065
- gitRoot: primaryWorkspaceDir,
1066
- executionId,
1067
- changeSetId,
1068
- baselineTree,
1069
- messages: status.messages,
1070
- deniedTokens,
1071
- commandProvenance,
1072
- // Scope sub-agent row stamping to this turn: the seeded prior sub-agents
1073
- // (cloned in on resume) are the "before this turn" rows to skip.
1074
- priorSubAgentToolCallIds: collectSubAgentToolCallIds(seededSubAgents),
1075
- // The CAS half: read the sidecar the hook staged this turn and compose it
1076
- // into the change set. hitlDir + storage are present when captureIgnored
1077
- // was on (a git tree's gitignored writes, or ALL writes in a non-git
1078
- // workspace). In a git tree this composes with the git diff (HYBRID); in a
1079
- // non-git workspace it IS the whole change set (CAS-only).
1080
- hitlDir,
1081
- storage: artifactStorage,
1082
- gitWorkspace,
1083
- });
1084
- capturedChangeCount = captured.length;
1085
- if (capturedChangeCount > 0) {
1086
- console.log(`ExecuteCursor capture: ${capturedChangeCount} file change(s) authored to the ` +
1087
- `file_review ledger (change_set=${changeSetId}), working tree left applied ` +
1088
- `for review (execution=${executionId})`);
1089
- }
1090
- }
1091
- // The gate reads each denied file's pre-edit `before` from the workspace the
1092
- // runner is co-located with (local FS for OSS; the sandbox in cloud), so a
1093
- // whole-file rewrite gate renders a true before/after diff. The tool was
1094
- // DENIED, so disk still holds the old content. User files are never platform
1095
- // paths, so no platformDir routing is needed here.
1096
- const gateWorkspaceBackend = new LocalWorkspaceBackend(primaryWorkspaceDir);
1097
- const deniedToolCalls = await reconcileDeniedToolCalls(status.messages, deniedLedger, mergedPolicies, gateWorkspaceBackend);
1098
- // Observability: a synthesized placeholder (id `approval:*`) means a denial
1099
- // correlated to NO streamed tool call in either the exact or the normalized
1100
- // pass. After the normalized-path fallback this should be ~0; a non-zero rate
1101
- // is the early-warning signal of a NEW identity drift (the gate would then
1102
- // show "No preview available" with no diff). Logged, not thrown — the
1103
- // synthesized gate still safely surfaces the approval.
1104
- const synthesizedGateCount = deniedToolCalls.filter((tc) => tc.id.startsWith("approval:")).length;
1105
- if (synthesizedGateCount > 0) {
1106
- console.warn(`ExecuteCursor reconcile synthesized ${synthesizedGateCount} placeholder gate(s) ` +
1107
- `with no correlated stream call (execution=${executionId}); ` +
1108
- `possible hook/stream identity drift — gate(s) will lack a diff`);
1109
- }
1110
- if (deniedToolCalls.length > 0 || capturedChangeCount > 0) {
1111
- if (deniedToolCalls.length > 0) {
1112
- // Deterministic clean-pause: a turn that pauses for approval must read as
1113
- // the same shape the native harness produces — pre-tool text + the gated
1114
- // tool calls — never the model's provisional reaction to Cursor's deny
1115
- // (e.g. "blocked by a hook; enable it in your Cursor settings"). We blank
1116
- // that reaction in place (keeping the message count, so the finalize stays
1117
- // append-only) rather than removing it. See
1118
- // clearProvisionalPostDenialNarration for the full rationale.
1119
- const redactedNarration = clearProvisionalPostDenialNarration(status.messages, deniedToolCalls);
1120
- if (redactedNarration.length > 0) {
1121
- console.log(`ExecuteCursor redacted ${redactedNarration.length} provisional post-denial narration message(s) before pausing for approval`);
1122
- }
1123
- }
1078
+ // Phase 12: The turn boundary author this turn's change set to the
1079
+ // file_review ledger (CANDIDATE_CAPTURED) and overlay the hook's denials as
1080
+ // WAITING_APPROVAL gate rows. The full pipeline and its ordering rationale
1081
+ // live in turn-boundary.ts; this closure binds the turn's state so the
1082
+ // recovery retries below (which re-run the agent AFTER this primary call)
1083
+ // can re-enter the IDENTICAL pipeline a retry's edits must reach the
1084
+ // ledger or they silently escape review. `baselineTree` is read at call
1085
+ // time, so both entries see the baseline authored at turn start.
1086
+ const runBoundary = (denialSettled) => runTurnBoundary({
1087
+ status,
1088
+ executionId,
1089
+ changeSetId,
1090
+ hitlDir,
1091
+ captureMode,
1092
+ baselineTree,
1093
+ primaryWorkspaceDir,
1094
+ gitWorkspace,
1095
+ turnStartMessageIndex,
1096
+ approvalGrants,
1097
+ globalBypass,
1098
+ seededSubAgents,
1099
+ artifactStorage,
1100
+ mergedPolicies,
1101
+ denialCancelSettled: denialSettled,
1102
+ });
1103
+ // Pauses for review exactly like the native harness: the boundary mutated
1104
+ // the transcript in place; we flip the phase, persist, and RETURN to the
1105
+ // workflow, which waits for the approval/file-review signal and reinvokes.
1106
+ const enterApprovalPause = async (boundary) => {
1124
1107
  status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
1125
1108
  await persist(status);
1126
- console.log(`ExecuteCursor returning WAITING_FOR_APPROVAL: ${deniedToolCalls.length} gated tool(s), ` +
1127
- `${capturedChangeCount} file card(s) pending`);
1109
+ console.log(`ExecuteCursor returning WAITING_FOR_APPROVAL: ${boundary.deniedToolCallCount} gated tool(s), ` +
1110
+ `${boundary.capturedChangeCount} file card(s) pending`);
1128
1111
  return slimStatus(status);
1112
+ };
1113
+ // Stream epilogue + boundary re-entry for a recovery retry (poisoned-handle /
1114
+ // transport-timeout). The retry re-runs the agent AFTER the primary epilogue
1115
+ // and boundary already ran, so its stream state must be settled the same way:
1116
+ // - flush the enricher's buffered deltas onto the rows (the primary loop
1117
+ // applies them every iteration; the bare retry loop does not — without the
1118
+ // flush, a completed tool call never receives its completedAt evidence and
1119
+ // finalize's reconciliation sweep leaves it RUNNING forever);
1120
+ // - finalize streaming state and sync sub-agents;
1121
+ // - refresh streaming usage (the retry's turns accumulated in memory only)
1122
+ // and re-stamp completedAt (stamped below BEFORE the retry ran);
1123
+ // - re-enter the turn boundary so edits made by the retry reach the
1124
+ // file_review ledger / approval gates — without this, a retry's file
1125
+ // edits silently escape review (production case
1126
+ // aex_01kws27q1e2esvkqjpvectttxf).
1127
+ // Returns undefined for a cancelled retry: parity with the primary path,
1128
+ // where cancellation exits before the boundary — there is no review to open.
1129
+ const settleRetryTurn = async (retryResultStatus) => {
1130
+ accumulator.finalize();
1131
+ deltaEnricher.applyEnrichments(status.messages);
1132
+ deltaEnricher.finalize(status.messages);
1133
+ status.subAgentExecutions = accumulator.subAgentExecutions;
1134
+ if (usageAccumulator.hasTurns) {
1135
+ status.streamingUsage = create(StreamingUsageSummarySchema, usageAccumulator.snapshot());
1136
+ }
1137
+ // Re-flush the (dev-only) event recorder: flush rewrites the full JSONL,
1138
+ // so the recorded trace now includes the retry's events too.
1139
+ await eventRecorder?.flush();
1140
+ const retryBoundary = retryResultStatus === "cancelled" ? undefined : await runBoundary();
1141
+ // Phase 13 stamped completedAt BEFORE the retry ran. A terminal outcome
1142
+ // re-stamps it to the true end; a review pause CLEARS it — the primary
1143
+ // pause path never stamps it (a waiting turn is not complete).
1144
+ status.completedAt = retryBoundary?.waiting ? "" : utcTimestamp();
1145
+ return retryBoundary;
1146
+ };
1147
+ // The denial-settle wait applies only when a first denial stopped THIS run;
1148
+ // the recovery retries have no early stop and pass no promise.
1149
+ const boundary = await runBoundary(firstDenialDetected ? denialCancelSettled : undefined);
1150
+ if (boundary.waiting) {
1151
+ return enterApprovalPause(boundary);
1129
1152
  }
1130
1153
  // Phase 13: Map final result
1131
1154
  const result = await run.wait();
@@ -1239,7 +1262,9 @@ turnSeq) {
1239
1262
  if (Context.current().cancellationSignal.aborted)
1240
1263
  break;
1241
1264
  retryWatchdog.recordActivity();
1265
+ eventRecorder?.record(retryEvent, eventCount);
1242
1266
  accumulator.processEvent(retryEvent);
1267
+ eventCount++;
1243
1268
  if (retryEvent.type === "status") {
1244
1269
  const retryStatusEvent = retryEvent;
1245
1270
  if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
@@ -1255,6 +1280,15 @@ turnSeq) {
1255
1280
  const retryResult = await retryRun.wait();
1256
1281
  console.log(`ExecuteCursor retry run.wait(): execution=${executionId}, ` +
1257
1282
  `retryResult=${JSON.stringify(retryResult)}`);
1283
+ const retryBoundary = await settleRetryTurn(retryResult.status);
1284
+ if (retryBoundary?.waiting) {
1285
+ // The retry's edits/denials armed the gate — pause for review. On a
1286
+ // retry error this supersedes the failure, exactly as on the primary
1287
+ // path (a captured change pauses the turn before run.wait() is
1288
+ // consulted).
1289
+ console.log(`ExecuteCursor poisoned-handle recovery paused for review: execution=${executionId}`);
1290
+ return enterApprovalPause(retryBoundary);
1291
+ }
1258
1292
  if (retryResult.status === "finished") {
1259
1293
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1260
1294
  console.log(`ExecuteCursor poisoned-handle recovery SUCCEEDED: execution=${executionId}`);
@@ -1336,7 +1370,9 @@ turnSeq) {
1336
1370
  if (Context.current().cancellationSignal.aborted)
1337
1371
  break;
1338
1372
  retryWatchdog.recordActivity();
1373
+ eventRecorder?.record(retryEvent, eventCount);
1339
1374
  accumulator.processEvent(retryEvent);
1375
+ eventCount++;
1340
1376
  if (retryEvent.type === "status") {
1341
1377
  const retryStatusEvent = retryEvent;
1342
1378
  if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
@@ -1350,6 +1386,14 @@ turnSeq) {
1350
1386
  retryWatchdog.stop();
1351
1387
  }
1352
1388
  const retryResult = await retryRun.wait();
1389
+ const retryBoundary = await settleRetryTurn(retryResult.status);
1390
+ if (retryBoundary?.waiting) {
1391
+ // The retry's edits/denials armed the gate — pause for review (see
1392
+ // the poisoned-handle branch above for the precedence rationale).
1393
+ console.log(`ExecuteCursor transport-timeout recovery paused for review: execution=${executionId}`);
1394
+ resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
1395
+ return enterApprovalPause(retryBoundary);
1396
+ }
1353
1397
  if (retryResult.status === "finished") {
1354
1398
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1355
1399
  resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
@@ -1409,10 +1453,10 @@ turnSeq) {
1409
1453
  if (structuredOutput !== undefined) {
1410
1454
  status.structuredOutput = structuredOutput;
1411
1455
  }
1412
- // Plan mode: publish the final plan message as a plan.md artifact. The
1413
- // Cursor harness has no auto-publish pipeline, so this is the only
1414
- // artifact path; build storage from the same config-driven factory the
1415
- // native harness uses.
1456
+ // Plan mode: publish the final plan message as a plan artifact (named
1457
+ // from the plan's title). The Cursor harness has no auto-publish
1458
+ // pipeline, so this is the only artifact path; build storage from the
1459
+ // same config-driven factory the native harness uses.
1416
1460
  if (interactionMode === InteractionMode.PLAN && finalText && artifactStorage) {
1417
1461
  try {
1418
1462
  await publishPlanArtifact({ status, executionId, planText: finalText, artifactStorage });
@@ -1613,6 +1657,12 @@ turnSeq) {
1613
1657
  `execution=${executionId}, error=${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`);
1614
1658
  }
1615
1659
  }
1660
+ // Release the workspace turn lock LAST — hitlCleanup above still mutates
1661
+ // the tree (restores .cursor/hooks.json), and the next queued turn must
1662
+ // not baseline until every mutation of this one has landed. Idempotent
1663
+ // and non-throwing (see workspace-lock.ts), so it can never mask the
1664
+ // turn's real outcome.
1665
+ await releaseWorkspaceLock?.();
1616
1666
  }
1617
1667
  }
1618
1668
  /**
@@ -1702,7 +1752,7 @@ import { jsonSchemaToZod } from "../../shared/json-schema-to-zod.js";
1702
1752
  * agent after resume failure skills; no prior conversation to inherit)
1703
1753
  */
1704
1754
  export function buildPrompt(input) {
1705
- const { resolution, approvalDecisions, instructions, userMessage, skills, subAgents, workspaceDirs, workspaceFileRefs, attachmentPaths, interactionMode, } = input;
1755
+ const { resolution, approvalDecisions, instructions, userMessage, skills, subAgents, workspaceDirs, workspaceFileRefs, attachmentPaths, interactionMode, buildFromPlan, } = input;
1706
1756
  const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
1707
1757
  // HITL reinvocation: the agent is resumed, so its native context carries the
1708
1758
  // prior conversation; the reinvocation prompt conveys the approval decisions
@@ -1711,9 +1761,20 @@ export function buildPrompt(input) {
1711
1761
  return buildReinvocationPrompt(input.pendingApprovals, approvalDecisions, input.appliedToolCallIds);
1712
1762
  }
1713
1763
  // A successfully resumed agent carries its own conversation context via the
1714
- // SDK's native store — send the raw user message with no preamble.
1764
+ // SDK's native store — send the raw user message with no preamble. The
1765
+ // exceptions are the per-EXECUTION directives, which never inherit from the
1766
+ // session's first turn: the interaction-mode prefix (a follow-up can switch
1767
+ // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
1768
+ // enforcement) and the implement-plan directive (the build turn is usually
1769
+ // a follow-up on a resumed agent).
1715
1770
  if (resolution.reason === "resumed_successfully") {
1716
- return userMessage;
1771
+ const prefixes = [
1772
+ formatInteractionModePrefix(interactionMode),
1773
+ formatImplementPlanSection(buildFromPlan, attachmentPaths),
1774
+ ].filter((p) => p !== undefined);
1775
+ return prefixes.length > 0
1776
+ ? [...prefixes, userMessage].join("\n\n")
1777
+ : userMessage;
1717
1778
  }
1718
1779
  // First execution, or a fresh agent created after a resume failure: there is
1719
1780
  // no prior conversation to inherit, so start a new turn with full context.
@@ -1726,6 +1787,7 @@ export function buildPrompt(input) {
1726
1787
  workspaceFileRefs,
1727
1788
  attachmentPaths,
1728
1789
  interactionMode,
1790
+ buildFromPlan,
1729
1791
  });
1730
1792
  }
1731
1793
  // ---------------------------------------------------------------------------