@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
@@ -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
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Durable Slack follow-up nudge store (Benny-style).
3
+ *
4
+ * Mid-turn inject and boundary resume share one exactly-once contract:
5
+ * pending nudges rename into delivered/ keyed by source message ts.
6
+ */
7
+
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import {
10
+ mkdir,
11
+ readdir,
12
+ readFile,
13
+ rename,
14
+ rm,
15
+ writeFile,
16
+ } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+
19
+ export interface SlackNudgeRecord {
20
+ id: string;
21
+ continuationKey: string;
22
+ sourceTs: string;
23
+ text: string;
24
+ author?: string;
25
+ createdAt: string;
26
+ /** Content hash for pending dedupe. */
27
+ contentHash: string;
28
+ }
29
+
30
+ export interface SlackNudgeAppendInput {
31
+ continuationKey: string;
32
+ sourceTs: string;
33
+ text: string;
34
+ author?: string;
35
+ }
36
+
37
+ /**
38
+ * Map an arbitrary key onto a single path segment under the store root.
39
+ * Rejects empty / `.` / `..` and strips remaining dots so path traversal
40
+ * cannot escape `<stateRoot>/slack-nudges` (or `slack-asks`).
41
+ */
42
+ function safeKey(continuationKey: string): string {
43
+ const trimmed = continuationKey.trim();
44
+ if (trimmed === "" || trimmed === "." || trimmed === "..") {
45
+ return "_empty";
46
+ }
47
+ const sanitized = trimmed
48
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
49
+ .replace(/^\.+/, "")
50
+ .replace(/\.+/g, "_")
51
+ .slice(0, 180);
52
+ if (sanitized === "" || sanitized === "." || sanitized === "..") {
53
+ return "_empty";
54
+ }
55
+ return sanitized;
56
+ }
57
+
58
+ function contentHash(text: string): string {
59
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
60
+ }
61
+
62
+ function newNudgeId(): string {
63
+ return `nudge_${randomBytes(8).toString("hex")}`;
64
+ }
65
+
66
+ export function formatSlackFollowupNudgeBlock(
67
+ nudges: readonly SlackNudgeRecord[]
68
+ ): string | undefined {
69
+ if (nudges.length === 0) {
70
+ return undefined;
71
+ }
72
+ const lines = [
73
+ "<slack_followup>",
74
+ "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.",
75
+ "",
76
+ ];
77
+ for (const nudge of nudges) {
78
+ const who =
79
+ nudge.author === undefined || nudge.author === ""
80
+ ? "someone"
81
+ : nudge.author;
82
+ lines.push(`- ${who} @ ${nudge.sourceTs}:`);
83
+ lines.push(nudge.text.trim() === "" ? "(empty)" : nudge.text.trim());
84
+ lines.push("");
85
+ }
86
+ lines.push("</slack_followup>");
87
+ return lines.join("\n").trimEnd();
88
+ }
89
+
90
+ /**
91
+ * Filesystem nudge store under `<root>/<continuation>/pending|delivered`.
92
+ */
93
+ export class SlackNudgeStore {
94
+ private readonly rootDir: string;
95
+
96
+ constructor(rootDir: string) {
97
+ this.rootDir = rootDir;
98
+ }
99
+
100
+ private continuationDir(continuationKey: string): string {
101
+ return join(this.rootDir, safeKey(continuationKey));
102
+ }
103
+
104
+ private pendingDir(continuationKey: string): string {
105
+ return join(this.continuationDir(continuationKey), "pending");
106
+ }
107
+
108
+ private deliveredDir(continuationKey: string): string {
109
+ return join(this.continuationDir(continuationKey), "delivered");
110
+ }
111
+
112
+ private deliveredPath(continuationKey: string, sourceTs: string): string {
113
+ return join(this.deliveredDir(continuationKey), safeKey(sourceTs));
114
+ }
115
+
116
+ async append(input: SlackNudgeAppendInput): Promise<boolean> {
117
+ const continuationKey = input.continuationKey.trim();
118
+ const sourceTs = input.sourceTs.trim();
119
+ const text = input.text.trim();
120
+ if (continuationKey === "" || sourceTs === "" || text === "") {
121
+ return false;
122
+ }
123
+ if (await this.isDelivered(continuationKey, sourceTs)) {
124
+ return false;
125
+ }
126
+ const pending = await this.listPending(continuationKey);
127
+ const hash = contentHash(text);
128
+ if (
129
+ pending.some(
130
+ (item) => item.sourceTs === sourceTs || item.contentHash === hash
131
+ )
132
+ ) {
133
+ return false;
134
+ }
135
+ const record: SlackNudgeRecord = {
136
+ id: newNudgeId(),
137
+ continuationKey,
138
+ sourceTs,
139
+ text,
140
+ ...(input.author === undefined || input.author === ""
141
+ ? {}
142
+ : { author: input.author }),
143
+ createdAt: new Date().toISOString(),
144
+ contentHash: hash,
145
+ };
146
+ const dir = this.pendingDir(continuationKey);
147
+ await mkdir(dir, { recursive: true });
148
+ const target = join(dir, `${record.id}.json`);
149
+ const tmp = `${target}.${process.pid}.tmp`;
150
+ await writeFile(tmp, `${JSON.stringify(record)}\n`, "utf8");
151
+ await rename(tmp, target);
152
+ return true;
153
+ }
154
+
155
+ async listPending(continuationKey: string): Promise<SlackNudgeRecord[]> {
156
+ const dir = this.pendingDir(continuationKey);
157
+ let names: string[];
158
+ try {
159
+ names = await readdir(dir);
160
+ } catch {
161
+ return [];
162
+ }
163
+ const records: SlackNudgeRecord[] = [];
164
+ for (const name of names) {
165
+ if (!name.endsWith(".json")) {
166
+ continue;
167
+ }
168
+ try {
169
+ const raw = await readFile(join(dir, name), "utf8");
170
+ const parsed = JSON.parse(raw) as SlackNudgeRecord;
171
+ if (
172
+ typeof parsed.id === "string" &&
173
+ typeof parsed.sourceTs === "string" &&
174
+ typeof parsed.text === "string"
175
+ ) {
176
+ records.push(parsed);
177
+ }
178
+ } catch {
179
+ // Skip corrupt pending files.
180
+ }
181
+ }
182
+ records.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
183
+ return records;
184
+ }
185
+
186
+ async isDelivered(
187
+ continuationKey: string,
188
+ sourceTs: string
189
+ ): Promise<boolean> {
190
+ if (continuationKey.trim() === "" || sourceTs.trim() === "") {
191
+ return false;
192
+ }
193
+ try {
194
+ await readFile(this.deliveredPath(continuationKey, sourceTs), "utf8");
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ async hasUndelivered(continuationKey: string): Promise<boolean> {
202
+ return (await this.listPending(continuationKey)).length > 0;
203
+ }
204
+
205
+ /**
206
+ * Atomically move all pending nudges to delivered and return them.
207
+ * Already-delivered source ts values are dropped (exactly-once).
208
+ */
209
+ async drainForInject(continuationKey: string): Promise<SlackNudgeRecord[]> {
210
+ const pending = await this.listPending(continuationKey);
211
+ if (pending.length === 0) {
212
+ return [];
213
+ }
214
+ await mkdir(this.deliveredDir(continuationKey), { recursive: true });
215
+ const delivered: SlackNudgeRecord[] = [];
216
+ for (const record of pending) {
217
+ const pendingPath = join(
218
+ this.pendingDir(continuationKey),
219
+ `${record.id}.json`
220
+ );
221
+ if (await this.isDelivered(continuationKey, record.sourceTs)) {
222
+ await rm(pendingPath, { force: true });
223
+ continue;
224
+ }
225
+ const dest = this.deliveredPath(continuationKey, record.sourceTs);
226
+ const tmp = `${dest}.${process.pid}.tmp`;
227
+ try {
228
+ await writeFile(tmp, `${JSON.stringify(record)}\n`, "utf8");
229
+ await rename(tmp, dest);
230
+ await rm(pendingPath, { force: true });
231
+ delivered.push(record);
232
+ } catch {
233
+ await rm(tmp, { force: true }).catch(() => {});
234
+ // Race: another drain won — treat as delivered.
235
+ await rm(pendingPath, { force: true }).catch(() => {});
236
+ }
237
+ }
238
+ return delivered;
239
+ }
240
+
241
+ async clear(continuationKey: string): Promise<void> {
242
+ await rm(this.continuationDir(continuationKey), {
243
+ recursive: true,
244
+ force: true,
245
+ });
246
+ }
247
+
248
+ /** Drain + format for tool/hook inject. */
249
+ async drainFormatted(continuationKey: string): Promise<string | undefined> {
250
+ return formatSlackFollowupNudgeBlock(
251
+ await this.drainForInject(continuationKey)
252
+ );
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Durable ask claim store: one marker per `channelId:message.ts` so delayed
258
+ * Slack redelivery cannot start a second full answer.
259
+ */
260
+ export class SlackAskDedupeStore {
261
+ private readonly rootDir: string;
262
+
263
+ constructor(rootDir: string) {
264
+ this.rootDir = rootDir;
265
+ }
266
+
267
+ private markerPath(channelId: string, messageTs: string): string {
268
+ return join(
269
+ this.rootDir,
270
+ `${safeKey(channelId)}__${safeKey(messageTs)}.claimed`
271
+ );
272
+ }
273
+
274
+ /** Returns true the first time this ask is claimed. */
275
+ async tryClaim(channelId: string, messageTs: string): Promise<boolean> {
276
+ if (channelId.trim() === "" || messageTs.trim() === "") {
277
+ return false;
278
+ }
279
+ const path = this.markerPath(channelId, messageTs);
280
+ await mkdir(this.rootDir, { recursive: true });
281
+ try {
282
+ await writeFile(path, `${new Date().toISOString()}\n`, {
283
+ encoding: "utf8",
284
+ flag: "wx",
285
+ });
286
+ return true;
287
+ } catch {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ /** Drop a claim so a failed delivery can be retried. */
293
+ async release(channelId: string, messageTs: string): Promise<void> {
294
+ if (channelId.trim() === "" || messageTs.trim() === "") {
295
+ return;
296
+ }
297
+ await rm(this.markerPath(channelId, messageTs), { force: true });
298
+ }
299
+
300
+ async wasClaimed(channelId: string, messageTs: string): Promise<boolean> {
301
+ try {
302
+ await readFile(this.markerPath(channelId, messageTs), "utf8");
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+ }