@cursor/july 0.1.6 → 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 (179) hide show
  1. package/dist/bin/agent-serve.js +73 -19
  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.DMboGKUD.js → app.BP8mAVMW.js} +1 -1
  43. package/dist/docs/assets/chunks/@localSearchIndexroot.DevOabKJ.js +1 -0
  44. package/dist/docs/assets/chunks/{VPLocalSearchBox.DkEqEnJU.js → VPLocalSearchBox.1FXIZbz8.js} +1 -1
  45. package/dist/docs/assets/chunks/{theme.ASugdMNm.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/evals.d.ts +2 -2
  101. package/dist/internal/cli-ax.d.ts +23 -10
  102. package/dist/internal/cli-ax.d.ts.map +1 -1
  103. package/dist/internal/cli-ax.js +358 -55
  104. package/dist/internal/cli-mcp.d.ts +64 -0
  105. package/dist/internal/cli-mcp.d.ts.map +1 -0
  106. package/dist/internal/cli-mcp.js +570 -0
  107. package/dist/internal/distribution.d.ts.map +1 -1
  108. package/dist/internal/distribution.js +2 -0
  109. package/dist/internal/eval-run-store.d.ts +2 -0
  110. package/dist/internal/eval-run-store.d.ts.map +1 -1
  111. package/dist/internal/eval-run-store.js +4 -4
  112. package/dist/internal/evals-client.d.ts +96 -0
  113. package/dist/internal/evals-client.d.ts.map +1 -0
  114. package/dist/internal/evals-client.js +262 -0
  115. package/dist/internal/host-kv.d.ts +17 -0
  116. package/dist/internal/host-kv.d.ts.map +1 -0
  117. package/dist/internal/host-kv.js +77 -0
  118. package/dist/internal/init-project.js +3 -3
  119. package/dist/internal/server.d.ts.map +1 -1
  120. package/dist/internal/server.js +19 -2
  121. package/dist/internal/session-engine.d.ts +42 -7
  122. package/dist/internal/session-engine.d.ts.map +1 -1
  123. package/dist/internal/session-engine.js +247 -51
  124. package/dist/internal/storage-coordinator.d.ts +18 -1
  125. package/dist/internal/storage-coordinator.d.ts.map +1 -1
  126. package/dist/internal/storage-coordinator.js +43 -0
  127. package/dist/playground/assets/index-Bpr4Th4M.js +319 -0
  128. package/dist/playground/index.html +1 -1
  129. package/dist/storage-backends/file-kv.d.ts +42 -0
  130. package/dist/storage-backends/file-kv.d.ts.map +1 -0
  131. package/dist/storage-backends/file-kv.js +190 -0
  132. package/dist/storage-backends/postgres-kv.d.ts +77 -0
  133. package/dist/storage-backends/postgres-kv.d.ts.map +1 -0
  134. package/dist/storage-backends/postgres-kv.js +180 -0
  135. package/dist/storage.d.ts +10 -1
  136. package/dist/storage.d.ts.map +1 -1
  137. package/dist/storage.js +10 -1
  138. package/dist/types.d.ts +79 -0
  139. package/dist/types.d.ts.map +1 -1
  140. package/docs/example-agents/index.md +14 -9
  141. package/docs/example-agents/oncall.md +216 -0
  142. package/docs/guides/slack.md +11 -2
  143. package/docs/reference/sessions.md +13 -4
  144. package/docs/storage.md +27 -0
  145. package/package.json +15 -1
  146. package/skills/framework-map/SKILL.md +6 -4
  147. package/src/bin/agent-serve.ts +79 -18
  148. package/src/channels/slack/channel-watch.ts +28 -1
  149. package/src/channels/slack/cursor-account.ts +20 -0
  150. package/src/channels/slack/defaults.ts +51 -20
  151. package/src/channels/slack/dispatch.ts +215 -40
  152. package/src/channels/slack/inbound.ts +30 -10
  153. package/src/channels/slack/index.ts +4 -0
  154. package/src/channels/slack/live-delivery.ts +1 -0
  155. package/src/channels/slack/manifest.ts +3 -0
  156. package/src/channels/slack/nudge-carrier.ts +182 -0
  157. package/src/channels/slack/nudge-store.ts +308 -0
  158. package/src/channels/slack/slack-channel.ts +106 -31
  159. package/src/channels/slack/stop.ts +25 -0
  160. package/src/channels/slack/thread-context.ts +572 -0
  161. package/src/channels/slack/types.ts +27 -0
  162. package/src/evals.ts +2 -2
  163. package/src/internal/cli-ax.ts +466 -75
  164. package/src/internal/cli-mcp.ts +708 -0
  165. package/src/internal/distribution.ts +2 -0
  166. package/src/internal/eval-run-store.ts +5 -0
  167. package/src/internal/evals-client.ts +431 -0
  168. package/src/internal/host-kv.ts +85 -0
  169. package/src/internal/init-project.ts +3 -3
  170. package/src/internal/server.ts +43 -0
  171. package/src/internal/session-engine.ts +351 -50
  172. package/src/internal/storage-coordinator.ts +41 -0
  173. package/src/storage-backends/file-kv.ts +221 -0
  174. package/src/storage-backends/postgres-kv.ts +248 -0
  175. package/src/storage.ts +11 -1
  176. package/src/types.ts +83 -0
  177. package/dist/docs/assets/chunks/@localSearchIndexroot.O6eP7QZr.js +0 -1
  178. package/dist/docs/assets/example-agents_index.md.CRqJlnIf.js +0 -2
  179. package/dist/playground/assets/index-mux9aj5P.js +0 -319
@@ -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
  }
