@cursor/july 0.1.7 → 0.1.9

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 (210) 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/post-update-delivery.js +318 -0
  31. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  32. package/dist/channels/slack/slack-channel.js +74 -25
  33. package/dist/channels/slack/stop.d.ts +14 -0
  34. package/dist/channels/slack/stop.d.ts.map +1 -0
  35. package/dist/channels/slack/stop.js +22 -0
  36. package/dist/channels/slack/thread-context.d.ts +81 -0
  37. package/dist/channels/slack/thread-context.d.ts.map +1 -0
  38. package/dist/channels/slack/thread-context.js +470 -0
  39. package/dist/channels/slack/types.d.ts +27 -0
  40. package/dist/channels/slack/types.d.ts.map +1 -1
  41. package/dist/docs/404.html +2 -2
  42. package/dist/docs/ab.html +4 -4
  43. package/dist/docs/assets/{app.COTN7wgo.js → app.Ciosf24M.js} +1 -1
  44. package/dist/docs/assets/chunks/@localSearchIndexroot.D154Detj.js +1 -0
  45. package/dist/docs/assets/chunks/{VPLocalSearchBox.BW3TBdT0.js → VPLocalSearchBox.BcRDly7n.js} +1 -1
  46. package/dist/docs/assets/chunks/{theme.BEJW0vE7.js → theme.CMpbjdfx.js} +2 -2
  47. package/dist/docs/assets/{deployment.md.BtfEsc9S.js → deployment.md.DC5-ImcM.js} +1 -1
  48. package/dist/docs/assets/{example-agents_approval-buddy.md.8R5phXb5.js → example-agents_approval-buddy.md.DFGBYLcc.js} +2 -2
  49. package/dist/docs/assets/{example-agents_approval-buddy.md.8R5phXb5.lean.js → example-agents_approval-buddy.md.DFGBYLcc.lean.js} +1 -1
  50. package/dist/docs/assets/example-agents_index.md.BPXjUtRB.js +2 -0
  51. package/dist/docs/assets/example-agents_index.md.BPXjUtRB.lean.js +1 -0
  52. package/dist/docs/assets/example-agents_oncall.md.BG_sUMly.js +10 -0
  53. package/dist/docs/assets/example-agents_oncall.md.BG_sUMly.lean.js +1 -0
  54. package/dist/docs/assets/{example-agents_security-reviewer.md.D2rtwDTO.js → example-agents_security-reviewer.md.CE9v-aKm.js} +2 -2
  55. package/dist/docs/assets/{example-agents_security-reviewer.md.D2rtwDTO.lean.js → example-agents_security-reviewer.md.CE9v-aKm.lean.js} +1 -1
  56. package/dist/docs/assets/{guides_slack.md.CCwqHvSV.js → guides_slack.md.FWale6Ip.js} +4 -3
  57. package/dist/docs/assets/{guides_slack.md.CCwqHvSV.lean.js → guides_slack.md.FWale6Ip.lean.js} +1 -1
  58. package/dist/docs/assets/{reference_sessions.md.DdjWabnN.js → reference_sessions.md.DLd6mvbv.js} +2 -2
  59. package/dist/docs/assets/{reference_sessions.md.DdjWabnN.lean.js → reference_sessions.md.DLd6mvbv.lean.js} +1 -1
  60. package/dist/docs/assets/{storage.md.CVnInNiN.js → storage.md.DRTdnFvd.js} +9 -2
  61. package/dist/docs/assets/{storage.md.CVnInNiN.lean.js → storage.md.DRTdnFvd.lean.js} +1 -1
  62. package/dist/docs/building-with-agents.html +4 -4
  63. package/dist/docs/concepts.html +4 -4
  64. package/dist/docs/deployment.html +6 -6
  65. package/dist/docs/evals.html +4 -4
  66. package/dist/docs/example-agents/approval-buddy.html +5 -5
  67. package/dist/docs/example-agents/benny.html +5 -5
  68. package/dist/docs/example-agents/bugbot.html +5 -5
  69. package/dist/docs/example-agents/codebase-wiki.html +4 -4
  70. package/dist/docs/example-agents/codeowners-review.html +4 -4
  71. package/dist/docs/example-agents/concierge.html +4 -4
  72. package/dist/docs/example-agents/fsd.html +4 -4
  73. package/dist/docs/example-agents/index.html +6 -6
  74. package/dist/docs/example-agents/knowledge-base.html +4 -4
  75. package/dist/docs/example-agents/oncall.html +34 -0
  76. package/dist/docs/example-agents/security-reviewer.html +6 -6
  77. package/dist/docs/example-agents/slack-agent.html +4 -4
  78. package/dist/docs/example-agents/weather-agent.html +4 -4
  79. package/dist/docs/guides/agent-to-agent.html +4 -4
  80. package/dist/docs/guides/cloud-runtime.html +4 -4
  81. package/dist/docs/guides/github.html +4 -4
  82. package/dist/docs/guides/human-in-the-loop.html +4 -4
  83. package/dist/docs/guides/slack.html +7 -6
  84. package/dist/docs/guides/webhooks.html +4 -4
  85. package/dist/docs/hashmap.json +1 -1
  86. package/dist/docs/hillclimbing.html +4 -4
  87. package/dist/docs/index.html +4 -4
  88. package/dist/docs/quickstart.html +4 -4
  89. package/dist/docs/reference/agent-config.html +4 -4
  90. package/dist/docs/reference/channels.html +4 -4
  91. package/dist/docs/reference/cli.html +4 -4
  92. package/dist/docs/reference/connections.html +4 -4
  93. package/dist/docs/reference/hooks.html +4 -4
  94. package/dist/docs/reference/http-api.html +4 -4
  95. package/dist/docs/reference/instructions.html +4 -4
  96. package/dist/docs/reference/playground.html +4 -4
  97. package/dist/docs/reference/project-layout.html +4 -4
  98. package/dist/docs/reference/schedules.html +4 -4
  99. package/dist/docs/reference/sessions.html +5 -5
  100. package/dist/docs/reference/skills.html +4 -4
  101. package/dist/docs/reference/subagents.html +4 -4
  102. package/dist/docs/reference/tools.html +4 -4
  103. package/dist/docs/scaffolding-agents.html +4 -4
  104. package/dist/docs/storage.html +13 -6
  105. package/dist/docs/troubleshooting.html +4 -4
  106. package/dist/internal/cli-deploy.d.ts.map +1 -1
  107. package/dist/internal/cli-deploy.js +45 -18
  108. package/dist/internal/cli-mcp.d.ts +64 -0
  109. package/dist/internal/cli-mcp.d.ts.map +1 -0
  110. package/dist/internal/cli-mcp.js +570 -0
  111. package/dist/internal/deploy-client.d.ts +7 -1
  112. package/dist/internal/deploy-client.d.ts.map +1 -1
  113. package/dist/internal/deploy-client.js +3 -0
  114. package/dist/internal/deploy-source.d.ts +3 -0
  115. package/dist/internal/deploy-source.d.ts.map +1 -1
  116. package/dist/internal/deploy-source.js +25 -7
  117. package/dist/internal/discovery.js +1 -1
  118. package/dist/internal/distribution.d.ts.map +1 -1
  119. package/dist/internal/distribution.js +3 -0
  120. package/dist/internal/host-kv.d.ts +17 -0
  121. package/dist/internal/host-kv.d.ts.map +1 -0
  122. package/dist/internal/host-kv.js +77 -0
  123. package/dist/internal/hosting.d.ts +12 -2
  124. package/dist/internal/hosting.d.ts.map +1 -1
  125. package/dist/internal/hosting.js +21 -4
  126. package/dist/internal/json-dir-store.js +100 -0
  127. package/dist/internal/logs-client.d.ts +16 -2
  128. package/dist/internal/logs-client.d.ts.map +1 -1
  129. package/dist/internal/logs-client.js +51 -16
  130. package/dist/internal/server.d.ts.map +1 -1
  131. package/dist/internal/server.js +8 -0
  132. package/dist/internal/session-engine.d.ts +42 -7
  133. package/dist/internal/session-engine.d.ts.map +1 -1
  134. package/dist/internal/session-engine.js +276 -66
  135. package/dist/internal/storage-coordinator.d.ts +18 -1
  136. package/dist/internal/storage-coordinator.d.ts.map +1 -1
  137. package/dist/internal/storage-coordinator.js +43 -0
  138. package/dist/internal/workspace.d.ts +19 -0
  139. package/dist/internal/workspace.d.ts.map +1 -1
  140. package/dist/internal/workspace.js +47 -13
  141. package/dist/playground/assets/{index-72vCOBWO.js → index-C0-QEG4O.js} +42 -42
  142. package/dist/playground/assets/index-z3JY9O2x.css +1 -0
  143. package/dist/playground/index.html +2 -2
  144. package/dist/storage-backends/cursor-hosted.d.ts +65 -0
  145. package/dist/storage-backends/cursor-hosted.d.ts.map +1 -0
  146. package/dist/storage-backends/cursor-hosted.js +203 -0
  147. package/dist/storage-backends/file-kv.d.ts +42 -0
  148. package/dist/storage-backends/file-kv.d.ts.map +1 -0
  149. package/dist/storage-backends/file-kv.js +190 -0
  150. package/dist/storage-backends/postgres-kv.d.ts +82 -0
  151. package/dist/storage-backends/postgres-kv.d.ts.map +1 -0
  152. package/dist/storage-backends/postgres-kv.js +185 -0
  153. package/dist/storage.d.ts +10 -1
  154. package/dist/storage.d.ts.map +1 -1
  155. package/dist/storage.js +10 -1
  156. package/dist/types.d.ts +79 -0
  157. package/dist/types.d.ts.map +1 -1
  158. package/docs/.vitepress/cache/deps/package.json +3 -0
  159. package/docs/deployment.md +7 -3
  160. package/docs/example-agents/approval-buddy.md +1 -1
  161. package/docs/example-agents/index.md +15 -10
  162. package/docs/example-agents/oncall.md +216 -0
  163. package/docs/example-agents/security-reviewer.md +5 -5
  164. package/docs/guides/slack.md +11 -2
  165. package/docs/reference/sessions.md +13 -4
  166. package/docs/storage.md +41 -0
  167. package/package.json +22 -1
  168. package/skills/framework-map/SKILL.md +6 -4
  169. package/src/bin/agent-serve.ts +32 -2
  170. package/src/channels/slack/channel-watch.ts +28 -1
  171. package/src/channels/slack/cursor-account.ts +20 -0
  172. package/src/channels/slack/defaults.ts +51 -20
  173. package/src/channels/slack/dispatch.ts +215 -40
  174. package/src/channels/slack/inbound.ts +30 -10
  175. package/src/channels/slack/index.ts +4 -0
  176. package/src/channels/slack/live-delivery.ts +1 -0
  177. package/src/channels/slack/manifest.ts +3 -0
  178. package/src/channels/slack/nudge-carrier.ts +182 -0
  179. package/src/channels/slack/nudge-store.ts +308 -0
  180. package/src/channels/slack/slack-channel.ts +106 -31
  181. package/src/channels/slack/stop.ts +25 -0
  182. package/src/channels/slack/thread-context.ts +572 -0
  183. package/src/channels/slack/types.ts +27 -0
  184. package/src/internal/cli-deploy.ts +58 -20
  185. package/src/internal/cli-mcp.ts +708 -0
  186. package/src/internal/deploy-client.ts +11 -0
  187. package/src/internal/deploy-source.ts +24 -6
  188. package/src/internal/discovery.ts +1 -1
  189. package/src/internal/distribution.ts +3 -0
  190. package/src/internal/host-kv.ts +85 -0
  191. package/src/internal/hosting.ts +24 -4
  192. package/src/internal/logs-client.ts +67 -21
  193. package/src/internal/server.ts +31 -0
  194. package/src/internal/session-engine.ts +379 -61
  195. package/src/internal/storage-coordinator.ts +41 -0
  196. package/src/internal/workspace.ts +48 -11
  197. package/src/storage-backends/cursor-hosted.ts +246 -0
  198. package/src/storage-backends/file-kv.ts +221 -0
  199. package/src/storage-backends/postgres-kv.ts +253 -0
  200. package/src/storage.ts +11 -1
  201. package/src/types.ts +83 -0
  202. package/dist/docs/assets/chunks/@localSearchIndexroot.B7UcKvIn.js +0 -1
  203. package/dist/docs/assets/example-agents_index.md.CRqJlnIf.js +0 -2
  204. package/dist/docs/assets/example-agents_index.md.CRqJlnIf.lean.js +0 -1
  205. package/dist/internal/persistence-coordinator.d.ts +0 -127
  206. package/dist/internal/persistence-coordinator.d.ts.map +0 -1
  207. package/dist/persistence.d.ts +0 -184
  208. package/dist/persistence.d.ts.map +0 -1
  209. package/dist/playground/assets/index-BjnMwYoR.css +0 -1
  210. /package/dist/docs/assets/{deployment.md.BtfEsc9S.lean.js → deployment.md.DC5-ImcM.lean.js} +0 -0
