@cursor/july 0.1.4 → 0.1.6

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 (182) hide show
  1. package/dist/ab.d.ts +8 -95
  2. package/dist/ab.d.ts.map +1 -1
  3. package/dist/ab.js +9 -150
  4. package/dist/bin/agent-serve.js +14 -6
  5. package/dist/docs/404.html +2 -2
  6. package/dist/docs/ab.html +4 -4
  7. package/dist/docs/assets/{app.DF-Yx4OP.js → app.DMboGKUD.js} +1 -1
  8. package/dist/docs/assets/chunks/@localSearchIndexroot.O6eP7QZr.js +1 -0
  9. package/dist/docs/assets/chunks/{VPLocalSearchBox.DSeJSudL.js → VPLocalSearchBox.DkEqEnJU.js} +1 -1
  10. package/dist/docs/assets/chunks/{theme.DZVLTXOC.js → theme.ASugdMNm.js} +2 -2
  11. package/dist/docs/assets/deployment.md.BtfEsc9S.js +55 -0
  12. package/dist/docs/assets/deployment.md.BtfEsc9S.lean.js +1 -0
  13. package/dist/docs/assets/example-agents_approval-buddy.md.8R5phXb5.js +10 -0
  14. package/dist/docs/assets/example-agents_approval-buddy.md.8R5phXb5.lean.js +1 -0
  15. package/dist/docs/assets/example-agents_benny.md.B0gjhI-p.js +7 -0
  16. package/dist/docs/assets/example-agents_benny.md.B0gjhI-p.lean.js +1 -0
  17. package/dist/docs/assets/example-agents_bugbot.md.DelIdhxB.js +11 -0
  18. package/dist/docs/assets/example-agents_bugbot.md.DelIdhxB.lean.js +1 -0
  19. package/dist/docs/assets/example-agents_codebase-wiki.md.DC6sgwn0.js +8 -0
  20. package/dist/docs/assets/example-agents_codebase-wiki.md.DC6sgwn0.lean.js +1 -0
  21. package/dist/docs/assets/example-agents_codeowners-review.md.Ku_tG2RY.js +8 -0
  22. package/dist/docs/assets/example-agents_codeowners-review.md.Ku_tG2RY.lean.js +1 -0
  23. package/dist/docs/assets/example-agents_concierge.md.4rQTSMXt.js +23 -0
  24. package/dist/docs/assets/example-agents_concierge.md.4rQTSMXt.lean.js +1 -0
  25. package/dist/docs/assets/example-agents_fsd.md.CzgUrDfi.js +15 -0
  26. package/dist/docs/assets/example-agents_fsd.md.CzgUrDfi.lean.js +1 -0
  27. package/dist/docs/assets/example-agents_index.md.CRqJlnIf.js +2 -0
  28. package/dist/docs/assets/example-agents_index.md.CRqJlnIf.lean.js +1 -0
  29. package/dist/docs/assets/example-agents_knowledge-base.md.BPJiVueF.js +11 -0
  30. package/dist/docs/assets/example-agents_knowledge-base.md.BPJiVueF.lean.js +1 -0
  31. package/dist/docs/assets/example-agents_security-reviewer.md.D2rtwDTO.js +19 -0
  32. package/dist/docs/assets/example-agents_security-reviewer.md.D2rtwDTO.lean.js +1 -0
  33. package/dist/docs/assets/example-agents_slack-agent.md.buLbgvBf.js +5 -0
  34. package/dist/docs/assets/example-agents_slack-agent.md.buLbgvBf.lean.js +1 -0
  35. package/dist/docs/assets/example-agents_weather-agent.md.C9Qv-W0o.js +24 -0
  36. package/dist/docs/assets/example-agents_weather-agent.md.C9Qv-W0o.lean.js +1 -0
  37. package/dist/docs/assets/index.md.COiu-1jL.js +20 -0
  38. package/dist/docs/assets/{index.md.Cylk70gg.lean.js → index.md.COiu-1jL.lean.js} +1 -1
  39. package/dist/docs/assets/reference_cli.md.D189RBCH.js +60 -0
  40. package/dist/docs/assets/reference_cli.md.D189RBCH.lean.js +1 -0
  41. package/dist/docs/building-with-agents.html +4 -4
  42. package/dist/docs/concepts.html +4 -4
  43. package/dist/docs/deployment.html +58 -17
  44. package/dist/docs/evals.html +4 -4
  45. package/dist/docs/example-agents/approval-buddy.html +34 -0
  46. package/dist/docs/example-agents/benny.html +31 -0
  47. package/dist/docs/example-agents/bugbot.html +35 -0
  48. package/dist/docs/example-agents/codebase-wiki.html +32 -0
  49. package/dist/docs/example-agents/codeowners-review.html +32 -0
  50. package/dist/docs/example-agents/concierge.html +47 -0
  51. package/dist/docs/example-agents/fsd.html +39 -0
  52. package/dist/docs/example-agents/index.html +26 -0
  53. package/dist/docs/example-agents/knowledge-base.html +35 -0
  54. package/dist/docs/example-agents/security-reviewer.html +43 -0
  55. package/dist/docs/example-agents/slack-agent.html +29 -0
  56. package/dist/docs/example-agents/weather-agent.html +48 -0
  57. package/dist/docs/guides/agent-to-agent.html +4 -4
  58. package/dist/docs/guides/cloud-runtime.html +5 -5
  59. package/dist/docs/guides/github.html +4 -4
  60. package/dist/docs/guides/human-in-the-loop.html +4 -4
  61. package/dist/docs/guides/slack.html +4 -4
  62. package/dist/docs/guides/webhooks.html +4 -4
  63. package/dist/docs/hashmap.json +1 -1
  64. package/dist/docs/hillclimbing.html +4 -4
  65. package/dist/docs/index.html +7 -7
  66. package/dist/docs/quickstart.html +4 -4
  67. package/dist/docs/reference/agent-config.html +4 -4
  68. package/dist/docs/reference/channels.html +4 -4
  69. package/dist/docs/reference/cli.html +52 -30
  70. package/dist/docs/reference/connections.html +4 -4
  71. package/dist/docs/reference/hooks.html +4 -4
  72. package/dist/docs/reference/http-api.html +4 -4
  73. package/dist/docs/reference/instructions.html +4 -4
  74. package/dist/docs/reference/playground.html +4 -4
  75. package/dist/docs/reference/project-layout.html +4 -4
  76. package/dist/docs/reference/schedules.html +4 -4
  77. package/dist/docs/reference/sessions.html +4 -4
  78. package/dist/docs/reference/skills.html +4 -4
  79. package/dist/docs/reference/subagents.html +4 -4
  80. package/dist/docs/reference/tools.html +4 -4
  81. package/dist/docs/scaffolding-agents.html +4 -4
  82. package/dist/docs/storage.html +4 -4
  83. package/dist/docs/troubleshooting.html +4 -4
  84. package/dist/evals.d.ts +4 -61
  85. package/dist/evals.d.ts.map +1 -1
  86. package/dist/evals.js +3 -66
  87. package/dist/index.d.ts +1 -1
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/internal/ab-collector.d.ts +7 -5
  90. package/dist/internal/ab-collector.d.ts.map +1 -1
  91. package/dist/internal/ab-collector.js +3 -14
  92. package/dist/internal/ab-snapshot.d.ts +2 -4
  93. package/dist/internal/ab-snapshot.d.ts.map +1 -1
  94. package/dist/internal/cli-ax.d.ts +18 -3
  95. package/dist/internal/cli-ax.d.ts.map +1 -1
  96. package/dist/internal/cli-ax.js +80 -42
  97. package/dist/internal/cli-deploy.d.ts.map +1 -1
  98. package/dist/internal/cli-deploy.js +5 -2
  99. package/dist/internal/cursor-event-relay.d.ts +4 -1
  100. package/dist/internal/cursor-event-relay.d.ts.map +1 -1
  101. package/dist/internal/cursor-event-relay.js +6 -0
  102. package/dist/internal/cursor-relay-core.d.ts +36 -1
  103. package/dist/internal/cursor-relay-core.d.ts.map +1 -1
  104. package/dist/internal/cursor-relay-core.js +89 -8
  105. package/dist/internal/cursor-slack-relay.d.ts +2 -2
  106. package/dist/internal/cursor-slack-relay.d.ts.map +1 -1
  107. package/dist/internal/cursor-slack-relay.js +4 -0
  108. package/dist/internal/discovery.js +3 -3
  109. package/dist/internal/eval-run-store.d.ts +33 -30
  110. package/dist/internal/eval-run-store.d.ts.map +1 -1
  111. package/dist/internal/eval-run-store.js +84 -96
  112. package/dist/internal/init-project.d.ts.map +1 -1
  113. package/dist/internal/init-project.js +3 -2
  114. package/dist/internal/playground-proxy.d.ts +5 -5
  115. package/dist/internal/playground-proxy.js +3 -3
  116. package/dist/internal/resolve-prod-target.d.ts +30 -0
  117. package/dist/internal/resolve-prod-target.d.ts.map +1 -1
  118. package/dist/internal/resolve-prod-target.js +74 -2
  119. package/dist/internal/server.d.ts.map +1 -1
  120. package/dist/internal/server.js +5 -3
  121. package/dist/internal/session-engine.d.ts +1 -2
  122. package/dist/internal/session-engine.d.ts.map +1 -1
  123. package/dist/internal/session-engine.js +14 -31
  124. package/dist/internal/storage-coordinator.d.ts +16 -15
  125. package/dist/internal/storage-coordinator.d.ts.map +1 -1
  126. package/dist/internal/storage-coordinator.js +73 -80
  127. package/dist/playground/assets/cursor-icons-16-CQ50JpfO.woff2 +0 -0
  128. package/dist/playground/assets/index-7GJIUFQj.css +1 -0
  129. package/dist/playground/assets/index-mux9aj5P.js +319 -0
  130. package/dist/playground/index.html +2 -2
  131. package/dist/storage.d.ts +51 -10
  132. package/dist/storage.d.ts.map +1 -1
  133. package/dist/storage.js +27 -10
  134. package/docs/README.md +34 -5
  135. package/docs/deployment.md +352 -149
  136. package/docs/example-agents/approval-buddy.md +270 -0
  137. package/docs/example-agents/benny.md +186 -0
  138. package/docs/example-agents/bugbot.md +231 -0
  139. package/docs/example-agents/codebase-wiki.md +174 -0
  140. package/docs/example-agents/codeowners-review.md +195 -0
  141. package/docs/example-agents/concierge.md +205 -0
  142. package/docs/example-agents/fsd.md +330 -0
  143. package/docs/example-agents/index.md +102 -0
  144. package/docs/example-agents/knowledge-base.md +171 -0
  145. package/docs/example-agents/security-reviewer.md +296 -0
  146. package/docs/example-agents/slack-agent.md +146 -0
  147. package/docs/example-agents/weather-agent.md +302 -0
  148. package/docs/reference/cli.md +546 -147
  149. package/package.json +1 -1
  150. package/src/ab.ts +9 -261
  151. package/src/bin/agent-serve.ts +14 -6
  152. package/src/evals.ts +4 -118
  153. package/src/index.ts +2 -0
  154. package/src/internal/ab-collector.ts +12 -22
  155. package/src/internal/ab-snapshot.ts +2 -4
  156. package/src/internal/cli-ax.ts +101 -45
  157. package/src/internal/cli-deploy.ts +5 -2
  158. package/src/internal/cursor-event-relay.ts +10 -1
  159. package/src/internal/cursor-relay-core.ts +115 -9
  160. package/src/internal/cursor-slack-relay.ts +6 -2
  161. package/src/internal/discovery.ts +2 -2
  162. package/src/internal/eval-run-store.ts +86 -100
  163. package/src/internal/init-project.ts +3 -2
  164. package/src/internal/playground-proxy.ts +5 -5
  165. package/src/internal/resolve-prod-target.ts +101 -3
  166. package/src/internal/server.ts +5 -3
  167. package/src/internal/session-engine.ts +9 -29
  168. package/src/internal/storage-coordinator.ts +109 -101
  169. package/src/storage.ts +79 -14
  170. package/dist/docs/assets/chunks/@localSearchIndexroot.0XrheBv2.js +0 -1
  171. package/dist/docs/assets/deployment.md.DTKwE15Z.js +0 -14
  172. package/dist/docs/assets/deployment.md.DTKwE15Z.lean.js +0 -1
  173. package/dist/docs/assets/index.md.Cylk70gg.js +0 -20
  174. package/dist/docs/assets/reference_cli.md.Bv6pOxcF.js +0 -38
  175. package/dist/docs/assets/reference_cli.md.Bv6pOxcF.lean.js +0 -1
  176. package/dist/internal/json-dir-store.d.ts +0 -32
  177. package/dist/internal/json-dir-store.d.ts.map +0 -1
  178. package/dist/internal/json-dir-store.js +0 -100
  179. package/dist/playground/assets/cursor-icons-outline-BxTT_FVJ.woff2 +0 -0
  180. package/dist/playground/assets/index-Ct_Usdm8.css +0 -1
  181. package/dist/playground/assets/index-Dsyb-Vaj.js +0 -319
  182. package/src/internal/json-dir-store.ts +0 -109
