@cursor/july 0.1.7 → 0.1.8

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 (173) hide show
  1. package/dist/bin/agent-serve.js +31 -2
  2. package/dist/channels/slack/channel-watch.d.ts +6 -1
  3. package/dist/channels/slack/channel-watch.d.ts.map +1 -1
  4. package/dist/channels/slack/channel-watch.js +22 -1
  5. package/dist/channels/slack/cursor-account.d.ts +4 -0
  6. package/dist/channels/slack/cursor-account.d.ts.map +1 -1
  7. package/dist/channels/slack/cursor-account.js +12 -2
  8. package/dist/channels/slack/defaults.d.ts +10 -5
  9. package/dist/channels/slack/defaults.d.ts.map +1 -1
  10. package/dist/channels/slack/defaults.js +39 -17
  11. package/dist/channels/slack/dispatch.d.ts +18 -0
  12. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  13. package/dist/channels/slack/dispatch.js +141 -38
  14. package/dist/channels/slack/inbound.d.ts +13 -3
  15. package/dist/channels/slack/inbound.d.ts.map +1 -1
  16. package/dist/channels/slack/inbound.js +17 -9
  17. package/dist/channels/slack/index.d.ts +4 -0
  18. package/dist/channels/slack/index.d.ts.map +1 -1
  19. package/dist/channels/slack/index.js +4 -0
  20. package/dist/channels/slack/live-delivery.d.ts.map +1 -1
  21. package/dist/channels/slack/live-delivery.js +1 -0
  22. package/dist/channels/slack/manifest.d.ts.map +1 -1
  23. package/dist/channels/slack/manifest.js +3 -0
  24. package/dist/channels/slack/nudge-carrier.d.ts +19 -0
  25. package/dist/channels/slack/nudge-carrier.d.ts.map +1 -0
  26. package/dist/channels/slack/nudge-carrier.js +166 -0
  27. package/dist/channels/slack/nudge-store.d.ts +61 -0
  28. package/dist/channels/slack/nudge-store.d.ts.map +1 -0
  29. package/dist/channels/slack/nudge-store.js +266 -0
  30. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  31. package/dist/channels/slack/slack-channel.js +74 -25
  32. package/dist/channels/slack/stop.d.ts +14 -0
  33. package/dist/channels/slack/stop.d.ts.map +1 -0
  34. package/dist/channels/slack/stop.js +22 -0
  35. package/dist/channels/slack/thread-context.d.ts +81 -0
  36. package/dist/channels/slack/thread-context.d.ts.map +1 -0
  37. package/dist/channels/slack/thread-context.js +470 -0
  38. package/dist/channels/slack/types.d.ts +27 -0
  39. package/dist/channels/slack/types.d.ts.map +1 -1
  40. package/dist/docs/404.html +2 -2
  41. package/dist/docs/ab.html +4 -4
  42. package/dist/docs/assets/{app.COTN7wgo.js → app.BP8mAVMW.js} +1 -1
  43. package/dist/docs/assets/chunks/@localSearchIndexroot.DevOabKJ.js +1 -0
  44. package/dist/docs/assets/chunks/{VPLocalSearchBox.BW3TBdT0.js → VPLocalSearchBox.1FXIZbz8.js} +1 -1
  45. package/dist/docs/assets/chunks/{theme.BEJW0vE7.js → theme.Dm1R02v9.js} +2 -2
  46. package/dist/docs/assets/example-agents_index.md.CwtWu5ix.js +2 -0
  47. package/dist/docs/assets/{example-agents_index.md.CRqJlnIf.lean.js → example-agents_index.md.CwtWu5ix.lean.js} +1 -1
  48. package/dist/docs/assets/example-agents_oncall.md.BG_sUMly.js +10 -0
  49. package/dist/docs/assets/example-agents_oncall.md.BG_sUMly.lean.js +1 -0
  50. package/dist/docs/assets/{guides_slack.md.CCwqHvSV.js → guides_slack.md.FWale6Ip.js} +4 -3
  51. package/dist/docs/assets/{guides_slack.md.CCwqHvSV.lean.js → guides_slack.md.FWale6Ip.lean.js} +1 -1
  52. package/dist/docs/assets/{reference_sessions.md.DdjWabnN.js → reference_sessions.md.DLd6mvbv.js} +2 -2
  53. package/dist/docs/assets/{reference_sessions.md.DdjWabnN.lean.js → reference_sessions.md.DLd6mvbv.lean.js} +1 -1
  54. package/dist/docs/assets/{storage.md.CVnInNiN.js → storage.md.Cu3MwM2a.js} +3 -2
  55. package/dist/docs/assets/{storage.md.CVnInNiN.lean.js → storage.md.Cu3MwM2a.lean.js} +1 -1
  56. package/dist/docs/building-with-agents.html +4 -4
  57. package/dist/docs/concepts.html +4 -4
  58. package/dist/docs/deployment.html +4 -4
  59. package/dist/docs/evals.html +4 -4
  60. package/dist/docs/example-agents/approval-buddy.html +4 -4
  61. package/dist/docs/example-agents/benny.html +5 -5
  62. package/dist/docs/example-agents/bugbot.html +5 -5
  63. package/dist/docs/example-agents/codebase-wiki.html +4 -4
  64. package/dist/docs/example-agents/codeowners-review.html +4 -4
  65. package/dist/docs/example-agents/concierge.html +4 -4
  66. package/dist/docs/example-agents/fsd.html +4 -4
  67. package/dist/docs/example-agents/index.html +6 -6
  68. package/dist/docs/example-agents/knowledge-base.html +4 -4
  69. package/dist/docs/example-agents/oncall.html +34 -0
  70. package/dist/docs/example-agents/security-reviewer.html +4 -4
  71. package/dist/docs/example-agents/slack-agent.html +4 -4
  72. package/dist/docs/example-agents/weather-agent.html +4 -4
  73. package/dist/docs/guides/agent-to-agent.html +4 -4
  74. package/dist/docs/guides/cloud-runtime.html +4 -4
  75. package/dist/docs/guides/github.html +4 -4
  76. package/dist/docs/guides/human-in-the-loop.html +4 -4
  77. package/dist/docs/guides/slack.html +7 -6
  78. package/dist/docs/guides/webhooks.html +4 -4
  79. package/dist/docs/hashmap.json +1 -1
  80. package/dist/docs/hillclimbing.html +4 -4
  81. package/dist/docs/index.html +4 -4
  82. package/dist/docs/quickstart.html +4 -4
  83. package/dist/docs/reference/agent-config.html +4 -4
  84. package/dist/docs/reference/channels.html +4 -4
  85. package/dist/docs/reference/cli.html +4 -4
  86. package/dist/docs/reference/connections.html +4 -4
  87. package/dist/docs/reference/hooks.html +4 -4
  88. package/dist/docs/reference/http-api.html +4 -4
  89. package/dist/docs/reference/instructions.html +4 -4
  90. package/dist/docs/reference/playground.html +4 -4
  91. package/dist/docs/reference/project-layout.html +4 -4
  92. package/dist/docs/reference/schedules.html +4 -4
  93. package/dist/docs/reference/sessions.html +5 -5
  94. package/dist/docs/reference/skills.html +4 -4
  95. package/dist/docs/reference/subagents.html +4 -4
  96. package/dist/docs/reference/tools.html +4 -4
  97. package/dist/docs/scaffolding-agents.html +4 -4
  98. package/dist/docs/storage.html +7 -6
  99. package/dist/docs/troubleshooting.html +4 -4
  100. package/dist/internal/cli-mcp.d.ts +64 -0
  101. package/dist/internal/cli-mcp.d.ts.map +1 -0
  102. package/dist/internal/cli-mcp.js +570 -0
  103. package/dist/internal/distribution.d.ts.map +1 -1
  104. package/dist/internal/distribution.js +2 -0
  105. package/dist/internal/host-kv.d.ts +17 -0
  106. package/dist/internal/host-kv.d.ts.map +1 -0
  107. package/dist/internal/host-kv.js +77 -0
  108. package/dist/internal/server.d.ts.map +1 -1
  109. package/dist/internal/server.js +8 -0
  110. package/dist/internal/session-engine.d.ts +42 -7
  111. package/dist/internal/session-engine.d.ts.map +1 -1
  112. package/dist/internal/session-engine.js +247 -51
  113. package/dist/internal/storage-coordinator.d.ts +18 -1
  114. package/dist/internal/storage-coordinator.d.ts.map +1 -1
  115. package/dist/internal/storage-coordinator.js +43 -0
  116. package/dist/playground/assets/index-7GJIUFQj.css +1 -0
  117. package/dist/playground/assets/index-Bpr4Th4M.js +319 -0
  118. package/dist/playground/index.html +2 -2
  119. package/dist/storage-backends/file-kv.d.ts +42 -0
  120. package/dist/storage-backends/file-kv.d.ts.map +1 -0
  121. package/dist/storage-backends/file-kv.js +190 -0
  122. package/dist/storage-backends/postgres-kv.d.ts +77 -0
  123. package/dist/storage-backends/postgres-kv.d.ts.map +1 -0
  124. package/dist/storage-backends/postgres-kv.js +180 -0
  125. package/dist/storage.d.ts +10 -1
  126. package/dist/storage.d.ts.map +1 -1
  127. package/dist/storage.js +10 -1
  128. package/dist/types.d.ts +79 -0
  129. package/dist/types.d.ts.map +1 -1
  130. package/docs/example-agents/index.md +14 -9
  131. package/docs/example-agents/oncall.md +216 -0
  132. package/docs/guides/slack.md +11 -2
  133. package/docs/reference/sessions.md +13 -4
  134. package/docs/storage.md +27 -0
  135. package/package.json +15 -1
  136. package/skills/framework-map/SKILL.md +6 -4
  137. package/src/bin/agent-serve.ts +32 -2
  138. package/src/channels/slack/channel-watch.ts +28 -1
  139. package/src/channels/slack/cursor-account.ts +20 -0
  140. package/src/channels/slack/defaults.ts +51 -20
  141. package/src/channels/slack/dispatch.ts +215 -40
  142. package/src/channels/slack/inbound.ts +30 -10
  143. package/src/channels/slack/index.ts +4 -0
  144. package/src/channels/slack/live-delivery.ts +1 -0
  145. package/src/channels/slack/manifest.ts +3 -0
  146. package/src/channels/slack/nudge-carrier.ts +182 -0
  147. package/src/channels/slack/nudge-store.ts +308 -0
  148. package/src/channels/slack/slack-channel.ts +106 -31
  149. package/src/channels/slack/stop.ts +25 -0
  150. package/src/channels/slack/thread-context.ts +572 -0
  151. package/src/channels/slack/types.ts +27 -0
  152. package/src/internal/cli-mcp.ts +708 -0
  153. package/src/internal/distribution.ts +2 -0
  154. package/src/internal/host-kv.ts +85 -0
  155. package/src/internal/server.ts +31 -0
  156. package/src/internal/session-engine.ts +351 -50
  157. package/src/internal/storage-coordinator.ts +41 -0
  158. package/src/storage-backends/file-kv.ts +221 -0
  159. package/src/storage-backends/postgres-kv.ts +248 -0
  160. package/src/storage.ts +11 -1
  161. package/src/types.ts +83 -0
  162. package/dist/channels/slack/post-update-delivery.d.ts +0 -85
  163. package/dist/channels/slack/post-update-delivery.d.ts.map +0 -1
  164. package/dist/docs/assets/chunks/@localSearchIndexroot.B7UcKvIn.js +0 -1
  165. package/dist/docs/assets/example-agents_index.md.CRqJlnIf.js +0 -2
  166. package/dist/internal/json-dir-store.d.ts +0 -32
  167. package/dist/internal/json-dir-store.d.ts.map +0 -1
  168. package/dist/internal/persistence-coordinator.d.ts +0 -127
  169. package/dist/internal/persistence-coordinator.d.ts.map +0 -1
  170. package/dist/persistence.d.ts +0 -184
  171. package/dist/persistence.d.ts.map +0 -1
  172. package/dist/playground/assets/index-72vCOBWO.js +0 -86
  173. package/dist/playground/assets/index-BjnMwYoR.css +0 -1
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Author-facing durable JSON KV for {@link HostContext.kv}.
3
+ *
4
+ * When `defineStorage` is authored and exposes `get`, values live in that
5
+ * sink under {@link storageKeys.kv}. Otherwise they land as one JSON file
6
+ * per key under `--state-root/kv` (local dogfood / no durable plug-in).
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { storageKeys } from "../storage.js";
13
+ import type { HostKvApi, JsonValue } from "../types.js";
14
+ import type { StorageCoordinator } from "./storage-coordinator.js";
15
+
16
+ export interface CreateHostKvApiOptions {
17
+ agentName: string;
18
+ stateRoot: string;
19
+ storage?: StorageCoordinator;
20
+ }
21
+
22
+ /** Build the host KV API for one agent process. */
23
+ export function createHostKvApi(options: CreateHostKvApiOptions): HostKvApi {
24
+ const storage = options.storage;
25
+ if (storage?.supportsAuthorKv === true) {
26
+ return {
27
+ get: (key) => storage.kvGet(normalizeAuthorKey(key)),
28
+ put: (key, value) => storage.kvPut(normalizeAuthorKey(key), value),
29
+ delete: (key) => storage.kvDelete(normalizeAuthorKey(key)),
30
+ };
31
+ }
32
+ return createFilesystemKvApi({
33
+ agentName: options.agentName,
34
+ stateRoot: options.stateRoot,
35
+ });
36
+ }
37
+
38
+ function createFilesystemKvApi(options: {
39
+ agentName: string;
40
+ stateRoot: string;
41
+ }): HostKvApi {
42
+ const dir = join(options.stateRoot, "kv");
43
+ const filePath = (authorKey: string): string =>
44
+ join(
45
+ dir,
46
+ `${encodeURIComponent(storageKeys.kv(options.agentName, authorKey))}.json`
47
+ );
48
+
49
+ return {
50
+ async get(key): Promise<JsonValue | undefined> {
51
+ const authorKey = normalizeAuthorKey(key);
52
+ try {
53
+ return JSON.parse(
54
+ await readFile(filePath(authorKey), "utf8")
55
+ ) as JsonValue;
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
58
+ return undefined;
59
+ }
60
+ throw error;
61
+ }
62
+ },
63
+
64
+ async put(key, value): Promise<void> {
65
+ const authorKey = normalizeAuthorKey(key);
66
+ await mkdir(dir, { recursive: true });
67
+ const target = filePath(authorKey);
68
+ const tmp = join(dir, `.tmp-${randomUUID()}`);
69
+ await writeFile(tmp, JSON.stringify(value), "utf8");
70
+ await rename(tmp, target);
71
+ },
72
+
73
+ async delete(key): Promise<void> {
74
+ const authorKey = normalizeAuthorKey(key);
75
+ await rm(filePath(authorKey), { force: true });
76
+ },
77
+ };
78
+ }
79
+
80
+ function normalizeAuthorKey(key: string): string {
81
+ if (typeof key !== "string" || key.trim() === "") {
82
+ throw new Error("host.kv key must be a non-empty string");
83
+ }
84
+ return key;
85
+ }
@@ -423,6 +423,17 @@ export async function startServer(
423
423
  },
424
424
  resolveApproval: (sessionId, callId, decision, auth) =>
425
425
  engine.resolveApproval(sessionId, callId, decision, auth),
426
+ hasContinuationSession: (continuationToken) =>
427
+ engine.hasContinuationSession(channel.id, continuationToken),
428
+ isContinuationBusy: (continuationToken) =>
429
+ engine.isContinuationBusy(channel.id, continuationToken),
430
+ getContinuationLastBotMessageTs: (continuationToken) =>
431
+ engine.getContinuationLastBotMessageTs(
432
+ channel.id,
433
+ continuationToken
434
+ ),
435
+ interruptContinuation: (continuationToken) =>
436
+ engine.interruptContinuation(channel.id, continuationToken),
426
437
  logger,
427
438
  };
428
439
  try {
@@ -1624,6 +1635,26 @@ async function startCursorSlackRelay(input: {
1624
1635
  envelope,
1625
1636
  send: args.send,
1626
1637
  host: args.host,
1638
+ hasContinuationSession: (continuationToken) =>
1639
+ target.runtime.engine.hasContinuationSession(
1640
+ target.channelId,
1641
+ continuationToken
1642
+ ),
1643
+ isContinuationBusy: (continuationToken) =>
1644
+ target.runtime.engine.isContinuationBusy(
1645
+ target.channelId,
1646
+ continuationToken
1647
+ ),
1648
+ getContinuationLastBotMessageTs: (continuationToken) =>
1649
+ target.runtime.engine.getContinuationLastBotMessageTs(
1650
+ target.channelId,
1651
+ continuationToken
1652
+ ),
1653
+ interruptContinuation: (continuationToken) =>
1654
+ target.runtime.engine.interruptContinuation(
1655
+ target.channelId,
1656
+ continuationToken
1657
+ ),
1627
1658
  });
1628
1659
  },
1629
1660
  });
@@ -17,6 +17,10 @@ import {
17
17
  resolveABSplit,
18
18
  } from "../ab.js";
19
19
  import { slackThreadPermalink } from "../channels/slack/links.js";
20
+ import {
21
+ SlackAskDedupeStore,
22
+ SlackNudgeStore,
23
+ } from "../channels/slack/nudge-store.js";
20
24
  import {
21
25
  type AgentProject,
22
26
  type ApprovalDecision,
@@ -43,6 +47,7 @@ import {
43
47
  type SessionInfo,
44
48
  type SessionRecord,
45
49
  type SessionSummary,
50
+ type SlackNudgeHostApi,
46
51
  type ToolCallOutcome,
47
52
  type ToolContext,
48
53
  type ToolNeedsApproval,
@@ -57,6 +62,7 @@ import { mergeCloudOptions, resolveSessionRuntime } from "./cloud-merge.js";
57
62
  import { isDevMode } from "./dev-mode.js";
58
63
  import { EventLogStore } from "./event-log.js";
59
64
  import { TurnEventMapper } from "./event-mapper.js";
65
+ import { createHostKvApi } from "./host-kv.js";
60
66
  import { createHostPlatforms, type HostPlatforms } from "./host-platforms.js";
61
67
  import {
62
68
  formatContinuationToken,
@@ -196,10 +202,29 @@ export interface EngineSendOptions extends SendMessageOptions {
196
202
  mode?: "chat" | "task";
197
203
  }
198
204
 
205
+ interface CoalescedQueuedTurn {
206
+ message: string;
207
+ sourceTs?: string;
208
+ }
209
+
210
+ interface AdmitTurnOptions {
211
+ admission?: "preempt" | "coalesce";
212
+ sourceTs?: string;
213
+ /**
214
+ * When true, skip `clearCoalesced` on the preempt admit path. Used by
215
+ * coalesce drain so asks that arrived during drain are not wiped.
216
+ */
217
+ retainCoalesced?: boolean;
218
+ }
219
+
199
220
  export class SessionEngine {
200
221
  readonly project: AgentProject;
201
222
  readonly stateRoot: string;
202
223
  readonly sessions: SessionStore;
224
+ /** Durable Slack mid-turn nudges under `<stateRoot>/slack-nudges`. */
225
+ readonly nudgeStore: SlackNudgeStore;
226
+ /** Durable `channelId:ts` ask claims under `<stateRoot>/slack-asks`. */
227
+ readonly askDedupe: SlackAskDedupeStore;
203
228
  /**
204
229
  * Runtime for `agent/storage.ts` (`defineStorage`), when
205
230
  * authored. All durable state changes funnel through it (session
@@ -226,6 +251,16 @@ export class SessionEngine {
226
251
  private readonly turnAborts = new Map<string, AbortController>();
227
252
  /** Serializes turn admission per session (latest follow-up wins). */
228
253
  private readonly admissionChains = new Map<string, Promise<unknown>>();
254
+ /**
255
+ * Per-session coalesce queue for `admission: "coalesce"` (Slack). Drained
256
+ * into one follow-up turn when the active turn finishes without interrupt.
257
+ */
258
+ private readonly coalesceQueues = new Map<string, CoalescedQueuedTurn[]>();
259
+ /**
260
+ * Sessions whose turn lifecycle is draining coalesced follow-ups. Counts as
261
+ * busy so inbound coalesce admits enqueue instead of racing `clearCoalesced`.
262
+ */
263
+ private readonly coalesceDrains = new Map<string, Promise<void>>();
229
264
  private readonly lastOutcomes = new Map<string, TurnOutcome>();
230
265
  private readonly updateChains = new Map<string, Promise<unknown>>();
231
266
  private readonly dispatchChains = new Map<string, Promise<void>>();
@@ -235,6 +270,7 @@ export class SessionEngine {
235
270
  private readonly approvals = new ApprovalGate();
236
271
  private readonly abCollector: ABCollector;
237
272
  private reminderApi: HostContext["reminders"];
273
+ private readonly kvApi: HostContext["kv"];
238
274
  /** Serve-time resolved connections (see {@link setResolvedConnections}). */
239
275
  private resolvedConnections: EngineResolvedConnection[] = [];
240
276
 
@@ -244,6 +280,12 @@ export class SessionEngine {
244
280
  this.runner = options.runner;
245
281
  this.logger = options.logger ?? (() => {});
246
282
  this.sessions = new SessionStore(join(options.stateRoot, "sessions"));
283
+ this.nudgeStore = new SlackNudgeStore(
284
+ join(options.stateRoot, "slack-nudges")
285
+ );
286
+ this.askDedupe = new SlackAskDedupeStore(
287
+ join(options.stateRoot, "slack-asks")
288
+ );
247
289
  this.logs = new EventLogStore(
248
290
  (sessionId) => this.sessions.eventFilePath(sessionId),
249
291
  (sessionId, error) =>
@@ -260,6 +302,11 @@ export class SessionEngine {
260
302
  projectRoot: options.project.rootDir,
261
303
  logger: this.logger,
262
304
  });
305
+ this.kvApi = createHostKvApi({
306
+ agentName: options.project.name,
307
+ stateRoot: options.stateRoot,
308
+ ...(this.storage === undefined ? {} : { storage: this.storage }),
309
+ });
263
310
  this.abCollector = new ABCollector(
264
311
  options.project.abs,
265
312
  this.logger,
@@ -302,13 +349,32 @@ export class SessionEngine {
302
349
  }
303
350
  }
304
351
 
305
- /** Shared host services (MCP / GitHub / Slack / reminders). */
352
+ /** Shared host services (MCP / GitHub / Slack / reminders / kv / Slack nudges). */
306
353
  get host(): HostContext {
307
354
  return {
308
355
  mcp: this.mcpHost,
309
356
  github: this.platforms.github,
310
357
  slack: this.platforms.slack,
311
358
  reminders: this.reminderApi,
359
+ kv: this.kvApi,
360
+ slackNudges: this.slackNudgeHostApi(),
361
+ };
362
+ }
363
+
364
+ private slackNudgeHostApi(): SlackNudgeHostApi {
365
+ return {
366
+ append: (input) => this.nudgeStore.append(input),
367
+ drainFormatted: (continuationKey) =>
368
+ this.nudgeStore.drainFormatted(continuationKey),
369
+ clear: (continuationKey) => this.nudgeStore.clear(continuationKey),
370
+ hasUndelivered: (continuationKey) =>
371
+ this.nudgeStore.hasUndelivered(continuationKey),
372
+ isDelivered: (continuationKey, sourceTs) =>
373
+ this.nudgeStore.isDelivered(continuationKey, sourceTs),
374
+ tryClaimAsk: (channelId, messageTs) =>
375
+ this.askDedupe.tryClaim(channelId, messageTs),
376
+ releaseAsk: (channelId, messageTs) =>
377
+ this.askDedupe.release(channelId, messageTs),
312
378
  };
313
379
  }
314
380
 
@@ -371,8 +437,13 @@ export class SessionEngine {
371
437
  pendingWorkspaceFiles: options.workspaceFiles,
372
438
  }));
373
439
  }
374
- await this.startTurn(record.sessionId, message);
375
- return this.channelSession(record.sessionId, record, false);
440
+ const coalesced = await this.startTurn(record.sessionId, message, {
441
+ admission: options.admission ?? "preempt",
442
+ ...(options.coalesceSourceTs === undefined
443
+ ? {}
444
+ : { sourceTs: options.coalesceSourceTs }),
445
+ });
446
+ return this.channelSession(record.sessionId, record, false, coalesced);
376
447
  }
377
448
 
378
449
  const now = new Date().toISOString();
@@ -402,8 +473,31 @@ export class SessionEngine {
402
473
  data: { channelId },
403
474
  });
404
475
  record = await this.enrollABs(record);
405
- await this.startTurn(record.sessionId, message);
406
- return this.channelSession(record.sessionId, record, true);
476
+ await this.startTurn(record.sessionId, message, {
477
+ admission: options.admission ?? "preempt",
478
+ ...(options.coalesceSourceTs === undefined
479
+ ? {}
480
+ : { sourceTs: options.coalesceSourceTs }),
481
+ });
482
+ return this.channelSession(record.sessionId, record, true, false);
483
+ }
484
+
485
+ /** Whether a continuation token already maps to a session on this channel. */
486
+ async hasContinuationSession(
487
+ channelId: string,
488
+ continuationToken: string
489
+ ): Promise<boolean> {
490
+ let existing = await this.sessions.findByContinuation(
491
+ channelId,
492
+ continuationToken
493
+ );
494
+ if (existing === undefined) {
495
+ existing = await this.restoreSessionByContinuation(
496
+ channelId,
497
+ continuationToken
498
+ );
499
+ }
500
+ return existing !== undefined;
407
501
  }
