@stigmer/runner 3.2.1 → 3.2.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 (145) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-transform.js +12 -2
  3. package/dist/activities/call-transform.js.map +1 -1
  4. package/dist/activities/call-validate.js +20 -2
  5. package/dist/activities/call-validate.js.map +1 -1
  6. package/dist/activities/discover-mcp-server.js +6 -20
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
  9. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  10. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  11. package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
  12. package/dist/activities/execute-cursor/approval-state.js +13 -1
  13. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  14. package/dist/activities/execute-cursor/hook-script.js +38 -0
  15. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  16. package/dist/activities/execute-cursor/index.d.ts +12 -0
  17. package/dist/activities/execute-cursor/index.js +72 -15
  18. package/dist/activities/execute-cursor/index.js.map +1 -1
  19. package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
  20. package/dist/activities/execute-cursor/message-translator.js +78 -2
  21. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  22. package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
  23. package/dist/activities/execute-cursor/prompt-builder.js +21 -0
  24. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  25. package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
  26. package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
  27. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  28. package/dist/activities/execute-cursor/turn-boundary.js +15 -2
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
  31. package/dist/activities/execute-deep-agent/hitl.js +48 -1
  32. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +10 -1
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
  39. package/dist/activities/execute-deep-agent/setup.js +16 -1
  40. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
  42. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  43. package/dist/activities/execute-deep-agent/status-builder.js +6 -0
  44. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
  46. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  47. package/dist/activities/workflow-event-activities.js +11 -0
  48. package/dist/activities/workflow-event-activities.js.map +1 -1
  49. package/dist/config.d.ts +17 -0
  50. package/dist/config.js +14 -0
  51. package/dist/config.js.map +1 -1
  52. package/dist/middleware/approval-gate.d.ts +20 -0
  53. package/dist/middleware/approval-gate.js +16 -1
  54. package/dist/middleware/approval-gate.js.map +1 -1
  55. package/dist/runner-manager.d.ts +2 -0
  56. package/dist/runner-manager.js +2 -1
  57. package/dist/runner-manager.js.map +1 -1
  58. package/dist/runner.d.ts +2 -0
  59. package/dist/runner.js +2 -1
  60. package/dist/runner.js.map +1 -1
  61. package/dist/shared/approval-policy.d.ts +24 -1
  62. package/dist/shared/approval-policy.js +35 -1
  63. package/dist/shared/approval-policy.js.map +1 -1
  64. package/dist/shared/context-bridge.d.ts +30 -0
  65. package/dist/shared/context-bridge.js +45 -0
  66. package/dist/shared/context-bridge.js.map +1 -0
  67. package/dist/shared/sender-identity.d.ts +50 -0
  68. package/dist/shared/sender-identity.js +69 -0
  69. package/dist/shared/sender-identity.js.map +1 -0
  70. package/dist/shared/with-timeout.d.ts +27 -0
  71. package/dist/shared/with-timeout.js +47 -0
  72. package/dist/shared/with-timeout.js.map +1 -0
  73. package/dist/workflow-engine/do-executor.d.ts +7 -0
  74. package/dist/workflow-engine/do-executor.js +59 -1
  75. package/dist/workflow-engine/do-executor.js.map +1 -1
  76. package/dist/workflow-engine/tasks/call-function.js +68 -1
  77. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  78. package/dist/workflow-engine/tasks/human-input.js +1 -0
  79. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  80. package/dist/workflow-engine/types.d.ts +48 -0
  81. package/dist/workflow-engine/types.js.map +1 -1
  82. package/dist/workflows/engine-core.js +5 -1
  83. package/dist/workflows/engine-core.js.map +1 -1
  84. package/dist/workflows/human-input-orchestrator.d.ts +2 -1
  85. package/dist/workflows/human-input-orchestrator.js +2 -1
  86. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/__tests__/config.test.ts +8 -0
  89. package/src/activities/__tests__/call-validate.test.ts +137 -0
  90. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  91. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  92. package/src/activities/__tests__/workflow-event-activities.test.ts +89 -0
  93. package/src/activities/call-transform.ts +20 -2
  94. package/src/activities/call-validate.ts +27 -2
  95. package/src/activities/discover-mcp-server.ts +7 -28
  96. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
  97. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
  98. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
  99. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
  100. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
  101. package/src/activities/execute-cursor/approval-state.ts +32 -1
  102. package/src/activities/execute-cursor/hook-script.ts +40 -0
  103. package/src/activities/execute-cursor/index.ts +86 -17
  104. package/src/activities/execute-cursor/message-translator.ts +82 -1
  105. package/src/activities/execute-cursor/prompt-builder.ts +44 -0
  106. package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
  107. package/src/activities/execute-cursor/turn-boundary.ts +23 -0
  108. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  109. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  110. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  111. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
  112. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  113. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
  114. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  115. package/src/activities/execute-deep-agent/hitl.ts +51 -0
  116. package/src/activities/execute-deep-agent/index.ts +11 -1
  117. package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
  118. package/src/activities/execute-deep-agent/setup.ts +31 -0
  119. package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
  120. package/src/activities/execute-deep-agent/status-builder.ts +7 -0
  121. package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
  122. package/src/activities/workflow-event-activities.ts +11 -0
  123. package/src/config.ts +23 -0
  124. package/src/middleware/__tests__/approval-gate.test.ts +95 -0
  125. package/src/middleware/approval-gate.ts +37 -0
  126. package/src/runner-manager.ts +6 -1
  127. package/src/runner.ts +6 -1
  128. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  129. package/src/shared/__tests__/context-bridge.test.ts +51 -0
  130. package/src/shared/__tests__/sender-identity.test.ts +92 -0
  131. package/src/shared/__tests__/with-timeout.test.ts +60 -0
  132. package/src/shared/approval-policy.ts +41 -2
  133. package/src/shared/context-bridge.ts +51 -0
  134. package/src/shared/sender-identity.ts +85 -0
  135. package/src/shared/with-timeout.ts +53 -0
  136. package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
  137. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
  138. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
  139. package/src/workflow-engine/do-executor.ts +65 -1
  140. package/src/workflow-engine/tasks/call-function.ts +84 -3
  141. package/src/workflow-engine/tasks/human-input.ts +1 -0
  142. package/src/workflow-engine/types.ts +50 -0
  143. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
  144. package/src/workflows/engine-core.ts +5 -1
  145. package/src/workflows/human-input-orchestrator.ts +2 -1