@@ -35,7 +35,12 @@ import { offerInstallCursorSkills } from "./install-cursor-skills.js";
35
35
  import { runLogsTail, sanitizeCustomerError } from "./logs-client.js";
36
36
  import { openBrowser } from "./open-browser.js";
37
37
  import { startPlaygroundProxy } from "./playground-proxy.js";
38
- import { resolveProdSlug, resolveProdTarget } from "./resolve-prod-target.js";
38
+ import {
39
+ ProdTargetError,
40
+ resolveProdAliasPlaygroundUrl,
41
+ resolveProdSlug,
42
+ resolveProdTarget,
43
+ } from "./resolve-prod-target.js";
39
44
  import { runSession } from "./run-client.js";
40
45
  import {
41
46
  fetchSessionEvents,
@@ -92,8 +97,15 @@ export interface AxCliOptions {
92
97
  * Route chat/resume/run/call/eval/logs/sessions at the team's hosted
93
98
  * deployment
94
99
  * instead of a local ephemeral server. Mutually exclusive with `--url`.
100
+ * `eval` targets the deployment by default, so there `--prod` is just
101
+ * being explicit.
95
102
  */
96
103
  prod?: boolean;
104
+ /**
105
+ * `eval`: run against an ephemeral local server instead of the hosted
106
+ * deployment (the eval default). Mutually exclusive with `--url`/`--prod`.
107
+ */
108
+ local?: boolean;
97
109
  /** Cursor team id for `--prod` (defaults to the account's team). */
98
110
  team?: string;
99
111
  /** Cursor API key override for `--prod` management calls. */
@@ -654,6 +666,79 @@ export async function cmdTrajectory(options: AxCliOptions): Promise<number> {
654
666
  return trajectory.ok ? 0 : 1;
655
667
  }
656
668
 
669
+ /**
670
+ * Which surface `eval` should target. Deployment is the default; `--local`
671
+ * opts into an ephemeral local server, `--url` into a running one.
672
+ * Exported for tests.
673
+ */
674
+ export function resolveEvalTargetMode(
675
+ options: Pick<AxCliOptions, "local" | "prod" | "url">
676
+ ): "local" | "url" | "deployment" | { error: string } {
677
+ const flags = [
678
+ options.local === true ? "--local" : undefined,
679
+ options.prod === true ? "--prod" : undefined,
680
+ options.url !== undefined ? "--url" : undefined,
681
+ ].filter((flag) => flag !== undefined);
682
+ if (flags.length > 1) {
683
+ return { error: `${flags.join(" and ")} are mutually exclusive` };
684
+ }
685
+ if (options.local === true) {
686
+ return "local";
687
+ }
688
+ if (options.url !== undefined) {
689
+ return "url";
690
+ }
691
+ return "deployment";
692
+ }
693
+
694
+ /** Resolve the eval target; prints a CLI-ready error and returns an exit code on failure. */
695
+ async function resolveEvalTarget(
696
+ options: AxCliOptions
697
+ ): Promise<ResolvedAgentTarget | number> {
698
+ const mode = resolveEvalTargetMode(options);
699
+ if (typeof mode === "object") {
700
+ process.stderr.write(`${mode.error}\n`);
701
+ return 2;
702
+ }
703
+ if (mode === "url") {
704
+ return { agentUrl: options.url!.replace(/\/$/, "") };
705
+ }
706
+ if (mode === "local") {
707
+ return startEphemeral(options);
708
+ }
709
+ try {
710
+ const prod = await resolveProdTarget({
711
+ dir: options.dir,
712
+ cwd: options.cwd,
713
+ slug: options.slug,
714
+ team: options.team,
715
+ apiKey: options.apiKey,
716
+ fetchImpl: options.fetchImpl,
717
+ });
718
+ return { agentUrl: prod.agentUrl, headers: prod.headers };
719
+ } catch (error) {
720
+ if (error instanceof ProdTargetError && error.notFound) {
721
+ // Keep the API error text: a 404 can also mean a wrong API host or a
722
+ // disabled feature gate, and the message distinguishes those from a
723
+ // genuinely missing deployment.
724
+ process.stderr.write(
725
+ `${sanitizeCustomerError(error.message)}\n` +
726
+ `Evals run on the hosted deployment by default.\n` +
727
+ ` deploy it first: ${CLI_COMMAND_NAME} deploy --slug ${error.slug}\n` +
728
+ ` or run locally: ${CLI_COMMAND_NAME} eval --local --dir ${options.dir}\n`
729
+ );
730
+ return 1;
731
+ }
732
+ process.stderr.write(
733
+ `${sanitizeCustomerError(
734
+ error instanceof Error ? error.message : String(error)
735
+ )}\n` +
736
+ `Evals run on the hosted deployment by default; pass --local to run on an ephemeral local server.\n`
737
+ );
738
+ return 1;
739
+ }
740
+ }
741
+
657
742
  export async function cmdEval(options: AxCliOptions): Promise<number> {
658
743
  const projectRoot = resolve(options.cwd ?? process.cwd(), options.dir);
659
744
 
@@ -695,7 +780,10 @@ export async function cmdEval(options: AxCliOptions): Promise<number> {
695
780
  return 0;
696
781
  }
697
782
 
698
- const target = await resolveTarget(options);
783
+ const target = await resolveEvalTarget(options);
784
+ if (typeof target === "number") {
785
+ return target;
786
+ }
699
787
  const stream = shouldStreamProgress(options);
700
788
  const progress = stream ? createStreamProgress() : undefined;
701
789
  try {
@@ -991,9 +1079,9 @@ export async function cmdSession(
991
1079
  /**
992
1080
  * `playground` — open the web UI against local / `--url` / `--prod`.
993
1081
  *
994
- * Hosted engines need short-lived access headers a browser cannot set, so
995
- * `--prod` (and `--bearer-token`) run a loopback reverse proxy that injects
996
- * them. Ctrl-C stops the proxy.
1082
+ * `--prod` opens the stable hosted alias playground URL; the SPA collects
1083
+ * the alias token. `--bearer-token` (and other injected headers) still run a
1084
+ * loopback reverse proxy — Ctrl-C stops that proxy.
997
1085
  */
998
1086
  export async function cmdPlayground(options: AxCliOptions): Promise<number> {
999
1087
  if (options.prod === true && options.url !== undefined) {
@@ -1006,17 +1094,18 @@ export async function cmdPlayground(options: AxCliOptions): Promise<number> {
1006
1094
  ? options.sessionId.trim()
1007
1095
  : undefined;
1008
1096
 
1009
- // Hosted: mint engineAccess and refresh it for the life of the proxy.
1097
+ // Hosted: open the alias playground; SPA owns alias-token sign-in.
1010
1098
  if (options.prod === true) {
1011
- let initial: Awaited<ReturnType<typeof resolveProdTarget>>;
1099
+ let resolved: Awaited<ReturnType<typeof resolveProdAliasPlaygroundUrl>>;
1012
1100
  try {
1013
- initial = await resolveProdTarget({
1101
+ resolved = await resolveProdAliasPlaygroundUrl({
1014
1102
  dir: options.dir,
1015
1103
  cwd: options.cwd,
1016
1104
  slug: options.slug,
1017
1105
  team: options.team,
1018
1106
  apiKey: options.apiKey,
1019
1107
  fetchImpl: options.fetchImpl,
1108
+ sessionId,
1020
1109
  });
1021
1110
  } catch (error) {
1022
1111
  process.stderr.write(
@@ -1027,44 +1116,11 @@ export async function cmdPlayground(options: AxCliOptions): Promise<number> {
1027
1116
  return 1;
1028
1117
  }
1029
1118
 
1030
- let proxy: Awaited<ReturnType<typeof startPlaygroundProxy>>;
1031
- try {
1032
- proxy = await startPlaygroundProxy({
1033
- upstreamUrl: initial.agentUrl,
1034
- headers: initial.headers,
1035
- expiresAt: initial.expiresAt,
1036
- refreshUpstream: async () => {
1037
- const next = await resolveProdTarget({
1038
- dir: options.dir,
1039
- cwd: options.cwd,
1040
- slug: options.slug ?? initial.slug,
1041
- team: options.team,
1042
- apiKey: options.apiKey,
1043
- fetchImpl: options.fetchImpl,
1044
- err: () => {},
1045
- });
1046
- return {
1047
- upstreamUrl: next.agentUrl,
1048
- headers: next.headers,
1049
- expiresAt: next.expiresAt,
1050
- };
1051
- },
1052
- });
1053
- } catch (error) {
1054
- process.stderr.write(
1055
- `${sanitizeCustomerError(
1056
- error instanceof Error ? error.message : String(error)
1057
- )}\n`
1058
- );
1059
- return 1;
1119
+ process.stdout.write(`${resolved.playgroundUrl}\n`);
1120
+ if (options.print !== true) {
1121
+ openBrowser(resolved.playgroundUrl);
1060
1122
  }
1061
-
1062
- return await runPlaygroundProxySession({
1063
- proxy,
1064
- sessionId,
1065
- print: options.print === true,
1066
- signal: options.signal,
1067
- });
1123
+ return 0;
1068
1124
  }
1069
1125
 
1070
1126
  const target = await resolveSessionTarget(options);
@@ -674,7 +674,8 @@ async function deployOne(
674
674
  ` generation: ${detail.generation}\n` +
675
675
  ` kind: ${detail.deploymentKind}\n` +
676
676
  ` alias: ${aliasUrl}\n` +
677
- ` playground: ${CLI} playground --prod --slug ${slug} --team ${ctx.teamId}\n`
677
+ ` playground: ${aliasUrl}/playground (sign in with the alias token)\n` +
678
+ ` open: ${CLI} playground --prod --slug ${slug} --team ${ctx.teamId}\n`
678
679
  );
679
680
  return 0;
680
681
  }
@@ -795,6 +796,7 @@ export async function cmdDeploymentStatus(
795
796
  ` generation: ${detail.generation}`,
796
797
  ` kind: ${detail.deploymentKind}`,
797
798
  ` alias: ${aliasUrl}`,
799
+ ` playground: ${aliasUrl}/playground`,
798
800
  ];
799
801
  if (detail.gitRepoUrl !== null && detail.gitRepoUrl !== undefined) {
800
802
  lines.push(` repo: ${detail.gitRepoUrl} @ ${detail.gitRef ?? ""}`);
@@ -1285,7 +1287,8 @@ function aliasTokenBlock(aliasToken: string): string {
1285
1287
  return (
1286
1288
  `\n Alias token (shown ONCE — save it now, it cannot be retrieved again):\n\n` +
1287
1289
  ` ${aliasToken}\n\n` +
1288
- ` Send it as the X-Agent-Alias-Token header on requests to the alias URL.\n` +
1290
+ ` Send it as the X-Agent-Alias-Token header on requests to the alias URL,\n` +
1291
+ ` or paste it into the hosted playground (<alias>/playground) sign-in gate.\n` +
1289
1292
  ` Lost tokens: \`${CLI} rotate-token <slug>\` mints a replacement.\n\n`
1290
1293
  );
1291
1294
  }
@@ -68,7 +68,10 @@ export interface CursorEventRelayOptions {
68
68
  repos: readonly string[];
69
69
  events: readonly string[];
70
70
  stateDir: string;
71
- /** Called for each matching event; failures are logged, not retried. */
71
+ /**
72
+ * Called for each matching event; failures retry a bounded number of
73
+ * times, then log and skip.
74
+ */
72
75
  dispatch: (event: CursorScmEvent) => Promise<void>;
73
76
  logger: (line: string) => void;
74
77
  /** Test seam. Defaults to global fetch. */
@@ -213,6 +216,12 @@ export class CursorEventRelay {
213
216
  events: [...this.options.events],
214
217
  })),
215
218
  }),
219
+ // Empty subscriptions drop this consumer's registration on clean stop
220
+ // instead of leaving it to lapse by TTL.
221
+ buildDeregistrationBody: (consumerId) => ({
222
+ consumerId,
223
+ subscriptions: [],
224
+ }),
216
225
  applyRegistrationResponse: (json) => {
217
226
  const result = json as { repos?: SubscriptionStatus[] };
218
227
  const statuses = result.repos ?? [];
@@ -23,6 +23,11 @@ const DEFAULT_IDLE_POLL_DELAY_MS = 250;
23
23
  const MIN_BACKOFF_MS = 1_000;
24
24
  const MAX_BACKOFF_MS = 30_000;
25
25
  const AUTH_FAILURE_BACKOFF_MS = 60_000;
26
+ /** Attempts per event before the offset advances past it. */
27
+ const DISPATCH_ATTEMPTS = 3;
28
+ const DISPATCH_RETRY_DELAYS_MS = [250, 1_000];
29
+ /** Budget for the best-effort deregistration PUT during stop(). */
30
+ const DEREGISTER_TIMEOUT_MS = 1_500;
26
31
 
27
32
  interface OffsetSnapshot {
28
33
  version: typeof OFFSET_SNAPSHOT_VERSION;
@@ -108,6 +113,14 @@ export interface RelayAdapter<TEvent extends { offset: string }> {
108
113
  readonly pollPath: string;
109
114
  /** The registration request body for this consumer id. */
110
115
  buildRegistrationBody(consumerId: string): unknown;
116
+ /**
117
+ * The empty-scope registration body used to deregister this consumer on a
118
+ * clean stop. When present, `stop()` best-effort PUTs it so the consumer's
119
+ * registration does not linger as a ghost until its server-side TTL (a
120
+ * ghost with this host's agent names makes name routing ambiguous for a
121
+ * redeployed sibling until it expires).
122
+ */
123
+ buildDeregistrationBody?(consumerId: string): unknown;
111
124
  /**
112
125
  * Apply the (ok) registration response: update the accepted-scope filter
113
126
  * and return the human-readable outcome lines to log. The core logs them
@@ -121,7 +134,10 @@ export interface RelayAdapter<TEvent extends { offset: string }> {
121
134
  * the offset but are not dispatched.
122
135
  */
123
136
  shouldDispatch(event: TEvent): boolean;
124
- /** Handle one matching event. Failures are logged, not retried. */
137
+ /**
138
+ * Handle one matching event. Failures retry a bounded number of times
139
+ * ({@link DISPATCH_ATTEMPTS}), then log and skip.
140
+ */
125
141
  dispatch(event: TEvent): Promise<void>;
126
142
  }
127
143
 
@@ -147,6 +163,14 @@ export class CursorRelayCore<TEvent extends { offset: string }> {
147
163
  private consumerId = "";
148
164
  /** undefined until the first registration, so a clean first success logs. */
149
165
  private lastRegistrationLog: string | undefined;
166
+ /** Whether any registration succeeded (gates the stop-time deregister). */
167
+ private registeredOnce = false;
168
+ /**
169
+ * The in-flight heartbeat re-registration, if any. `stop()` awaits it
170
+ * before deregistering so a just-sent heartbeat cannot land after the
171
+ * deregistration PUT and resurrect the registration for a full TTL.
172
+ */
173
+ private heartbeatPromise: Promise<boolean> | undefined;
150
174
 
151
175
  constructor(private readonly options: CursorRelayCoreOptions<TEvent>) {
152
176
  this.offsetStore = new RelayOffsetStore(options.stateDir);
@@ -170,7 +194,7 @@ export class CursorRelayCore<TEvent extends { offset: string }> {
170
194
  const registered = await this.registerUntilSuccess();
171
195
  if (!registered) return;
172
196
  this.registerTimer = setInterval(() => {
173
- void this.registerOnce();
197
+ this.heartbeatPromise = this.registerOnce();
174
198
  }, DEFAULT_REGISTER_INTERVAL_MS);
175
199
  this.registerTimer.unref?.();
176
200
  await this.consumeLoop();
@@ -229,6 +253,56 @@ export class CursorRelayCore<TEvent extends { offset: string }> {
229
253
  timer.unref?.();
230
254
  }),
231
255
  ]);
256
+ // A heartbeat re-registration may be in flight (its fetch was just
257
+ // aborted); let it settle so it cannot land after the deregistration.
258
+ await this.heartbeatPromise?.catch(() => {});
259
+ await this.deregister();
260
+ }
261
+
262
+ /**
263
+ * Best-effort empty-scope PUT on clean stop, so this consumer's
264
+ * registration is dropped immediately instead of lingering until its
265
+ * server-side TTL. A ghost registration keeps this host's agent names
266
+ * live and makes name routing ambiguous for a redeployed sibling with a
267
+ * fresh consumer id. Failures only log — the TTL still cleans up.
268
+ */
269
+ private async deregister(): Promise<void> {
270
+ const adapter = this.options.adapter;
271
+ if (
272
+ adapter.buildDeregistrationBody === undefined ||
273
+ !this.registeredOnce ||
274
+ this.consumerId === ""
275
+ ) {
276
+ return;
277
+ }
278
+ const url = `${this.options.baseUrl.replace(/\/+$/, "")}${adapter.subscribePath}`;
279
+ const controller = new AbortController();
280
+ const timer = setTimeout(() => controller.abort(), DEREGISTER_TIMEOUT_MS);
281
+ timer.unref?.();
282
+ try {
283
+ const response = await this.fetchImpl(url, {
284
+ method: "PUT",
285
+ headers: {
286
+ authorization: `Bearer ${this.options.apiKey}`,
287
+ "content-type": "application/json",
288
+ },
289
+ body: JSON.stringify(adapter.buildDeregistrationBody(this.consumerId)),
290
+ signal: controller.signal,
291
+ });
292
+ if (response.ok) {
293
+ this.log("deregistered on shutdown");
294
+ } else {
295
+ this.log(
296
+ `deregistration failed: ${response.status} (registration lapses by TTL)`
297
+ );
298
+ }
299
+ } catch (error) {
300
+ this.log(
301
+ `deregistration failed: ${describeRelayError(error)} (registration lapses by TTL)`
302
+ );
303
+ } finally {
304
+ clearTimeout(timer);
305
+ }
232
306
  }
233
307
 
234
308
  private log(line: string): void {
@@ -291,6 +365,7 @@ export class CursorRelayCore<TEvent extends { offset: string }> {
291
365
  this.logRegistrationOutcome(
292
366
  this.options.adapter.applyRegistrationResponse(json)
293
367
  );
368
+ this.registeredOnce = true;
294
369
  return true;
295
370
  } catch (error) {
296
371
  if (this.isAbort(error)) return false;
@@ -373,13 +448,7 @@ export class CursorRelayCore<TEvent extends { offset: string }> {
373
448
  // on replay after a crash between dispatch and save).
374
449
  if (compareStreamOffsets(event.offset, offset) <= 0) continue;
375
450
  if (this.options.adapter.shouldDispatch(event)) {
376
- try {
377
- await this.options.adapter.dispatch(event);
378
- } catch (error) {
379
- this.log(
380
- `dispatch failed at offset ${event.offset}: ${describeRelayError(error)}`
381
- );
382
- }
451
+ await this.dispatchWithRetry(event);
383
452
  }
384
453
  offset = event.offset;
385
454
  }
@@ -403,6 +472,43 @@ export class CursorRelayCore<TEvent extends { offset: string }> {
403
472
  }
404
473
  }
405
474
 
475
+ /**
476
+ * Dispatch one event with a small bounded retry before the offset advances
477
+ * past it. Transient local failures (e.g. a connection reset while POSTing
478
+ * to the channel) used to drop the event outright; a poison event still
479
+ * only costs {@link DISPATCH_ATTEMPTS} tries and is then skipped, keeping
480
+ * at-least-once semantics without a retry loop.
481
+ */
482
+ private async dispatchWithRetry(event: TEvent): Promise<void> {
483
+ for (let attempt = 1; attempt <= DISPATCH_ATTEMPTS; attempt++) {
484
+ if (attempt > 1 && !this.running) {
485
+ this.log(
486
+ `dispatch abandoned at offset ${event.offset}: relay stopping`
487
+ );
488
+ return;
489
+ }
490
+ try {
491
+ await this.options.adapter.dispatch(event);
492
+ return;
493
+ } catch (error) {
494
+ if (attempt === DISPATCH_ATTEMPTS) {
495
+ this.log(
496
+ `dispatch failed at offset ${event.offset} after ${attempt} attempts: ${describeRelayError(error)}`
497
+ );
498
+ return;
499
+ }
500
+ this.log(
501
+ `dispatch attempt ${attempt} failed at offset ${event.offset}: ${describeRelayError(error)}; retrying`
502
+ );
503
+ await this.sleep(
504
+ DISPATCH_RETRY_DELAYS_MS[
505
+ Math.min(attempt - 1, DISPATCH_RETRY_DELAYS_MS.length - 1)
506
+ ]
507
+ );
508
+ }
509
+ }
510
+ }
511
+
406
512
  /** Log the failure, sleep, and return the backoff for the NEXT failure. */
407
513
  private async backoffAfterHttpFailure(
408
514
  response: Response,
@@ -44,8 +44,8 @@ export interface CursorSlackRelayOptions {
44
44
  agents: readonly CursorSlackAgentRegistration[];
45
45
  stateDir: string;
46
46
  /**
47
- * Called for each event addressed to this consumer's agents; failures are
48
- * logged, not retried.
47
+ * Called for each event addressed to this consumer's agents; failures
48
+ * retry a bounded number of times, then log and skip.
49
49
  */
50
50
  dispatch: (envelope: CursorSlackRelayEvent) => Promise<void>;
51
51
  logger: (line: string) => void;
@@ -163,6 +163,10 @@ export class CursorSlackRelay {
163
163
  })),
164
164
  };
165
165
  },
166
+ // Empty agents drop this consumer's registration on clean stop, so a
167
+ // redeployed sibling's identical agent names never tie with this
168
+ // host's ghost until the server-side TTL lapses.
169
+ buildDeregistrationBody: (consumerId) => ({ consumerId, agents: [] }),
166
170
  applyRegistrationResponse: (json) => {
167
171
  const result = json as RegistrationResponse;
168
172
  const agentStatuses = result.agents ?? [];
@@ -1611,8 +1611,8 @@ export function projectInfo(project: AgentProject): AgentProjectInfo {
1611
1611
  maxPlaygroundSessions: resolveABMaxPlaygroundSessions(
1612
1612
  project.abConfig?.maxPlaygroundSessions
1613
1613
  ),
1614
- durableSamples: project.abConfig?.persistSamples !== undefined,
1615
- durableSnapshots: project.abConfig?.persistSnapshots !== undefined,
1614
+ durableSamples: project.storage?.abs !== undefined,
1615
+ durableSnapshots: project.storage?.abs?.putSnapshot !== undefined,
1616
1616
  },
1617
1617
  }),
1618
1618
  storage: