@cursor/july 0.1.17 → 0.1.19

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 (143) hide show
  1. package/dist/bin/agent-serve.js +20 -3
  2. package/dist/channels/slack/cursor-account.d.ts.map +1 -1
  3. package/dist/channels/slack/cursor-account.js +1 -1
  4. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  5. package/dist/channels/slack/dispatch.js +4 -1
  6. package/dist/channels/slack/inbound.d.ts.map +1 -1
  7. package/dist/channels/slack/inbound.js +6 -1
  8. package/dist/channels/slack/index.d.ts +1 -0
  9. package/dist/channels/slack/index.d.ts.map +1 -1
  10. package/dist/channels/slack/index.js +1 -0
  11. package/dist/channels/slack/init.d.ts.map +1 -1
  12. package/dist/channels/slack/init.js +4 -1
  13. package/dist/channels/slack/interactive.d.ts.map +1 -1
  14. package/dist/channels/slack/interactive.js +1 -0
  15. package/dist/channels/slack/manifest.d.ts +10 -0
  16. package/dist/channels/slack/manifest.d.ts.map +1 -1
  17. package/dist/channels/slack/manifest.js +33 -14
  18. package/dist/channels/slack/message-body.d.ts +13 -0
  19. package/dist/channels/slack/message-body.d.ts.map +1 -0
  20. package/dist/channels/slack/message-body.js +82 -0
  21. package/dist/channels/slack/thread-context.d.ts +0 -2
  22. package/dist/channels/slack/thread-context.d.ts.map +1 -1
  23. package/dist/channels/slack/thread-context.js +1 -65
  24. package/dist/channels/slack/types.d.ts +16 -1
  25. package/dist/channels/slack/types.d.ts.map +1 -1
  26. package/dist/docs/404.html +2 -2
  27. package/dist/docs/ab.html +3 -3
  28. package/dist/docs/assets/{app.BbEXr2KO.js → app.CrsWMchO.js} +1 -1
  29. package/dist/docs/assets/chunks/@localSearchIndexroot.BnHRjfoe.js +1 -0
  30. package/dist/docs/assets/chunks/{VPLocalSearchBox.DBEi4HDp.js → VPLocalSearchBox.D1JqzSh8.js} +1 -1
  31. package/dist/docs/assets/chunks/{theme.DGfptcHw.js → theme.DaBvZYwl.js} +2 -2
  32. package/dist/docs/assets/{example-agents_security-reviewer.md.BZSNycMf.js → example-agents_security-reviewer.md.Dhj_m7_B.js} +1 -1
  33. package/dist/docs/building-with-agents.html +3 -3
  34. package/dist/docs/concepts.html +3 -3
  35. package/dist/docs/deployment.html +3 -3
  36. package/dist/docs/evals.html +3 -3
  37. package/dist/docs/example-agents/approval-buddy.html +3 -3
  38. package/dist/docs/example-agents/benny.html +3 -3
  39. package/dist/docs/example-agents/bugbot.html +3 -3
  40. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  41. package/dist/docs/example-agents/codeowners-review.html +3 -3
  42. package/dist/docs/example-agents/concierge.html +3 -3
  43. package/dist/docs/example-agents/fsd.html +3 -3
  44. package/dist/docs/example-agents/index.html +3 -3
  45. package/dist/docs/example-agents/knowledge-base.html +3 -3
  46. package/dist/docs/example-agents/oncall.html +3 -3
  47. package/dist/docs/example-agents/security-reviewer.html +5 -5
  48. package/dist/docs/example-agents/slack-agent.html +3 -3
  49. package/dist/docs/example-agents/weather-agent.html +3 -3
  50. package/dist/docs/guides/agent-to-agent.html +3 -3
  51. package/dist/docs/guides/cloud-runtime.html +3 -3
  52. package/dist/docs/guides/github.html +3 -3
  53. package/dist/docs/guides/human-in-the-loop.html +3 -3
  54. package/dist/docs/guides/mcp-oauth.html +3 -3
  55. package/dist/docs/guides/slack.html +3 -3
  56. package/dist/docs/guides/webhooks.html +3 -3
  57. package/dist/docs/hashmap.json +1 -1
  58. package/dist/docs/hillclimbing.html +3 -3
  59. package/dist/docs/index.html +3 -3
  60. package/dist/docs/quickstart.html +3 -3
  61. package/dist/docs/reference/agent-config.html +3 -3
  62. package/dist/docs/reference/channels.html +3 -3
  63. package/dist/docs/reference/cli.html +3 -3
  64. package/dist/docs/reference/connections.html +3 -3
  65. package/dist/docs/reference/hooks.html +3 -3
  66. package/dist/docs/reference/http-api.html +3 -3
  67. package/dist/docs/reference/instructions.html +3 -3
  68. package/dist/docs/reference/playground.html +3 -3
  69. package/dist/docs/reference/project-layout.html +3 -3
  70. package/dist/docs/reference/prompt.html +3 -3
  71. package/dist/docs/reference/schedules.html +3 -3
  72. package/dist/docs/reference/sessions.html +3 -3
  73. package/dist/docs/reference/skills.html +3 -3
  74. package/dist/docs/reference/subagents.html +3 -3
  75. package/dist/docs/reference/tools.html +3 -3
  76. package/dist/docs/scaffolding-agents.html +3 -3
  77. package/dist/docs/storage.html +3 -3
  78. package/dist/docs/troubleshooting.html +3 -3
  79. package/dist/internal/builtin-tools/index.d.ts +10 -0
  80. package/dist/internal/builtin-tools/index.d.ts.map +1 -0
  81. package/dist/internal/builtin-tools/index.js +27 -0
  82. package/dist/internal/builtin-tools/reminders.d.ts +9 -0
  83. package/dist/internal/builtin-tools/reminders.d.ts.map +1 -0
  84. package/dist/internal/builtin-tools/reminders.js +138 -0
  85. package/dist/internal/cli-skills.d.ts +19 -0
  86. package/dist/internal/cli-skills.d.ts.map +1 -0
  87. package/dist/internal/cli-skills.js +72 -0
  88. package/dist/internal/cli-slack.d.ts.map +1 -1
  89. package/dist/internal/cli-slack.js +36 -15
  90. package/dist/internal/cursor-event-relay.d.ts +30 -0
  91. package/dist/internal/cursor-event-relay.d.ts.map +1 -1
  92. package/dist/internal/cursor-event-relay.js +37 -0
  93. package/dist/internal/discovery.d.ts.map +1 -1
  94. package/dist/internal/discovery.js +41 -0
  95. package/dist/internal/install-cursor-skills.d.ts +46 -4
  96. package/dist/internal/install-cursor-skills.d.ts.map +1 -1
  97. package/dist/internal/install-cursor-skills.js +117 -49
  98. package/dist/internal/reminder-runner.d.ts +7 -0
  99. package/dist/internal/reminder-runner.d.ts.map +1 -1
  100. package/dist/internal/reminder-runner.js +21 -0
  101. package/dist/internal/sdk-runner.d.ts +15 -0
  102. package/dist/internal/sdk-runner.d.ts.map +1 -1
  103. package/dist/internal/sdk-runner.js +113 -13
  104. package/dist/internal/server.d.ts.map +1 -1
  105. package/dist/internal/server.js +1 -0
  106. package/dist/internal/session-engine.d.ts.map +1 -1
  107. package/dist/internal/session-engine.js +9 -0
  108. package/dist/internal/slack-provision-client.d.ts +6 -3
  109. package/dist/internal/slack-provision-client.d.ts.map +1 -1
  110. package/dist/internal/slack-provision-client.js +5 -3
  111. package/dist/playground/assets/{index-DNqirsrK.js → index-C2SU2xV5.js} +43 -43
  112. package/dist/playground/index.html +1 -1
  113. package/dist/types.d.ts +28 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/docs/example-agents/security-reviewer.md +1 -1
  116. package/package.json +1 -1
  117. package/skills/setup-slack/SKILL.md +4 -0
  118. package/src/bin/agent-serve.ts +21 -3
  119. package/src/channels/slack/cursor-account.ts +1 -0
  120. package/src/channels/slack/dispatch.ts +3 -1
  121. package/src/channels/slack/inbound.ts +6 -1
  122. package/src/channels/slack/index.ts +1 -0
  123. package/src/channels/slack/init.ts +3 -0
  124. package/src/channels/slack/interactive.ts +1 -0
  125. package/src/channels/slack/manifest.ts +34 -6
  126. package/src/channels/slack/message-body.ts +90 -0
  127. package/src/channels/slack/thread-context.ts +1 -69
  128. package/src/channels/slack/types.ts +16 -1
  129. package/src/internal/builtin-tools/index.ts +40 -0
  130. package/src/internal/builtin-tools/reminders.ts +158 -0
  131. package/src/internal/cli-skills.ts +95 -0
  132. package/src/internal/cli-slack.ts +38 -14
  133. package/src/internal/cursor-event-relay.ts +67 -0
  134. package/src/internal/discovery.ts +52 -0
  135. package/src/internal/install-cursor-skills.ts +168 -60
  136. package/src/internal/reminder-runner.ts +30 -0
  137. package/src/internal/sdk-runner.ts +131 -13
  138. package/src/internal/server.ts +2 -0
  139. package/src/internal/session-engine.ts +14 -0
  140. package/src/internal/slack-provision-client.ts +7 -3
  141. package/src/types.ts +33 -0
  142. package/dist/docs/assets/chunks/@localSearchIndexroot.DCBsrfwP.js +0 -1
  143. /package/dist/docs/assets/{example-agents_security-reviewer.md.BZSNycMf.lean.js → example-agents_security-reviewer.md.Dhj_m7_B.lean.js} +0 -0
