@cursor/july 0.1.62 → 0.1.68

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 (180) hide show
  1. package/dist/bin/agent-serve.js +8 -0
  2. package/dist/channels/slack/channel-watch.d.ts +2 -1
  3. package/dist/channels/slack/channel-watch.d.ts.map +1 -1
  4. package/dist/channels/slack/channel-watch.js +7 -3
  5. package/dist/channels/slack/dispatch.d.ts +9 -0
  6. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  7. package/dist/channels/slack/dispatch.js +41 -21
  8. package/dist/channels/slack/manifest.d.ts.map +1 -1
  9. package/dist/channels/slack/manifest.js +1 -0
  10. package/dist/channels/slack/nudge-store.d.ts +16 -0
  11. package/dist/channels/slack/nudge-store.d.ts.map +1 -1
  12. package/dist/channels/slack/nudge-store.js +53 -0
  13. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  14. package/dist/channels/slack/slack-channel.js +4 -1
  15. package/dist/channels/slack/types.d.ts +22 -5
  16. package/dist/channels/slack/types.d.ts.map +1 -1
  17. package/dist/docs/404.html +2 -2
  18. package/dist/docs/ab.html +3 -3
  19. package/dist/docs/assets/{app.B-biogkN.js → app.C5XUTCw9.js} +1 -1
  20. package/dist/docs/assets/chunks/@localSearchIndexroot.DvUR3lv9.js +1 -0
  21. package/dist/docs/assets/chunks/{VPLocalSearchBox.DZqKtFZS.js → VPLocalSearchBox.CGDTAYHY.js} +1 -1
  22. package/dist/docs/assets/chunks/{theme.BwC5zP2r.js → theme.lPixh250.js} +2 -2
  23. package/dist/docs/assets/{reference_connections.md.CSQiNzre.js → reference_connections.md.Bwswbm6H.js} +13 -3
  24. package/dist/docs/assets/{reference_connections.md.CSQiNzre.lean.js → reference_connections.md.Bwswbm6H.lean.js} +1 -1
  25. package/dist/docs/assets/{reference_tools.md.kJfJAP2m.js → reference_tools.md.Wmvb7Lob.js} +2 -2
  26. package/dist/docs/assets/{reference_tools.md.kJfJAP2m.lean.js → reference_tools.md.Wmvb7Lob.lean.js} +1 -1
  27. package/dist/docs/building-with-agents.html +3 -3
  28. package/dist/docs/concepts.html +3 -3
  29. package/dist/docs/deployment.html +3 -3
  30. package/dist/docs/evals.html +3 -3
  31. package/dist/docs/example-agents/approval-buddy.html +3 -3
  32. package/dist/docs/example-agents/benny.html +3 -3
  33. package/dist/docs/example-agents/bugbot.html +3 -3
  34. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  35. package/dist/docs/example-agents/codeowners-review.html +3 -3
  36. package/dist/docs/example-agents/concierge.html +3 -3
  37. package/dist/docs/example-agents/fsd.html +3 -3
  38. package/dist/docs/example-agents/index.html +3 -3
  39. package/dist/docs/example-agents/knowledge-base.html +3 -3
  40. package/dist/docs/example-agents/oncall.html +3 -3
  41. package/dist/docs/example-agents/security-reviewer.html +3 -3
  42. package/dist/docs/example-agents/slack-agent.html +3 -3
  43. package/dist/docs/example-agents/weather-agent.html +3 -3
  44. package/dist/docs/guides/agent-to-agent.html +3 -3
  45. package/dist/docs/guides/cloud-runtime.html +3 -3
  46. package/dist/docs/guides/convert-automation.html +3 -3
  47. package/dist/docs/guides/github.html +3 -3
  48. package/dist/docs/guides/human-in-the-loop.html +3 -3
  49. package/dist/docs/guides/mcp-oauth.html +3 -3
  50. package/dist/docs/guides/opentelemetry.html +3 -3
  51. package/dist/docs/guides/slack.html +3 -3
  52. package/dist/docs/guides/webhooks.html +3 -3
  53. package/dist/docs/hashmap.json +1 -1
  54. package/dist/docs/hillclimbing.html +3 -3
  55. package/dist/docs/index.html +3 -3
  56. package/dist/docs/quickstart.html +3 -3
  57. package/dist/docs/reference/agent-config.html +3 -3
  58. package/dist/docs/reference/artifacts.html +3 -3
  59. package/dist/docs/reference/channels.html +3 -3
  60. package/dist/docs/reference/cli.html +3 -3
  61. package/dist/docs/reference/connections.html +15 -5
  62. package/dist/docs/reference/hooks.html +3 -3
  63. package/dist/docs/reference/http-api.html +3 -3
  64. package/dist/docs/reference/instructions.html +3 -3
  65. package/dist/docs/reference/playground.html +3 -3
  66. package/dist/docs/reference/project-layout.html +3 -3
  67. package/dist/docs/reference/prompt.html +3 -3
  68. package/dist/docs/reference/schedules.html +3 -3
  69. package/dist/docs/reference/sessions.html +3 -3
  70. package/dist/docs/reference/skills.html +3 -3
  71. package/dist/docs/reference/subagents.html +3 -3
  72. package/dist/docs/reference/tools.html +5 -5
  73. package/dist/docs/scaffolding-agents.html +3 -3
  74. package/dist/docs/storage.html +3 -3
  75. package/dist/docs/templates/agentic-owners.html +3 -3
  76. package/dist/docs/templates/demo.html +3 -3
  77. package/dist/docs/templates/pr-autofixer.html +3 -3
  78. package/dist/docs/templates/security-reviewer.html +3 -3
  79. package/dist/docs/templates/triage.html +3 -3
  80. package/dist/docs/troubleshooting.html +3 -3
  81. package/dist/files-backends/agent-store-presigned-url.js +1 -1
  82. package/dist/index.d.ts +2 -2
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -1
  85. package/dist/internal/advertise-tools.d.ts +74 -0
  86. package/dist/internal/advertise-tools.d.ts.map +1 -0
  87. package/dist/internal/advertise-tools.js +152 -0
  88. package/dist/internal/cli-deploy.d.ts.map +1 -1
  89. package/dist/internal/cli-deploy.js +11 -3
  90. package/dist/internal/cli-manifest.d.ts +19 -0
  91. package/dist/internal/cli-manifest.d.ts.map +1 -0
  92. package/dist/internal/cli-manifest.js +49 -0
  93. package/dist/internal/cli-slack.d.ts.map +1 -1
  94. package/dist/internal/cli-slack.js +9 -1
  95. package/dist/internal/cursor-agent-template.d.ts +9 -0
  96. package/dist/internal/cursor-agent-template.d.ts.map +1 -0
  97. package/dist/internal/cursor-agent-template.js +37 -0
  98. package/dist/internal/deploy-client.d.ts +8 -0
  99. package/dist/internal/deploy-client.d.ts.map +1 -1
  100. package/dist/internal/deploy-client.js +21 -3
  101. package/dist/internal/deploy-manifest.d.ts +85 -0
  102. package/dist/internal/deploy-manifest.d.ts.map +1 -0
  103. package/dist/internal/deploy-manifest.js +106 -0
  104. package/dist/internal/deployment-scm-event-stream.d.ts +113 -0
  105. package/dist/internal/deployment-scm-event-stream.d.ts.map +1 -0
  106. package/dist/internal/deployment-scm-event-stream.js +460 -0
  107. package/dist/internal/discovery.d.ts.map +1 -1
  108. package/dist/internal/discovery.js +69 -0
  109. package/dist/internal/init-project.d.ts.map +1 -1
  110. package/dist/internal/init-project.js +5 -17
  111. package/dist/internal/mcp-host.d.ts +18 -0
  112. package/dist/internal/mcp-host.d.ts.map +1 -1
  113. package/dist/internal/mcp-host.js +41 -5
  114. package/dist/internal/sdk-runner.d.ts +1 -1
  115. package/dist/internal/sdk-runner.d.ts.map +1 -1
  116. package/dist/internal/sdk-runner.js +36 -13
  117. package/dist/internal/server.d.ts.map +1 -1
  118. package/dist/internal/server.js +18 -0
  119. package/dist/internal/session-engine.d.ts +66 -0
  120. package/dist/internal/session-engine.d.ts.map +1 -1
  121. package/dist/internal/session-engine.js +430 -110
  122. package/dist/internal/shallow-clone.d.ts +115 -0
  123. package/dist/internal/shallow-clone.d.ts.map +1 -0
  124. package/dist/internal/shallow-clone.js +292 -0
  125. package/dist/internal/slack-provision-client.d.ts +2 -0
  126. package/dist/internal/slack-provision-client.d.ts.map +1 -1
  127. package/dist/internal/slack-provision-client.js +1 -0
  128. package/dist/internal/turn-lease-store.d.ts +44 -0
  129. package/dist/internal/turn-lease-store.d.ts.map +1 -0
  130. package/dist/internal/turn-lease-store.js +139 -0
  131. package/dist/internal/workspace.d.ts +12 -0
  132. package/dist/internal/workspace.d.ts.map +1 -1
  133. package/dist/internal/workspace.js +28 -0
  134. package/dist/playground/assets/index-CoL7xCJl.css +1 -0
  135. package/dist/playground/assets/index-yxs1heea.js +85 -0
  136. package/dist/playground/index.html +2 -2
  137. package/dist/storage-protocol.d.ts +122 -0
  138. package/dist/storage-protocol.d.ts.map +1 -0
  139. package/dist/storage-protocol.js +173 -0
  140. package/dist/storage.d.ts +2 -72
  141. package/dist/storage.d.ts.map +1 -1
  142. package/dist/storage.js +2 -82
  143. package/dist/types.d.ts +113 -3
  144. package/dist/types.d.ts.map +1 -1
  145. package/docs/reference/connections.md +80 -0
  146. package/docs/reference/tools.md +14 -0
  147. package/package.json +4 -2
  148. package/src/bin/agent-serve.ts +9 -0
  149. package/src/channels/slack/channel-watch.ts +6 -3
  150. package/src/channels/slack/dispatch.ts +40 -3
  151. package/src/channels/slack/manifest.ts +1 -0
  152. package/src/channels/slack/nudge-store.ts +56 -0
  153. package/src/channels/slack/slack-channel.ts +3 -0
  154. package/src/channels/slack/types.ts +22 -5
  155. package/src/index.ts +5 -0
  156. package/src/internal/advertise-tools.ts +229 -0
  157. package/src/internal/cli-deploy.ts +22 -1
  158. package/src/internal/cli-manifest.ts +63 -0
  159. package/src/internal/cli-slack.ts +10 -1
  160. package/src/internal/cursor-agent-template.ts +46 -0
  161. package/src/internal/deploy-client.ts +35 -6
  162. package/src/internal/deploy-manifest.ts +167 -0
  163. package/src/internal/deployment-scm-event-stream.ts +577 -0
  164. package/src/internal/discovery.ts +85 -0
  165. package/src/internal/init-project.ts +11 -20
  166. package/src/internal/mcp-host.ts +66 -9
  167. package/src/internal/sdk-runner.ts +50 -11
  168. package/src/internal/server.ts +19 -0
  169. package/src/internal/session-engine.ts +385 -19
  170. package/src/internal/shallow-clone.ts +407 -0
  171. package/src/internal/slack-provision-client.ts +3 -0
  172. package/src/internal/turn-lease-store.ts +147 -0
  173. package/src/internal/workspace.ts +27 -0
  174. package/src/storage-protocol.ts +247 -0
  175. package/src/storage.ts +16 -110
  176. package/src/types.ts +117 -3
  177. package/templates/pr-autofixer/agent/lib/drive-schemas.ts +4 -1
  178. package/dist/docs/assets/chunks/@localSearchIndexroot.5ozYNf74.js +0 -1
  179. package/dist/playground/assets/index-B4rcOgeP.css +0 -1
  180. package/dist/playground/assets/index-DXcY42Jv.js +0 -85