@@ -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,
@@ -67,7 +73,6 @@ import {
67
73
  import { validateToolInput } from "./json-schema.js";
68
74
  import { McpHost } from "./mcp-host.js";
69
75
  import {
70
- buildIdentitySection,
71
76
  buildSessionContextBlock,
72
77
  SCHEDULE_CHANNEL_PREFIX,
73
78
  } from "./prompt-context.js";
@@ -77,7 +82,8 @@ import { SessionStore } from "./session-store.js";
77
82
  import { StorageCoordinator } from "./storage-coordinator.js";
78
83
  import { normalizeToolResult, toolCallErrorMessage } from "./tool-result.js";
79
84
  import {
80
- buildAgentToolsCatalog,
85
+ buildAgentsMdContent,
86
+ isNestedInGitRepo,
81
87
  materializeWorkspace,
82
88
  writeWorkspaceFiles,
83
89
  } from "./workspace.js";
@@ -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.
@@ -730,12 +1019,22 @@ export class SessionEngine {
730
1019
  return rest;
731
1020
  });
732
1021
  }
1022
+ // The harness only loads the scaffolded AGENTS.md when the session
1023
+ // workspace is its own project root. With a per-session cwd override
1024
+ // (PR worktree) the scaffold lands elsewhere, and a workspace nested
1025
+ // inside a git repo re-roots harness rules at that repo — in both
1026
+ // cases deliver instructions inline on the first turn instead.
1027
+ const instructionsInvisibleToHarness =
1028
+ isFirstTurn &&
1029
+ (record.workspaceOverride !== undefined ||
1030
+ (await isNestedInGitRepo(harnessCwd)));
733
1031
  const request = this.buildTurnRequest(
734
1032
  record,
735
1033
  agent,
736
1034
  turnId,
737
1035
  message,
738
1036
  isFirstTurn,
1037
+ instructionsInvisibleToHarness,
739
1038
  emit,
740
1039
  async (update) => {
741
1040
  mapper.handleUpdate(update);
@@ -831,6 +1130,7 @@ export class SessionEngine {
831
1130
  turnId: string,
832
1131
  prompt: string,
833
1132
  isFirstTurn: boolean,
1133
+ instructionsInvisibleToHarness: boolean,
834
1134
  emit: (payload: SessionEventPayload) => void,
835
1135
  onUpdate: RunnerTurnRequest["onUpdate"],
836
1136
  signal: AbortSignal
@@ -858,15 +1158,21 @@ export class SessionEngine {
858
1158
  });
859
1159
  turnPrompt = `${contextBlock}\n\n${prompt}`;
860
1160
  }
861
- if (runtime === "cloud" && isFirstTurn) {
862
- const preamble = [
863
- agent.instructions?.trim() ?? "",
864
- buildIdentitySection(agent),
865
- buildAgentToolsCatalog(agent.tools, { includeScripts: true }),
866
- ]
867
- .filter((part) => part !== "")
868
- .join("\n\n");
869
- turnPrompt = `${preamble}\n\n${turnPrompt}`;
1161
+ if (isFirstTurn) {
1162
+ if (runtime === "cloud") {
1163
+ // Cloud VMs have no materialized workspace: ship the full context,
1164
+ // including agent tool script bodies.
1165
+ const preamble = buildAgentsMdContent(agent, { includeScripts: true });
1166
+ turnPrompt = `${preamble}\n\n${turnPrompt}`;
1167
+ } else if (instructionsInvisibleToHarness) {
1168
+ // Local workspace whose AGENTS.md the harness will not load. With a
1169
+ // per-session harness cwd override, agent tool scripts live only in
1170
+ // the durable scaffold dir — embed bodies like cloud. Otherwise the
1171
+ // catalog can reference on-disk paths under the harness cwd.
1172
+ const includeScripts = record.workspaceOverride !== undefined;
1173
+ const preamble = buildAgentsMdContent(agent, { includeScripts });
1174
+ turnPrompt = `${preamble}\n\n${turnPrompt}`;
1175
+ }
870
1176
  }
871
1177
  return {
872
1178
  sessionId: record.sessionId,
@@ -1498,15 +1804,20 @@ export class SessionEngine {
1498
1804
  async waitForCompletion(sessionId: string): Promise<TurnOutcome> {
1499
1805
  // Drain admission first so pendingTurns → activeTurns is visible before we
1500
1806
  // 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;
1807
+ // The active lifecycle promise also awaits coalesce drain + the drained
1808
+ // follow-up's admission, so Slack coalesced waiters see the resume turn.
1809
+ for (;;) {
1810
+ const chain = this.admissionChains.get(sessionId);
1811
+ if (chain !== undefined) {
1812
+ await chain.catch(() => {});
1813
+ }
1814
+ const active = this.activeTurns.get(sessionId);
1815
+ if (active !== undefined) {
1816
+ await active;
1817
+ continue;
1818
+ }
1819
+ return this.lastOutcomes.get(sessionId) ?? { status: "finished" };
1508
1820
  }
1509
- return this.lastOutcomes.get(sessionId) ?? { status: "finished" };
1510
1821
  }
1511
1822
 
1512
1823
  /**
@@ -1674,9 +1985,14 @@ export class SessionEngine {
1674
1985
  }
1675
1986
 
1676
1987
  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);
1988
+ // Include the admit window (pendingTurns) and coalesce drain so busy
1989
+ // policies do not treat a turn that has finished executeTurn but not yet
1990
+ // resumed queued follow-ups as idle (avoids clearCoalesced races).
1991
+ return (
1992
+ this.activeTurns.has(sessionId) ||
1993
+ this.pendingTurns.has(sessionId) ||
1994
+ this.coalesceDrains.has(sessionId)
1995
+ );
1680
1996
  }
1681
1997
 
1682
1998
  sessionInfo(record: SessionRecord): SessionInfo {
@@ -1698,12 +2014,14 @@ export class SessionEngine {
1698
2014
  private channelSession(
1699
2015
  sessionId: string,
1700
2016
  record: SessionRecord,
1701
- isNew: boolean
2017
+ isNew: boolean,
2018
+ coalesced = false
1702
2019
  ): ChannelSession {
1703
2020
  return {
1704
2021
  id: sessionId,
1705
2022
  continuationToken: record.continuationKey,
1706
2023
  isNew,
2024
+ ...(coalesced ? { coalesced: true } : {}),
1707
2025
  info: this.sessionInfo(record),
1708
2026
  getEventStream: (options?: { startIndex?: number }) =>
1709
2027
  this.eventStream(sessionId, options?.startIndex ?? 0),
@@ -392,6 +392,47 @@ export class StorageCoordinator {
392
392
  );
393
393
  }
394
394
 
395
+ // ==========================================================================
396
+ // Author KV ({@link HostContext.kv})
397
+ // ==========================================================================
398
+
399
+ /**
400
+ * Whether the sink can serve awaited author reads/writes (`get` is
401
+ * required — `put` is always present on {@link StorageConfig}).
402
+ */
403
+ get supportsAuthorKv(): boolean {
404
+ return this.definition.get !== undefined;
405
+ }
406
+
407
+ /**
408
+ * Awaited author KV read. Unlike framework session mirrors, this awaits
409
+ * the sink and propagates errors — handlers need read-after-write.
410
+ */
411
+ async kvGet(authorKey: string): Promise<JsonValue | undefined> {
412
+ const get = this.definition.get;
413
+ if (get === undefined) {
414
+ throw new Error("storage get is not configured");
415
+ }
416
+ const key = storageKeys.kv(this.agentName, authorKey);
417
+ // Preserve stored JSON `null` (distinct from a missing key).
418
+ return await get(key, this.context("restore"));
419
+ }
420
+
421
+ /**
422
+ * Awaited author KV write. Goes straight to the sink (not the
423
+ * at-most-once delivery queue) so callers observe durable success/failure.
424
+ */
425
+ async kvPut(authorKey: string, value: JsonValue): Promise<void> {
426
+ const key = storageKeys.kv(this.agentName, authorKey);
427
+ await this.definition.put(key, value, this.context("policy"));
428
+ }
429
+
430
+ /** Awaited author KV delete. No-op when the sink omits `delete`. */
431
+ async kvDelete(authorKey: string): Promise<void> {
432
+ const key = storageKeys.kv(this.agentName, authorKey);
433
+ await this.definition.delete?.(key, this.context("policy"));
434
+ }
435
+
395
436
  // ==========================================================================
396
437
  // Evals (dedicated `evals` table)
397
438
  // ==========================================================================