@@ -39,12 +39,14 @@ import type { Run, ConversationTurn } from "@cursor/sdk";
39
39
 
40
40
  import type { Config } from "../../config.js";
41
41
  import { StigmerClient } from "../../client/stigmer-client.js";
42
- import { resolveAgent } from "./session-lifecycle.js";
42
+ import { resolveAgentWithTransportRecovery } 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
46
  import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
47
47
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
48
+ import { readContextBridge } from "../../shared/context-bridge.js";
49
+ import { readSenderIdentity } from "../../shared/sender-identity.js";
48
50
  import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
49
51
  import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
50
52
  import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
@@ -55,7 +57,7 @@ import { StreamingUpdateScheduler, loadStreamingConfig } from "../../shared/stre
55
57
  import { createCursorEventRecorder } from "./cursor-event-recorder.js";
56
58
  import { resolveMcpServers, validateMcpServerEnv } from "./mcp-resolver.js";
57
59
  import { mergeApprovalPolicies } from "./approval-policy.js";
58
- import { deriveActiveLeases } from "../../shared/approval-policy.js";
60
+ import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
59
61
  import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
60
62
  import { resolveExecutionEnv } from "./env-resolver.js";
61
63
  import { resolveBlueprint } from "./blueprint-resolver.js";
@@ -257,6 +259,25 @@ async function executeCursorInner(
257
259
  // can be classified with the same context as the run.wait() error path.
258
260
  let errorContext = { model: "default", mode: "local", agentId: "" };
259
261
 
262
+ // Periodic heartbeat for the ENTIRE activity, started before any phase runs.
263
+ // Setup phases make network calls (blueprint resolution, workspace clone, MCP
264
+ // backfill, Agent.create) that can stall; the scattered manual heartbeat()
265
+ // pulses between them leave every individual call uncovered. The production
266
+ // stale-proxy incident hung inside Agent.create with zero heartbeats and
267
+ // surfaced as an opaque 5-minute Temporal timeout. The label names the
268
+ // current phase so a stall is attributed in Temporal heartbeat details, and
269
+ // cancellation stays observable throughout. Safe ONLY because every SDK call
270
+ // below is itself bounded (agentResolveTimeoutMs, stall watchdog) — an
271
+ // unbounded hang under a live heartbeat would keep a dead activity alive
272
+ // forever.
273
+ let heartbeatPhase = "setup";
274
+ const taskQueue = Context.current().info.taskQueue;
275
+ const shutdownSignal = getShutdownSignalForQueue(taskQueue);
276
+ periodicHeartbeat = startHeartbeat(30_000, () => ({
277
+ phase: heartbeatPhase,
278
+ execution: executionId,
279
+ }), { shutdownSignal });
280
+
260
281
  try {
261
282
  // Phase 1: Hydrate execution from DB
262
283
  await reportSetupProgress(client, executionId, "Fetching execution");
@@ -270,6 +291,7 @@ async function executeCursorInner(
270
291
  const blueprint = await resolveBlueprint(client, session, config.workspaceRootDir);
271
292
 
272
293
  // Phase 2b: Resolve execution environment (MCP server credentials)
294
+ heartbeatPhase = "resolving_environment";
273
295
  await reportSetupProgress(client, executionId, "Resolving environment");
274
296
  const { envVars, secretKeys } = await resolveExecutionEnv(client, executionId);
275
297
  heartbeat();
@@ -280,6 +302,7 @@ async function executeCursorInner(
280
302
  // disabled the runner must provision the workspace itself, mirroring the
281
303
  // native harness. Git provisioning is idempotent across multi-turn and
282
304
  // HITL reinvocations.
305
+ heartbeatPhase = "provisioning_workspace";
283
306
  await reportSetupProgress(client, executionId, "Provisioning workspace");
284
307
  const workspaceProvision = await provisionCursorWorkspace(
285
308
  config, session, envVars, sessionId ?? "",
@@ -554,6 +577,7 @@ async function executeCursorInner(
554
577
  );
555
578
 
556
579
  // Phase 4a: Connect backfill for undiscovered MCP servers
580
+ heartbeatPhase = "resolving_mcp_servers";
557
581
  const sessionOrg = session.metadata?.org ?? "";
558
582
  mcpResolution = await backfillMcpServersIfNeeded(
559
583
  client, mcpResolution, blueprint.mergedMcpServerUsages, envVars, sessionOrg,
@@ -697,6 +721,11 @@ async function executeCursorInner(
697
721
  // absent a git tree keeps gating gitignored writes and a non-git workspace
698
722
  // falls back to the deny-gate entirely (no regression).
699
723
  const captureIgnored = captureMode && !!artifactStorage;
724
+ // Unattended approval mode (DD-014): approver-less surfaces (channels,
725
+ // guest shares) stamp APPROVAL_MODE_UNATTENDED; the hook then records
726
+ // approval denials with the non-pausing "unattended" kind, so the
727
+ // first-denial stop never fires and the turn boundary settles the denied
728
+ // calls as SKIPPED instead of pausing a turn nobody can approve.
700
729
  const approvalState = buildApprovalState(
701
730
  mergedPolicies,
702
731
  globalBypass,
@@ -705,6 +734,7 @@ async function executeCursorInner(
705
734
  captureMode,
706
735
  captureIgnored,
707
736
  gitWorkspace,
737
+ isUnattendedApprovalMode(execution),
708
738
  );
709
739
  const hitlGate = await installHitlGate({
710
740
  workspaceRoot: primaryWorkspaceDir,
@@ -815,11 +845,31 @@ async function executeCursorInner(
815
845
  agents: cursorSubAgents,
816
846
  };
817
847
 
818
- let resolution: AgentResolution = await resolveAgent(
819
- threadId,
848
+ // Agent.create/Agent.resume have no timeout of their own — a degraded
849
+ // transport (dead proxy connection, stale HTTP/2 session) hangs them
850
+ // forever, which the periodic heartbeat would happily keep alive. Each
851
+ // attempt is bounded; on expiry the wrapper resets the proxy transport
852
+ // and retries once, so a stale-session hang recovers without failing the
853
+ // execution. A second expiry propagates a plain Error to the generic
854
+ // catch below, which persists EXECUTION_FAILED (no Temporal retry —
855
+ // the activity returns rather than throws, and maximumAttempts is 1).
856
+ heartbeatPhase = "resolving_agent";
857
+ const resolveTimeoutSeconds = Math.round(config.agentResolveTimeoutMs / 1000);
858
+ let resolution: AgentResolution = await resolveAgentWithTransportRecovery({
859
+ harnessStateId: threadId,
820
860
  createOptions,
821
- agentMode,
822
- );
861
+ mode: agentMode,
862
+ timeoutMs: config.agentResolveTimeoutMs,
863
+ buildTimeoutMessage: (finalAttempt) =>
864
+ `Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
865
+ `(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
866
+ `The transport connection is likely dead. ` +
867
+ (finalAttempt
868
+ ? `An automatic retry on a fresh transport connection also timed out. ` +
869
+ `Retry the message later; if this persists, check proxy and network health.`
870
+ : `Resetting the transport and retrying automatically.`),
871
+ resetTransport: closeProxySessions,
872
+ });
823
873
 
824
874
  console.log(
825
875
  `ExecuteCursor agent resolved: execution=${executionId}, ` +
@@ -878,6 +928,8 @@ async function executeCursorInner(
878
928
  appliedToolCallIds,
879
929
  interactionMode,
880
930
  buildFromPlan,
931
+ contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
932
+ senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
881
933
  });
882
934
 
883
935
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -962,16 +1014,10 @@ async function executeCursorInner(
962
1014
  maxCostUsd,
963
1015
  };
964
1016
 
965
- // Periodic heartbeat keeps Temporal informed during silent SDK operations
966
- // (e.g. long tool calls, MCP requests, model thinking). Without this,
967
- // the 2-minute heartbeat timeout can cancel the activity and mislabel
968
- // the execution as "paused by user".
969
- const taskQueue = Context.current().info.taskQueue;
970
- const shutdownSignal = getShutdownSignalForQueue(taskQueue);
971
- periodicHeartbeat = startHeartbeat(30_000, () => ({
972
- phase: "cursor_streaming",
973
- execution: executionId,
974
- }), { shutdownSignal });
1017
+ // The activity-wide periodic heartbeat (started at entry) keeps Temporal
1018
+ // informed during silent SDK operations (long tool calls, MCP requests,
1019
+ // model thinking); relabel it for the streaming phase.
1020
+ heartbeatPhase = "cursor_streaming";
975
1021
 
976
1022
  // The Cursor SDK registers abort listeners on the cancellation signal for
977
1023
  // each concurrent tool call (fetch, MCP, shell). With 10+ parallel tools,
@@ -1460,6 +1506,8 @@ async function executeCursorInner(
1460
1506
  attachmentPaths,
1461
1507
  pendingApprovals: adjudicatedApprovals,
1462
1508
  interactionMode,
1509
+ contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1510
+ senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1463
1511
  });
1464
1512
 
1465
1513
  console.log(
@@ -1866,6 +1914,12 @@ async function executeCursorInner(
1866
1914
 
1867
1915
  return slimStatus(status);
1868
1916
  } finally {
1917
+ // Stop the activity-wide periodic heartbeat on EVERY exit path
1918
+ // (idempotent). The epilogue and catch stop it at the pause/shutdown
1919
+ // disambiguation points; this covers early returns (e.g. the
1920
+ // pure-reconcile resume) so no orphaned timer survives the activity.
1921
+ periodicHeartbeat?.stop();
1922
+
1869
1923
  // End the OTel turn span + record metrics with the final token snapshot on
1870
1924
  // EVERY exit path (idempotent). Placed here so the span covers any recovery
1871
1925
  // retry (whose tokens accrue after the primary stream) and never leaks on an
@@ -2019,6 +2073,18 @@ export interface BuildPromptInput {
2019
2073
  * .build_from_plan): both prompt paths carry the implement-plan directive.
2020
2074
  */
2021
2075
  buildFromPlan?: boolean;
2076
+ /**
2077
+ * Rollover context bridge from `SessionSpec.metadata` (cloud DD-013).
2078
+ * Only the enhanced-prompt path consumes it — a resumed agent's native
2079
+ * context IS the previous conversation, so it needs no bridge.
2080
+ */
2081
+ contextBridge?: string;
2082
+ /**
2083
+ * Channel sender identity from `SessionSpec.metadata`. Like the bridge,
2084
+ * only the enhanced-prompt path consumes it — a resumed agent's native
2085
+ * context already carries it from the session's first turn.
2086
+ */
2087
+ senderIdentity?: import("../../shared/sender-identity.js").SenderIdentity;
2022
2088
  }
2023
2089
 
2024
2090
  /**
@@ -2083,7 +2149,8 @@ export function buildPrompt(input: BuildPromptInput): string {
2083
2149
  }
2084
2150
 
2085
2151
  // First execution, or a fresh agent created after a resume failure: there is
2086
- // no prior conversation to inherit, so start a new turn with full context.
2152
+ // no prior conversation to inherit, so start a new turn with full context
2153
+ // including the rollover bridge, when the session carries one.
2087
2154
  return buildEnhancedPrompt({
2088
2155
  instructions,
2089
2156
  userMessage,
@@ -2094,6 +2161,8 @@ export function buildPrompt(input: BuildPromptInput): string {
2094
2161
  attachmentPaths,
2095
2162
  interactionMode,
2096
2163
  buildFromPlan,
2164
+ contextBridge: input.contextBridge,
2165
+ senderIdentity: input.senderIdentity,
2097
2166
  });
2098
2167
  }
2099
2168
 
@@ -36,7 +36,7 @@ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/a
36
36
  import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
37
37
  import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
38
38
  import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
39
- import { MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
39
+ import { ApprovalPolicySource, MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
40
40
  import type { SDKMessage } from "@cursor/sdk";
41
41
  import type { MergedToolPolicy } from "./approval-policy.js";
42
42
  import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
@@ -44,6 +44,7 @@ import {
44
44
  POLICY_ENGINE_VERSION,
45
45
  resolveApprovalProvenance,
46
46
  toProtoPolicySource,
47
+ unattendedSkipMessage,
47
48
  } from "../../shared/approval-policy.js";
48
49
  import {
49
50
  approvalDenials,
@@ -1916,6 +1917,86 @@ export function detectUnattributedHookBlocks(
1916
1917
  return blocks;
1917
1918
  }
1918
1919
 
1920
+ /**
1921
+ * Stamp the tool calls the hook denied under UNATTENDED approval mode
1922
+ * (DD-014) as terminal TOOL_CALL_SKIPPED rows with UNATTENDED_SKIP
1923
+ * provenance — the Cursor twin of the native harness's
1924
+ * `reconcileUnattendedSkips`, so both harnesses persist the same honest
1925
+ * shape for a platform-resolved skip.
1926
+ *
1927
+ * An unattended denial never pauses the run (its ledger kind is excluded
1928
+ * from {@link approvalDenials}), so the stream leaves the denied call as a
1929
+ * FAILED row carrying Cursor's generic hook-block error — dishonest ("the
1930
+ * tool broke") and alarming in a transcript an org admin reviews. This pass
1931
+ * correlates the unattended ledger entries to their streamed calls with the
1932
+ * SAME two identities the rest of the file uses — exact token, then
1933
+ * (category, workspace-normalized path) for the abs-vs-rel drift — and
1934
+ * settles each match to SKIPPED with a result explaining the skip.
1935
+ *
1936
+ * Scope: hook-blocked FAILED rows and still-non-terminal (PENDING/RUNNING)
1937
+ * rows only — a COMPLETED row is a real execution and is never rewritten
1938
+ * (an unattended deny cannot produce one). `approval_action`/`approved_by`
1939
+ * stay untouched: server-owned, human-decision-only fields (DD-014 D-e).
1940
+ * Unmatched ledger entries need no synthesis — there is no pause to
1941
+ * surface; the model already saw the deny and adapted in-turn.
1942
+ *
1943
+ * Returns how many tool calls were stamped.
1944
+ */
1945
+ export function stampUnattendedSkippedToolCalls(
1946
+ messages: readonly AgentMessage[],
1947
+ subAgentExecutions: readonly SubAgentExecution[],
1948
+ unattendedLedger: readonly DeniedLedgerEntry[],
1949
+ workspaceRoot?: string,
1950
+ ): number {
1951
+ if (unattendedLedger.length === 0) return 0;
1952
+
1953
+ const ledgerTokens = new Set(unattendedLedger.map((e) => e.token));
1954
+ const ledgerNormalizedSalients = new Set<string>();
1955
+ if (workspaceRoot) {
1956
+ for (const entry of unattendedLedger) {
1957
+ const decoded = decodeIdentityToken(entry.token);
1958
+ if (!decoded) continue;
1959
+ const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
1960
+ if (normalized) ledgerNormalizedSalients.add(normalized);
1961
+ }
1962
+ }
1963
+
1964
+ const matchesLedger = (tc: ToolCall): boolean => {
1965
+ if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
1966
+ if (!workspaceRoot) return false;
1967
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1968
+ const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
1969
+ return !!normalized && ledgerNormalizedSalients.has(normalized);
1970
+ };
1971
+
1972
+ let stamped = 0;
1973
+ const apply = (msgs: readonly AgentMessage[]): void => {
1974
+ for (const msg of msgs) {
1975
+ for (const tc of msg.toolCalls) {
1976
+ const deniedShape =
1977
+ (tc.status === ToolCallStatus.TOOL_CALL_FAILED && isHookBlockError(tc.error)) ||
1978
+ tc.status === ToolCallStatus.TOOL_CALL_PENDING ||
1979
+ tc.status === ToolCallStatus.TOOL_CALL_RUNNING;
1980
+ if (!deniedShape || !matchesLedger(tc)) continue;
1981
+ tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
1982
+ tc.approvalPolicySource = ApprovalPolicySource.UNATTENDED_SKIP;
1983
+ tc.policyEngineVersion = POLICY_ENGINE_VERSION;
1984
+ tc.error = "";
1985
+ tc.result = unattendedSkipMessage(tc.name);
1986
+ tc.isStreaming = false;
1987
+ if (!tc.completedAt) tc.completedAt = utcTimestamp();
1988
+ stamped++;
1989
+ }
1990
+ }
1991
+ };
1992
+
1993
+ apply(messages);
1994
+ for (const subAgent of subAgentExecutions) {
1995
+ apply(subAgent.messages);
1996
+ }
1997
+ return stamped;
1998
+ }
1999
+
1919
2000
  /**
1920
2001
  * Compute a streamed tool call's identity token in the same canonical space the
1921
2002
  * preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
@@ -20,6 +20,11 @@ import { resolve } from "node:path";
20
20
  import type { SubAgent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
21
21
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
22
22
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
23
+ import { formatContextBridgeText } from "../../shared/context-bridge.js";
24
+ import {
25
+ formatSenderIdentityText,
26
+ type SenderIdentity,
27
+ } from "../../shared/sender-identity.js";
23
28
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
24
29
  import {
25
30
  buildImplementPlanDirective,
@@ -60,6 +65,22 @@ export interface EnhancedPromptOptions {
60
65
  * The user message itself is just a short label ("Build from plan").
61
66
  */
62
67
  buildFromPlan?: boolean;
68
+ /**
69
+ * Rollover context bridge (cloud DD-013): a digest of the previous
70
+ * session's conversation, read from `SessionSpec.metadata`. Lands in the
71
+ * first message, so it persists in the cursor agent's own conversation
72
+ * store for the session's lifetime — buildEnhancedPrompt firing only on
73
+ * first/fresh executions is exactly the right delivery.
74
+ */
75
+ contextBridge?: string;
76
+ /**
77
+ * Channel sender identity (attribution, not authorization): the
78
+ * provider-verified identifier of the person on the channel, read from
79
+ * `SessionSpec.metadata`. Like the bridge, it lands in the first message
80
+ * and persists in the cursor agent's own conversation store — identity
81
+ * is constant per conversation (channel sessions are keyed per-sender).
82
+ */
83
+ senderIdentity?: SenderIdentity;
63
84
  }
64
85
 
65
86
  /**
@@ -119,6 +140,21 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
119
140
  sections.push(formatResponseRules());
120
141
  }
121
142
 
143
+ // The sender identity precedes the bridge: it is standing context about
144
+ // WHO the conversation is with, which the carried conversation may refer
145
+ // back to.
146
+ if (options.senderIdentity) {
147
+ sections.push(formatSenderIdentitySection(options.senderIdentity));
148
+ }
149
+
150
+ // The rollover context bridge sits directly before the protocol + task so
151
+ // the carried conversation is the freshest CONTEXT the model reads —
152
+ // while the approval protocol keeps its pinned last-before-task slot (it
153
+ // is an INSTRUCTION and must outweigh everything, including this bridge).
154
+ if (options.contextBridge) {
155
+ sections.push(formatContextBridgeSection(options.contextBridge));
156
+ }
157
+
122
158
  // Always last before the task: the platform's tool-approval protocol. Placed
123
159
  // here for recency so it outweighs any "ask the user first" guidance Cursor
124
160
  // surfaces from a connected MCP server (see formatToolApprovalProtocol).
@@ -252,6 +288,14 @@ export function formatInstructions(instructions: string): string {
252
288
  return `<agent_instructions>\n${instructions}\n</agent_instructions>`;
253
289
  }
254
290
 
291
+ export function formatContextBridgeSection(bridge: string): string {
292
+ return `<previous_conversation_context>\n${formatContextBridgeText(bridge)}\n</previous_conversation_context>`;
293
+ }
294
+
295
+ export function formatSenderIdentitySection(identity: SenderIdentity): string {
296
+ return `<conversation_sender>\n${formatSenderIdentityText(identity)}\n</conversation_sender>`;
297
+ }
298
+
255
299
  export function formatSkillsSection(skills: SkillMetadata[]): string {
256
300
  const entries = skills.map(
257
301
  (s) => `- **${s.name}**: ${s.description}\n Path: \`${s.path}\``,
@@ -41,6 +41,7 @@ import { join } from "node:path";
41
41
 
42
42
  import { Agent } from "@cursor/sdk";
43
43
  import type { SDKAgent, CursorAgentPlatformOptions, AgentDefinition } from "@cursor/sdk";
44
+ import { withTimeout, TimeoutError } from "../../shared/with-timeout.js";
44
45
  import type { CursorMcpServerConfig } from "./mcp-resolver.js";
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -437,6 +438,81 @@ export async function resolveAgent(
437
438
  };
438
439
  }
439
440
 
441
+ // ---------------------------------------------------------------------------
442
+ // Transport-recovery wrapper
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export interface ResolveWithTransportRecoveryOptions {
446
+ harnessStateId: string;
447
+ createOptions: CreateAgentOptions | CreateCloudAgentOptions;
448
+ mode: "local" | "cloud";
449
+ /** Bound applied to each attempt independently. */
450
+ timeoutMs: number;
451
+ /**
452
+ * Builds the timeout rejection message. `finalAttempt` is true when no
453
+ * further automatic retry will follow, so the message can be honest about
454
+ * whether "retry" means the system or the user.
455
+ */
456
+ buildTimeoutMessage: (finalAttempt: boolean) => string;
457
+ /**
458
+ * Closes the transport the hung attempt is riding on so the next attempt
459
+ * dials fresh. Injected (rather than imported from the http2 interceptor)
460
+ * to keep this module transport-agnostic and the recovery unit-testable.
461
+ */
462
+ resetTransport: () => void;
463
+ }
464
+
465
+ /**
466
+ * Resolve a Cursor Agent with one automatic recovery from a transport hang.
467
+ *
468
+ * Agent.create/Agent.resume have no timeout of their own — a degraded
469
+ * transport (dead proxy connection, stale HTTP/2 session) hangs them forever.
470
+ * Each attempt is bounded by `timeoutMs`; when the first attempt expires,
471
+ * the transport is reset and the full resolveAgent() is retried once. Only
472
+ * a TimeoutError triggers recovery: deterministic failures (auth, validation)
473
+ * propagate immediately — resetting the transport cannot fix them.
474
+ *
475
+ * The retry re-invokes resolveAgent(), not createAgent() directly: at
476
+ * resolve time only the transport is suspect, not the agent handle, so a
477
+ * timed-out resume is retried resume-first and conversation context is
478
+ * preserved. (This deliberately diverges from the stream-phase recovery in
479
+ * index.ts, which jumps to a fresh create because there the handle itself
480
+ * has already failed a run.)
481
+ *
482
+ * Orphan semantics: withTimeout bounds the wait, not the work, so the first
483
+ * attempt's promise survives its expiry. resetTransport() closes the HTTP/2
484
+ * session that attempt is riding on, so the orphan rejects promptly and
485
+ * withTimeout's attached catch absorbs the late rejection (settled promise —
486
+ * no unhandledRejection). In the residual case where the orphan completes
487
+ * before the reset, it leaves an agent that never receives a prompt: no LLM
488
+ * cost, no state impact, cleaned up with the sandbox.
489
+ */
490
+ export async function resolveAgentWithTransportRecovery(
491
+ opts: ResolveWithTransportRecoveryOptions,
492
+ ): Promise<AgentResolution> {
493
+ const attempt = (finalAttempt: boolean) =>
494
+ withTimeout(
495
+ opts.timeoutMs,
496
+ () => opts.buildTimeoutMessage(finalAttempt),
497
+ () => resolveAgent(opts.harnessStateId, opts.createOptions, opts.mode),
498
+ );
499
+
500
+ try {
501
+ return await attempt(false);
502
+ } catch (err) {
503
+ if (!(err instanceof TimeoutError)) throw err;
504
+
505
+ console.warn(
506
+ `resolveAgentWithTransportRecovery: agent resolution timed out after ` +
507
+ `${opts.timeoutMs}ms (sessionId=${opts.createOptions.sessionId}, mode=${opts.mode}, ` +
508
+ `resume=${!!opts.harnessStateId}) — resetting transport and retrying once`,
509
+ );
510
+ opts.resetTransport();
511
+
512
+ return attempt(true);
513
+ }
514
+ }
515
+
440
516
  /**
441
517
  * Dispose a Cursor Agent when a session is deleted.
442
518
  * Best-effort: logs and swallows errors.
@@ -42,6 +42,7 @@ import {
42
42
  denialKindOf,
43
43
  primaryToken,
44
44
  readDenialLedger,
45
+ unattendedDenials,
45
46
  type ApprovalGrant,
46
47
  } from "./approval-state.js";
47
48
  import { deriveTurnCommandProvenance } from "./command-provenance.js";
@@ -50,6 +51,7 @@ import {
50
51
  clearProvisionalPostDenialNarration,
51
52
  detectUnattributedHookBlocks,
52
53
  reconcileDeniedToolCalls,
54
+ stampUnattendedSkippedToolCalls,
53
55
  type UnattributedHookBlock,
54
56
  } from "./message-translator.js";
55
57
 
@@ -302,6 +304,27 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
302
304
  }
303
305
  }
304
306
 
307
+ // Unattended approval mode (DD-014): denials the hook resolved with the
308
+ // non-pausing "unattended" kind never became gates above; settle their
309
+ // streamed rows (FAILED-with-hook-error or interrupted non-terminal) to
310
+ // honest TOOL_CALL_SKIPPED + UNATTENDED_SKIP provenance — the same shape
311
+ // the native harness's reconcileUnattendedSkips persists. Runs before the
312
+ // #205 attribution pass, which then never sees them as hook-block FAILED
313
+ // rows (they are in the full ledger regardless, so attribution was safe
314
+ // either way).
315
+ const stampedUnattended = stampUnattendedSkippedToolCalls(
316
+ status.messages,
317
+ status.subAgentExecutions,
318
+ unattendedDenials(deniedLedger),
319
+ primaryWorkspaceDir,
320
+ );
321
+ if (stampedUnattended > 0) {
322
+ console.log(
323
+ `ExecuteCursor turn boundary: ${stampedUnattended} unattended-mode tool call(s) ` +
324
+ `settled as SKIPPED (no approver on this surface; execution=${executionId})`,
325
+ );
326
+ }
327
+
305
328
  // Issue #205 invariant: a blocked tool must never silently complete. Match
306
329
  // this turn's hook-blocked FAILED rows against the FULL ledger (all kinds) —
307
330
  // anything left over was denied by a hook that is not ours (or by our hook
@@ -251,6 +251,7 @@ const httpConfig: Config = {
251
251
  checkpointerProxyEndpoint: "http://localhost:7234",
252
252
  primaryModel: "claude-sonnet",
253
253
  cursorStreamStallTimeoutMs: 180000,
254
+ agentResolveTimeoutMs: 120000,
254
255
  workspaceLockTimeoutMs: 900000,
255
256
  };
256
257
 
@@ -296,6 +296,7 @@ const baseConfig: Config = {
296
296
  checkpointerProxyEndpoint: "http://localhost:7234",
297
297
  primaryModel: "claude-sonnet",
298
298
  cursorStreamStallTimeoutMs: 180000,
299
+ agentResolveTimeoutMs: 120000,
299
300
  workspaceLockTimeoutMs: 900000,
300
301
  };
301
302
 
@@ -250,6 +250,7 @@ const baseConfig: Config = {
250
250
  checkpointerProxyEndpoint: "http://localhost:7234",
251
251
  primaryModel: "claude-sonnet",
252
252
  cursorStreamStallTimeoutMs: 180000,
253
+ agentResolveTimeoutMs: 120000,
253
254
  workspaceLockTimeoutMs: 900000,
254
255
  };
255
256
 
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { Command } from "@langchain/langgraph";
3
- import { resolveResumeInput, reconcileNonExecutingDecisions } from "../hitl.js";
4
- import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
+ import { resolveResumeInput, reconcileNonExecutingDecisions, reconcileUnattendedSkips } from "../hitl.js";
4
+ import { ApprovalAction, ApprovalPolicySource, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
5
5
  import { create } from "@bufbuild/protobuf";
6
6
  import {
7
7
  AgentMessageSchema,
@@ -238,3 +238,104 @@ describe("reconcileNonExecutingDecisions", () => {
238
238
  expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
239
239
  });
240
240
  });
241
+
242
+ describe("reconcileUnattendedSkips (DD-014)", () => {
243
+ function statusWithCalls(
244
+ toolCalls: Array<{ id: string; status: ToolCallStatus; result?: string }>,
245
+ ) {
246
+ return create(AgentExecutionStatusSchema, {
247
+ messages: [
248
+ create(AgentMessageSchema, {
249
+ toolCalls: toolCalls.map(tc =>
250
+ create(ToolCallSchema, {
251
+ id: tc.id,
252
+ name: "gated_tool",
253
+ status: tc.status,
254
+ result: tc.result ?? "",
255
+ }),
256
+ ),
257
+ }),
258
+ ],
259
+ });
260
+ }
261
+
262
+ it("terminalizes a registry-recorded call to SKIPPED with UNATTENDED_SKIP provenance", () => {
263
+ // The stream saw the gate's skip ToolMessage as a normal tool result and
264
+ // marked the call COMPLETED — the reconciler owns the honest terminal shape.
265
+ const status = statusWithCalls([
266
+ { id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "was skipped" },
267
+ ]);
268
+
269
+ reconcileUnattendedSkips(status, new Set(["call-1"]));
270
+
271
+ const tc = status.messages[0].toolCalls[0];
272
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
273
+ expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
274
+ expect(tc.policyEngineVersion).not.toBe("");
275
+ // Server-owned human-decision fields stay untouched (DD-014 D-e).
276
+ expect(tc.approvalAction).toBe(ApprovalAction.UNSPECIFIED);
277
+ expect(tc.approvedBy).toBe("");
278
+ });
279
+
280
+ it("backfills the skip result on a row whose tool events never fired", () => {
281
+ const status = statusWithCalls([
282
+ { id: "call-1", status: ToolCallStatus.TOOL_CALL_RUNNING },
283
+ ]);
284
+
285
+ reconcileUnattendedSkips(status, new Set(["call-1"]));
286
+
287
+ const tc = status.messages[0].toolCalls[0];
288
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
289
+ expect(tc.result).toContain("skipped automatically");
290
+ });
291
+
292
+ it("touches only registry entries and no-ops on an empty/absent registry", () => {
293
+ const status = statusWithCalls([
294
+ { id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "real result" },
295
+ ]);
296
+
297
+ reconcileUnattendedSkips(status, new Set(["other-call"]));
298
+ reconcileUnattendedSkips(status, new Set());
299
+ reconcileUnattendedSkips(status, undefined);
300
+
301
+ const tc = status.messages[0].toolCalls[0];
302
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
303
+ expect(tc.result).toBe("real result");
304
+ expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNSPECIFIED);
305
+ });
306
+
307
+ it("covers sub-agent transcripts (sub-agent gates share the parent registry)", () => {
308
+ const status = create(AgentExecutionStatusSchema, {
309
+ messages: [],
310
+ subAgentExecutions: [{
311
+ messages: [
312
+ create(AgentMessageSchema, {
313
+ toolCalls: [create(ToolCallSchema, {
314
+ id: "sub-call-1",
315
+ name: "gated_tool",
316
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
317
+ })],
318
+ }),
319
+ ],
320
+ }],
321
+ });
322
+
323
+ reconcileUnattendedSkips(status, new Set(["sub-call-1"]));
324
+
325
+ const tc = status.subAgentExecutions[0].messages[0].toolCalls[0];
326
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
327
+ expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
328
+ });
329
+
330
+ it("is idempotent", () => {
331
+ const status = statusWithCalls([
332
+ { id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED },
333
+ ]);
334
+
335
+ reconcileUnattendedSkips(status, new Set(["call-1"]));
336
+ const after = JSON.stringify(status.messages[0].toolCalls[0]);
337
+ reconcileUnattendedSkips(status, new Set(["call-1"]));
338
+
339
+ expect(JSON.stringify(status.messages[0].toolCalls[0])).toBe(after);
340
+ });
341
+ });