@@ -18,7 +18,7 @@ import {
18
18
  } from "node:fs/promises";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
- import { packageRootDir } from "./distribution.js";
21
+ import { CLI_COMMAND_NAME, packageRootDir } from "./distribution.js";
22
22
  import { stdoutPalette } from "./terminal-style.js";
23
23
 
24
24
  /** Monorepo-only skill; excluded from the published package. */
@@ -27,6 +27,9 @@ const SKIP_SKILL_DIRS = new Set(["operate"]);
27
27
  /** Category folder under ~/.cursor/skills (Cursor discovers nested skills). */
28
28
  export const CURSOR_SKILLS_NAMESPACE = "agentkit";
29
29
 
30
+ /** How long the `init` offer waits for an answer before skipping the install. */
31
+ export const SKILL_PROMPT_TIMEOUT_MS = 45_000;
32
+
30
33
  export interface PackageSkill {
31
34
  /** Frontmatter `name` (e.g. agentkit-create-agent). */
32
35
  name: string;
@@ -115,6 +118,13 @@ export async function findMissingCursorSkills(options?: {
115
118
  return missing;
116
119
  }
117
120
 
121
+ export interface SkillInstallResult {
122
+ /** Leaf dirs written. */
123
+ installed: string[];
124
+ /** Leaf dirs deleted because the package no longer ships them. */
125
+ removed: string[];
126
+ }
127
+
118
128
  /**
119
129
  * Copy each skill directory into `skillsHome/agentkit/<leafDir>/`.
120
130
  * Always overwrites so a package upgrade refreshes local Cursor skills.
@@ -123,24 +133,13 @@ export async function findMissingCursorSkills(options?: {
123
133
  export async function installCursorSkills(
124
134
  skills: readonly PackageSkill[],
125
135
  skillsHome: string = cursorSkillsHome()
126
- ): Promise<string[]> {
136
+ ): Promise<SkillInstallResult> {
127
137
  const namespaceDir = agentkitCursorSkillsDir(skillsHome);
128
138
  await mkdir(namespaceDir, { recursive: true });
129
139
 
130
- const shippedLeaves = new Set(skills.map((skill) => skill.leafDir));
131
- let existingLeaves: string[] = [];
132
- try {
133
- const entries = await readdir(namespaceDir, { withFileTypes: true });
134
- existingLeaves = entries
135
- .filter((entry) => entry.isDirectory())
136
- .map((entry) => entry.name);
137
- } catch {
138
- existingLeaves = [];
139
- }
140
- for (const leaf of existingLeaves) {
141
- if (!shippedLeaves.has(leaf)) {
142
- await rm(join(namespaceDir, leaf), { recursive: true, force: true });
143
- }
140
+ const removed = await orphanLeaves(namespaceDir, skills);
141
+ for (const leaf of removed) {
142
+ await rm(join(namespaceDir, leaf), { recursive: true, force: true });
144
143
  }
145
144
 
146
145
  const installed: string[] = [];
@@ -155,7 +154,24 @@ export async function installCursorSkills(
155
154
  }
156
155
  installed.push(skill.leafDir);
157
156
  }
158
- return installed;
157
+ return { installed, removed };
158
+ }
159
+
160
+ /** Installed leaf dirs the package no longer ships. Installing deletes these. */
161
+ async function orphanLeaves(
162
+ namespaceDir: string,
163
+ skills: readonly PackageSkill[]
164
+ ): Promise<string[]> {
165
+ const shipped = new Set(skills.map((skill) => skill.leafDir));
166
+ let entries: Array<{ name: string; isDirectory: () => boolean }>;
167
+ try {
168
+ entries = await readdir(namespaceDir, { withFileTypes: true });
169
+ } catch {
170
+ return [];
171
+ }
172
+ return entries
173
+ .filter((entry) => entry.isDirectory() && !shipped.has(entry.name))
174
+ .map((entry) => entry.name);
159
175
  }
160
176
 
161
177
  /** Drop monorepo `paths` globs and fix package doc links for ~/.cursor installs. */
@@ -191,20 +207,72 @@ function stripPathsFromFrontmatter(frontmatter: string): string {
191
207
  return out.join("\n");
192
208
  }
193
209
 
210
+ export interface SkillInstallPlan {
211
+ shipped: PackageSkill[];
212
+ /** Resolved `~/.cursor/skills`, to install into. */
213
+ skillsHome: string;
214
+ /** Install target: `<skillsHome>/agentkit`. */
215
+ directory: string;
216
+ /** Leaf dirs the package ships. */
217
+ leaves: string[];
218
+ /** Nothing installed yet, so this is a fresh install rather than a refresh. */
219
+ firstInstall: boolean;
220
+ /** Installed leaf dirs the package dropped; installing deletes them. */
221
+ orphans: string[];
222
+ }
223
+
224
+ /** What an install would do, for the offer and for `install-skills`. */
225
+ export async function planSkillInstall(options?: {
226
+ packageRoot?: string;
227
+ skillsHome?: string;
228
+ }): Promise<SkillInstallPlan> {
229
+ const skillsHome = options?.skillsHome ?? cursorSkillsHome();
230
+ const directory = agentkitCursorSkillsDir(skillsHome);
231
+ const shipped = await listShippedPackageSkills(options?.packageRoot);
232
+ const missing = await findMissingCursorSkills({
233
+ packageRoot: options?.packageRoot,
234
+ skillsHome,
235
+ });
236
+ return {
237
+ shipped,
238
+ skillsHome,
239
+ directory,
240
+ leaves: shipped.map((skill) => skill.leafDir),
241
+ firstInstall: missing.length === shipped.length,
242
+ orphans: await orphanLeaves(directory, shipped),
243
+ };
244
+ }
245
+
246
+ /** `Installed 9 agentkit skills`, what it dropped, and where it went. */
247
+ export function formatSkillInstall(
248
+ plan: SkillInstallPlan,
249
+ result: SkillInstallResult
250
+ ): string {
251
+ const noun = result.installed.length === 1 ? "skill" : "skills";
252
+ const verb = plan.firstInstall ? "Installed" : "Updated";
253
+ const lines = [`${verb} ${result.installed.length} agentkit ${noun}`];
254
+ if (result.removed.length > 0) {
255
+ lines.push(` removed: ${result.removed.join(", ")}`);
256
+ }
257
+ lines.push(` location: ${plan.directory}`);
258
+ return `${lines.join("\n")}\n`;
259
+ }
260
+
194
261
  export interface OfferInstallCursorSkillsOptions {
195
262
  /** Skip when false (non-TTY / `--json`). */
196
263
  interactive?: boolean;
197
264
  packageRoot?: string;
198
265
  skillsHome?: string;
199
- /** Injected yes/no for tests. */
200
- confirm?: (question: string) => Promise<boolean>;
266
+ /** Injected yes/no for tests. `undefined` means the prompt went unanswered. */
267
+ confirm?: (question: string) => Promise<boolean | undefined>;
201
268
  out?: (text: string) => void;
202
269
  }
203
270
 
204
271
  /**
205
272
  * On a TTY, offer to install/update every shipped package skill into
206
273
  * ~/.cursor/skills/agentkit (always overwriting). No-op when non-interactive
207
- * or the package ships no skills.
274
+ * or the package ships no skills. An unanswered prompt skips the install
275
+ * rather than blocking the caller — see `SKILL_PROMPT_TIMEOUT_MS`.
208
276
  */
209
277
  export async function offerInstallCursorSkills(
210
278
  options: OfferInstallCursorSkillsOptions = {}
@@ -213,48 +281,60 @@ export async function offerInstallCursorSkills(
213
281
  const interactive =
214
282
  options.interactive ??
215
283
  (process.stdin.isTTY === true && process.stdout.isTTY === true);
216
- const shipped = await listShippedPackageSkills(options.packageRoot);
217
- if (shipped.length === 0) {
284
+ if (!interactive) {
218
285
  return { installed: [], skipped: true };
219
286
  }
220
287
 
221
- const home = options.skillsHome ?? cursorSkillsHome();
222
- const targetDir = agentkitCursorSkillsDir(home);
223
- const missing = await findMissingCursorSkills({
288
+ const plan = await planSkillInstall({
224
289
  packageRoot: options.packageRoot,
225
- skillsHome: home,
290
+ skillsHome: options.skillsHome,
226
291
  });
227
- const names = shipped.map((skill) => skill.leafDir).join(", ");
228
- const noun = shipped.length === 1 ? "skill" : "skills";
229
- const verb = missing.length === shipped.length ? "Install" : "Update";
230
- const question = `${verb} ${shipped.length} agentkit ${noun} in ${targetDir}?`;
231
-
232
- if (!interactive) {
292
+ if (plan.shipped.length === 0) {
233
293
  return { installed: [], skipped: true };
234
294
  }
235
295
 
296
+ const noun = plan.shipped.length === 1 ? "skill" : "skills";
297
+ const verb = plan.firstInstall ? "Install" : "Update";
236
298
  const c = stdoutPalette();
237
- out(`${question}\n`);
238
- out(c.dim(` ${names}\n`));
239
- if (missing.length < shipped.length) {
299
+ out(
300
+ `${verb} ${plan.shipped.length} agentkit ${noun} in ${plan.directory}?\n`
301
+ );
302
+ out(c.dim(` ${plan.leaves.join(", ")}\n`));
303
+ if (!plan.firstInstall) {
240
304
  out(c.dim(" (overwrites existing copies with the package version)\n"));
241
305
  }
306
+ if (plan.orphans.length > 0) {
307
+ out(c.dim(` (deletes ${plan.orphans.join(", ")})\n`));
308
+ }
242
309
 
243
310
  const confirm =
244
- options.confirm ?? ((q) => promptYesNo(q, { defaultYes: true, out }));
245
- const accepted = await confirm(
246
- missing.length === shipped.length ? "Install now?" : "Update now?"
247
- );
248
- if (!accepted) {
249
- out(c.dim("Skipped skill install.\n\n"));
311
+ options.confirm ??
312
+ ((q) =>
313
+ promptYesNo(q, {
314
+ defaultYes: true,
315
+ out,
316
+ timeoutMs: SKILL_PROMPT_TIMEOUT_MS,
317
+ }));
318
+ const accepted = await confirm(`${verb} now?`);
319
+
320
+ if (accepted !== true) {
321
+ // `undefined` is the prompt timing out or stdin closing; both mean nobody
322
+ // said yes, so do not claim a duration neither case is guaranteed to have.
323
+ const why =
324
+ accepted === undefined
325
+ ? "No answer — skipped the skill install."
326
+ : "Skipped skill install.";
327
+ out(
328
+ c.dim(
329
+ `${why}\nInstall later: ${CLI_COMMAND_NAME} install-skills (${plan.directory})\n\n`
330
+ )
331
+ );
250
332
  return { installed: [], skipped: true };
251
333
  }
252
334
 
253
- const installed = await installCursorSkills(shipped, home);
254
- out(
255
- `${verb === "Install" ? "Installed" : "Updated"} ${installed.length} ${noun} in ${targetDir}\n\n`
256
- );
257
- return { installed, skipped: false };
335
+ const result = await installCursorSkills(plan.shipped, plan.skillsHome);
336
+ out(`${formatSkillInstall(plan, result)}\n`);
337
+ return { installed: result.installed, skipped: false };
258
338
  }
259
339
 
260
340
  function parseSkillName(skillMd: string): string | undefined {
@@ -274,19 +354,34 @@ function parseSkillName(skillMd: string): string | undefined {
274
354
  return raw.replace(/^["']|["']$/g, "");
275
355
  }
276
356
 
277
- async function promptYesNo(
357
+ export interface PromptYesNoOptions {
358
+ defaultYes: boolean;
359
+ out: (text: string) => void;
360
+ /** Give up after this long; `0` (or omitted) waits forever. */
361
+ timeoutMs?: number;
362
+ /** Answer source; defaults to `process.stdin`. */
363
+ input?: NodeJS.ReadableStream;
364
+ }
365
+
366
+ /**
367
+ * Ask a yes/no question. Resolves `undefined` when the answer never comes —
368
+ * the prompt timed out, or stdin closed on an empty line.
369
+ */
370
+ export async function promptYesNo(
278
371
  question: string,
279
- options: {
280
- defaultYes: boolean;
281
- out: (text: string) => void;
282
- }
283
- ): Promise<boolean> {
372
+ options: PromptYesNoOptions
373
+ ): Promise<boolean | undefined> {
284
374
  const hint = options.defaultYes ? "Y/n" : "y/N";
375
+ const input = options.input ?? process.stdin;
285
376
  options.out(`${question} [${hint}] `);
286
- process.stdin.resume();
287
- const line = await readOneLine(process.stdin);
288
- process.stdin.pause();
289
- if (line === undefined || line.trim() === "") {
377
+ input.resume();
378
+ const line = await readOneLine(input, options.timeoutMs);
379
+ input.pause();
380
+ if (line === undefined) {
381
+ options.out("\n");
382
+ return undefined;
383
+ }
384
+ if (line.trim() === "") {
290
385
  return options.defaultYes;
291
386
  }
292
387
  const answer = line.trim().toLowerCase();
@@ -299,11 +394,14 @@ async function promptYesNo(
299
394
  return options.defaultYes;
300
395
  }
301
396
 
397
+ /** The line, or `undefined` when the stream ends or the clock runs out first. */
302
398
  function readOneLine(
303
- stream: NodeJS.ReadableStream
399
+ stream: NodeJS.ReadableStream,
400
+ timeoutMs?: number
304
401
  ): Promise<string | undefined> {
305
402
  return new Promise((resolve) => {
306
403
  let buffer = "";
404
+ let timer: NodeJS.Timeout | undefined;
307
405
  const onData = (chunk: string | Buffer): void => {
308
406
  buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
309
407
  const newline = buffer.indexOf("\n");
@@ -313,15 +411,25 @@ function readOneLine(
313
411
  cleanup();
314
412
  resolve(buffer.slice(0, newline).replace(/\r$/, ""));
315
413
  };
316
- const onEnd = (): void => {
414
+ // EOF and the timeout both land here. A tty holds a half-typed line until
415
+ // Enter, so a non-empty buffer only ever happens on piped input.
416
+ const giveUp = (): void => {
317
417
  cleanup();
318
418
  resolve(buffer === "" ? undefined : buffer.replace(/\r$/, ""));
319
419
  };
320
420
  const cleanup = (): void => {
421
+ if (timer !== undefined) {
422
+ clearTimeout(timer);
423
+ }
321
424
  stream.off("data", onData);
322
- stream.off("end", onEnd);
425
+ stream.off("end", giveUp);
323
426
  };
324
427
  stream.on("data", onData);
325
- stream.on("end", onEnd);
428
+ stream.on("end", giveUp);
429
+ if (timeoutMs !== undefined && timeoutMs > 0) {
430
+ timer = setTimeout(giveUp, timeoutMs);
431
+ // Never keep the process alive purely to wait for an answer.
432
+ timer.unref();
433
+ }
326
434
  });
327
435
  }
@@ -336,6 +336,36 @@ export class ReminderRunner {
336
336
  return n;
337
337
  }
338
338
 
339
+ /**
340
+ * Re-key armed reminders when a channel rotates a session's continuation
341
+ * token (the built-in HTTP channel rotates after every follow-up). Without
342
+ * this, a reminder armed before the rotation misses its session at fire
343
+ * time and disarms as `session_gone`.
344
+ */
345
+ async rekeyContinuation(
346
+ channelId: string,
347
+ oldToken: string,
348
+ newToken: string
349
+ ): Promise<number> {
350
+ const armed = await this.store.listArmed();
351
+ let n = 0;
352
+ for (const record of armed) {
353
+ if (
354
+ record.channelId !== channelId ||
355
+ record.continuationToken !== oldToken
356
+ ) {
357
+ continue;
358
+ }
359
+ await this.persist({
360
+ ...record,
361
+ continuationToken: newToken,
362
+ updatedAt: new Date().toISOString(),
363
+ });
364
+ n += 1;
365
+ }
366
+ return n;
367
+ }
368
+
339
369
  /** Fire one reminder immediately (dev / hillclimb). */
340
370
  async dispatch(reminderId: string): Promise<{
341
371
  reminderId: string;
@@ -86,16 +86,90 @@ export interface CursorSdkRunnerOptions {
86
86
  logger?: (line: string) => void;
87
87
  }
88
88
 
89
+ /**
90
+ * How long one SDK agent handle (and the Connect transport inside it) may be
91
+ * reused before the runner retires it.
92
+ *
93
+ * The transport exchanges the account API key for a short-lived access token
94
+ * (`SessionType.API_KEY_TOKEN`, 60 minutes) once per handle, and has no
95
+ * reliable refresh: an Unauthenticated rejection on the agent's streaming RPC
96
+ * surfaces while the stream is being read — outside the transport's
97
+ * request-time 401 retry — so an expired token gets replayed on every
98
+ * subsequent turn until the handle is rebuilt. Long-lived sessions (watch
99
+ * wakes, crons) hit this at the ~60 minute mark and then fail forever.
100
+ * Retiring the handle comfortably before the token can expire keeps them
101
+ * healthy: `Agent.resume` rehydrates the same conversation from the runner
102
+ * store on a fresh transport, so recycling costs only transport warmth.
103
+ */
104
+ const MAX_AGENT_HANDLE_AGE_MS = 45 * 60 * 1000;
105
+
106
+ interface CachedAgentHandle {
107
+ agent: Promise<SDKAgent>;
108
+ openedAtMs: number;
109
+ }
110
+
111
+ /**
112
+ * The backend's `NotLoggedIn` copy ("Authentication error" title, "try
113
+ * logging out and back in." detail) as it surfaces through SDK RunResults.
114
+ * Deliberately narrow: this gates a one-shot in-place retry, and matching
115
+ * broader error text would retry turns that a fresh transport cannot fix.
116
+ */
117
+ export function isAuthErrorMessage(message: string | undefined): boolean {
118
+ if (message === undefined) {
119
+ return false;
120
+ }
121
+ return /authentication error|try logging out and back in/i.test(message);
122
+ }
123
+
89
124
  export class CursorSdkRunner implements AgentRunner {
90
- private readonly agents = new Map<string, Promise<SDKAgent>>();
125
+ private readonly agents = new Map<string, CachedAgentHandle>();
91
126
  private readonly stores = new Map<string, Promise<LocalAgentStore>>();
92
127
  private readonly prewarmReleases: Array<() => Promise<void>> = [];
93
128
 
94
129
  constructor(private readonly options: CursorSdkRunnerOptions = {}) {}
95
130
 
96
131
  async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
132
+ let sawUpdate = false;
133
+ const tracked: RunnerTurnRequest = {
134
+ ...request,
135
+ onUpdate: (update) => {
136
+ sawUpdate = true;
137
+ return request.onUpdate(update);
138
+ },
139
+ };
140
+ const outcome = await this.runTurnAttempt(tracked);
141
+ if (
142
+ outcome.status !== "error" ||
143
+ !isAuthErrorMessage(outcome.errorMessage) ||
144
+ sawUpdate ||
145
+ request.isFirstTurn ||
146
+ request.signal?.aborted === true
147
+ ) {
148
+ return outcome;
149
+ }
150
+ // The turn died on an authentication failure before producing any output
151
+ // — the signature of a handle whose exchanged access token expired (the
152
+ // failed attempt already evicted it). Retrying once on a fresh handle is
153
+ // safe here because nothing streamed and no tool ran. First turns are
154
+ // excluded: their token was exchanged moments ago, so the credential
155
+ // itself is the problem and a retry cannot succeed.
156
+ this.log(
157
+ `[agentkit] session ${request.sessionId}: retrying the turn on a fresh SDK agent handle after an authentication failure`
158
+ );
159
+ try {
160
+ return await this.runTurnAttempt(tracked);
161
+ } catch {
162
+ // The retry was best effort; report the original failure.
163
+ return outcome;
164
+ }
165
+ }
166
+
167
+ private async runTurnAttempt(
168
+ request: RunnerTurnRequest
169
+ ): Promise<TurnOutcome> {
170
+ const agentPromise = this.getAgent(request);
97
171
  try {
98
- const agent = await this.getAgent(request);
172
+ const agent = await agentPromise;
99
173
  const run = await agent.send(request.prompt, {
100
174
  onDelta: async ({ update }) => {
101
175
  try {
@@ -141,14 +215,24 @@ export class CursorSdkRunner implements AgentRunner {
141
215
  }
142
216
  try {
143
217
  const result = await run.wait();
144
- return toTurnOutcome(result);
218
+ const outcome = toTurnOutcome(result);
219
+ if (outcome.status === "error") {
220
+ // An error result usually means the handle's transport is poisoned
221
+ // — most often a cached access token that aged past its 60-minute
222
+ // lifetime and gets replayed on every request (stream failures
223
+ // bypass the transport's 401 retry). Retire the handle so the next
224
+ // attempt resumes the conversation from the store on a fresh
225
+ // transport instead of failing every turn until a restart.
226
+ this.evictAgent(request.sessionId, agentPromise);
227
+ }
228
+ return outcome;
145
229
  } finally {
146
230
  signal?.removeEventListener("abort", onAbort);
147
231
  }
148
232
  } catch (error) {
149
233
  // Drop the cached agent so a dead Connect/HTTP2 session cannot poison
150
234
  // the next turn (terminal/shell overflow often kills the transport).
151
- this.agents.delete(request.sessionId);
235
+ this.evictAgent(request.sessionId, agentPromise);
152
236
  throw error;
153
237
  }
154
238
  }
@@ -178,7 +262,9 @@ export class CursorSdkRunner implements AgentRunner {
178
262
  async dispose(): Promise<void> {
179
263
  const releases = this.prewarmReleases.splice(0);
180
264
  await Promise.allSettled(releases.map((release) => release()));
181
- const agents = await Promise.allSettled([...this.agents.values()]);
265
+ const agents = await Promise.allSettled(
266
+ [...this.agents.values()].map((handle) => handle.agent)
267
+ );
182
268
  this.agents.clear();
183
269
  for (const settled of agents) {
184
270
  if (settled.status === "fulfilled") {
@@ -188,17 +274,49 @@ export class CursorSdkRunner implements AgentRunner {
188
274
  }
189
275
 
190
276
  private getAgent(request: RunnerTurnRequest): Promise<SDKAgent> {
191
- let agentPromise = this.agents.get(request.sessionId);
192
- if (agentPromise === undefined) {
193
- agentPromise = this.openAgent(request).catch((error) => {
194
- this.agents.delete(request.sessionId);
195
- throw error;
196
- });
197
- this.agents.set(request.sessionId, agentPromise);
277
+ const cached = this.agents.get(request.sessionId);
278
+ if (cached !== undefined) {
279
+ const ageMs = Date.now() - cached.openedAtMs;
280
+ if (ageMs < MAX_AGENT_HANDLE_AGE_MS) {
281
+ return cached.agent;
282
+ }
283
+ // Past this age the handle's exchanged access token could expire
284
+ // mid-turn. Retire it and reopen below on a fresh transport.
285
+ this.log(
286
+ `[agentkit] session ${request.sessionId}: recycling the SDK agent handle after ${Math.round(ageMs / 60_000)} min so the turn runs on a fresh access token`
287
+ );
288
+ this.evictAgent(request.sessionId, cached.agent);
198
289
  }
290
+ const agentPromise = this.openAgent(request).catch((error) => {
291
+ // Nothing opened, so there is no handle to close — just free the slot.
292
+ this.agents.delete(request.sessionId);
293
+ throw error;
294
+ });
295
+ this.agents.set(request.sessionId, {
296
+ agent: agentPromise,
297
+ openedAtMs: Date.now(),
298
+ });
199
299
  return agentPromise;
200
300
  }
201
301
 
302
+ /**
303
+ * Drop a cached handle and close it best-effort to release its transport
304
+ * and executor lease. Identity-checked so a turn that lost a race with a
305
+ * newer open cannot evict the replacement handle.
306
+ */
307
+ private evictAgent(sessionId: string, agent: Promise<SDKAgent>): void {
308
+ const cached = this.agents.get(sessionId);
309
+ if (cached === undefined || cached.agent !== agent) {
310
+ return;
311
+ }
312
+ this.agents.delete(sessionId);
313
+ void cached.agent.then((sdkAgent) => sdkAgent.close()).catch(() => {});
314
+ }
315
+
316
+ private log(line: string): void {
317
+ (this.options.logger ?? (() => {}))(line);
318
+ }
319
+
202
320
  private requireApiKey(): string {
203
321
  const apiKey = this.options.apiKey ?? process.env.CURSOR_API_KEY;
204
322
  if (apiKey === undefined || apiKey.trim() === "") {
@@ -274,7 +392,7 @@ export class CursorSdkRunner implements AgentRunner {
274
392
  // conversation under the same id beats failing this thread's turns
275
393
  // forever ("Agent … not found" on every follow-up). The channel's
276
394
  // warm-turn prompt context re-hydrates the thread history.
277
- (this.options.logger ?? (() => {}))(
395
+ this.log(
278
396
  `[agentkit] no stored conversation for ${agentId} on this host; starting a fresh one`
279
397
  );
280
398
  return Agent.create({ ...options, agentId });
@@ -421,6 +421,8 @@ export async function startServer(
421
421
  cancel: (id, reason) => reminderRunner.cancel(id, reason),
422
422
  cancelForContinuation: (token, reason) =>
423
423
  reminderRunner.cancelForContinuation(token, reason),
424
+ rekeyContinuation: (channelId, oldToken, newToken) =>
425
+ reminderRunner.rekeyContinuation(channelId, oldToken, newToken),
424
426
  dispatch: (id) => reminderRunner.dispatch(id),
425
427
  });
426
428
  engine.setEvals({
@@ -2148,10 +2148,24 @@ export class SessionEngine {
2148
2148
  sessionId: string,
2149
2149
  token: string | null
2150
2150
  ): Promise<void> {
2151
+ const previous = await this.sessions.get(sessionId);
2151
2152
  await this.updateRecord(sessionId, (current) => ({
2152
2153
  ...current,
2153
2154
  continuationKey: token,
2154
2155
  }));
2156
+ // Keep armed reminders addressed at the conversation across rotation
2157
+ // (the built-in HTTP channel rotates after every follow-up).
2158
+ if (
2159
+ previous?.continuationKey != null &&
2160
+ token !== null &&
2161
+ token !== previous.continuationKey
2162
+ ) {
2163
+ await this.reminderApi?.rekeyContinuation(
2164
+ previous.channelId,
2165
+ previous.continuationKey,
2166
+ token
2167
+ );
2168
+ }
2155
2169
  }
2156
2170
 
2157
2171
  isTurnRunning(sessionId: string): boolean {
@@ -9,9 +9,10 @@
9
9
  * The endpoints are gated on the `agent_serve_slack_provisioning` Statsig
10
10
  * gate and 404 when it is off — the error mapping says so instead of
11
11
  * "not found". Expected provisioning failures carry a stable JSON `code`
12
- * (`not_connected`, `insufficient_scopes`, `app_conflict`,
13
- * `app_cap_reached`, `ratelimited`, `invalid_app_id`, …) which flows
14
- * through on {@link SlackProvisionApiError.code} so the CLI can branch.
12
+ * (`not_connected`, `insufficient_scopes`, `manager_reauth_required`,
13
+ * `app_conflict`, `app_cap_reached`, `ratelimited`, `invalid_app_id`, …)
14
+ * which flows through on {@link SlackProvisionApiError.code} so the CLI
15
+ * can branch.
15
16
  */
16
17
 
17
18
  import { z } from "zod";
@@ -21,6 +22,8 @@ export type SlackAppEnv = "dev" | "prod";
21
22
  /** One manager-connected Slack workspace from `GET /status`. */
22
23
  export interface SlackConnectedWorkspace {
23
24
  slackTeamId: string;
25
+ /** Workspace name; absent on older backends, null on pre-existing rows. */
26
+ slackTeamName?: string | null;
24
27
  slackUserId: string;
25
28
  connectedAt: string;
26
29
  }
@@ -113,6 +116,7 @@ const statusResponseSchema = z
113
116
  z
114
117
  .object({
115
118
  slackTeamId: z.string(),
119
+ slackTeamName: z.string().nullish(),
116
120
  slackUserId: z.string(),
117
121
  connectedAt: z.string(),
118
122
  })
package/src/types.ts CHANGED
@@ -198,6 +198,29 @@ export interface AgentConfig {
198
198
  * Engine-wide turn admission limits. See {@link AgentConcurrencyOptions}.
199
199
  */
200
200
  concurrency?: AgentConcurrencyOptions;
201
+ /**
202
+ * Framework-provided model-facing tools, opted in per capability. See
203
+ * {@link BuiltinToolsConfig}.
204
+ */
205
+ builtinTools?: BuiltinToolsConfig;
206
+ }
207
+
208
+ /**
209
+ * Built-in tools on {@link AgentConfig.builtinTools}. Each enabled
210
+ * capability materializes as ordinary server tools at discovery time, so
211
+ * turns, direct calls, `info`, and the playground treat them like authored
212
+ * tools. Authored tools with the same name win (with a warning), and none
213
+ * of them run on cloud turns (server tools are local-runtime only).
214
+ */
215
+ export interface BuiltinToolsConfig {
216
+ /**
217
+ * Reminder tools bound to the current conversation, over
218
+ * {@link HostContext.reminders}: `reminders_create`, `reminders_list`,
219
+ * and `reminders_cancel` (the name the framework's reminder fire prompt
220
+ * tells the model to call when a stop condition is met). Sessions
221
+ * without a continuation key cannot arm reminders.
222
+ */
223
+ reminders?: boolean;
201
224
  }
202
225
 
203
226
  /**
@@ -1722,6 +1745,16 @@ export interface ReminderHostApi {
1722
1745
  continuationToken: string,
1723
1746
  reason: string
1724
1747
  ): Promise<number>;
1748
+ /**
1749
+ * Re-key armed reminders after a channel rotates a session's continuation
1750
+ * token, so earlier reminders keep waking the conversation. The engine
1751
+ * calls this from `setContinuationToken`; channels rarely need to.
1752
+ */
1753
+ rekeyContinuation(
1754
+ channelId: string,
1755
+ oldToken: string,
1756
+ newToken: string
1757
+ ): Promise<number>;
1725
1758
  /**
1726
1759
  * Fire immediately (dev / hillclimb).
1727
1760
  * `outcome` is `"noop"` when already firing or not armed.