@@ -31,15 +31,19 @@ export function slackTextToMarkdown(text: string): string {
31
31
 
32
32
  function parseAuthor(event: JsonRecord): SlackAuthor | undefined {
33
33
  const userId = asString(event.user);
34
- if (userId === undefined || userId === "") {
34
+ const botId = asString(event.bot_id);
35
+ const isBot = botId !== undefined && botId.length > 0;
36
+ // Webhook-style bot posts can lack `user`; keep a bot author so the
37
+ // channel watch can attribute (and self-filter) them by `bot_id`.
38
+ if ((userId === undefined || userId === "") && !isBot) {
35
39
  return undefined;
36
40
  }
37
- const botId = asString(event.bot_id);
38
41
  const userName = asString(event.username);
39
42
  return {
40
- userId,
43
+ userId: userId ?? "",
41
44
  ...(userName === undefined ? {} : { userName }),
42
- isBot: botId !== undefined && botId.length > 0,
45
+ isBot,
46
+ ...(isBot ? { botId } : {}),
43
47
  };
44
48
  }
45
49
 
@@ -131,15 +135,27 @@ export type SlackChannelMessageEvent =
131
135
  | { kind: "edited"; message: SlackMessage }
132
136
  | { kind: "deleted"; channelId: string; ts: string };
133
137
 
138
+ export interface ParseChannelMessageOptions {
139
+ /**
140
+ * Keep bot-authored posts (including `subtype: "bot_message"` webhook
141
+ * posts) instead of dropping them at parse time. The channel watch
142
+ * enforces its own per-channel policy ({@link SlackChannelPostsOptions.includeBotPosts})
143
+ * and always drops the watching bot's own posts.
144
+ */
145
+ includeBotAuthored?: boolean;
146
+ }
147
+
134
148
  /**
135
149
  * Parse channel / private-channel message events for the opt-in channel
136
150
  * watch. Accepts `channel_type === "channel" | "group"` (or `C…`/`G…`
137
151
  * channel ids when the type is absent). Bot-authored messages are dropped
138
- * (loop safety); edits and deletions are surfaced so a debounce window can
139
- * update or cancel a pending dispatch.
152
+ * (loop safety) unless `options.includeBotAuthored` is set; edits and
153
+ * deletions are surfaced so a debounce window can update or cancel a
154
+ * pending dispatch.
140
155
  */
141
156
  export function parseChannelMessageEvent(
142
- body: unknown
157
+ body: unknown,
158
+ options?: ParseChannelMessageOptions
143
159
  ): SlackChannelMessageEvent | null {
144
160
  const root = asRecord(body);
145
161
  if (root === null || root.type !== "event_callback") {
@@ -163,6 +179,7 @@ export function parseChannelMessageEvent(
163
179
 
164
180
  const teamId = asString(root.team_id);
165
181
  const subtype = asString(event.subtype) ?? "";
182
+ const includeBots = options?.includeBotAuthored === true;
166
183
 
167
184
  if (subtype === "message_deleted") {
168
185
  const deletedTs = asString(event.deleted_ts) ?? "";
@@ -177,7 +194,7 @@ export function parseChannelMessageEvent(
177
194
  return null;
178
195
  }
179
196
  const botId = asString(nested.bot_id);
180
- if (botId !== undefined && botId.length > 0) {
197
+ if (!includeBots && botId !== undefined && botId.length > 0) {
181
198
  return null;
182
199
  }
183
200
  const message = buildSlackMessage(
@@ -187,11 +204,14 @@ export function parseChannelMessageEvent(
187
204
  return message === null ? null : { kind: "edited", message };
188
205
  }
189
206
 
190
- if (subtype !== "" && subtype !== "file_share") {
207
+ const allowedSubtypes = includeBots
208
+ ? ["", "file_share", "bot_message"]
209
+ : ["", "file_share"];
210
+ if (!allowedSubtypes.includes(subtype)) {
191
211
  return null;
192
212
  }
193
213
  const botId = asString(event.bot_id);
194
- if (botId !== undefined && botId.length > 0) {
214
+ if (!includeBots && botId !== undefined && botId.length > 0) {
195
215
  return null;
196
216
  }
197
217
  const message = buildSlackMessage(event, teamId);
@@ -36,10 +36,14 @@ export * from "./interactive.js";
36
36
  export * from "./links.js";
37
37
  export * from "./log.js";
38
38
  export * from "./manifest.js";
39
+ export * from "./nudge-carrier.js";
40
+ export * from "./nudge-store.js";
39
41
  export * from "./pr-ref.js";
40
42
  export * from "./setup.js";
41
43
  export * from "./slack-channel.js";
42
44
  export * from "./socket-mode.js";
45
+ export * from "./stop.js";
43
46
  export * from "./stream.js";
47
+ export * from "./thread-context.js";
44
48
  export * from "./tool-approval-events.js";
45
49
  export * from "./types.js";
@@ -153,6 +153,7 @@ export function createStreamDeliveryStrategy(
153
153
  if (stopped.ok) {
154
154
  channel.state.streamTs = null;
155
155
  channel.state.lastPostedText = text;
156
+ channel.state.lastBotMessageTs = streamTs;
156
157
  log.info("reply delivered via stream", {
157
158
  channel: channelId,
158
159
  stream_ts: streamTs,
@@ -56,6 +56,9 @@ const BOT_SCOPES = [
56
56
  "im:read",
57
57
  "im:write",
58
58
  "mpim:history",
59
+ // Emoji acknowledgements (e.g. an agent reacting 👀 when it locks onto
60
+ // a watched-channel post before investigating).
61
+ "reactions:write",
59
62
  "users:read",
60
63
  ] as const;
61
64
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Harness-side nudge carrier: a small Cursor hooks.json + script that drains
3
+ * the durable Slack nudge store at tool boundaries (MCP / builtin tools).
4
+ */
5
+
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+
9
+ export const SLACK_NUDGE_ROOT_REL = ".agent-serve/slack-nudge-root";
10
+ export const SLACK_NUDGE_CONTINUATION_REL =
11
+ ".agent-serve/slack-nudge-continuation";
12
+ export const SLACK_NUDGE_CARRIER_REL = ".agent-serve/slack-nudge-carrier.mjs";
13
+
14
+ /** Carrier script body — reads nudge-root + continuation, drains pending. */
15
+ export const SLACK_NUDGE_CARRIER_SCRIPT: string = `#!/usr/bin/env node
16
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const workspace = dirname(here);
22
+
23
+ function safeKey(value) {
24
+ const trimmed = String(value ?? "").trim();
25
+ if (trimmed === "" || trimmed === "." || trimmed === "..") return "_empty";
26
+ const sanitized = trimmed
27
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
28
+ .replace(/^\\.+/, "")
29
+ .replace(/\\.+/g, "_")
30
+ .slice(0, 180);
31
+ if (sanitized === "" || sanitized === "." || sanitized === "..") return "_empty";
32
+ return sanitized;
33
+ }
34
+
35
+ async function readText(rel) {
36
+ try {
37
+ return (await readFile(join(workspace, rel), "utf8")).trim();
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ async function main() {
44
+ const root = await readText(${JSON.stringify(SLACK_NUDGE_ROOT_REL)});
45
+ const continuation = await readText(
46
+ ${JSON.stringify(SLACK_NUDGE_CONTINUATION_REL)}
47
+ );
48
+ if (root === "" || continuation === "") {
49
+ process.stdout.write("{}\\n");
50
+ return;
51
+ }
52
+ const pendingDir = join(root, safeKey(continuation), "pending");
53
+ const deliveredDir = join(root, safeKey(continuation), "delivered");
54
+ let names = [];
55
+ try {
56
+ names = await readdir(pendingDir);
57
+ } catch {
58
+ process.stdout.write("{}\\n");
59
+ return;
60
+ }
61
+ const nudges = [];
62
+ await mkdir(deliveredDir, { recursive: true });
63
+ for (const name of names) {
64
+ if (!name.endsWith(".json")) continue;
65
+ const pendingPath = join(pendingDir, name);
66
+ let record;
67
+ try {
68
+ record = JSON.parse(await readFile(pendingPath, "utf8"));
69
+ } catch {
70
+ continue;
71
+ }
72
+ const sourceTs = typeof record.sourceTs === "string" ? record.sourceTs : "";
73
+ if (sourceTs === "") continue;
74
+ const dest = join(deliveredDir, safeKey(sourceTs));
75
+ try {
76
+ await readFile(dest, "utf8");
77
+ await rm(pendingPath, { force: true });
78
+ continue;
79
+ } catch {
80
+ // not delivered yet
81
+ }
82
+ const tmp = dest + "." + process.pid + ".tmp";
83
+ try {
84
+ await writeFile(tmp, JSON.stringify(record) + "\\n", "utf8");
85
+ await rename(tmp, dest);
86
+ await rm(pendingPath, { force: true });
87
+ nudges.push(record);
88
+ } catch {
89
+ await rm(tmp, { force: true }).catch(() => {});
90
+ await rm(pendingPath, { force: true }).catch(() => {});
91
+ }
92
+ }
93
+ if (nudges.length === 0) {
94
+ process.stdout.write("{}\\n");
95
+ return;
96
+ }
97
+ const lines = [
98
+ "<slack_followup>",
99
+ "New Slack follow-up(s) arrived while you were working. Incorporate them before your final reply. If an ask was already answered in-thread, ack briefly instead of re-answering.",
100
+ "",
101
+ ];
102
+ for (const nudge of nudges) {
103
+ const who =
104
+ typeof nudge.author === "string" && nudge.author !== ""
105
+ ? nudge.author
106
+ : "someone";
107
+ lines.push("- " + who + " @ " + nudge.sourceTs + ":");
108
+ lines.push(
109
+ typeof nudge.text === "string" && nudge.text.trim() !== ""
110
+ ? nudge.text.trim()
111
+ : "(empty)"
112
+ );
113
+ lines.push("");
114
+ }
115
+ lines.push("</slack_followup>");
116
+ process.stdout.write(
117
+ JSON.stringify({ additional_context: lines.join("\\n").trimEnd() }) + "\\n"
118
+ );
119
+ }
120
+
121
+ main().catch(() => {
122
+ process.stdout.write("{}\\n");
123
+ });
124
+ `;
125
+
126
+ type HooksConfig = {
127
+ version?: number;
128
+ hooks?: Record<string, Array<{ command: string; failClosed?: boolean }>>;
129
+ };
130
+
131
+ /**
132
+ * Write carrier script + pointer files and ensure pre/postToolUse hooks invoke it.
133
+ * Safe to call every turn (idempotent overwrite of carrier + pointers).
134
+ */
135
+ export async function materializeSlackNudgeCarrier(args: {
136
+ harnessCwd: string;
137
+ nudgeRootDir: string;
138
+ continuationKey: string;
139
+ }): Promise<void> {
140
+ if (args.continuationKey.trim() === "") {
141
+ return;
142
+ }
143
+ await mkdir(join(args.harnessCwd, ".agent-serve"), { recursive: true });
144
+ await mkdir(join(args.harnessCwd, ".cursor"), { recursive: true });
145
+ await writeFile(
146
+ join(args.harnessCwd, SLACK_NUDGE_ROOT_REL),
147
+ `${args.nudgeRootDir}\n`,
148
+ "utf8"
149
+ );
150
+ await writeFile(
151
+ join(args.harnessCwd, SLACK_NUDGE_CONTINUATION_REL),
152
+ `${args.continuationKey}\n`,
153
+ "utf8"
154
+ );
155
+ await writeFile(
156
+ join(args.harnessCwd, SLACK_NUDGE_CARRIER_REL),
157
+ SLACK_NUDGE_CARRIER_SCRIPT,
158
+ "utf8"
159
+ );
160
+
161
+ const hooksPath = join(args.harnessCwd, ".cursor", "hooks.json");
162
+ let existing: HooksConfig = { version: 1, hooks: {} };
163
+ try {
164
+ existing = JSON.parse(await readFile(hooksPath, "utf8")) as HooksConfig;
165
+ } catch {
166
+ // fresh
167
+ }
168
+ const hooks = { ...(existing.hooks ?? {}) };
169
+ const command = `node ${SLACK_NUDGE_CARRIER_REL}`;
170
+ for (const step of ["preToolUse", "postToolUse"] as const) {
171
+ const list = [...(hooks[step] ?? [])].filter(
172
+ (entry) => !entry.command.includes("slack-nudge-carrier")
173
+ );
174
+ list.push({ command, failClosed: false });
175
+ hooks[step] = list;
176
+ }
177
+ const next: HooksConfig = {
178
+ version: existing.version ?? 1,
179
+ hooks,
180
+ };
181
+ await writeFile(hooksPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
182
+ }