@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cursor/july",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
@@ -124,6 +124,20 @@
124
124
  "import": "./dist/storage.js",
125
125
  "default": "./dist/storage.js"
126
126
  },
127
+ "./storage/file-kv": {
128
+ "anysphere-source": "./src/storage-backends/file-kv.ts",
129
+ "bun": "./src/storage-backends/file-kv.ts",
130
+ "types": "./dist/storage-backends/file-kv.d.ts",
131
+ "import": "./dist/storage-backends/file-kv.js",
132
+ "default": "./dist/storage-backends/file-kv.js"
133
+ },
134
+ "./storage/postgres-kv": {
135
+ "anysphere-source": "./src/storage-backends/postgres-kv.ts",
136
+ "bun": "./src/storage-backends/postgres-kv.ts",
137
+ "types": "./dist/storage-backends/postgres-kv.d.ts",
138
+ "import": "./dist/storage-backends/postgres-kv.js",
139
+ "default": "./dist/storage-backends/postgres-kv.js"
140
+ },
127
141
  "./package.json": {
128
142
  "anysphere-source": "./package.json",
129
143
  "bun": "./package.json",
@@ -97,10 +97,12 @@ Two handles, two owners:
97
97
  - **sessionId** — runtime-owned handle for streaming/inspection
98
98
  (`GET /v1/session/:id/stream?startIndex=N`, durable NDJSON replay).
99
99
 
100
- A follow-up to a busy session **interrupts the in-flight turn** and waits
101
- for it to settle before the new turn sends. `POST /v1/session/:id/stop`
102
- interrupts without sending. Session-bound deterministic tool calls are
103
- rejected with `409 session_busy` while a turn runs.
100
+ A follow-up to a busy HTTP/MCP session **interrupts the in-flight turn**
101
+ and waits for it to settle before the new turn sends. Slack uses
102
+ **coalesce** instead (enqueue + boundary drain; see
103
+ `docs/reference/sessions.md`). `POST /v1/session/:id/stop` interrupts
104
+ without sending. Session-bound deterministic tool calls are rejected with
105
+ `409 session_busy` while a turn runs.
104
106
 
105
107
  Key built-in routes (each agent, under `/<slug>` in multi-agent mode):
106
108
  `POST /v1/session`, `POST /v1/session/:id`, `GET /v1/session/:id/stream`,
@@ -89,6 +89,10 @@ Usage:
89
89
  ${CLI} call <tool> [--input <json>] [--dir <path> | --url <baseUrl> | --prod]
90
90
  [--session <id>] [--slug <slug>] [--team <id>]
91
91
  ${CLI} init [dir] [--json]
92
+ ${CLI} mcp --prod [--slug <slug>] [--team <id>] [--dir <path>]
93
+ | --url <baseUrl> [--bearer-token <t>]
94
+ ${CLI} mcp install [--name <serverName>] [--print] [--json]
95
+ (same target flags as mcp)
92
96
  ${CLI} eval [--dir <path>] [evalId...] [--list] [--json]
93
97
  [--tag <tag>]... [--verbose] [--timeout-ms <n>]
94
98
  [--no-stream] [--no-wait]
@@ -158,6 +162,15 @@ Commands:
158
162
  run login when unsigned, then print next steps. --json is
159
163
  machine-readable and never blocks on login. No directory:
160
164
  print the setup guide.
165
+ mcp Serve this agent's MCP endpoint over stdio for MCP clients
166
+ that spawn local servers (e.g. Cursor). Proxies
167
+ newline-delimited JSON-RPC to <target>/v1/mcp. --prod resolves
168
+ the hosted deployment with the signed-in Cursor account and
169
+ re-mints short-lived engine credentials as they expire;
170
+ --url targets a running server. stdout is reserved for the
171
+ MCP wire. \`mcp install\` writes the matching entry into
172
+ ~/.cursor/mcp.json (--print to preview; --name to override
173
+ the server name).
161
174
  eval Run filesystem evals under evals/**/*.eval.ts (streams on a TTY).
162
175
  Default: ephemeral local harness against evals on disk. Pass
163
176
  --prod to kick off the batch on the team's hosted deployment
@@ -418,8 +431,9 @@ async function main(argv: string[]): Promise<number> {
418
431
  const dir = values.dir ?? ".";
419
432
 
420
433
  // Best-effort, cached, and silent on failure. `update` does its own fresh
421
- // lookup, and --json runs stay quiet for machine consumers.
422
- if (command !== "update" && values.json !== true) {
434
+ // lookup, --json runs stay quiet for machine consumers, and `mcp` skips it
435
+ // so MCP client spawns stay fast (stdout there is the protocol wire).
436
+ if (command !== "update" && command !== "mcp" && values.json !== true) {
423
437
  await warnIfOutdated();
424
438
  }
425
439
 
@@ -486,6 +500,22 @@ async function main(argv: string[]): Promise<number> {
486
500
  });
487
501
  }
488
502
 
503
+ case "mcp": {
504
+ const { cmdMcp } = await import("../internal/cli-mcp.js");
505
+ return cmdMcp(positionals[0], {
506
+ dir,
507
+ slug: values.slug,
508
+ team: values.team,
509
+ apiKey: values["api-key"],
510
+ url: values.url,
511
+ prod: values.prod,
512
+ bearerToken: values["bearer-token"],
513
+ name: values.name,
514
+ print: values.print,
515
+ json: values.json,
516
+ });
517
+ }
518
+
489
519
  case "login": {
490
520
  const { cmdLogin } = await import("../internal/cli-cursor.js");
491
521
  return cmdLogin({
@@ -6,7 +6,10 @@
6
6
  * - Top-level posts only by default; thread replies never dispatch unless
7
7
  * `posts: "all"` is configured.
8
8
  * - Posts that @mention the bot are skipped here (the `app_mention` path
9
- * owns them), and bot-authored posts are dropped upstream in parsing.
9
+ * owns them).
10
+ * - Bot-authored posts are dropped unless `includeBotPosts` is configured
11
+ * (alert feeds / webhook integrations); the watching bot's own posts are
12
+ * always dropped either way (loop safety).
10
13
  * - An optional debounce window lets rapid edits settle: edits update the
11
14
  * pending text, deleting the post cancels the dispatch.
12
15
  */
@@ -114,11 +117,14 @@ export function createChannelPostWatcher(args: {
114
117
  allowlist: SlackChannelAllowlist;
115
118
  /** Bot user id (from `auth.test`) so bot mentions route via app_mention. */
116
119
  getBotUserId: () => string | undefined;
120
+ /** This app's own `bot_id` (from `auth.test`) for self-post loop safety. */
121
+ getBotId?: () => string | undefined;
117
122
  dispatch: (message: SlackMessage, eventId?: string) => void;
118
123
  log?: SlackLog;
119
124
  }): ChannelPostWatcher {
120
125
  const log = args.log ?? defaultSlackLog;
121
126
  const includeReplies = args.options.posts === "all";
127
+ const includeBotPosts = args.options.includeBotPosts === true;
122
128
  const debounceMs = args.options.debounceMs ?? 0;
123
129
  const pending = new Map<
124
130
  string,
@@ -185,6 +191,27 @@ export function createChannelPostWatcher(args: {
185
191
  return skip("thread reply (posts: top-level)", message);
186
192
  }
187
193
  const botUserId = args.getBotUserId();
194
+ const author = message.author;
195
+ if (author?.isBot === true) {
196
+ if (!includeBotPosts) {
197
+ return skip("bot-authored post (includeBotPosts off)", message);
198
+ }
199
+ const selfBotId = args.getBotId?.();
200
+ if (selfBotId === undefined && botUserId === undefined) {
201
+ return skip(
202
+ "bot-authored post (bot identity unknown; loop safety)",
203
+ message
204
+ );
205
+ }
206
+ if (
207
+ (selfBotId !== undefined && author.botId === selfBotId) ||
208
+ (botUserId !== undefined &&
209
+ author.userId !== "" &&
210
+ author.userId === botUserId)
211
+ ) {
212
+ return skip("own post (loop safety)", message);
213
+ }
214
+ }
188
215
  if (botUserId !== undefined && message.text.includes(`<@${botUserId}>`)) {
189
216
  return skip("bot mention (app_mention path owns it)", message);
190
217
  }
@@ -114,6 +114,12 @@ export async function dispatchCursorSlackEnvelope(args: {
114
114
  envelope: CursorSlackEnvelope;
115
115
  send: SendMessageFn;
116
116
  host: HostContext;
117
+ hasContinuationSession?: (continuationToken: string) => Promise<boolean>;
118
+ isContinuationBusy?: (continuationToken: string) => Promise<boolean>;
119
+ getContinuationLastBotMessageTs?: (
120
+ continuationToken: string
121
+ ) => Promise<string | undefined>;
122
+ interruptContinuation?: (continuationToken: string) => Promise<boolean>;
117
123
  }): Promise<void> {
118
124
  const { runtime, envelope } = args;
119
125
  const log = runtime.getLog();
@@ -161,6 +167,20 @@ export async function dispatchCursorSlackEnvelope(args: {
161
167
  credentials: runtime.credentials,
162
168
  host: args.host,
163
169
  log,
170
+ ...(args.hasContinuationSession === undefined
171
+ ? {}
172
+ : { hasContinuationSession: args.hasContinuationSession }),
173
+ ...(args.isContinuationBusy === undefined
174
+ ? {}
175
+ : { isContinuationBusy: args.isContinuationBusy }),
176
+ ...(args.getContinuationLastBotMessageTs === undefined
177
+ ? {}
178
+ : {
179
+ getContinuationLastBotMessageTs: args.getContinuationLastBotMessageTs,
180
+ }),
181
+ ...(args.interruptContinuation === undefined
182
+ ? {}
183
+ : { interruptContinuation: args.interruptContinuation }),
164
184
  ...(envelope.eventId === undefined ? {} : { eventId: envelope.eventId }),
165
185
  });
166
186
  }
@@ -26,28 +26,51 @@ import type {
26
26
  SlackSuggestedPrompt,
27
27
  } from "./types.js";
28
28
 
29
- const defaultOnInbound: SlackMessageHandler = async (ctx, message) => {
30
- await ctx.thread.startTyping("Thinking…");
31
- const auth = defaultSlackAuth(message, ctx);
32
- return auth === null ? null : { auth };
33
- };
29
+ function resolveLoadingMessages(
30
+ loadingMessages: readonly string[] | undefined
31
+ ): string[] {
32
+ if (loadingMessages !== undefined && loadingMessages.length > 0) {
33
+ return [...loadingMessages];
34
+ }
35
+ return [...SLACK_DEFAULT_LOADING_MESSAGES];
36
+ }
34
37
 
35
- export const defaultOnAppMention: SlackMessageHandler = defaultOnInbound;
36
- export const defaultOnDirectMessage: SlackMessageHandler = defaultOnInbound;
38
+ /** Default mention/DM inbound handler (optional custom loading rotation). */
39
+ export function createDefaultOnInbound(
40
+ loadingMessages?: readonly string[]
41
+ ): SlackMessageHandler {
42
+ const rotating = resolveLoadingMessages(loadingMessages);
43
+ return async (ctx, message) => {
44
+ await ctx.thread.startTyping("Thinking…", rotating);
45
+ const auth = defaultSlackAuth(message, ctx);
46
+ return auth === null ? null : { auth };
47
+ };
48
+ }
37
49
 
38
50
  /**
39
- * Default watched-channel-post handler: same thread-scoped principal as
40
- * mentions (so a later @mention continues the session), tagged with an
41
- * `engagement: "channel_post"` attribute for auth policies.
51
+ * Default watched-channel-post handler (optional custom loading rotation).
52
+ * Same thread-scoped principal as mentions, tagged with
53
+ * `engagement: "channel_post"` for auth policies.
42
54
  */
43
- export const defaultOnChannelPost: SlackMessageHandler = async (
44
- ctx,
45
- message
46
- ) => {
47
- await ctx.thread.startTyping("Thinking…");
48
- const auth = defaultSlackAuth(message, ctx, { engagement: "channel_post" });
49
- return auth === null ? null : { auth };
50
- };
55
+ export function createDefaultOnChannelPost(
56
+ loadingMessages?: readonly string[]
57
+ ): SlackMessageHandler {
58
+ const rotating = resolveLoadingMessages(loadingMessages);
59
+ return async (ctx, message) => {
60
+ await ctx.thread.startTyping("Thinking…", rotating);
61
+ const auth = defaultSlackAuth(message, ctx, {
62
+ engagement: "channel_post",
63
+ });
64
+ return auth === null ? null : { auth };
65
+ };
66
+ }
67
+
68
+ export const defaultOnAppMention: SlackMessageHandler =
69
+ createDefaultOnInbound();
70
+ export const defaultOnDirectMessage: SlackMessageHandler =
71
+ createDefaultOnInbound();
72
+ export const defaultOnChannelPost: SlackMessageHandler =
73
+ createDefaultOnChannelPost();
51
74
 
52
75
  export interface BuildDefaultEventsOptions {
53
76
  credentials?: SlackCredentials;
@@ -55,6 +78,8 @@ export interface BuildDefaultEventsOptions {
55
78
  thinkingSteps?: boolean;
56
79
  suggestedPrompts?: SlackSuggestedPrompt[];
57
80
  suggestedPromptsTitle?: string;
81
+ /** Rotating assistant status lines; defaults to pack constants. */
82
+ loadingMessages?: readonly string[];
58
83
  /** Resolved per-call so channel onStart can rebind the server logger. */
59
84
  getLog?: () => SlackLog;
60
85
  }
@@ -74,6 +99,7 @@ export function buildDefaultEvents(
74
99
  const thinkingSteps = options.thinkingSteps !== false;
75
100
  const suggestedPrompts = options.suggestedPrompts ?? [];
76
101
  const suggestedPromptsTitle = options.suggestedPromptsTitle;
102
+ const loadingMessages = resolveLoadingMessages(options.loadingMessages);
77
103
  const botToken = credentials?.botToken;
78
104
  const getLog = options.getLog ?? (() => defaultSlackLog);
79
105
  const strategy = createStreamDeliveryStrategy({ botToken, getLog });
@@ -87,6 +113,7 @@ export function buildDefaultEvents(
87
113
  channel.state.streamTs = null;
88
114
  channel.state.streamChannelId = null;
89
115
  channel.state.lastPostedText = null;
116
+ // Keep lastBotMessageTs across turns — warm delta watermark.
90
117
  channel.state.approvalMessageByCallId = {};
91
118
 
92
119
  const state = channel.state;
@@ -106,7 +133,7 @@ export function buildDefaultEvents(
106
133
  channelId: state.channelId,
107
134
  threadTs: state.threadTs,
108
135
  status: "Working…",
109
- loadingMessages: [...SLACK_DEFAULT_LOADING_MESSAGES],
136
+ loadingMessages,
110
137
  });
111
138
 
112
139
  // Retry streaming each turn unless a prior call proved the feature
@@ -178,7 +205,10 @@ export function buildDefaultEvents(
178
205
  channelId: channel.state.channelId,
179
206
  threadTs: channel.state.threadTs,
180
207
  status,
181
- loadingMessages: [status, ...SLACK_DEFAULT_LOADING_MESSAGES],
208
+ loadingMessages: [
209
+ status,
210
+ ...loadingMessages.filter((message) => message !== status),
211
+ ],
182
212
  });
183
213
  channel.state.lastReasoningTypingAtMs = now;
184
214
  channel.state.lastReasoningTypingStatus = status;
@@ -337,6 +367,7 @@ export function buildDefaultEvents(
337
367
  threadTs: state.threadTs,
338
368
  });
339
369
  channel.state.lastPostedText = event.data.text;
370
+ channel.state.lastBotMessageTs = posted.ts;
340
371
  log.info("reply delivered via postMessage", {
341
372
  channel: state.channelId,
342
373
  thread_ts: state.threadTs,
@@ -2,7 +2,6 @@
2
2
  * Shared inbound dispatch for Slack Events API bodies and Socket Mode.
3
3
  */
4
4
 
5
- import { SessionBusyError } from "../../internal/session-engine.js";
6
5
  import type { HostContext, JsonValue, SendMessageFn } from "../../types.js";
7
6
  import { buildSlackBinding, slackContinuationKey } from "./api.js";
8
7
  import { firstLineTitle, maybeSetThreadTitle } from "./assistant.js";
@@ -15,6 +14,11 @@ import {
15
14
  parseDirectMessageEvent,
16
15
  } from "./inbound.js";
17
16
  import { defaultSlackLog, type SlackLog } from "./log.js";
17
+ import { isWholeMessageStop } from "./stop.js";
18
+ import {
19
+ loadSlackThreadContextBlock,
20
+ loadSlackThreadDeltaBlock,
21
+ } from "./thread-context.js";
18
22
  import type {
19
23
  SlackChannelState,
20
24
  SlackCredentials,
@@ -23,8 +27,7 @@ import type {
23
27
  SlackMessageHandler,
24
28
  } from "./types.js";
25
29
 
26
- const BUSY_REPLY =
27
- "I'm still working on your previous message in this thread — I'll reply when that finishes. Send another message after that.";
30
+ const FOLLOWUP_TYPING = "Saw your follow-up…";
28
31
 
29
32
  /** Blank per-session adapter state. */
30
33
  export function emptySlackChannelState(
@@ -41,6 +44,7 @@ export function emptySlackChannelState(
41
44
  streamTs: null,
42
45
  streamChannelId: null,
43
46
  lastPostedText: null,
47
+ lastBotMessageTs: null,
44
48
  streamingUnavailable: false,
45
49
  approvalMessageByCallId: {},
46
50
  ...patch,
@@ -62,6 +66,37 @@ export interface SlackInboundDispatchDeps {
62
66
  directMessagesEnabled?: boolean;
63
67
  /** Opt-in channel watch; absent = channel posts are ignored. */
64
68
  channelWatch?: ChannelPostWatcher;
69
+ /**
70
+ * When set, full-thread hydration runs only for cold-start turns (no
71
+ * existing session). Warm turns load a `<slack_thread_delta>` instead.
72
+ */
73
+ hasContinuationSession?: (continuationToken: string) => Promise<boolean>;
74
+ /** True when a turn is already running for this Slack continuation. */
75
+ isContinuationBusy?: (continuationToken: string) => Promise<boolean>;
76
+ /**
77
+ * Warm-delta watermark from session channel state (`lastBotMessageTs`).
78
+ * When absent, delta falls back to the last matching bot post in-thread.
79
+ */
80
+ getContinuationLastBotMessageTs?: (
81
+ continuationToken: string
82
+ ) => Promise<string | undefined>;
83
+ /** Cancel the active turn + clear coalesced nudges for this continuation. */
84
+ interruptContinuation?: (continuationToken: string) => Promise<boolean>;
85
+ }
86
+
87
+ function triggeringMergeMessage(
88
+ message: SlackMessage
89
+ ): Record<string, unknown> {
90
+ return {
91
+ ts: message.ts,
92
+ ...(message.author?.userId === undefined || message.author.userId === ""
93
+ ? {}
94
+ : { user: message.author.userId }),
95
+ ...(message.author?.botId === undefined
96
+ ? {}
97
+ : { bot_id: message.author.botId }),
98
+ text: message.text,
99
+ };
65
100
  }
66
101
 
67
102
  export async function dispatchInboundMessage(args: {
@@ -71,6 +106,12 @@ export async function dispatchInboundMessage(args: {
71
106
  send: SendMessageFn;
72
107
  credentials: SlackCredentials | undefined;
73
108
  host: HostContext;
109
+ hasContinuationSession?: (continuationToken: string) => Promise<boolean>;
110
+ isContinuationBusy?: (continuationToken: string) => Promise<boolean>;
111
+ getContinuationLastBotMessageTs?: (
112
+ continuationToken: string
113
+ ) => Promise<string | undefined>;
114
+ interruptContinuation?: (continuationToken: string) => Promise<boolean>;
74
115
  log?: SlackLog;
75
116
  eventId?: string;
76
117
  }): Promise<void> {
@@ -95,6 +136,38 @@ export async function dispatchInboundMessage(args: {
95
136
  team: message.teamId,
96
137
  });
97
138
 
139
+ const continuation = slackContinuationKey(
140
+ message.channelId,
141
+ message.threadTs
142
+ );
143
+
144
+ // Whole-message stop: cancel active turn + clear nudges (Benny parity).
145
+ if (
146
+ isWholeMessageStop(message.text) ||
147
+ isWholeMessageStop(message.markdown)
148
+ ) {
149
+ log.info("stop directive", {
150
+ kind,
151
+ channel: message.channelId,
152
+ thread_ts: message.threadTs,
153
+ continuation,
154
+ event_id: args.eventId,
155
+ });
156
+ await args.host.slackNudges?.clear(continuation);
157
+ const interrupted =
158
+ args.interruptContinuation === undefined
159
+ ? false
160
+ : await args.interruptContinuation(continuation);
161
+ try {
162
+ await binding.thread.startTyping(
163
+ interrupted ? "Stopped." : "Nothing to stop."
164
+ );
165
+ } catch {
166
+ // best-effort status
167
+ }
168
+ return;
169
+ }
170
+
98
171
  if (message.ts === message.threadTs) {
99
172
  await maybeSetThreadTitle({
100
173
  botToken: args.credentials?.botToken,
@@ -127,7 +200,67 @@ export async function dispatchInboundMessage(args: {
127
200
  return;
128
201
  }
129
202
 
203
+ // Reply-once / delayed parent: claim channel:ts after the handler accepts.
204
+ const claimed = await args.host.slackNudges?.tryClaimAsk(
205
+ message.channelId,
206
+ message.ts
207
+ );
208
+ if (claimed === false) {
209
+ log.info("inbound ask dedupe drop", {
210
+ kind,
211
+ channel: message.channelId,
212
+ message_ts: message.ts,
213
+ continuation,
214
+ event_id: args.eventId,
215
+ });
216
+ return;
217
+ }
218
+
130
219
  const userText = decision.message ?? message.markdown;
220
+ const excludeThreadTimestamps = [
221
+ message.ts,
222
+ ...(decision.excludeThreadTimestamps ?? []),
223
+ ];
224
+ const hasSession =
225
+ args.hasContinuationSession === undefined
226
+ ? false
227
+ : await args.hasContinuationSession(continuation);
228
+ const mergeMessages = [triggeringMergeMessage(message)];
229
+ const afterTs =
230
+ hasSession && args.getContinuationLastBotMessageTs !== undefined
231
+ ? await args.getContinuationLastBotMessageTs(continuation)
232
+ : undefined;
233
+
234
+ // Cold start: full <slack_thread>. Warm: <slack_thread_delta> since last
235
+ // agent post (session watermark), else last matching bot post in-thread.
236
+ const threadContext = hasSession
237
+ ? await loadSlackThreadDeltaBlock({
238
+ botToken: args.credentials?.botToken,
239
+ channelId: message.channelId,
240
+ threadTs: message.threadTs,
241
+ ...(afterTs === undefined ? {} : { afterTs }),
242
+ excludeTs: excludeThreadTimestamps,
243
+ mergeMessages,
244
+ log,
245
+ })
246
+ : await loadSlackThreadContextBlock({
247
+ botToken: args.credentials?.botToken,
248
+ channelId: message.channelId,
249
+ threadTs: message.threadTs,
250
+ excludeTs: excludeThreadTimestamps,
251
+ mergeMessages,
252
+ log,
253
+ });
254
+ if (threadContext !== undefined) {
255
+ log.info(hasSession ? "thread delta loaded" : "thread context loaded", {
256
+ kind,
257
+ channel: message.channelId,
258
+ thread_ts: message.threadTs,
259
+ chars: threadContext.length,
260
+ event_id: args.eventId,
261
+ ...(afterTs === undefined ? {} : { after_ts: afterTs }),
262
+ });
263
+ }
131
264
  const prompt = [
132
265
  formatSlackContextBlock({
133
266
  userId: message.author?.userId ?? "",
@@ -138,6 +271,7 @@ export async function dispatchInboundMessage(args: {
138
271
  : { userName: message.author.userName }),
139
272
  ...(message.teamId === undefined ? {} : { teamId: message.teamId }),
140
273
  }),
274
+ ...(threadContext === undefined ? [] : [threadContext]),
141
275
  ...(decision.context ?? []),
142
276
  "",
143
277
  userText,
@@ -145,39 +279,43 @@ export async function dispatchInboundMessage(args: {
145
279
  .filter((part) => part.length > 0)
146
280
  .join("\n");
147
281
 
148
- const continuation = slackContinuationKey(
149
- message.channelId,
150
- message.threadTs
151
- );
282
+ const sendOptions = {
283
+ auth: decision.auth,
284
+ continuationToken: continuation,
285
+ admission: "coalesce" as const,
286
+ coalesceSourceTs: message.ts,
287
+ state: emptySlackChannelState({
288
+ channelId: message.channelId,
289
+ threadTs: message.threadTs,
290
+ teamId: message.teamId ?? null,
291
+ triggeringUserId: message.author?.userId ?? null,
292
+ }) as unknown as JsonValue,
293
+ title: decision.title ?? `slack:${message.channelId}:${message.threadTs}`,
294
+ workspaceDir: decision.workspaceDir,
295
+ workspaceFiles: decision.workspaceFiles,
296
+ cloud: decision.cloud,
297
+ sdkAgentId: decision.sdkAgentId,
298
+ };
152
299
 
153
300
  try {
154
- const session = await args.send(prompt, {
155
- auth: decision.auth,
156
- continuationToken: continuation,
157
- state: emptySlackChannelState({
158
- channelId: message.channelId,
159
- threadTs: message.threadTs,
160
- teamId: message.teamId ?? null,
161
- triggeringUserId: message.author?.userId ?? null,
162
- }) as unknown as JsonValue,
163
- title: decision.title ?? `slack:${message.channelId}:${message.threadTs}`,
164
- workspaceDir: decision.workspaceDir,
165
- workspaceFiles: decision.workspaceFiles,
166
- cloud: decision.cloud,
167
- sdkAgentId: decision.sdkAgentId,
168
- });
169
- log.info("session start", {
170
- kind,
171
- session: session.id,
172
- is_new: session.isNew,
173
- continuation,
174
- channel: message.channelId,
175
- thread_ts: message.threadTs,
176
- event_id: args.eventId,
177
- });
178
- } catch (error) {
179
- if (error instanceof SessionBusyError) {
180
- log.info("session busy; notifying thread", {
301
+ const session = await args.send(prompt, sendOptions);
302
+ log.info(
303
+ session.coalesced === true ? "session coalesced" : "session start",
304
+ {
305
+ kind,
306
+ session: session.id,
307
+ is_new: session.isNew,
308
+ coalesced: session.coalesced === true,
309
+ continuation,
310
+ channel: message.channelId,
311
+ thread_ts: message.threadTs,
312
+ event_id: args.eventId,
313
+ }
314
+ );
315
+ // Coalesce admits leave the active turn running; follow-ups drain into one
316
+ // boundary turn when it finishes (no mid-turn tool/hook inject).
317
+ if (session.coalesced === true) {
318
+ log.info("session busy; coalescing follow-up", {
181
319
  kind,
182
320
  channel: message.channelId,
183
321
  thread_ts: message.threadTs,
@@ -185,11 +323,13 @@ export async function dispatchInboundMessage(args: {
185
323
  event_id: args.eventId,
186
324
  });
187
325
  try {
188
- await binding.thread.post(BUSY_REPLY);
189
- } catch (postError) {
326
+ await binding.thread.startTyping(FOLLOWUP_TYPING);
327
+ } catch (typingError) {
190
328
  log.error(
191
- `busy reply failed: ${
192
- postError instanceof Error ? postError.message : String(postError)
329
+ `follow-up typing failed: ${
330
+ typingError instanceof Error
331
+ ? typingError.message
332
+ : String(typingError)
193
333
  }`,
194
334
  {
195
335
  channel: message.channelId,
@@ -198,8 +338,9 @@ export async function dispatchInboundMessage(args: {
198
338
  }
199
339
  );
200
340
  }
201
- return;
202
341
  }
342
+ } catch (error) {
343
+ await args.host.slackNudges?.releaseAsk(message.channelId, message.ts);
203
344
  log.error(
204
345
  `${kind} delivery failed: ${
205
346
  error instanceof Error ? error.message : String(error)
@@ -252,6 +393,21 @@ export function dispatchInboundFromEventBody(
252
393
  credentials: deps.credentials,
253
394
  host: deps.host,
254
395
  log,
396
+ ...(deps.hasContinuationSession === undefined
397
+ ? {}
398
+ : { hasContinuationSession: deps.hasContinuationSession }),
399
+ ...(deps.isContinuationBusy === undefined
400
+ ? {}
401
+ : { isContinuationBusy: deps.isContinuationBusy }),
402
+ ...(deps.getContinuationLastBotMessageTs === undefined
403
+ ? {}
404
+ : {
405
+ getContinuationLastBotMessageTs:
406
+ deps.getContinuationLastBotMessageTs,
407
+ }),
408
+ ...(deps.interruptContinuation === undefined
409
+ ? {}
410
+ : { interruptContinuation: deps.interruptContinuation }),
255
411
  ...(eventId === undefined ? {} : { eventId }),
256
412
  })
257
413
  );
@@ -275,6 +431,21 @@ export function dispatchInboundFromEventBody(
275
431
  credentials: deps.credentials,
276
432
  host: deps.host,
277
433
  log,
434
+ ...(deps.hasContinuationSession === undefined
435
+ ? {}
436
+ : { hasContinuationSession: deps.hasContinuationSession }),
437
+ ...(deps.isContinuationBusy === undefined
438
+ ? {}
439
+ : { isContinuationBusy: deps.isContinuationBusy }),
440
+ ...(deps.getContinuationLastBotMessageTs === undefined
441
+ ? {}
442
+ : {
443
+ getContinuationLastBotMessageTs:
444
+ deps.getContinuationLastBotMessageTs,
445
+ }),
446
+ ...(deps.interruptContinuation === undefined
447
+ ? {}
448
+ : { interruptContinuation: deps.interruptContinuation }),
278
449
  ...(eventId === undefined ? {} : { eventId }),
279
450
  })
280
451
  );
@@ -282,7 +453,11 @@ export function dispatchInboundFromEventBody(
282
453
  }
283
454
 
284
455
  if (deps.channelWatch !== undefined) {
285
- const channelEvent = parseChannelMessageEvent(body);
456
+ // Parse keeps bot-authored posts; the watcher enforces the per-channel
457
+ // policy (includeBotPosts, own-post loop safety) with skip logging.
458
+ const channelEvent = parseChannelMessageEvent(body, {
459
+ includeBotAuthored: true,
460
+ });
286
461
  if (channelEvent !== null) {
287
462
  return deps.channelWatch.handle(channelEvent, eventId);
288
463
  }