@cursor/july 0.1.4 → 0.1.5

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 (56) hide show
  1. package/dist/docs/404.html +1 -1
  2. package/dist/docs/ab.html +2 -2
  3. package/dist/docs/assets/{app.DF-Yx4OP.js → app.DabPG-io.js} +1 -1
  4. package/dist/docs/assets/chunks/@localSearchIndexroot.QwK5BtEH.js +1 -0
  5. package/dist/docs/assets/chunks/{VPLocalSearchBox.DSeJSudL.js → VPLocalSearchBox.jmyr0bU0.js} +1 -1
  6. package/dist/docs/assets/chunks/{theme.DZVLTXOC.js → theme.DysN9-VN.js} +2 -2
  7. package/dist/docs/building-with-agents.html +2 -2
  8. package/dist/docs/concepts.html +2 -2
  9. package/dist/docs/deployment.html +2 -2
  10. package/dist/docs/evals.html +2 -2
  11. package/dist/docs/guides/agent-to-agent.html +2 -2
  12. package/dist/docs/guides/cloud-runtime.html +2 -2
  13. package/dist/docs/guides/github.html +2 -2
  14. package/dist/docs/guides/human-in-the-loop.html +2 -2
  15. package/dist/docs/guides/slack.html +2 -2
  16. package/dist/docs/guides/webhooks.html +2 -2
  17. package/dist/docs/hillclimbing.html +2 -2
  18. package/dist/docs/index.html +2 -2
  19. package/dist/docs/quickstart.html +2 -2
  20. package/dist/docs/reference/agent-config.html +2 -2
  21. package/dist/docs/reference/channels.html +2 -2
  22. package/dist/docs/reference/cli.html +2 -2
  23. package/dist/docs/reference/connections.html +2 -2
  24. package/dist/docs/reference/hooks.html +2 -2
  25. package/dist/docs/reference/http-api.html +2 -2
  26. package/dist/docs/reference/instructions.html +2 -2
  27. package/dist/docs/reference/playground.html +2 -2
  28. package/dist/docs/reference/project-layout.html +2 -2
  29. package/dist/docs/reference/schedules.html +2 -2
  30. package/dist/docs/reference/sessions.html +2 -2
  31. package/dist/docs/reference/skills.html +2 -2
  32. package/dist/docs/reference/subagents.html +2 -2
  33. package/dist/docs/reference/tools.html +2 -2
  34. package/dist/docs/scaffolding-agents.html +2 -2
  35. package/dist/docs/storage.html +2 -2
  36. package/dist/docs/troubleshooting.html +2 -2
  37. package/dist/internal/cli-deploy.d.ts.map +1 -1
  38. package/dist/internal/cli-deploy.js +5 -2
  39. package/dist/internal/cursor-event-relay.d.ts +4 -1
  40. package/dist/internal/cursor-event-relay.d.ts.map +1 -1
  41. package/dist/internal/cursor-event-relay.js +6 -0
  42. package/dist/internal/cursor-relay-core.d.ts +36 -1
  43. package/dist/internal/cursor-relay-core.d.ts.map +1 -1
  44. package/dist/internal/cursor-relay-core.js +89 -8
  45. package/dist/internal/cursor-slack-relay.d.ts +2 -2
  46. package/dist/internal/cursor-slack-relay.d.ts.map +1 -1
  47. package/dist/internal/cursor-slack-relay.js +4 -0
  48. package/dist/playground/assets/{index-Ct_Usdm8.css → index-BEauYlII.css} +1 -1
  49. package/dist/playground/assets/{index-Dsyb-Vaj.js → index-BtM0wEGg.js} +94 -94
  50. package/dist/playground/index.html +2 -2
  51. package/package.json +1 -1
  52. package/src/internal/cli-deploy.ts +5 -2
  53. package/src/internal/cursor-event-relay.ts +10 -1
  54. package/src/internal/cursor-relay-core.ts +115 -9
  55. package/src/internal/cursor-slack-relay.ts +6 -2
  56. package/dist/docs/assets/chunks/@localSearchIndexroot.0XrheBv2.js +0 -1
@@ -8,8 +8,8 @@
8
8
  />
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10
10
  <title>agent-serve playground</title>
11
- <script type="module" crossorigin src="./assets/index-Dsyb-Vaj.js"></script>
12
- <link rel="stylesheet" crossorigin href="./assets/index-Ct_Usdm8.css">
11
+ <script type="module" crossorigin src="./assets/index-BtM0wEGg.js"></script>
12
+ <link rel="stylesheet" crossorigin href="./assets/index-BEauYlII.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cursor/july",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
@@ -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
+ ` or: ${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 ?? [];