@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
@@ -43,11 +43,11 @@ import { resolveAgent } from "./session-lifecycle.js";
43
43
  import type { AgentResolution, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
44
44
  import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
45
45
  import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
46
- import { MessageAccumulator, reconcileDeniedToolCalls, clearProvisionalPostDenialNarration, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
46
+ import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
47
47
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
48
- import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
48
+ import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
49
49
  import { startStallWatchdog, StallTimeoutError, formatStallFailure, type StallWatchdog } from "../../shared/stall-watchdog.js";
50
- import { createArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
50
+ import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
51
51
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
52
52
  import { DeltaEnricher } from "./delta-enricher.js";
53
53
  import { TodoTracker } from "./todo-tracker.js";
@@ -61,22 +61,33 @@ import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
61
61
  import { resolveExecutionEnv } from "./env-resolver.js";
62
62
  import { resolveBlueprint } from "./blueprint-resolver.js";
63
63
  import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
64
- import { resolveSkills, removeStigmerSymlink } from "./skill-resolver.js";
64
+ import { resolveSkills } from "./skill-resolver.js";
65
+ import { removeStigmerSymlink } from "./stigmer-link.js";
65
66
  import { resolveAttachments } from "./attachment-resolver.js";
66
- import { buildEnhancedPrompt, buildReinvocationPrompt } from "./prompt-builder.js";
67
+ import { buildEnhancedPrompt, buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
67
68
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
68
69
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
70
+ import {
71
+ acquireWorkspaceLock,
72
+ WorkspaceLockCancelledError,
73
+ WorkspaceLockTimeoutError,
74
+ type ReleaseWorkspaceLock,
75
+ } from "../../shared/workspace/workspace-lock.js";
69
76
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
70
- import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, primaryToken, readDenialLedger, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
71
- import { deriveTurnCommandProvenance } from "./command-provenance.js";
77
+ import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, readDenialLedger, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
72
78
  import { applyApprovedWholeFileWrites, excludeAppliedFromGrants } from "./exact-apply.js";
73
79
  import { isGitWorkTree } from "../../shared/filereview/git-substrate.js";
74
80
  import {
75
81
  captureBaselineToLedger,
76
- captureTurnToLedger,
82
+ captureProgressToStatus,
77
83
  applyCaptureDecisions,
78
84
  deriveCaptureMode,
79
85
  } from "./capture-flow.js";
86
+ import { runTurnBoundary, type TurnBoundaryResult } from "./turn-boundary.js";
87
+ import {
88
+ newProgressCaptureState,
89
+ type ProgressCaptureState,
90
+ } from "../../shared/filereview/progress.js";
80
91
  import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
81
92
  import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
82
93
  import { provisionCursorWorkspace } from "./workspace-provision.js";
@@ -95,12 +106,6 @@ import { setMaxListeners } from "node:events";
95
106
  import { startHeartbeat } from "../../shared/heartbeat.js";
96
107
  import { getShutdownSignalForQueue } from "../../runner-manager.js";
97
108
 
98
- // How long Phase 12 waits for the first-denial-stop's run.cancel() to settle
99
- // before reading the final denial ledger and capturing the turn's tree. Long
100
- // enough for the SDK's normal teardown, short enough that a wedged cancel
101
- // cannot noticeably delay the approval pause the user is already waiting on.
102
- const FIRST_DENIAL_CANCEL_TIMEOUT_MS = 5_000;
103
-
104
109
  /**
105
110
  * Creates the activity functions bound to the runner config.
106
111
  * Returned object is passed to Temporal Worker.create({ activities }).
@@ -167,27 +172,31 @@ async function executeCursorInner(
167
172
 
168
173
  // Artifact storage for offloading oversized tool outputs (screenshots, giant
169
174
  // dumps) out of the persisted status, and for publishing the plan artifact.
170
- // Created once here so it is available to EVERY persist below. Best-effort:
171
- // if it can't be built (e.g. proxy mode without a token), offload is disabled
172
- // but persistStatus still enforces the aggregate size cap, so persistence can
173
- // never silently blow past the gRPC limit.
174
- let artifactStorage: ArtifactStorage | undefined;
175
- try {
176
- artifactStorage = createArtifactStorage(loadArtifactStorageConfig(config));
177
- } catch (storageErr) {
178
- console.warn(
179
- `ExecuteCursor artifact storage unavailable — tool-output offload disabled ` +
180
- `(aggregate size guard still active): execution=${executionId}, error=${storageErr}`,
181
- );
182
- }
175
+ // Resolved once here so it is available to EVERY persist below. Best-effort via
176
+ // the shared resolver (identical to the deep-agent harness): `undefined` never
177
+ // a throw when there is no working substrate (proxy misconfig OR an unwritable
178
+ // local path). An absent store disables offload (persistStatus still enforces
179
+ // the aggregate size cap) and flips capture mode off (deny-gate fallback).
180
+ const artifactStorage: ArtifactStorage | undefined =
181
+ await resolveUsableArtifactStorage(loadArtifactStorageConfig(config), { executionId });
183
182
  const statusOffload = artifactStorage
184
183
  ? { artifactStorage, executionId }
185
184
  : undefined;
186
185
  // ALL status persistence in this activity flows through `persist`, so the
187
186
  // single size-bounding guard (offload + aggregate elision) is unforgeable and
188
187
  // a future call site cannot accidentally skip it.
189
- const persist = (s: AgentExecutionStatus = status) =>
190
- persistStatus(client, executionId, s, { offload: statusOffload });
188
+ const persist = (s: AgentExecutionStatus = status) => {
189
+ // Never-persist-secret backstop (DD-26 #2): before EVERY persist, withhold
190
+ // content from any built-in write row targeting a secret-like path (top-level
191
+ // + sub-agent). This is the single airtight choke point for the Cursor harness
192
+ // — the deny-gate analog of capture mode's stamping scrub, and the only
193
+ // guarantee under auto_approve_all (where the hook installs no gate). Safe on
194
+ // every call: Cursor sets tool args atomically from the SDK tool_call event
195
+ // (buildToolCallProto), so there is no mid-stream partial-args hazard, and the
196
+ // pass only ever touches secret-like write rows (idempotent, else a no-op).
197
+ withholdSecretContentFromMessages(s.messages, s.subAgentExecutions);
198
+ return persistStatus(client, executionId, s, { offload: statusOffload });
199
+ };
191
200
 
192
201
  let sessionId: string | undefined;
193
202
  let session: import("@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb").Session | undefined;
@@ -234,6 +243,14 @@ async function executeCursorInner(
234
243
  // (issue #173). Runs in the finally, covering every success/error/approval
235
244
  // exit path. Undefined until the gate is installed.
236
245
  let hitlCleanup: (() => Promise<void>) | undefined;
246
+ // Exclusive turn lock on the primary workspace working tree. Held across the
247
+ // ENTIRE tree-mutating window (decision reconcile, HITL gate install, the
248
+ // agent's own writes, candidate capture) so a concurrent execution sharing
249
+ // this directory can never write between this turn's baseline and candidate
250
+ // snapshots — the misattribution that showed another session's file as this
251
+ // turn's change. Released in the finally AFTER hitlCleanup (which still
252
+ // mutates the tree). See shared/workspace/workspace-lock.ts.
253
+ let releaseWorkspaceLock: ReleaseWorkspaceLock | undefined;
237
254
  // Carries model/mode/agentId out to the outer catch so a thrown CursorSdkError
238
255
  // can be classified with the same context as the run.wait() error path.
239
256
  let errorContext = { model: "default", mode: "local", agentId: "" };
@@ -285,6 +302,9 @@ async function executeCursorInner(
285
302
  // Pre-turn baseline tree, pinned before the agent runs (capture mode only)
286
303
  // so the turn-end capture diffs against it and the tree restores exactly.
287
304
  let baselineTree: string | undefined;
305
+ // Per-turn state for mid-run live capture (DD-32): the last progress tree sha
306
+ // (short-circuit) + last capture time (floor), threaded across persists.
307
+ const progressState: ProgressCaptureState = newProgressCaptureState();
288
308
  // Deterministic id of the change set this turn may produce:
289
309
  // `${executionId}:${turnSeq}`. Minted from the workflow-threaded turn index
290
310
  // so it is stable across a Temporal retry (idempotent ledger authoring) and
@@ -294,6 +314,46 @@ async function executeCursorInner(
294
314
  const changeSetId = `${executionId}:${turnSeq}`;
295
315
  heartbeat();
296
316
 
317
+ // Serialize this turn against every other execution sharing this working
318
+ // tree — sessions declaring the same localPath (or the shared runner root)
319
+ // resolve to ONE directory, and an unserialized concurrent write lands
320
+ // inside this turn's baseline→candidate window, misattributing another
321
+ // session's file to this turn's review. Acquired before ANY tree mutation
322
+ // below (decision reconcile, gate install, agent writes, capture). While
323
+ // another turn holds the lock this surfaces a visible waiting state and
324
+ // heartbeats; a cancel aborts the wait immediately.
325
+ if (primaryWorkspaceDir) {
326
+ try {
327
+ releaseWorkspaceLock = await acquireWorkspaceLock(primaryWorkspaceDir, {
328
+ onWaiting: () => reportSetupProgress(
329
+ client, executionId, "Waiting for workspace — in use by another session",
330
+ ),
331
+ heartbeat,
332
+ signal: Context.current().cancellationSignal,
333
+ timeoutMs: config.workspaceLockTimeoutMs,
334
+ });
335
+ } catch (lockErr) {
336
+ if (lockErr instanceof WorkspaceLockCancelledError) {
337
+ throw new CancelledFailure("Activity cancelled while waiting for the workspace lock");
338
+ }
339
+ if (lockErr instanceof WorkspaceLockTimeoutError) {
340
+ status.phase = ExecutionPhase.EXECUTION_FAILED;
341
+ status.error = lockErr.message;
342
+ status.completedAt = utcTimestamp();
343
+ status.messages.push(create(AgentMessageSchema, {
344
+ type: MessageType.MESSAGE_SYSTEM,
345
+ content: `Execution failed: ${lockErr.message}`,
346
+ timestamp: utcTimestamp(),
347
+ }));
348
+ await persist(status);
349
+ console.warn(`ExecuteCursor workspace lock timeout: execution=${executionId}`);
350
+ return slimStatus(status);
351
+ }
352
+ throw lockErr;
353
+ }
354
+ }
355
+ heartbeat();
356
+
297
357
  // Set OTel baggage so downstream calls carry execution context.
298
358
  try {
299
359
  const { setBaggage, BAGGAGE_EXECUTION_ID, BAGGAGE_SESSION_ID, BAGGAGE_ORG_ID } = await import("../../otel.js");
@@ -511,13 +571,15 @@ async function executeCursorInner(
511
571
  });
512
572
  heartbeat();
513
573
 
514
- // Phase 5b: Resolve attachments
515
- const attachmentResults = await resolveAttachments(
516
- spec.attachments,
574
+ // Phase 5b: Resolve attachments (fail-hard — explicit user inputs; see
575
+ // attachment-resolver.ts). Downloads by storage key through the same
576
+ // artifactStorage resolved for status offload above.
577
+ const attachmentResults = await resolveAttachments(spec.attachments, {
517
578
  sessionId,
518
579
  primaryWorkspaceDir,
519
- config.mode,
520
- );
580
+ mode: config.mode,
581
+ storage: artifactStorage,
582
+ });
521
583
  const attachmentPaths = attachmentResults.map((a) => a.relativePath);
522
584
 
523
585
  // Phase 5b3: Exact-apply approved whole-file writes (HITL "what you approve
@@ -741,6 +803,7 @@ async function executeCursorInner(
741
803
  // Phase 10: Build the prompt
742
804
  const interactionMode = spec.executionConfig?.interactionMode
743
805
  ?? InteractionMode.UNSPECIFIED;
806
+ const buildFromPlan = spec.executionConfig?.buildFromPlan ?? false;
744
807
 
745
808
  const prompt = buildPrompt({
746
809
  resolution,
@@ -755,6 +818,7 @@ async function executeCursorInner(
755
818
  pendingApprovals: adjudicatedApprovals,
756
819
  appliedToolCallIds,
757
820
  interactionMode,
821
+ buildFromPlan,
758
822
  });
759
823
 
760
824
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -1005,6 +1069,22 @@ async function executeCursorInner(
1005
1069
  // accumulator tracked sub-agents in memory but they only reached the
1006
1070
  // status (and the subscriber stream) after the loop ended.
1007
1071
  status.subAgentExecutions = accumulator.subAgentExecutions;
1072
+ // Mid-run live capture (DD-32): attach the "N files changed so far"
1073
+ // snapshot onto status.file_change_progress, throttled internally by the
1074
+ // floor + tree-sha short-circuit. Git capture mode only (a pinned baseline
1075
+ // exists); shell + sub-agent + tool edits are all captured for free by the
1076
+ // workspace-wide diff. Never authoritative — the turn-boundary candidate
1077
+ // remains the reviewed diff.
1078
+ if (captureMode && gitWorkspace && baselineTree && primaryWorkspaceDir) {
1079
+ await captureProgressToStatus({
1080
+ status,
1081
+ gitRoot: primaryWorkspaceDir,
1082
+ executionId,
1083
+ changeSetId,
1084
+ baselineTree,
1085
+ state: progressState,
1086
+ });
1087
+ }
1008
1088
  const signal = await persist(status);
1009
1089
  deltaEnricher.markPersisted();
1010
1090
  todoTracker.markPersisted();
@@ -1182,155 +1262,88 @@ async function executeCursorInner(
1182
1262
  return slimStatus(status);
1183
1263
  }
1184
1264
 
1185
- // Phase 12: Surface tools the preToolUse hook gated (HITL).
1186
- //
1187
- // The hook records each denial to the ledger; we mark the corresponding tool
1188
- // calls WAITING_APPROVAL. The backend projects pending_approvals from that
1189
- // tool-call status (PendingApprovalComputer), so exactly like the native
1190
- // harness the approval surface is driven entirely by tool-call status. We
1191
- // deliberately do NOT set status.pendingApprovals here: any value would be
1192
- // discarded by the backend's recompute on the next updateStatus.
1193
- //
1194
- // Before reading the ledger, wait (timeboxed) for the first-denial-stop's
1195
- // run.cancel() to settle. run.cancel() races the SDK's auto-execution: until
1196
- // it lands, the agent process may still attempt a post-denial workaround
1197
- // whose hook denial would land AFTER a premature ledger read — the row then
1198
- // never collapses and renders as RUNNING forever (production case
1199
- // aex_01kwj07f7g23c3wp9sn8496z5g) — or a late tool could mutate the tree
1200
- // mid-capture. The timebox keeps a wedged cancel from hanging the pause;
1201
- // the Phase 12 trims below remain the backstop for that degraded case.
1202
- if (firstDenialDetected && denialCancelSettled) {
1203
- await Promise.race([
1204
- denialCancelSettled,
1205
- new Promise<void>((resolve) => {
1206
- const timer = setTimeout(resolve, FIRST_DENIAL_CANCEL_TIMEOUT_MS);
1207
- timer.unref();
1208
- }),
1209
- ]);
1210
- }
1211
- const deniedLedger = await readDenialLedger(hitlDir ?? "");
1212
-
1213
- // Capture mode: author the net change set to the file_review ledger as the
1214
- // CANDIDATE_CAPTURED event (projected server-side to a file_change_set
1215
- // AWAITING_REVIEW — the single review surface). The runner-owned gate files
1216
- // are excluded from the capture. The agent's edits are LEFT applied on the
1217
- // working tree (Cursor parity — the user reviews the real change; nothing is
1218
- // committed and the next turn is blocked until approval, and a reject snaps
1219
- // each file back on resume). Runs BEFORE the denial reconcile so a denied
1220
- // (gitignored) write stays on the deny-gate path while every flowed edit is
1221
- // captured to the ledger.
1222
- let capturedChangeCount = 0;
1223
- // `baselineTree !== undefined` means a baseline was authored this turn — the
1224
- // git tree sha for a git workspace, or "" (empty, but authored) for a non-git
1225
- // one. A plain truthiness check would wrongly skip the non-git capture.
1226
- if (captureMode && baselineTree !== undefined && primaryWorkspaceDir) {
1227
- const deniedTokens = new Set(deniedLedger.map((e) => e.token));
1228
- // Approved-command turn facts (DD-28): when every mutation-capable call
1229
- // this turn was a consented shell command, attach the provenance so the
1230
- // backend can verify the cited consent rows and auto-keep the set instead
1231
- // of arming a second gate. Fail-closed: any non-qualifying turn attaches
1232
- // nothing and reviews manually exactly as before.
1233
- const commandProvenance = deriveTurnCommandProvenance({
1234
- messages: status.messages,
1235
- turnStartIndex: turnStartMessageIndex,
1236
- deniedTokens,
1237
- grantTokenToConsentId: new Map(
1238
- (approvalGrants ?? []).map((g) => [
1239
- primaryToken(g.key, g.salient, g.contentDigest),
1240
- g.sourceToolCallId,
1241
- ]),
1242
- ),
1243
- globalBypass,
1244
- });
1245
- if (commandProvenance) {
1246
- console.log(
1247
- `ExecuteCursor capture: turn qualifies for approved-command auto-keep ` +
1248
- `(consent rows: ${commandProvenance.consentToolCallIds.join(",") || "(auto_approve_all)"}); ` +
1249
- `attaching provenance to candidate (execution=${executionId})`,
1250
- );
1251
- }
1252
- const captured = await captureTurnToLedger({
1265
+ // Phase 12: The turn boundary author this turn's change set to the
1266
+ // file_review ledger (CANDIDATE_CAPTURED) and overlay the hook's denials as
1267
+ // WAITING_APPROVAL gate rows. The full pipeline and its ordering rationale
1268
+ // live in turn-boundary.ts; this closure binds the turn's state so the
1269
+ // recovery retries below (which re-run the agent AFTER this primary call)
1270
+ // can re-enter the IDENTICAL pipeline a retry's edits must reach the
1271
+ // ledger or they silently escape review. `baselineTree` is read at call
1272
+ // time, so both entries see the baseline authored at turn start.
1273
+ const runBoundary = (denialSettled?: Promise<void>) =>
1274
+ runTurnBoundary({
1253
1275
  status,
1254
- gitRoot: primaryWorkspaceDir,
1255
1276
  executionId,
1256
1277
  changeSetId,
1257
- baselineTree,
1258
- messages: status.messages,
1259
- deniedTokens,
1260
- commandProvenance,
1261
- // Scope sub-agent row stamping to this turn: the seeded prior sub-agents
1262
- // (cloned in on resume) are the "before this turn" rows to skip.
1263
- priorSubAgentToolCallIds: collectSubAgentToolCallIds(seededSubAgents),
1264
- // The CAS half: read the sidecar the hook staged this turn and compose it
1265
- // into the change set. hitlDir + storage are present when captureIgnored
1266
- // was on (a git tree's gitignored writes, or ALL writes in a non-git
1267
- // workspace). In a git tree this composes with the git diff (HYBRID); in a
1268
- // non-git workspace it IS the whole change set (CAS-only).
1269
1278
  hitlDir,
1270
- storage: artifactStorage,
1279
+ captureMode,
1280
+ baselineTree,
1281
+ primaryWorkspaceDir,
1271
1282
  gitWorkspace,
1283
+ turnStartMessageIndex,
1284
+ approvalGrants,
1285
+ globalBypass,
1286
+ seededSubAgents,
1287
+ artifactStorage,
1288
+ mergedPolicies,
1289
+ denialCancelSettled: denialSettled,
1272
1290
  });
1273
- capturedChangeCount = captured.length;
1274
- if (capturedChangeCount > 0) {
1275
- console.log(
1276
- `ExecuteCursor capture: ${capturedChangeCount} file change(s) authored to the ` +
1277
- `file_review ledger (change_set=${changeSetId}), working tree left applied ` +
1278
- `for review (execution=${executionId})`,
1279
- );
1280
- }
1281
- }
1282
-
1283
- // The gate reads each denied file's pre-edit `before` from the workspace the
1284
- // runner is co-located with (local FS for OSS; the sandbox in cloud), so a
1285
- // whole-file rewrite gate renders a true before/after diff. The tool was
1286
- // DENIED, so disk still holds the old content. User files are never platform
1287
- // paths, so no platformDir routing is needed here.
1288
- const gateWorkspaceBackend = new LocalWorkspaceBackend(primaryWorkspaceDir);
1289
- const deniedToolCalls = await reconcileDeniedToolCalls(
1290
- status.messages,
1291
- deniedLedger,
1292
- mergedPolicies,
1293
- gateWorkspaceBackend,
1294
- );
1295
- // Observability: a synthesized placeholder (id `approval:*`) means a denial
1296
- // correlated to NO streamed tool call in either the exact or the normalized
1297
- // pass. After the normalized-path fallback this should be ~0; a non-zero rate
1298
- // is the early-warning signal of a NEW identity drift (the gate would then
1299
- // show "No preview available" with no diff). Logged, not thrown — the
1300
- // synthesized gate still safely surfaces the approval.
1301
- const synthesizedGateCount = deniedToolCalls.filter((tc) =>
1302
- tc.id.startsWith("approval:"),
1303
- ).length;
1304
- if (synthesizedGateCount > 0) {
1305
- console.warn(
1306
- `ExecuteCursor reconcile synthesized ${synthesizedGateCount} placeholder gate(s) ` +
1307
- `with no correlated stream call (execution=${executionId}); ` +
1308
- `possible hook/stream identity drift — gate(s) will lack a diff`,
1309
- );
1310
- }
1311
- if (deniedToolCalls.length > 0 || capturedChangeCount > 0) {
1312
- if (deniedToolCalls.length > 0) {
1313
- // Deterministic clean-pause: a turn that pauses for approval must read as
1314
- // the same shape the native harness produces — pre-tool text + the gated
1315
- // tool calls — never the model's provisional reaction to Cursor's deny
1316
- // (e.g. "blocked by a hook; enable it in your Cursor settings"). We blank
1317
- // that reaction in place (keeping the message count, so the finalize stays
1318
- // append-only) rather than removing it. See
1319
- // clearProvisionalPostDenialNarration for the full rationale.
1320
- const redactedNarration = clearProvisionalPostDenialNarration(status.messages, deniedToolCalls);
1321
- if (redactedNarration.length > 0) {
1322
- console.log(
1323
- `ExecuteCursor redacted ${redactedNarration.length} provisional post-denial narration message(s) before pausing for approval`,
1324
- );
1325
- }
1326
- }
1291
+ // Pauses for review exactly like the native harness: the boundary mutated
1292
+ // the transcript in place; we flip the phase, persist, and RETURN to the
1293
+ // workflow, which waits for the approval/file-review signal and reinvokes.
1294
+ const enterApprovalPause = async (boundary: TurnBoundaryResult) => {
1327
1295
  status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
1328
1296
  await persist(status);
1329
1297
  console.log(
1330
- `ExecuteCursor returning WAITING_FOR_APPROVAL: ${deniedToolCalls.length} gated tool(s), ` +
1331
- `${capturedChangeCount} file card(s) pending`,
1298
+ `ExecuteCursor returning WAITING_FOR_APPROVAL: ${boundary.deniedToolCallCount} gated tool(s), ` +
1299
+ `${boundary.capturedChangeCount} file card(s) pending`,
1332
1300
  );
1333
1301
  return slimStatus(status);
1302
+ };
1303
+
1304
+ // Stream epilogue + boundary re-entry for a recovery retry (poisoned-handle /
1305
+ // transport-timeout). The retry re-runs the agent AFTER the primary epilogue
1306
+ // and boundary already ran, so its stream state must be settled the same way:
1307
+ // - flush the enricher's buffered deltas onto the rows (the primary loop
1308
+ // applies them every iteration; the bare retry loop does not — without the
1309
+ // flush, a completed tool call never receives its completedAt evidence and
1310
+ // finalize's reconciliation sweep leaves it RUNNING forever);
1311
+ // - finalize streaming state and sync sub-agents;
1312
+ // - refresh streaming usage (the retry's turns accumulated in memory only)
1313
+ // and re-stamp completedAt (stamped below BEFORE the retry ran);
1314
+ // - re-enter the turn boundary so edits made by the retry reach the
1315
+ // file_review ledger / approval gates — without this, a retry's file
1316
+ // edits silently escape review (production case
1317
+ // aex_01kws27q1e2esvkqjpvectttxf).
1318
+ // Returns undefined for a cancelled retry: parity with the primary path,
1319
+ // where cancellation exits before the boundary — there is no review to open.
1320
+ const settleRetryTurn = async (
1321
+ retryResultStatus: string,
1322
+ ): Promise<TurnBoundaryResult | undefined> => {
1323
+ accumulator.finalize();
1324
+ deltaEnricher.applyEnrichments(status.messages);
1325
+ deltaEnricher.finalize(status.messages);
1326
+ status.subAgentExecutions = accumulator.subAgentExecutions;
1327
+ if (usageAccumulator.hasTurns) {
1328
+ status.streamingUsage = create(StreamingUsageSummarySchema, usageAccumulator.snapshot());
1329
+ }
1330
+ // Re-flush the (dev-only) event recorder: flush rewrites the full JSONL,
1331
+ // so the recorded trace now includes the retry's events too.
1332
+ await eventRecorder?.flush();
1333
+ const retryBoundary =
1334
+ retryResultStatus === "cancelled" ? undefined : await runBoundary();
1335
+ // Phase 13 stamped completedAt BEFORE the retry ran. A terminal outcome
1336
+ // re-stamps it to the true end; a review pause CLEARS it — the primary
1337
+ // pause path never stamps it (a waiting turn is not complete).
1338
+ status.completedAt = retryBoundary?.waiting ? "" : utcTimestamp();
1339
+ return retryBoundary;
1340
+ };
1341
+
1342
+ // The denial-settle wait applies only when a first denial stopped THIS run;
1343
+ // the recovery retries have no early stop and pass no promise.
1344
+ const boundary = await runBoundary(firstDenialDetected ? denialCancelSettled : undefined);
1345
+ if (boundary.waiting) {
1346
+ return enterApprovalPause(boundary);
1334
1347
  }
1335
1348
 
1336
1349
  // Phase 13: Map final result
@@ -1460,7 +1473,9 @@ async function executeCursorInner(
1460
1473
  for await (const retryEvent of retryRun.stream()) {
1461
1474
  if (Context.current().cancellationSignal.aborted) break;
1462
1475
  retryWatchdog.recordActivity();
1476
+ eventRecorder?.record(retryEvent, eventCount);
1463
1477
  accumulator.processEvent(retryEvent);
1478
+ eventCount++;
1464
1479
  if (retryEvent.type === "status") {
1465
1480
  const retryStatusEvent = retryEvent as { status?: string; message?: string };
1466
1481
  if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
@@ -1479,6 +1494,18 @@ async function executeCursorInner(
1479
1494
  `retryResult=${JSON.stringify(retryResult)}`,
1480
1495
  );
1481
1496
 
1497
+ const retryBoundary = await settleRetryTurn(retryResult.status);
1498
+ if (retryBoundary?.waiting) {
1499
+ // The retry's edits/denials armed the gate — pause for review. On a
1500
+ // retry error this supersedes the failure, exactly as on the primary
1501
+ // path (a captured change pauses the turn before run.wait() is
1502
+ // consulted).
1503
+ console.log(
1504
+ `ExecuteCursor poisoned-handle recovery paused for review: execution=${executionId}`,
1505
+ );
1506
+ return enterApprovalPause(retryBoundary);
1507
+ }
1508
+
1482
1509
  if (retryResult.status === "finished") {
1483
1510
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1484
1511
  console.log(
@@ -1569,7 +1596,9 @@ async function executeCursorInner(
1569
1596
  for await (const retryEvent of retryRun.stream()) {
1570
1597
  if (Context.current().cancellationSignal.aborted) break;
1571
1598
  retryWatchdog.recordActivity();
1599
+ eventRecorder?.record(retryEvent, eventCount);
1572
1600
  accumulator.processEvent(retryEvent);
1601
+ eventCount++;
1573
1602
  if (retryEvent.type === "status") {
1574
1603
  const retryStatusEvent = retryEvent as { status?: string; message?: string };
1575
1604
  if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
@@ -1583,6 +1612,17 @@ async function executeCursorInner(
1583
1612
  }
1584
1613
 
1585
1614
  const retryResult = await retryRun.wait();
1615
+ const retryBoundary = await settleRetryTurn(retryResult.status);
1616
+ if (retryBoundary?.waiting) {
1617
+ // The retry's edits/denials armed the gate — pause for review (see
1618
+ // the poisoned-handle branch above for the precedence rationale).
1619
+ console.log(
1620
+ `ExecuteCursor transport-timeout recovery paused for review: execution=${executionId}`,
1621
+ );
1622
+ resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
1623
+ return enterApprovalPause(retryBoundary);
1624
+ }
1625
+
1586
1626
  if (retryResult.status === "finished") {
1587
1627
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1588
1628
  resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
@@ -1660,10 +1700,10 @@ async function executeCursorInner(
1660
1700
  status.structuredOutput = structuredOutput as JsonObject;
1661
1701
  }
1662
1702
 
1663
- // Plan mode: publish the final plan message as a plan.md artifact. The
1664
- // Cursor harness has no auto-publish pipeline, so this is the only
1665
- // artifact path; build storage from the same config-driven factory the
1666
- // native harness uses.
1703
+ // Plan mode: publish the final plan message as a plan artifact (named
1704
+ // from the plan's title). The Cursor harness has no auto-publish
1705
+ // pipeline, so this is the only artifact path; build storage from the
1706
+ // same config-driven factory the native harness uses.
1667
1707
  if (interactionMode === InteractionMode.PLAN && finalText && artifactStorage) {
1668
1708
  try {
1669
1709
  await publishPlanArtifact({ status, executionId, planText: finalText, artifactStorage });
@@ -1885,6 +1925,13 @@ async function executeCursorInner(
1885
1925
  );
1886
1926
  }
1887
1927
  }
1928
+
1929
+ // Release the workspace turn lock LAST — hitlCleanup above still mutates
1930
+ // the tree (restores .cursor/hooks.json), and the next queued turn must
1931
+ // not baseline until every mutation of this one has landed. Idempotent
1932
+ // and non-throwing (see workspace-lock.ts), so it can never mask the
1933
+ // turn's real outcome.
1934
+ await releaseWorkspaceLock?.();
1888
1935
  }
1889
1936
  }
1890
1937
 
@@ -1996,6 +2043,11 @@ export interface BuildPromptInput {
1996
2043
  */
1997
2044
  appliedToolCallIds?: ReadonlySet<string>;
1998
2045
  interactionMode?: InteractionMode;
2046
+ /**
2047
+ * The execution is a Build-from-plan turn (spec.execution_config
2048
+ * .build_from_plan): both prompt paths carry the implement-plan directive.
2049
+ */
2050
+ buildFromPlan?: boolean;
1999
2051
  }
2000
2052
 
2001
2053
  /**
@@ -2026,6 +2078,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2026
2078
  workspaceFileRefs,
2027
2079
  attachmentPaths,
2028
2080
  interactionMode,
2081
+ buildFromPlan,
2029
2082
  } = input;
2030
2083
 
2031
2084
  const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
@@ -2042,9 +2095,20 @@ export function buildPrompt(input: BuildPromptInput): string {
2042
2095
  }
2043
2096
 
2044
2097
  // A successfully resumed agent carries its own conversation context via the
2045
- // SDK's native store — send the raw user message with no preamble.
2098
+ // SDK's native store — send the raw user message with no preamble. The
2099
+ // exceptions are the per-EXECUTION directives, which never inherit from the
2100
+ // session's first turn: the interaction-mode prefix (a follow-up can switch
2101
+ // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
2102
+ // enforcement) and the implement-plan directive (the build turn is usually
2103
+ // a follow-up on a resumed agent).
2046
2104
  if (resolution.reason === "resumed_successfully") {
2047
- return userMessage;
2105
+ const prefixes = [
2106
+ formatInteractionModePrefix(interactionMode),
2107
+ formatImplementPlanSection(buildFromPlan, attachmentPaths),
2108
+ ].filter((p): p is string => p !== undefined);
2109
+ return prefixes.length > 0
2110
+ ? [...prefixes, userMessage].join("\n\n")
2111
+ : userMessage;
2048
2112
  }
2049
2113
 
2050
2114
  // First execution, or a fresh agent created after a resume failure: there is
@@ -2058,6 +2122,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2058
2122
  workspaceFileRefs,
2059
2123
  attachmentPaths,
2060
2124
  interactionMode,
2125
+ buildFromPlan,
2061
2126
  });
2062
2127
  }
2063
2128
 
@@ -50,7 +50,8 @@ import { utcTimestamp } from "../../shared/status.js";
50
50
  import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
51
51
  import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
52
52
  import { resolveWorkspacePath } from "../../shared/file-change.js";
53
- import { contentDigest } from "../../shared/file-tools.js";
53
+ import { contentDigest, extractFilePath } from "../../shared/file-tools.js";
54
+ import { isSecretLikePath } from "../../shared/filereview/secret-paths.js";
54
55
  import { buildElidedArgsPreview } from "../../shared/args-preview.js";
55
56
  import type { WorkspaceBackend } from "../../shared/workspace/types.js";
56
57
 
@@ -1631,6 +1632,13 @@ function applyGateInput(
1631
1632
  input: Record<string, unknown> | undefined,
1632
1633
  ): void {
1633
1634
  if (!input) return;
1635
+ // Defense-in-depth (DD-26 #2): never overlay a secret-like write's content into
1636
+ // the persisted approval preview. Normally unreachable — the hook hard-blocks a
1637
+ // secret write and records no ledger input — but if a hook classify failure fell
1638
+ // one through, its content must still never reach args/args_preview. The
1639
+ // Invariant-A backstop is the final net; this closes the path at the source.
1640
+ const gatePath = extractFilePath(input);
1641
+ if (gatePath !== null && isSecretLikePath(gatePath)) return;
1634
1642
  tc.args = input as JsonObject;
1635
1643
  tc.argsPreview = buildElidedArgsPreview(input, SALIENT_ARG_FIELDS);
1636
1644
  // Stamp the content digest from the AUTHORITATIVE captured input, so the