408
502
 
409
503
  /**
@@ -559,17 +653,25 @@ export class SessionEngine {
559
653
  // ==========================================================================
560
654
 
561
655
  /**
562
- * Admit a turn for `sessionId`. A follow-up that arrives while a turn is in
563
- * flight preempts it: the running turn is cancelled and its SDK run marked
564
- * terminal before the new turn starts. Admissions are serialized per session
565
- * so a burst of follow-ups can't race two runs onto the same SDK agent — the
566
- * last message wins. Resolves once the new turn has started (not finished).
656
+ * Admit a turn for `sessionId`.
657
+ *
658
+ * Default (`preempt`): a follow-up cancels the running turn and starts
659
+ * immediately (HTTP / MCP). With `admission: "coalesce"` (Slack), a busy
660
+ * session enqueues the message and returns without interrupting; the queue
661
+ * drains into one follow-up when the active turn finishes.
662
+ *
663
+ * Resolves to `true` when the message was coalesced (enqueued), `false`
664
+ * when a turn was started.
567
665
  */
568
- private startTurn(sessionId: string, message: string): Promise<void> {
666
+ private startTurn(
667
+ sessionId: string,
668
+ message: string,
669
+ options: AdmitTurnOptions = {}
670
+ ): Promise<boolean> {
569
671
  const prior = this.admissionChains.get(sessionId) ?? Promise.resolve();
570
672
  const admitted = prior
571
673
  .catch(() => {})
572
- .then(() => this.admitTurn(sessionId, message));
674
+ .then(() => this.admitTurn(sessionId, message, options));
573
675
  this.admissionChains.set(
574
676
  sessionId,
575
677
  admitted.catch(() => {})
@@ -577,11 +679,88 @@ export class SessionEngine {
577
679
  return admitted;
578
680
  }
579
681
 
580
- private async admitTurn(sessionId: string, message: string): Promise<void> {
682
+ private enqueueCoalesced(sessionId: string, item: CoalescedQueuedTurn): void {
683
+ const queue = this.coalesceQueues.get(sessionId) ?? [];
684
+ if (
685
+ item.sourceTs !== undefined &&
686
+ queue.some((entry) => entry.sourceTs === item.sourceTs)
687
+ ) {
688
+ return;
689
+ }
690
+ queue.push(item);
691
+ this.coalesceQueues.set(sessionId, queue);
692
+ }
693
+
694
+ private clearCoalesced(sessionId: string): void {
695
+ this.coalesceQueues.delete(sessionId);
696
+ }
697
+
698
+ private formatCoalescedFollowups(
699
+ items: readonly CoalescedQueuedTurn[]
700
+ ): string {
701
+ if (items.length === 1) {
702
+ return items[0]!.message;
703
+ }
704
+ const parts = [
705
+ "<slack_followups>",
706
+ `ANSWER-EACH: ${items.length} asks arrived while you were busy. Address each in order; keep replies compact.`,
707
+ "",
708
+ ];
709
+ for (let i = 0; i < items.length; i++) {
710
+ parts.push(`### Ask ${i + 1}`);
711
+ parts.push(items[i]!.message);
712
+ parts.push("");
713
+ }
714
+ parts.push("</slack_followups>");
715
+ return parts.join("\n").trimEnd();
716
+ }
717
+
718
+ private async drainCoalescedFollowups(sessionId: string): Promise<void> {
719
+ const queued = this.coalesceQueues.get(sessionId) ?? [];
720
+ if (queued.length === 0) {
721
+ return;
722
+ }
723
+ this.coalesceQueues.delete(sessionId);
724
+ const message = this.formatCoalescedFollowups(queued);
725
+ this.logger(
726
+ `[session] coalesce drain starting follow-up (${queued.length}) ${sessionId}`
727
+ );
728
+ await this.startTurn(sessionId, message, {
729
+ admission: "preempt",
730
+ retainCoalesced: true,
731
+ });
732
+ }
733
+
734
+ private async admitTurn(
735
+ sessionId: string,
736
+ message: string,
737
+ options: AdmitTurnOptions = {}
738
+ ): Promise<boolean> {
739
+ const admission = options.admission ?? "preempt";
740
+ if (admission === "coalesce" && this.isTurnRunning(sessionId)) {
741
+ this.enqueueCoalesced(sessionId, {
742
+ message,
743
+ ...(options.sourceTs === undefined
744
+ ? {}
745
+ : { sourceTs: options.sourceTs }),
746
+ });
747
+ this.logger(`[session] coalesce enqueue ${sessionId}`);
748
+ return true;
749
+ }
750
+
581
751
  // Interrupt any turn in flight and wait for it to settle so the SDK run is
582
752
  // terminal before this turn's send (the follow-up also force-expires as a
583
- // belt-and-suspenders — see buildTurnRequest).
584
- await this.interruptActiveTurn(sessionId);
753
+ // belt-and-suspenders — see buildTurnRequest). Drain-resume skips interrupt:
754
+ // the parent lifecycle already cleared activeTurns, and awaiting the in-
755
+ // flight coalesce drain here would deadlock on itself.
756
+ if (options.retainCoalesced !== true) {
757
+ await this.interruptActiveTurn(sessionId);
758
+ }
759
+ // Preempt owns the conversation — drop stale coalesced asks. Coalesce
760
+ // admits and drain-resume keep the queue (retainCoalesced / coalesce path).
761
+ if (admission === "preempt" && options.retainCoalesced !== true) {
762
+ this.clearCoalesced(sessionId);
763
+ }
585
764
  this.pendingTurns.add(sessionId);
586
765
 
587
766
  try {
@@ -607,31 +786,58 @@ export class SessionEngine {
607
786
 
608
787
  const abort = new AbortController();
609
788
  this.turnAborts.set(sessionId, abort);
610
- const turnPromise = this.executeTurn(
611
- record,
612
- turnId,
613
- message,
614
- isFirstTurn,
615
- abort.signal
616
- );
617
- this.activeTurns.set(sessionId, turnPromise);
618
- void turnPromise
619
- .then((outcome) => {
620
- this.lastOutcomes.set(sessionId, outcome);
621
- })
622
- .catch(() => {})
623
- .finally(() => {
624
- // Guard against clobbering a newer turn that already replaced us.
625
- if (this.activeTurns.get(sessionId) === turnPromise) {
626
- this.activeTurns.delete(sessionId);
627
- }
628
- if (this.turnAborts.get(sessionId) === abort) {
629
- this.turnAborts.delete(sessionId);
789
+ // Lifecycle promise includes post-turn coalesce drain so waiters and
790
+ // interrupt settle after follow-ups are admitted (not only executeTurn).
791
+ // Assigned before the async body runs so self-identity checks typecheck.
792
+ let lifecycle!: Promise<TurnOutcome>;
793
+ lifecycle = (async (): Promise<TurnOutcome> => {
794
+ let outcome: TurnOutcome;
795
+ try {
796
+ outcome = await this.executeTurn(
797
+ record,
798
+ turnId,
799
+ message,
800
+ isFirstTurn,
801
+ abort.signal
802
+ );
803
+ } catch (error) {
804
+ outcome = {
805
+ status: "error",
806
+ errorMessage: describeError(error),
807
+ };
808
+ }
809
+ this.lastOutcomes.set(sessionId, outcome);
810
+ const wasCurrent = this.activeTurns.get(sessionId) === lifecycle;
811
+ if (wasCurrent) {
812
+ this.activeTurns.delete(sessionId);
813
+ }
814
+ if (this.turnAborts.get(sessionId) === abort) {
815
+ this.turnAborts.delete(sessionId);
816
+ }
817
+ if (wasCurrent && !abort.signal.aborted) {
818
+ const drain = this.drainCoalescedFollowups(sessionId).catch(
819
+ (error) => {
820
+ this.logger(
821
+ `[session] coalesce drain failed for ${sessionId}: ${describeError(error)}`
822
+ );
823
+ }
824
+ );
825
+ this.coalesceDrains.set(sessionId, drain);
826
+ try {
827
+ await drain;
828
+ } finally {
829
+ if (this.coalesceDrains.get(sessionId) === drain) {
830
+ this.coalesceDrains.delete(sessionId);
831
+ }
630
832
  }
631
- });
833
+ }
834
+ return outcome;
835
+ })();
836
+ this.activeTurns.set(sessionId, lifecycle);
632
837
  } finally {
633
838
  this.pendingTurns.delete(sessionId);
634
839
  }
840
+ return false;
635
841
  }
636
842
 
637
843
  /**
@@ -645,19 +851,102 @@ export class SessionEngine {
645
851
  if (active !== undefined) {
646
852
  await active.catch(() => {});
647
853
  }
854
+ const drain = this.coalesceDrains.get(sessionId);
855
+ if (drain !== undefined) {
856
+ await drain.catch(() => {});
857
+ }
648
858
  }
649
859
 
650
860
  /**
651
861
  * Abort the in-flight turn without starting a follow-up. Returns whether
652
- * a turn was active (or pending abort) when called.
862
+ * a turn was active (or pending abort) when called. Also clears any
863
+ * coalesced follow-ups queued behind the session.
653
864
  */
654
865
  async interrupt(sessionId: string): Promise<boolean> {
655
866
  const hadWork =
656
- this.turnAborts.has(sessionId) || this.activeTurns.has(sessionId);
867
+ this.turnAborts.has(sessionId) ||
868
+ this.activeTurns.has(sessionId) ||
869
+ this.coalesceDrains.has(sessionId) ||
870
+ (this.coalesceQueues.get(sessionId)?.length ?? 0) > 0;
871
+ this.clearCoalesced(sessionId);
872
+ const record = await this.sessions.get(sessionId);
873
+ const continuationKey = record?.continuationKey;
874
+ if (continuationKey !== undefined && continuationKey !== null) {
875
+ await this.nudgeStore.clear(continuationKey);
876
+ }
657
877
  await this.interruptActiveTurn(sessionId);
658
878
  return hadWork;
659
879
  }
660
880
 
881
+ async isContinuationBusy(
882
+ channelId: string,
883
+ continuationToken: string
884
+ ): Promise<boolean> {
885
+ let existing = await this.sessions.findByContinuation(
886
+ channelId,
887
+ continuationToken
888
+ );
889
+ if (existing === undefined) {
890
+ existing = await this.restoreSessionByContinuation(
891
+ channelId,
892
+ continuationToken
893
+ );
894
+ }
895
+ if (existing === undefined) {
896
+ return false;
897
+ }
898
+ return this.isTurnRunning(existing.sessionId);
899
+ }
900
+
901
+ /**
902
+ * Last agent-posted Slack message ts for warm `<slack_thread_delta>`, from
903
+ * the session's channel state when present.
904
+ */
905
+ async getContinuationLastBotMessageTs(
906
+ channelId: string,
907
+ continuationToken: string
908
+ ): Promise<string | undefined> {
909
+ let existing = await this.sessions.findByContinuation(
910
+ channelId,
911
+ continuationToken
912
+ );
913
+ if (existing === undefined) {
914
+ existing = await this.restoreSessionByContinuation(
915
+ channelId,
916
+ continuationToken
917
+ );
918
+ }
919
+ if (existing === undefined) {
920
+ return undefined;
921
+ }
922
+ const state = existing.channelState;
923
+ if (state === null || state === undefined || typeof state !== "object") {
924
+ return undefined;
925
+ }
926
+ const ts = (state as { lastBotMessageTs?: unknown }).lastBotMessageTs;
927
+ return typeof ts === "string" && ts.trim() !== "" ? ts.trim() : undefined;
928
+ }
929
+
930
+ async interruptContinuation(
931
+ channelId: string,
932
+ continuationToken: string
933
+ ): Promise<boolean> {
934
+ let existing = await this.sessions.findByContinuation(
935
+ channelId,
936
+ continuationToken
937
+ );
938
+ if (existing === undefined) {
939
+ existing = await this.restoreSessionByContinuation(
940
+ channelId,
941
+ continuationToken
942
+ );
943
+ }
944
+ if (existing === undefined) {
945
+ return false;
946
+ }
947
+ return this.interrupt(existing.sessionId);
948
+ }
949
+
661
950
  /**
662
951
  * Local harness cwd for a session: per-send override, else
663
952
  * `<agent.local.cwd>/<sessionId>`, else the durable session workspace.
@@ -1498,15 +1787,20 @@ export class SessionEngine {
1498
1787
  async waitForCompletion(sessionId: string): Promise<TurnOutcome> {
1499
1788
  // Drain admission first so pendingTurns → activeTurns is visible before we
1500
1789
  // decide the session is idle (reminders use this for queueBehindTurn).
1501
- const chain = this.admissionChains.get(sessionId);
1502
- if (chain !== undefined) {
1503
- await chain.catch(() => {});
1504
- }
1505
- const active = this.activeTurns.get(sessionId);
1506
- if (active !== undefined) {
1507
- return active;
1790
+ // The active lifecycle promise also awaits coalesce drain + the drained
1791
+ // follow-up's admission, so Slack coalesced waiters see the resume turn.
1792
+ for (;;) {
1793
+ const chain = this.admissionChains.get(sessionId);
1794
+ if (chain !== undefined) {
1795
+ await chain.catch(() => {});
1796
+ }
1797
+ const active = this.activeTurns.get(sessionId);
1798
+ if (active !== undefined) {
1799
+ await active;
1800
+ continue;
1801
+ }
1802
+ return this.lastOutcomes.get(sessionId) ?? { status: "finished" };
1508
1803
  }
1509
- return this.lastOutcomes.get(sessionId) ?? { status: "finished" };
1510
1804
  }
1511
1805
 
1512
1806
  /**
@@ -1674,9 +1968,14 @@ export class SessionEngine {
1674
1968
  }
1675
1969
 
1676
1970
  isTurnRunning(sessionId: string): boolean {
1677
- // Include the admit window (pendingTurns) so busy policies do not treat a
1678
- // turn that has been accepted but not yet marked active as idle.
1679
- return this.activeTurns.has(sessionId) || this.pendingTurns.has(sessionId);
1971
+ // Include the admit window (pendingTurns) and coalesce drain so busy
1972
+ // policies do not treat a turn that has finished executeTurn but not yet
1973
+ // resumed queued follow-ups as idle (avoids clearCoalesced races).
1974
+ return (
1975
+ this.activeTurns.has(sessionId) ||
1976
+ this.pendingTurns.has(sessionId) ||
1977
+ this.coalesceDrains.has(sessionId)
1978
+ );
1680
1979
  }
1681
1980
 
1682
1981
  sessionInfo(record: SessionRecord): SessionInfo {
@@ -1698,12 +1997,14 @@ export class SessionEngine {
1698
1997
  private channelSession(
1699
1998
  sessionId: string,
1700
1999
  record: SessionRecord,
1701
- isNew: boolean
2000
+ isNew: boolean,
2001
+ coalesced = false
1702
2002
  ): ChannelSession {
1703
2003
  return {
1704
2004
  id: sessionId,
1705
2005
  continuationToken: record.continuationKey,
1706
2006
  isNew,
2007
+ ...(coalesced ? { coalesced: true } : {}),
1707
2008
  info: this.sessionInfo(record),
1708
2009
  getEventStream: (options?: { startIndex?: number }) =>
1709
2010
  this.eventStream(sessionId, options?.startIndex ?? 0),