@@ -17,6 +17,8 @@ import {
17
17
  resolveABMaxPlaygroundSessions,
18
18
  resolveABSplit,
19
19
  } from "../ab.js";
20
+ import { SLACK_CHANNEL_ID } from "../channels/slack/constants.js";
21
+ import { materializeSlackNudgeCarrier } from "../channels/slack/nudge-carrier.js";
20
22
  import {
21
23
  SlackAskDedupeStore,
22
24
  SlackNudgeStore,
@@ -73,6 +75,7 @@ import {
73
75
  import { ABCollector } from "./ab-collector.js";
74
76
  import { foldABStream } from "./ab-fold.js";
75
77
  import { type ABSnapshot, buildABSnapshot } from "./ab-snapshot.js";
78
+ import { resolveAdvertisedTools } from "./advertise-tools.js";
76
79
  import { ApprovalGate } from "./approval-gate.js";
77
80
  import { ArtifactsStore } from "./artifacts-store.js";
78
81
  import { SessionAuthError, samePrincipal } from "./auth.js";
@@ -132,9 +135,11 @@ import {
132
135
  roundCostUsd,
133
136
  } from "./turn-cost.js";
134
137
  import { TurnGovernor } from "./turn-governor.js";
138
+ import { TurnLeaseStore } from "./turn-lease-store.js";
135
139
  import {
136
140
  buildAgentsMdContent,
137
141
  buildCloudMemorySection,
142
+ ensureOwnGitRoot,
138
143
  isNestedInGitRepo,
139
144
  materializeWorkspace,
140
145
  SERVER_TOOLS_MCP_SERVER_NAME,
@@ -318,6 +323,8 @@ export class SessionEngine {
318
323
  readonly nudgeStore: SlackNudgeStore;
319
324
  /** Durable `channelId:ts` ask claims under `<stateRoot>/slack-asks`. */
320
325
  readonly askDedupe: SlackAskDedupeStore;
326
+ /** Cross-process turn ownership under `<stateRoot>/turn-leases`. */
327
+ readonly turnLeases: TurnLeaseStore;
321
328
  /**
322
329
  * Runtime for `agent/storage.ts` (`defineStorage`), when
323
330
  * authored. All durable state changes funnel through it (session
@@ -342,6 +349,12 @@ export class SessionEngine {
342
349
  private readonly activeTurns = new Map<string, Promise<TurnOutcome>>();
343
350
  /** Context used by the cloud turn's HTTP MCP callbacks. */
344
351
  private readonly activeToolContexts = new Map<string, ActiveToolContext>();
352
+ /**
353
+ * Delivered-nudge count per session at the last tool boundary. The carrier
354
+ * drains out-of-process, so a growing count is how the engine learns a
355
+ * follow-up reached the agent.
356
+ */
357
+ private readonly deliveredNudges = new Map<string, number>();
345
358
  /** Aborts the in-flight turn per session so a follow-up can preempt it. */
346
359
  private readonly turnAborts = new Map<string, AbortController>();
347
360
  /** Serializes turn admission per session (latest follow-up wins). */
@@ -415,6 +428,9 @@ export class SessionEngine {
415
428
  this.askDedupe = new SlackAskDedupeStore(
416
429
  join(options.stateRoot, "slack-asks")
417
430
  );
431
+ this.turnLeases = new TurnLeaseStore(
432
+ join(options.stateRoot, "turn-leases")
433
+ );
418
434
  this.logs = new EventLogStore(
419
435
  (sessionId) => this.sessions.eventFilePath(sessionId),
420
436
  (sessionId, error) =>
@@ -731,6 +747,110 @@ export class SessionEngine {
731
747
  return record;
732
748
  }
733
749
 
750
+ /**
751
+ * Install the Slack nudge carrier into a local session's harness cwd, so a
752
+ * follow-up that arrives mid-turn reaches the running agent at its next tool
753
+ * boundary instead of waiting for the boundary turn.
754
+ *
755
+ * Slack-only and local-only: the carrier is a Cursor hook in the workspace,
756
+ * and a cloud VM has neither the workspace nor the nudge store.
757
+ *
758
+ * The harness only reads these hooks when the workspace is its own git root;
759
+ * nested in an outer repo it reads that repo's instead and the carrier never
760
+ * runs, leaving follow-ups to drain at the turn boundary as before.
761
+ */
762
+ private async materializeNudgeCarrier(
763
+ record: SessionRecord,
764
+ harnessCwd: string
765
+ ): Promise<void> {
766
+ if (
767
+ record.channelId !== SLACK_CHANNEL_ID ||
768
+ record.continuationKey === null ||
769
+ record.continuationKey === "" ||
770
+ // Borrowed: a PR worktree, or a checkout shared by every session. Hooks
771
+ // written there dirty a tree this session did not create, and a shared
772
+ // cwd holds one continuation pointer for all of them — the carrier would
773
+ // inject one thread's follow-ups into another thread's turn. Nothing is
774
+ // lost: a borrowed checkout is its own git root, so the harness reads its
775
+ // hooks rather than these, and follow-ups drain at the turn boundary.
776
+ this.hasBorrowedWorkspace(record)
777
+ ) {
778
+ return;
779
+ }
780
+ try {
781
+ await materializeSlackNudgeCarrier({
782
+ harnessCwd,
783
+ nudgeRootDir: join(this.stateRoot, "slack-nudges"),
784
+ continuationKey: record.continuationKey,
785
+ });
786
+ } catch (error) {
787
+ // Best-effort: without the carrier, follow-ups still drain at the turn
788
+ // boundary — one turn later, never lost.
789
+ this.logger(
790
+ `[session] slack nudge carrier not installed: ${describeError(error)}`
791
+ );
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Report a mid-turn follow-up reaching the agent, as `nudge.delivered`.
797
+ *
798
+ * The workspace carrier drains `pending/` into `delivered/` out of process,
799
+ * so the engine watches the delivered count grow rather than the turn's own
800
+ * events. Counting rather than edge-detecting on "still queued" matters:
801
+ * the carrier runs at the same tool boundary this check does, so a nudge
802
+ * queued and drained between two readings is never seen as pending.
803
+ *
804
+ * Channels need this to move a live progress message below the follow-up —
805
+ * Slack cannot reorder messages.
806
+ */
807
+ private async emitDeliveredNudges(
808
+ record: SessionRecord,
809
+ emit: (event: SessionEventPayload) => void
810
+ ): Promise<void> {
811
+ const delivered = await this.readDeliveredNudges(record);
812
+ if (delivered === undefined) {
813
+ return;
814
+ }
815
+ const previous = this.deliveredNudges.get(record.sessionId);
816
+ this.deliveredNudges.set(record.sessionId, delivered);
817
+ if (previous !== undefined && delivered > previous) {
818
+ emit({ type: "nudge.delivered", data: {} });
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Record the thread's delivered count before the turn runs. Earlier turns
824
+ * leave their own records behind, so only growth from here is this turn's.
825
+ */
826
+ private async baselineDeliveredNudges(record: SessionRecord): Promise<void> {
827
+ const delivered = await this.readDeliveredNudges(record);
828
+ if (delivered !== undefined) {
829
+ this.deliveredNudges.set(record.sessionId, delivered);
830
+ }
831
+ }
832
+
833
+ private async readDeliveredNudges(
834
+ record: SessionRecord
835
+ ): Promise<number | undefined> {
836
+ const continuationKey = record.continuationKey;
837
+ // Same gate as the carrier: only Slack queues follow-ups mid-turn, and
838
+ // this runs on every tool boundary of every turn.
839
+ if (
840
+ record.channelId !== SLACK_CHANNEL_ID ||
841
+ continuationKey === null ||
842
+ continuationKey === ""
843
+ ) {
844
+ return undefined;
845
+ }
846
+ try {
847
+ return await this.nudgeStore.deliveredCount(continuationKey);
848
+ } catch {
849
+ // A store read must never break the turn it is observing.
850
+ return undefined;
851
+ }
852
+ }
853
+
734
854
  private slackNudgeHostApi(): SlackNudgeHostApi {
735
855
  return {
736
856
  append: (input) => this.nudgeStore.append(input),
@@ -1126,6 +1246,67 @@ export class SessionEngine {
1126
1246
  this.coalesceQueues.delete(sessionId);
1127
1247
  }
1128
1248
 
1249
+ /**
1250
+ * Drop queued follow-ups the nudge carrier already carried into the turn
1251
+ * that just finished — the contract {@link SendMessageOptions.coalesceSourceTs}
1252
+ * describes. Re-asking them would answer the same message twice.
1253
+ *
1254
+ * Items with no `sourceTs` cannot be matched and are kept.
1255
+ */
1256
+ private async withoutNudgedFollowups(
1257
+ sessionId: string,
1258
+ queued: readonly CoalescedQueuedTurn[]
1259
+ ): Promise<CoalescedQueuedTurn[]> {
1260
+ const record = await this.sessions.get(sessionId);
1261
+ const continuationKey = record?.continuationKey;
1262
+ if (continuationKey === undefined || continuationKey === null) {
1263
+ return [...queued];
1264
+ }
1265
+ const kept: CoalescedQueuedTurn[] = [];
1266
+ for (const item of queued) {
1267
+ if (item.sourceTs === undefined) {
1268
+ kept.push(item);
1269
+ continue;
1270
+ }
1271
+ try {
1272
+ if (
1273
+ !(await this.nudgeStore.isDelivered(continuationKey, item.sourceTs))
1274
+ ) {
1275
+ kept.push(item);
1276
+ }
1277
+ } catch {
1278
+ // A store read failure must not swallow the ask.
1279
+ kept.push(item);
1280
+ }
1281
+ }
1282
+ return kept;
1283
+ }
1284
+
1285
+ /** Mark drained follow-ups delivered so the next turn's carrier skips them. */
1286
+ private async settleDrainedFollowups(
1287
+ sessionId: string,
1288
+ items: readonly CoalescedQueuedTurn[]
1289
+ ): Promise<void> {
1290
+ const record = await this.sessions.get(sessionId);
1291
+ const continuationKey = record?.continuationKey;
1292
+ if (continuationKey === undefined || continuationKey === null) {
1293
+ return;
1294
+ }
1295
+ for (const item of items) {
1296
+ if (item.sourceTs === undefined) {
1297
+ continue;
1298
+ }
1299
+ try {
1300
+ await this.nudgeStore.markDelivered(continuationKey, item.sourceTs);
1301
+ } catch (error) {
1302
+ // Worst case the carrier re-injects it; the ask still gets answered.
1303
+ this.logger(
1304
+ `[session] nudge markDelivered failed for ${sessionId}: ${describeError(error)}`
1305
+ );
1306
+ }
1307
+ }
1308
+ }
1309
+
1129
1310
  private formatCoalescedFollowups(
1130
1311
  items: readonly CoalescedQueuedTurn[]
1131
1312
  ): string {
@@ -1152,9 +1333,20 @@ export class SessionEngine {
1152
1333
  return;
1153
1334
  }
1154
1335
  this.coalesceQueues.delete(sessionId);
1155
- const message = this.formatCoalescedFollowups(queued);
1336
+ const pending = await this.withoutNudgedFollowups(sessionId, queued);
1337
+ if (pending.length === 0) {
1338
+ this.logger(
1339
+ `[session] coalesce drain skipped; all follow-ups already nudged ${sessionId}`
1340
+ );
1341
+ return;
1342
+ }
1343
+ // These become the next turn's prompt, so settle them in the nudge store
1344
+ // first: a surviving pending record would be injected by that turn's own
1345
+ // carrier, re-asking what the turn is already about.
1346
+ await this.settleDrainedFollowups(sessionId, pending);
1347
+ const message = this.formatCoalescedFollowups(pending);
1156
1348
  this.logger(
1157
- `[session] coalesce drain starting follow-up (${queued.length}) ${sessionId}`
1349
+ `[session] coalesce drain starting follow-up (${pending.length}) ${sessionId}`
1158
1350
  );
1159
1351
  await this.startTurn(sessionId, message, {
1160
1352
  admission: "preempt",
@@ -1179,6 +1371,15 @@ export class SessionEngine {
1179
1371
  return true;
1180
1372
  }
1181
1373
 
1374
+ // Another server owns this session, so coalesce even a preempt. No local
1375
+ // enqueue: `drainCoalescedFollowups` reads only this process's queue, so
1376
+ // the shared nudge store is the carrier. Not redundant with `acquire`
1377
+ // below — this returns before preempt's `clearCoalesced` drops the queue.
1378
+ if (await this.turnLeases.runningElsewhere(sessionId)) {
1379
+ this.logger(`[session] coalesce; another server owns ${sessionId}`);
1380
+ return true;
1381
+ }
1382
+
1182
1383
  // Interrupt any turn in flight and wait for it to settle so the SDK run is
1183
1384
  // terminal before this turn's send (the follow-up also force-expires as a
1184
1385
  // belt-and-suspenders — see buildTurnRequest). Drain-resume skips interrupt:
@@ -1192,8 +1393,20 @@ export class SessionEngine {
1192
1393
  if (admission === "preempt" && options.retainCoalesced !== true) {
1193
1394
  this.clearCoalesced(sessionId);
1194
1395
  }
1396
+ // A neighbour won the race; same outcome as the branch above.
1397
+ const lease = await this.turnLeases.acquire(sessionId, () => {
1398
+ // Stalled past the stale window and a successor took over. Stand down
1399
+ // rather than answer the thread alongside it.
1400
+ this.logger(`[session] lease lost mid-turn; aborting ${sessionId}`);
1401
+ this.turnAborts.get(sessionId)?.abort();
1402
+ });
1403
+ if (lease === undefined) {
1404
+ this.logger(`[session] lease lost during admit ${sessionId}`);
1405
+ return true;
1406
+ }
1195
1407
  this.pendingTurns.add(sessionId);
1196
1408
 
1409
+ let lifecycleInstalled = false;
1197
1410
  try {
1198
1411
  let record = await this.updateRecord(sessionId, (current) => ({
1199
1412
  ...current,
@@ -1259,11 +1472,20 @@ export class SessionEngine {
1259
1472
  }
1260
1473
  }
1261
1474
  }
1475
+ // The drain may have nested a successor turn, which took its own
1476
+ // reference; the lease outlives this release until that one ends too.
1477
+ await lease.release();
1262
1478
  return outcome;
1263
1479
  })();
1264
1480
  this.activeTurns.set(sessionId, lifecycle);
1481
+ lifecycleInstalled = true;
1265
1482
  } finally {
1266
1483
  this.pendingTurns.delete(sessionId);
1484
+ if (!lifecycleInstalled) {
1485
+ // Nothing will run the release above, and the session would sit behind
1486
+ // this lease until it went stale.
1487
+ await lease.release();
1488
+ }
1267
1489
  }
1268
1490
  return false;
1269
1491
  }
@@ -1435,7 +1657,13 @@ export class SessionEngine {
1435
1657
  ...(agent.allowedTools === undefined
1436
1658
  ? {}
1437
1659
  : { tools: agent.allowedTools }),
1660
+ // No session yet, so auth'd connections are omitted rather than
1661
+ // warmed without an identity; the per-send flag still has to match
1662
+ // the turns' requests or the warmed executor is one no turn asks for.
1438
1663
  mcpServers: this.buildMcpServers("local"),
1664
+ ...(this.hasAttachedAuthedConnections()
1665
+ ? { perSendMcpServers: true }
1666
+ : {}),
1439
1667
  subagents: buildRunnerSubagents(agent),
1440
1668
  ...(agent.model === undefined ? {} : { model: agent.model }),
1441
1669
  });
@@ -1521,6 +1749,18 @@ export class SessionEngine {
1521
1749
  seed: isFirstTurn,
1522
1750
  stateRoot: this.stateRoot,
1523
1751
  });
1752
+ // Must precede the carrier: the harness resolves rules, skills,
1753
+ // `mcp.json` and hooks from its git root, so a workspace nested in an
1754
+ // outer repo would load that repo's hooks instead of the ones written
1755
+ // just below. Only for directories this session owns — a borrowed
1756
+ // checkout or worktree keeps its own root.
1757
+ if (!this.hasBorrowedWorkspace(record)) {
1758
+ await ensureOwnGitRoot(harnessCwd);
1759
+ }
1760
+ await this.materializeNudgeCarrier(record, harnessCwd);
1761
+ // Baseline before the agent can run, so a follow-up that arrives and
1762
+ // drains before the first tool boundary still counts as an increase.
1763
+ await this.baselineDeliveredNudges(record);
1524
1764
  const pendingFiles = record.pendingWorkspaceFiles;
1525
1765
  const pendingImages = record.pendingImages;
1526
1766
  if (
@@ -1555,7 +1795,7 @@ export class SessionEngine {
1555
1795
  isFirstTurn &&
1556
1796
  (this.hasBorrowedWorkspace(record) ||
1557
1797
  (await isNestedInGitRepo(harnessCwd)));
1558
- const request = this.buildTurnRequest(
1798
+ const request = await this.buildTurnRequest(
1559
1799
  record,
1560
1800
  agent,
1561
1801
  turnId,
@@ -1574,6 +1814,9 @@ export class SessionEngine {
1574
1814
  // typecheck green against older published `@cursor/sdk` pins.
1575
1815
  (update as { type: string }).type === "tool-call-delta"
1576
1816
  ) {
1817
+ // Same boundary the carrier hook runs on, so a drain is visible
1818
+ // by the time this fires.
1819
+ await this.emitDeliveredNudges(record, emit);
1577
1820
  await yieldEventLoop();
1578
1821
  }
1579
1822
  },
@@ -1593,6 +1836,9 @@ export class SessionEngine {
1593
1836
  permit.release();
1594
1837
  this.approvals.cancelSession(sessionId, "deny");
1595
1838
  await this.clearPendingApprovals(sessionId);
1839
+ // Anything still queued belongs to the next turn's boundary drain, not
1840
+ // to this turn's edge detection.
1841
+ this.deliveredNudges.delete(sessionId);
1596
1842
  }
1597
1843
  }
1598
1844
 
@@ -1672,7 +1918,7 @@ export class SessionEngine {
1672
1918
  };
1673
1919
  }
1674
1920
 
1675
- private buildTurnRequest(
1921
+ private async buildTurnRequest(
1676
1922
  record: SessionRecord,
1677
1923
  agent: ResolvedAgent,
1678
1924
  turnId: string,
@@ -1683,7 +1929,7 @@ export class SessionEngine {
1683
1929
  onUpdate: RunnerTurnRequest["onUpdate"],
1684
1930
  signal: AbortSignal,
1685
1931
  images?: Array<{ data: string; mimeType: string }>
1686
- ): RunnerTurnRequest {
1932
+ ): Promise<RunnerTurnRequest> {
1687
1933
  // Per-session cloud attach (channel/tool handoff) forces cloud runtime
1688
1934
  // even when the agent defaults to local for chat server tools.
1689
1935
  const runtime = resolveSessionRuntime({
@@ -1699,6 +1945,21 @@ export class SessionEngine {
1699
1945
  `Agent "${agent.name}" declares a built-in tool allowlist (tools), which cannot be enforced on cloud turns yet. Refusing the cloud session rather than running it unrestricted.`
1700
1946
  );
1701
1947
  }
1948
+ // Same fail-closed posture for per-session connection features:
1949
+ // advertised tools are server tools (in-process, local turns only) and
1950
+ // per-session auth headers cannot reach a cloud VM's MCP config. A turn
1951
+ // that silently runs without its per-session tools or identity is worse
1952
+ // than a refused one. Cloud-runtime agents already fail discovery; this
1953
+ // guards hybrid agents' per-send cloud sessions.
1954
+ const perSessionConnection = agent.connections.find(
1955
+ (connection) =>
1956
+ connection.advertiseTools === true || connection.auth !== undefined
1957
+ );
1958
+ if (runtime === "cloud" && perSessionConnection !== undefined) {
1959
+ throw new Error(
1960
+ `Agent "${agent.name}" connection "${perSessionConnection.name}" uses advertiseTools/auth, which only run on local turns. Refusing the cloud session rather than running it without the connection's tools or identity.`
1961
+ );
1962
+ }
1702
1963
  const mergedCloud =
1703
1964
  runtime === "cloud"
1704
1965
  ? mergeCloudOptions({
@@ -1764,14 +2025,26 @@ export class SessionEngine {
1764
2025
  runtime,
1765
2026
  ...(mergedCloud === undefined ? {} : { cloud: mergedCloud }),
1766
2027
  customTools:
1767
- runtime === "local" ? this.buildCustomTools(record, turnId, emit) : {},
1768
- mcpServers: this.buildMcpServers(runtime, record.sessionId),
2028
+ runtime === "local"
2029
+ ? await this.buildCustomTools(record, agent, turnId, emit)
2030
+ : {},
2031
+ mcpServers: this.buildMcpServers(
2032
+ runtime,
2033
+ record.sessionId,
2034
+ await this.evaluateConnectionAuth(record)
2035
+ ),
2036
+ ...(runtime === "local" && this.hasAttachedAuthedConnections()
2037
+ ? { perSendMcpServers: true }
2038
+ : {}),
1769
2039
  subagents: buildRunnerSubagents(agent),
1770
2040
  onUpdate,
1771
2041
  signal,
1772
2042
  // Local follow-ups expire any lingering active run so a preempted turn
1773
2043
  // (or one orphaned by a crashed process) never blocks this send.
1774
2044
  forceExpireActiveRun: runtime === "local" && !isFirstTurn,
2045
+ onRequestId: (requestId) => {
2046
+ emit({ type: "turn.bound", data: { requestId } });
2047
+ },
1775
2048
  onSdkAgentId: async (sdkAgentId) => {
1776
2049
  if (record.sdkAgentId === sdkAgentId) {
1777
2050
  return;
@@ -1871,11 +2144,18 @@ export class SessionEngine {
1871
2144
  */
1872
2145
  private buildMcpServers(
1873
2146
  runtime: "local" | "cloud",
1874
- sessionId?: string
2147
+ sessionId?: string,
2148
+ sessionAuth?: ReadonlyMap<string, Record<string, string>>
1875
2149
  ): Record<string, ConnectionTransport> {
2150
+ // Advertised connections surface as named server tools instead of an
2151
+ // attached MCP server (each tool appears exactly once), so they are
2152
+ // excluded here alongside hostOnly.
1876
2153
  const hostOnlyNames = new Set(
1877
2154
  this.project.agent.connections
1878
- .filter((connection) => connection.hostOnly === true)
2155
+ .filter(
2156
+ (connection) =>
2157
+ connection.hostOnly === true || connection.advertiseTools === true
2158
+ )
1879
2159
  .map((connection) => connection.name)
1880
2160
  );
1881
2161
  const bridgedNames = new Set(
@@ -1920,6 +2200,23 @@ export class SessionEngine {
1920
2200
  }
1921
2201
  continue;
1922
2202
  }
2203
+ // Per-session auth: merge the evaluated headers over the static
2204
+ // ones. With no evaluation in hand (prewarm has no session) the
2205
+ // connection is omitted rather than attached without its identity.
2206
+ if (connection.auth !== undefined) {
2207
+ const evaluated = sessionAuth?.get(connection.name);
2208
+ if (evaluated === undefined) {
2209
+ continue;
2210
+ }
2211
+ const transport = connection.transport;
2212
+ if ("url" in transport) {
2213
+ servers[connection.name] = {
2214
+ ...transport,
2215
+ headers: { ...transport.headers, ...evaluated },
2216
+ };
2217
+ }
2218
+ continue;
2219
+ }
1923
2220
  servers[connection.name] = connection.transport;
1924
2221
  }
1925
2222
  for (const resolved of this.resolvedConnections) {
@@ -1966,20 +2263,67 @@ export class SessionEngine {
1966
2263
  return servers;
1967
2264
  }
1968
2265
 
1969
- private buildCustomTools(
2266
+ /**
2267
+ * True when a connection with per-session `auth` attaches to the model
2268
+ * (not `hostOnly`, not advertised). These turns set
2269
+ * {@link RunnerTurnRequest.perSendMcpServers} so the evaluated headers
2270
+ * ride every send instead of being pinned on the cached agent handle.
2271
+ */
2272
+ private hasAttachedAuthedConnections(): boolean {
2273
+ return this.project.agent.connections.some(
2274
+ (connection) =>
2275
+ connection.auth !== undefined &&
2276
+ connection.hostOnly !== true &&
2277
+ connection.advertiseTools !== true
2278
+ );
2279
+ }
2280
+
2281
+ /**
2282
+ * Evaluate per-session connection auth (`defineConnection({ auth })`)
2283
+ * for connections that attach to the model this turn. Advertised
2284
+ * connections evaluate their own auth inside the tool synthesis path. A
2285
+ * callback that throws fails the turn — a turn never silently runs
2286
+ * without the connection's identity.
2287
+ */
2288
+ private async evaluateConnectionAuth(
2289
+ record: SessionRecord
2290
+ ): Promise<ReadonlyMap<string, Record<string, string>> | undefined> {
2291
+ const authed = this.project.agent.connections.filter(
2292
+ (connection) =>
2293
+ connection.auth !== undefined &&
2294
+ connection.hostOnly !== true &&
2295
+ connection.advertiseTools !== true
2296
+ );
2297
+ if (authed.length === 0) {
2298
+ return undefined;
2299
+ }
2300
+ const session = this.sessionInfo(record);
2301
+ const evaluated = new Map<string, Record<string, string>>();
2302
+ for (const connection of authed) {
2303
+ const auth = connection.auth;
2304
+ if (auth === undefined) {
2305
+ continue;
2306
+ }
2307
+ try {
2308
+ evaluated.set(connection.name, (await auth(session)).headers);
2309
+ } catch (error) {
2310
+ throw new Error(
2311
+ `Connection "${connection.name}" auth failed: ${describeError(error)}`,
2312
+ { cause: error }
2313
+ );
2314
+ }
2315
+ }
2316
+ return evaluated;
2317
+ }
2318
+
2319
+ private async buildCustomTools(
1970
2320
  record: SessionRecord,
2321
+ agent: ResolvedAgent,
1971
2322
  turnId: string,
1972
2323
  emit: (payload: SessionEventPayload) => void
1973
- ): Record<string, SDKCustomTool> {
2324
+ ): Promise<Record<string, SDKCustomTool>> {
1974
2325
  const tools: Record<string, SDKCustomTool> = {};
1975
- for (const tool of this.project.agent.tools) {
1976
- if (tool.execution !== "server") {
1977
- continue;
1978
- }
1979
- const execute = tool.definition.execute;
1980
- if (typeof execute !== "function") {
1981
- continue;
1982
- }
2326
+ const register = (tool: ResolvedAgent["tools"][number]): void => {
1983
2327
  tools[tool.name] = {
1984
2328
  description: tool.description,
1985
2329
  inputSchema: tool.inputSchema as SDKCustomTool["inputSchema"],
@@ -1994,6 +2338,26 @@ export class SessionEngine {
1994
2338
  });
1995
2339
  },
1996
2340
  };
2341
+ };
2342
+ for (const tool of agent.tools) {
2343
+ if (tool.execution !== "server") {
2344
+ continue;
2345
+ }
2346
+ if (typeof tool.definition.execute !== "function") {
2347
+ continue;
2348
+ }
2349
+ register(tool);
2350
+ }
2351
+ // Advertised connections (`defineConnection({ advertiseTools })`):
2352
+ // named passthroughs resolved fresh on every turn — reminder fires and
2353
+ // post-restart follow-ups included — and a listing failure throws,
2354
+ // failing the turn closed.
2355
+ for (const tool of await resolveAdvertisedTools({
2356
+ agent,
2357
+ session: this.sessionInfo(record),
2358
+ hostMcp: this.mcpHost,
2359
+ })) {
2360
+ register(tool);
1997
2361
  }
1998
2362
  return tools;
1999
2363
  }
@@ -3476,6 +3840,8 @@ export class SessionEngine {
3476
3840
  await Promise.allSettled([...this.backgroundWork]);
3477
3841
  await Promise.allSettled([...this.dispatchChains.values()]);
3478
3842
  await Promise.allSettled([...this.updateChains.values()]);
3843
+ // After the drain, so a successor need not wait out the heartbeat TTL.
3844
+ await this.turnLeases.releaseAll().catch(() => {});
3479
3845
  await this.logs.flushAll();
3480
3846
  await this.storage?.close();
3481
3847
  await this.mcpHost.close();