@app-vitals/agent-runtime 0.0.0-stage → 0.1.0

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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/slack.ts ADDED
@@ -0,0 +1,1286 @@
1
+ /**
2
+ * Slack Bolt event handlers for the Shipwright agent.
3
+ *
4
+ * All dependencies are injected — no global config reads, no hardcoded
5
+ * runClaude import. Call createSlackApp() with the runner returned by
6
+ * createRunClaude() and your injected config values.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { readFile, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
14
+ import type { App } from "@slack/bolt";
15
+ import type { WebAPIPlatformError } from "@slack/web-api";
16
+ import {
17
+ type AgentAccountStatusRef,
18
+ agentAccountStatusRef,
19
+ } from "./agent-account-status-ref.ts";
20
+ import {
21
+ type AgentSlackMembershipRef,
22
+ agentSlackMembershipRef,
23
+ } from "./agent-slack-membership-ref.ts";
24
+ import {
25
+ type AgentTrialExpiryRef,
26
+ agentTrialExpiryRef,
27
+ } from "./agent-trial-expiry-ref.ts";
28
+ import {
29
+ type ChatTokenReporter,
30
+ NoopChatTokenReporter,
31
+ } from "./chat-token-reporter.ts";
32
+ import type { ClaudeRunResult, ProgressCallback } from "./claude.ts";
33
+ import {
34
+ ClaudeRunError,
35
+ ClaudeTimeoutError,
36
+ reportClaudeError,
37
+ } from "./claude.ts";
38
+ import {
39
+ formatPlanLink,
40
+ markdownToBlocks,
41
+ markdownToSlack,
42
+ richTextToMarkdown,
43
+ } from "./format.ts";
44
+ import { type Marker, parseMarkers } from "./markers.ts";
45
+ import { threadKey as defaultThreadKey } from "./sessions.ts";
46
+ import { SlackProgress } from "./slack-progress.ts";
47
+ import { ThreadStatusTracker } from "./slack-thread-status-tracker.ts";
48
+ import type { VoiceConfig } from "./voice.ts";
49
+ import { synthesizeSpeech, transcribeAudio } from "./voice.ts";
50
+
51
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
52
+
53
+ function isInvalidBlocksError(err: unknown): boolean {
54
+ const e = err as Partial<WebAPIPlatformError>;
55
+ return (
56
+ e.code === "slack_webapi_platform_error" &&
57
+ e.data?.error === "invalid_blocks"
58
+ );
59
+ }
60
+
61
+ export interface SlackFile {
62
+ name: string;
63
+ mimetype: string;
64
+ size: number;
65
+ url_private?: string;
66
+ }
67
+
68
+ export async function downloadFile(
69
+ file: SlackFile,
70
+ botToken: string,
71
+ ): Promise<string | null> {
72
+ if (file.size > MAX_FILE_SIZE_BYTES) {
73
+ console.warn(
74
+ `[slack] skipping ${file.name} (${file.size} bytes) — exceeds 10MB limit`,
75
+ );
76
+ return null;
77
+ }
78
+ const url = file.url_private;
79
+ if (!url) return null;
80
+
81
+ let resp: Response;
82
+ try {
83
+ resp = await fetch(url, {
84
+ headers: { Authorization: `Bearer ${botToken}` },
85
+ });
86
+ } catch (err) {
87
+ console.warn(`[slack] file fetch error for ${file.name}:`, err);
88
+ return null;
89
+ }
90
+
91
+ if (!resp.ok) {
92
+ console.warn(
93
+ `[slack] file download failed for ${file.name}: ${resp.status}`,
94
+ );
95
+ return null;
96
+ }
97
+
98
+ const buffer = Buffer.from(await resp.arrayBuffer());
99
+ const tmpPath = join(tmpdir(), `shipwright-agent-${Date.now()}-${file.name}`);
100
+ await writeFile(tmpPath, buffer);
101
+ return tmpPath;
102
+ }
103
+
104
+ interface SlackConfig {
105
+ botToken: string;
106
+ appToken: string;
107
+ signingSecret: string;
108
+ }
109
+
110
+ /**
111
+ * Whether the agent has the credentials required to start its Slack Bolt App.
112
+ *
113
+ * Bolt's Socket Mode constructor throws "Must provide an App-Level Token" when
114
+ * `appToken` is empty, so the agent must skip Slack startup (offline mode)
115
+ * unless BOTH the bot token and the app-level token are present. Whitespace-only
116
+ * values are treated as absent.
117
+ */
118
+ export function hasSlackCredentials(cfg: {
119
+ botToken: string;
120
+ appToken: string;
121
+ }): boolean {
122
+ return cfg.botToken.trim() !== "" && cfg.appToken.trim() !== "";
123
+ }
124
+
125
+ type AppFactory = (cfg: {
126
+ token: string;
127
+ appToken: string;
128
+ socketMode: true;
129
+ signingSecret: string;
130
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt App type is complex
131
+ }) => any;
132
+
133
+ const defaultAppFactory: AppFactory = (cfg) =>
134
+ new (require("@slack/bolt").App)(cfg);
135
+
136
+ const noopSentryClient: ErrorCapturingClient = {
137
+ captureException: () => {},
138
+ };
139
+
140
+ type FileDownloaderFn = (
141
+ file: SlackFile,
142
+ botToken: string,
143
+ ) => Promise<string | null>;
144
+
145
+ type ConversationsRepliesFn = (
146
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
147
+ client: any,
148
+ channel: string,
149
+ ts: string,
150
+ ) => Promise<{ messages?: { user?: string; text?: string; ts?: string }[] }>;
151
+
152
+ export type TranscribeAudioFn = typeof transcribeAudio;
153
+ export type SynthesizeSpeechFn = typeof synthesizeSpeech;
154
+ type GetSessionFn = (key: string) => Promise<string | undefined>;
155
+
156
+ export type ClaudeRunner = (
157
+ message: string,
158
+ sessionKey?: string,
159
+ onProgress?: ProgressCallback,
160
+ extraEnv?: Record<string, string>,
161
+ ) => Promise<ClaudeRunResult>;
162
+
163
+ type ResolveUserFn = (
164
+ userId: string,
165
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt client is complex
166
+ client: any,
167
+ ) => Promise<string>;
168
+
169
+ type ResolveUserEmailFn = (
170
+ userId: string,
171
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt client is complex
172
+ client: any,
173
+ ) => Promise<string | undefined>;
174
+
175
+ /**
176
+ * The single shared Slack membership gate used by all three event handlers
177
+ * (message, app_mention, reaction_added) — SMR-4.1.
178
+ *
179
+ * Fails OPEN (allow) while the membership ref has never synced, or when
180
+ * restrict is false, so a persistent config-sync failure or a deliberately
181
+ * unrestricted config never blocks Slack access. Once restrict is true and
182
+ * the ref has synced, only a case-insensitive match against the synced
183
+ * member email list is allowed — an undefined email (resolution failed or
184
+ * absent) is rejected, never treated as a wildcard.
185
+ */
186
+ export function isAllowedSlackSender(
187
+ email: string | undefined,
188
+ membershipRef: AgentSlackMembershipRef,
189
+ ): boolean {
190
+ if (!membershipRef.hasSynced()) return true;
191
+ const { restrict, emails } = membershipRef.get();
192
+ if (!restrict) return true;
193
+ if (!email) return false;
194
+ const lowered = email.toLowerCase();
195
+ return emails.some((e) => e.toLowerCase() === lowered);
196
+ }
197
+
198
+ /**
199
+ * The message posted (once per inbound event, no dedup) when isTrialExpired()
200
+ * rejects a Slack message/mention/reaction — ATE-3.1. Unlike
201
+ * shouldRejectSlackSender (which silently drops the message), the caller
202
+ * always replies with this so the user isn't met with unexplained silence.
203
+ */
204
+ const TRIAL_EXPIRED_NOTICE = "This trial has ended — contact us to continue.";
205
+
206
+ /**
207
+ * The shared trial-expiry gate used by all three event handlers (message,
208
+ * app_mention, reaction_added) — ATE-3.1. Runs before shouldRejectSlackSender
209
+ * and before any Claude session is invoked.
210
+ *
211
+ * Fails OPEN (not expired) while the ref has never synced, or when no trial
212
+ * is configured (trialExpiresAt null), so a persistent config-sync failure
213
+ * never locks a paying customer out of their own agent. Only a
214
+ * trialExpiresAt strictly in the past counts as expired — a trial expiring
215
+ * at exactly `now` is not yet locked out.
216
+ */
217
+ export function isTrialExpired(
218
+ trialExpiryRef: AgentTrialExpiryRef,
219
+ now: () => Date = () => new Date(),
220
+ ): boolean {
221
+ if (!trialExpiryRef.hasSynced()) return false;
222
+ const trialExpiresAt = trialExpiryRef.get();
223
+ if (!trialExpiresAt) return false;
224
+ return trialExpiresAt.getTime() < now().getTime();
225
+ }
226
+
227
+ /**
228
+ * The message posted (once per inbound event) when isAccountPaused() rejects a
229
+ * Slack message/mention/reaction — SSP-8.3.
230
+ */
231
+ export const ACCOUNT_PAUSED_NOTICE =
232
+ "This agent is paused because its account is not active — contact us to resume.";
233
+
234
+ /**
235
+ * The shared account-status gate used by all three event handlers — SSP-8.3.
236
+ * Paused when the owning account is 'suspended' or 'trial_expired'. Fails OPEN
237
+ * while the ref has never synced, and for agents with no account (null) or an
238
+ * active account. Self-heals on the next config sync after reactivation.
239
+ */
240
+ export function isAccountPaused(
241
+ accountStatusRef: AgentAccountStatusRef,
242
+ ): boolean {
243
+ if (!accountStatusRef.hasSynced()) return false;
244
+ const status = accountStatusRef.get();
245
+ return status === "suspended" || status === "trial_expired";
246
+ }
247
+
248
+ /**
249
+ * Returns the notice to reply with when an inbound event must be rejected
250
+ * (trial ended, or account paused), or null when the event may proceed.
251
+ */
252
+ function gateNotice(
253
+ trialExpiryRef: AgentTrialExpiryRef,
254
+ accountStatusRef: AgentAccountStatusRef,
255
+ ): string | null {
256
+ if (isTrialExpired(trialExpiryRef)) return TRIAL_EXPIRED_NOTICE;
257
+ if (isAccountPaused(accountStatusRef)) return ACCOUNT_PAUSED_NOTICE;
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * Resolves the sender's email and checks it against isAllowedSlackSender(),
263
+ * logging a debug line for rejected senders. Returns true when the handler
264
+ * should return early (reject) — shared by all three event handlers so the
265
+ * resolve/check/log sequence isn't repeated per handler.
266
+ */
267
+ async function shouldRejectSlackSender(
268
+ userId: string,
269
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
270
+ client: any,
271
+ resolveUserEmailFn: ResolveUserEmailFn,
272
+ membershipRef: AgentSlackMembershipRef,
273
+ ): Promise<boolean> {
274
+ const senderEmail = await resolveUserEmailFn(userId, client).catch(
275
+ () => undefined,
276
+ );
277
+ if (isAllowedSlackSender(senderEmail, membershipRef)) return false;
278
+ console.log(`[slack] rejected sender (not a member): user=${userId}`);
279
+ return true;
280
+ }
281
+
282
+ // Normalizes text for the speak-marker fallback's duplicate-content check:
283
+ // lowercased, trimmed, trailing punctuation stripped, internal whitespace
284
+ // collapsed. Deliberately simple — not fuzzy matching, just enough to catch
285
+ // the common "Here is the answer. [speak:Here is the answer]" pattern where
286
+ // the marker text is a near-verbatim echo of the already-posted reply.
287
+ function normalizeForComparison(text: string | undefined): string {
288
+ if (!text) return "";
289
+ return text
290
+ .trim()
291
+ .toLowerCase()
292
+ .replace(/[.!?]+$/, "")
293
+ .replace(/\s+/g, " ")
294
+ .trim();
295
+ }
296
+
297
+ export async function dispatchMarkers(
298
+ markers: Marker[],
299
+ {
300
+ client,
301
+ channel,
302
+ postedTs,
303
+ threadTs,
304
+ workspace,
305
+ synthesizeSpeechFn,
306
+ voiceConfig = {},
307
+ postedText,
308
+ }: {
309
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt client type is complex
310
+ client: any;
311
+ channel: string;
312
+ postedTs?: string;
313
+ threadTs?: string;
314
+ workspace?: string;
315
+ synthesizeSpeechFn?: SynthesizeSpeechFn;
316
+ voiceConfig?: VoiceConfig;
317
+ // The already-posted visible reply text (e.g. `cleaned`/`formatted`), if
318
+ // any. Used so the speak-marker text fallback can skip posting when it
319
+ // would just duplicate content the user already sees.
320
+ postedText?: string;
321
+ },
322
+ ): Promise<void> {
323
+ for (const marker of markers) {
324
+ if (marker.type === "silent") continue;
325
+
326
+ if (marker.type === "react") {
327
+ if (postedTs) {
328
+ for (const emoji of marker.emojis) {
329
+ await client.reactions
330
+ .add({ channel, timestamp: postedTs, name: emoji })
331
+ .catch((err: unknown) =>
332
+ console.warn("[markers] react failed:", err),
333
+ );
334
+ }
335
+ }
336
+ } else if (marker.type === "upload") {
337
+ const absPath = marker.path.startsWith("/")
338
+ ? marker.path
339
+ : workspace
340
+ ? join(workspace, marker.path)
341
+ : marker.path;
342
+ if (existsSync(absPath)) {
343
+ await client.files
344
+ .uploadV2({
345
+ channel_id: channel,
346
+ thread_ts: threadTs,
347
+ file: await readFile(absPath),
348
+ filename: absPath.split("/").pop() ?? "file",
349
+ })
350
+ .catch((err: unknown) =>
351
+ console.warn("[markers] upload failed:", err),
352
+ );
353
+ } else {
354
+ console.warn(`[markers] upload: file not found: ${absPath}`);
355
+ }
356
+ } else if (marker.type === "plan") {
357
+ const { text, blocks } = formatPlanLink(marker.url);
358
+ await client.chat
359
+ .postMessage({ channel, thread_ts: threadTs, text, blocks })
360
+ .catch((err: unknown) =>
361
+ console.warn("[markers] plan link post failed:", err),
362
+ );
363
+ } else if (marker.type === "speak") {
364
+ if (!synthesizeSpeechFn) {
365
+ console.warn(
366
+ "[markers] speak marker found but synthesizeSpeechFn not injected — skipping",
367
+ );
368
+ continue;
369
+ }
370
+ const postSpeakTextFallback = () => {
371
+ const normalizedPosted = normalizeForComparison(postedText);
372
+ if (
373
+ normalizedPosted.length > 0 &&
374
+ normalizedPosted === normalizeForComparison(marker.text)
375
+ ) {
376
+ console.warn(
377
+ "[markers] speak text fallback skipped — duplicates already-posted reply",
378
+ );
379
+ return Promise.resolve();
380
+ }
381
+ return client.chat
382
+ .postMessage({ channel, thread_ts: threadTs, text: marker.text })
383
+ .catch((err: unknown) =>
384
+ console.warn("[markers] speak text fallback post failed:", err),
385
+ );
386
+ };
387
+
388
+ try {
389
+ const audioPath = await synthesizeSpeechFn(marker.text, voiceConfig);
390
+ if (audioPath) {
391
+ await client.files
392
+ .uploadV2({
393
+ channel_id: channel,
394
+ thread_ts: threadTs,
395
+ file: await readFile(audioPath),
396
+ filename: audioPath.split("/").pop() ?? "response.mp3",
397
+ })
398
+ .catch((err: unknown) =>
399
+ console.warn("[markers] speak upload failed:", err),
400
+ );
401
+ } else {
402
+ console.warn(
403
+ "[markers] speak synthesis returned null — posting text fallback",
404
+ );
405
+ await postSpeakTextFallback();
406
+ }
407
+ } catch (err) {
408
+ console.warn(
409
+ "[markers] speak synthesis failed — posting text fallback:",
410
+ err,
411
+ );
412
+ await postSpeakTextFallback();
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ function _classifyError(err: unknown): { lead: string; known: boolean } {
419
+ if (err instanceof ClaudeTimeoutError) {
420
+ return {
421
+ lead: `:hourglass_flowing_sand: Session timed out after ${Math.round(err.timeoutMs / 60_000)} minutes. Please retry.`,
422
+ known: true,
423
+ };
424
+ }
425
+ if (err instanceof ClaudeRunError) {
426
+ const status = err.apiErrorStatus;
427
+ const detail = err.resultMessage;
428
+ if (status === 429 && /usage limit/i.test(detail)) {
429
+ return {
430
+ lead: ":warning: I've hit the org's monthly Claude usage limit. Please try again later or contact the agent owner.",
431
+ known: true,
432
+ };
433
+ }
434
+ if (status === 429) {
435
+ return {
436
+ lead: ":warning: Rate-limited by Anthropic — try again in a moment.",
437
+ known: true,
438
+ };
439
+ }
440
+ if (status === 529) {
441
+ return {
442
+ lead: ":warning: Anthropic's API is overloaded. This is usually transient — try again in ~30s. Status: https://status.claude.com",
443
+ known: true,
444
+ };
445
+ }
446
+ if (status === 500 || status === 502 || status === 503 || status === 504) {
447
+ return {
448
+ lead: `:warning: Anthropic API hiccup (${status}). This is server-side and usually transient — please retry. Status: https://status.claude.com`,
449
+ known: true,
450
+ };
451
+ }
452
+ if (status === 401 || status === 403) {
453
+ return {
454
+ lead: `:warning: Auth failure with Anthropic (${status}). The agent's API key or OAuth token may need to be refreshed.`,
455
+ known: true,
456
+ };
457
+ }
458
+ return {
459
+ lead: `:warning: Claude returned an error${status ? ` (${status})` : ""}.`,
460
+ known: false,
461
+ };
462
+ }
463
+ return { lead: ":warning: Something went wrong.", known: false };
464
+ }
465
+
466
+ function _detailLine(err: unknown): string {
467
+ if (err instanceof ClaudeRunError) return err.resultMessage;
468
+ if (err instanceof Error) return `${err.name}: ${err.message}`;
469
+ return String(err);
470
+ }
471
+
472
+ export function formatRunErrorForSlack(err: unknown): string {
473
+ const { lead, known } = _classifyError(err);
474
+ const detail = _detailLine(err);
475
+ const stack =
476
+ !known && err instanceof Error && err.stack ? err.stack : undefined;
477
+ const stackBlock = stack ? `\n\n\`\`\`\n${stack}\n\`\`\`` : "";
478
+ return `${lead}\n\n_Detail:_ ${detail}${stackBlock}`;
479
+ }
480
+
481
+ const defaultConversationsRepliesFn: ConversationsRepliesFn = async (
482
+ client,
483
+ channel,
484
+ ts,
485
+ ) => client.conversations.replies({ channel, ts, limit: 200 });
486
+
487
+ async function buildPromptWithFiles(
488
+ text: string,
489
+ files: SlackFile[] | undefined,
490
+ fileDownloaderFn: FileDownloaderFn,
491
+ botToken: string,
492
+ transcribeAudioFn: TranscribeAudioFn,
493
+ voiceConfig: VoiceConfig,
494
+ ): Promise<string> {
495
+ if (!files?.length) return text;
496
+ const fileParts: string[] = [];
497
+ for (const file of files) {
498
+ try {
499
+ const filePath = await fileDownloaderFn(file, botToken);
500
+ if (filePath) {
501
+ if (file.mimetype.startsWith("audio/")) {
502
+ try {
503
+ const transcript = await transcribeAudioFn(filePath, voiceConfig);
504
+ fileParts.push(
505
+ transcript
506
+ ? `[voice transcript: ${transcript}]`
507
+ : `[file: ${filePath}]`,
508
+ );
509
+ } catch (err) {
510
+ console.warn("[voice] transcription error:", err);
511
+ fileParts.push(`[file: ${filePath}]`);
512
+ }
513
+ } else {
514
+ fileParts.push(`[file: ${filePath}]`);
515
+ }
516
+ }
517
+ } catch (err) {
518
+ console.warn(`[slack] file download error for ${file.name}:`, err);
519
+ }
520
+ }
521
+ return fileParts.length ? `${fileParts.join("\n")}\n${text}`.trim() : text;
522
+ }
523
+
524
+ /**
525
+ * Starts a run's progress tracking. enter() on ThreadStatusTracker is
526
+ * ALWAYS called by every handler (every enter() needs a matching exit(), or
527
+ * the refcount leaks upward forever) — every message gets its own
528
+ * independent stream lifecycle regardless of the refcount; the boolean only
529
+ * picks the seed card's title ("Queued…" when another message on this
530
+ * thread is already in flight, "Thinking…" otherwise).
531
+ */
532
+ async function startProgress(
533
+ progress: SlackProgress,
534
+ isFirstInThread: boolean,
535
+ ): Promise<void> {
536
+ await progress.start({ queued: !isFirstInThread });
537
+ }
538
+
539
+ /**
540
+ * The finish()-side counterpart of startProgress() — see its doc comment.
541
+ * `wasSuppressed` (STS2-3.1) signals the run's reply was suppressed (e.g. a
542
+ * [silent] marker) so finish() titles the stream's fallback completion card
543
+ * "Ack" instead of "Done" — see SlackProgressFinishOptions. Defaults to
544
+ * `false`, matching prior behavior for callers that don't compute silence.
545
+ */
546
+ async function finishProgress(
547
+ progress: SlackProgress,
548
+ isLastInThread: boolean,
549
+ wasSuppressed = false,
550
+ ): Promise<void> {
551
+ await progress.finish({
552
+ stillInFlight: !isLastInThread,
553
+ silent: wasSuppressed,
554
+ });
555
+ }
556
+
557
+ export function createSlackApp(
558
+ runner: ClaudeRunner,
559
+ formatter: (text: string) => string = markdownToSlack,
560
+ getThreadKey: typeof defaultThreadKey = defaultThreadKey,
561
+ appFactory: AppFactory = defaultAppFactory,
562
+ slackConfig: SlackConfig = { botToken: "", appToken: "", signingSecret: "" },
563
+ sentryClient: ErrorCapturingClient = noopSentryClient,
564
+ fileDownloaderFn: FileDownloaderFn = downloadFile,
565
+ voiceConfig: VoiceConfig = {},
566
+ transcribeAudioFn: TranscribeAudioFn = transcribeAudio,
567
+ synthesizeSpeechFn: SynthesizeSpeechFn = synthesizeSpeech,
568
+ resolveUserFn: ResolveUserFn = async (userId) => userId,
569
+ botUserId: string | undefined = undefined,
570
+ conversationsRepliesFn: ConversationsRepliesFn = defaultConversationsRepliesFn,
571
+ getSessionFn: GetSessionFn = async () => undefined,
572
+ blocksConverter: typeof markdownToBlocks = markdownToBlocks,
573
+ chatTokenReporter: ChatTokenReporter = new NoopChatTokenReporter(),
574
+ resolveUserEmailFn: ResolveUserEmailFn = async () => undefined,
575
+ membershipRef: AgentSlackMembershipRef = agentSlackMembershipRef,
576
+ trialExpiryRef: AgentTrialExpiryRef = agentTrialExpiryRef,
577
+ accountStatusRef: AgentAccountStatusRef = agentAccountStatusRef,
578
+ ): App {
579
+ const app = appFactory({
580
+ token: slackConfig.botToken,
581
+ appToken: slackConfig.appToken,
582
+ socketMode: true,
583
+ signingSecret: slackConfig.signingSecret,
584
+ });
585
+
586
+ // Per-thread refcount gating SlackProgress.start()/finish() — see
587
+ // slack-thread-status-tracker.ts for why this exists. Closure-scoped (one
588
+ // instance per createSlackApp() call), never module-level, so multiple app
589
+ // instances (e.g. in tests) stay isolated from each other's refcount state.
590
+ const threadStatusTracker = new ThreadStatusTracker();
591
+
592
+ // Registry of live streams: SlackProgress.streamKey() ("channel:ts") ->
593
+ // the instance itself (AGS-1.1). Populated when startStream() succeeds
594
+ // (i.e. progress.streamKey() first becomes defined, right after start())
595
+ // and cleared unconditionally when the stream closes, success or failure
596
+ // (right after finish()) — so it never leaks entries for completed runs.
597
+ // Closure-scoped (one instance per createSlackApp() call), same lifecycle
598
+ // as threadStatusTracker above — never module-level.
599
+ const liveStreams = new Map<string, SlackProgress>();
600
+
601
+ function registerStream(progress: SlackProgress): void {
602
+ const key = progress.streamKey();
603
+ if (key) liveStreams.set(key, progress);
604
+ }
605
+
606
+ function unregisterStream(progress: SlackProgress): void {
607
+ const key = progress.streamKey();
608
+ if (key) liveStreams.delete(key);
609
+ }
610
+
611
+ // The bot's own workspace team ID — chat.startStream()'s recipient_team_id
612
+ // requires it for every non-DM stream (STS-2.1; see SlackProgress.isDM).
613
+ // Resolved once via client.auth.test() here at app construction and cached
614
+ // in this closure for the life of this createSlackApp() instance — mirrors
615
+ // threadStatusTracker/liveStreams above (closure-scoped, never
616
+ // module-level, so multiple app instances in tests stay isolated).
617
+ // Kicked off eagerly (not lazily per-message) so the round-trip overlaps
618
+ // Bolt's own startup instead of adding latency to the first non-DM
619
+ // message. Resolves to undefined — never throws, never rejects — when the
620
+ // app's client lacks `auth.test` entirely (e.g. a test/mock appFactory) or
621
+ // the call itself fails; callers then just omit recipient_team_id, same as
622
+ // a DM payload.
623
+ const teamIdPromise: Promise<string | undefined> =
624
+ typeof app.client?.auth?.test === "function"
625
+ ? app.client.auth.test().then(
626
+ (resp: { team_id?: string }) => resp?.team_id,
627
+ (err: unknown) => {
628
+ console.warn(
629
+ "[slack] auth.test() failed — recipient_team_id unavailable for non-DM Thinking Steps streams:",
630
+ err instanceof Error ? err.message : String(err),
631
+ );
632
+ return undefined;
633
+ },
634
+ )
635
+ : Promise.resolve(undefined);
636
+
637
+ // DMs: respond to every message.
638
+ // Channels: respond only if bot has already replied in this thread.
639
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
640
+ app.message(async ({ message, say, client }: any) => {
641
+ if (message.subtype && message.subtype !== "file_share") return;
642
+ const msg = message as {
643
+ text?: string;
644
+ channel: string;
645
+ ts: string;
646
+ thread_ts?: string;
647
+ channel_type?: string;
648
+ files?: SlackFile[];
649
+ user?: string;
650
+ blocks?: Array<{ type: string; elements?: unknown[] }>;
651
+ };
652
+
653
+ const isDM = msg.channel_type === "im";
654
+ const hasText = !!msg.text?.trim();
655
+ const hasFiles = !!msg.files?.length;
656
+ const hasRichText = !!msg.blocks?.some((b) => b.type === "rich_text");
657
+
658
+ if (!hasText && !hasRichText && !hasFiles) return;
659
+
660
+ if (!isDM) {
661
+ if (!msg.thread_ts) return;
662
+ if (!(await getSessionFn(getThreadKey(msg.channel, msg.thread_ts))))
663
+ return;
664
+ }
665
+
666
+ const gateMsg = gateNotice(trialExpiryRef, accountStatusRef);
667
+ if (gateMsg) {
668
+ await say({
669
+ text: gateMsg,
670
+ thread_ts: msg.thread_ts ?? msg.ts,
671
+ });
672
+ return;
673
+ }
674
+
675
+ if (
676
+ msg.user &&
677
+ (await shouldRejectSlackSender(
678
+ msg.user,
679
+ client,
680
+ resolveUserEmailFn,
681
+ membershipRef,
682
+ ))
683
+ ) {
684
+ return;
685
+ }
686
+
687
+ const sessionKey = getThreadKey(msg.channel, msg.thread_ts ?? msg.ts);
688
+ const replyTs = msg.thread_ts ?? msg.ts;
689
+
690
+ // recipient_team_id is only meaningful (and only fetched) for non-DM
691
+ // threads — see teamIdPromise's doc comment and SlackProgress.isDM
692
+ // (STS-2.1).
693
+ const recipientTeamId = isDM ? undefined : await teamIdPromise;
694
+ const progress = new SlackProgress({
695
+ client,
696
+ channel: msg.channel,
697
+ threadTs: replyTs,
698
+ isDM,
699
+ recipientUserId: msg.user,
700
+ recipientTeamId,
701
+ });
702
+ // enter()/exit() are ALWAYS called (every enter() needs a matching
703
+ // exit(), or the refcount leaks upward forever) — see startProgress()'s
704
+ // doc comment for what the boolean gates (AC #5).
705
+ await startProgress(progress, threadStatusTracker.enter(sessionKey));
706
+ // Registers the stream (no-op when startStream() failed to open one —
707
+ // streamKey() is undefined either way) so agent_session_stopped can look
708
+ // this instance up (AGS-1.1).
709
+ registerStream(progress);
710
+
711
+ const rawText =
712
+ msg.text || (hasRichText ? richTextToMarkdown(msg.blocks ?? []) : "");
713
+
714
+ let prompt = await buildPromptWithFiles(
715
+ rawText,
716
+ msg.files,
717
+ fileDownloaderFn,
718
+ slackConfig.botToken,
719
+ transcribeAudioFn,
720
+ voiceConfig,
721
+ );
722
+
723
+ if (msg.user) {
724
+ const name = await resolveUserFn(msg.user, client);
725
+ prompt = `[${name}]: ${prompt}`;
726
+ }
727
+
728
+ if (!isDM && msg.thread_ts) {
729
+ prompt = `[Thread message — respond normally, or use [silent] if no response is needed]\n${prompt}`;
730
+ }
731
+
732
+ // Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
733
+ // in the finally block can title the stream's completion card "Ack"
734
+ // rather than the generic "Done" when the reply was suppressed.
735
+ let wasSuppressed = false;
736
+ try {
737
+ const runResult = await runner(prompt, sessionKey, progress.onProgress, {
738
+ SLACK_CHANNEL_ID: msg.channel,
739
+ SLACK_THREAD_TS: replyTs,
740
+ });
741
+ if (runResult.streamIncomplete) {
742
+ // Clean process exit, but the stream never emitted a terminal
743
+ // `result` event — treat this the same as a genuine failure rather
744
+ // than silently posting nothing and logging it as a normal
745
+ // markers-only response (see CSU-1.1 review).
746
+ throw new ClaudeRunError(
747
+ "claude stream ended without a terminal result event",
748
+ undefined,
749
+ "stream incomplete — no terminal result event",
750
+ runResult.sessionId,
751
+ runResult.modelUsage,
752
+ );
753
+ }
754
+ const { result, usage, totalCostUsd, modelUsage } = runResult;
755
+ await chatTokenReporter.recordSession(usage, totalCostUsd, modelUsage);
756
+ const { cleaned, markers } = parseMarkers(result);
757
+
758
+ const isSilent = markers.some((m) => m.type === "silent");
759
+ const dmOverride = isSilent && isDM && cleaned.trim().length > 0;
760
+ const shouldSuppress = isSilent && !dmOverride;
761
+ wasSuppressed = shouldSuppress;
762
+
763
+ if (dmOverride) {
764
+ console.log("[slack] ignoring [silent] marker in DM — posting reply");
765
+ }
766
+ if (shouldSuppress) {
767
+ console.log(
768
+ `[slack] silent response — not posting (cleaned: ${cleaned.length} chars)`,
769
+ );
770
+ }
771
+
772
+ let postedTs: string | undefined;
773
+ if (!shouldSuppress && cleaned.trim().length > 0) {
774
+ const delivered = await progress.deliverContent(cleaned);
775
+ if (!delivered) {
776
+ const blocks = blocksConverter(cleaned);
777
+ let sayResult: unknown;
778
+ if (blocks) {
779
+ try {
780
+ sayResult = await say({
781
+ text: blocks.text,
782
+ blocks: blocks.blocks,
783
+ thread_ts: msg.thread_ts ?? msg.ts,
784
+ });
785
+ } catch (err: unknown) {
786
+ if (!isInvalidBlocksError(err)) throw err;
787
+ console.warn("[slack] invalid_blocks — retrying with plain text");
788
+ sayResult = await say({
789
+ text: blocks.text || formatter(cleaned),
790
+ thread_ts: msg.thread_ts ?? msg.ts,
791
+ });
792
+ }
793
+ } else {
794
+ sayResult = await say({
795
+ text: formatter(cleaned),
796
+ thread_ts: msg.thread_ts ?? msg.ts,
797
+ });
798
+ }
799
+ postedTs = (sayResult as { ts?: string } | undefined)?.ts;
800
+ }
801
+ } else if (!shouldSuppress) {
802
+ console.log("[slack] markers-only response — skipping text post");
803
+ }
804
+
805
+ await dispatchMarkers(markers, {
806
+ client,
807
+ channel: msg.channel,
808
+ postedTs,
809
+ threadTs: msg.thread_ts ?? msg.ts,
810
+ synthesizeSpeechFn,
811
+ voiceConfig,
812
+ postedText: shouldSuppress ? undefined : cleaned,
813
+ });
814
+ } catch (err) {
815
+ console.error("[slack] error:", err);
816
+ reportClaudeError(sentryClient, err);
817
+ // Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) BEFORE the
818
+ // say() below — a UI-state signal only; reportClaudeError(sentryClient, err)
819
+ // above remains the single log point for this error. Ordering matters: if
820
+ // say() throws (rate limit, invalid_auth, channel_not_found), this must
821
+ // still run so finishProgress() doesn't send a "Done" card for a run that
822
+ // both errored and failed to notify the user (matches reaction_added).
823
+ progress.reportError();
824
+ // Wrap the error notification in its own try/catch: a failed say()
825
+ // (rate limit, invalid_auth, channel_not_found) must not escape the
826
+ // handler and skip the finally block's finishProgress().
827
+ try {
828
+ await say({
829
+ text: formatRunErrorForSlack(err),
830
+ thread_ts: msg.thread_ts ?? msg.ts,
831
+ });
832
+ } catch (sayErr) {
833
+ console.error("[slack] failed to post error notification:", sayErr);
834
+ }
835
+ } finally {
836
+ // exit() is ALWAYS called (matching the always-called enter() above)
837
+ // — see finishProgress()'s doc comment for what it gates.
838
+ await finishProgress(
839
+ progress,
840
+ threadStatusTracker.exit(sessionKey),
841
+ wasSuppressed,
842
+ );
843
+ // Clears the registry entry unconditionally — success or failure —
844
+ // so it never leaks a stale instance for a run that's already done
845
+ // (AGS-1.1).
846
+ unregisterStream(progress);
847
+ }
848
+ });
849
+
850
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
851
+ app.event("app_mention", async ({ event, say, client }: any) => {
852
+ const ev = event as {
853
+ text: string;
854
+ channel: string;
855
+ ts: string;
856
+ thread_ts?: string;
857
+ user?: string;
858
+ files?: SlackFile[];
859
+ };
860
+ const sessionKey = getThreadKey(ev.channel, ev.thread_ts ?? ev.ts);
861
+ const replyTs = ev.thread_ts ?? ev.ts;
862
+
863
+ // Drop @mention if bot is already participating in this thread —
864
+ // the message handler covers it and would double-respond otherwise.
865
+ if (ev.thread_ts && (await getSessionFn(sessionKey))) {
866
+ return;
867
+ }
868
+
869
+ const gateMsg = gateNotice(trialExpiryRef, accountStatusRef);
870
+ if (gateMsg) {
871
+ await say({ text: gateMsg, thread_ts: replyTs });
872
+ return;
873
+ }
874
+
875
+ if (
876
+ ev.user &&
877
+ (await shouldRejectSlackSender(
878
+ ev.user,
879
+ client,
880
+ resolveUserEmailFn,
881
+ membershipRef,
882
+ ))
883
+ ) {
884
+ return;
885
+ }
886
+
887
+ // app_mention never fires for a DM (mentions in a DM arrive as a plain
888
+ // message event, handled above) — always a channel/group thread, so
889
+ // recipient_team_id is always fetched here (STS-2.1).
890
+ const progress = new SlackProgress({
891
+ client,
892
+ channel: ev.channel,
893
+ threadTs: replyTs,
894
+ isDM: false,
895
+ recipientUserId: ev.user,
896
+ recipientTeamId: await teamIdPromise,
897
+ });
898
+ // enter() is ALWAYS called — see startProgress()'s doc comment for
899
+ // what it gates (STS2-1.1 AC #5).
900
+ await startProgress(progress, threadStatusTracker.enter(sessionKey));
901
+ // Registers the stream so agent_session_stopped can look this instance
902
+ // up (AGS-1.1) — see the app.message handler's identical comment above.
903
+ registerStream(progress);
904
+
905
+ let prompt = await buildPromptWithFiles(
906
+ ev.text,
907
+ ev.files,
908
+ fileDownloaderFn,
909
+ slackConfig.botToken,
910
+ transcribeAudioFn,
911
+ voiceConfig,
912
+ );
913
+
914
+ if (ev.user) {
915
+ const name = await resolveUserFn(ev.user as string, client);
916
+ prompt = `[${name}]: ${prompt}`;
917
+ }
918
+
919
+ if (event.thread_ts && !(await getSessionFn(sessionKey))) {
920
+ try {
921
+ const repliesResult = await conversationsRepliesFn(
922
+ client,
923
+ event.channel as string,
924
+ event.thread_ts as string,
925
+ );
926
+ const messages = repliesResult.messages ?? [];
927
+ const historyLines = await Promise.all(
928
+ messages
929
+ .filter(
930
+ (m) =>
931
+ m.text &&
932
+ m.text.trim() !== "" &&
933
+ m.user &&
934
+ (!botUserId || m.user !== botUserId) &&
935
+ m.ts !== (event.ts as string),
936
+ )
937
+ .map(async (m) => {
938
+ const name = await resolveUserFn(m.user as string, client);
939
+ return `[${name}]: ${m.text}`;
940
+ }),
941
+ );
942
+ if (historyLines.length > 0) {
943
+ prompt = `[Thread context]\n${historyLines.join("\n")}\n[end thread context]\n\n${prompt}`;
944
+ }
945
+ } catch (err) {
946
+ console.warn("[slack] failed to fetch thread history:", err);
947
+ }
948
+ }
949
+
950
+ // Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
951
+ // in the finally block can title the stream's completion card "Ack"
952
+ // rather than the generic "Done" when the reply was suppressed.
953
+ let wasSuppressed = false;
954
+ try {
955
+ const runResult = await runner(prompt, sessionKey, progress.onProgress, {
956
+ SLACK_CHANNEL_ID: ev.channel,
957
+ SLACK_THREAD_TS: replyTs,
958
+ });
959
+ if (runResult.streamIncomplete) {
960
+ // Clean process exit, but the stream never emitted a terminal
961
+ // `result` event — treat this the same as a genuine failure rather
962
+ // than silently posting nothing and logging it as a normal
963
+ // markers-only response (see CSU-1.1 review).
964
+ throw new ClaudeRunError(
965
+ "claude stream ended without a terminal result event",
966
+ undefined,
967
+ "stream incomplete — no terminal result event",
968
+ runResult.sessionId,
969
+ runResult.modelUsage,
970
+ );
971
+ }
972
+ const { result, usage, totalCostUsd, modelUsage } = runResult;
973
+ await chatTokenReporter.recordSession(usage, totalCostUsd, modelUsage);
974
+ const { cleaned, markers } = parseMarkers(result);
975
+
976
+ const isSilent = markers.some((m) => m.type === "silent");
977
+ wasSuppressed = isSilent;
978
+ if (isSilent) {
979
+ console.log(
980
+ `[slack] silent response — not posting (channel mention, cleaned: ${cleaned.length} chars)`,
981
+ );
982
+ }
983
+
984
+ let postedMentionTs: string | undefined;
985
+ if (!isSilent && cleaned.trim().length > 0) {
986
+ const delivered = await progress.deliverContent(cleaned);
987
+ if (!delivered) {
988
+ const blocks = blocksConverter(cleaned);
989
+ let sayResult: unknown;
990
+ if (blocks) {
991
+ try {
992
+ sayResult = await say({
993
+ text: blocks.text,
994
+ blocks: blocks.blocks,
995
+ thread_ts: replyTs,
996
+ });
997
+ } catch (err: unknown) {
998
+ if (!isInvalidBlocksError(err)) throw err;
999
+ console.warn("[slack] invalid_blocks — retrying with plain text");
1000
+ sayResult = await say({
1001
+ text: blocks.text || formatter(cleaned),
1002
+ thread_ts: replyTs,
1003
+ });
1004
+ }
1005
+ } else {
1006
+ sayResult = await say({
1007
+ text: formatter(cleaned),
1008
+ thread_ts: replyTs,
1009
+ });
1010
+ }
1011
+ postedMentionTs = (sayResult as { ts?: string } | undefined)?.ts;
1012
+ }
1013
+ } else if (!isSilent) {
1014
+ console.log("[slack] markers-only response — skipping text post");
1015
+ }
1016
+
1017
+ await dispatchMarkers(markers, {
1018
+ client,
1019
+ channel: ev.channel,
1020
+ postedTs: postedMentionTs,
1021
+ threadTs: replyTs,
1022
+ synthesizeSpeechFn,
1023
+ voiceConfig,
1024
+ postedText: isSilent ? undefined : cleaned,
1025
+ });
1026
+ } catch (err) {
1027
+ console.error("[slack] error:", err);
1028
+ reportClaudeError(sentryClient, err);
1029
+ // Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) BEFORE the
1030
+ // say() below — a UI-state signal only; reportClaudeError(sentryClient, err)
1031
+ // above remains the single log point for this error. Ordering matters: if
1032
+ // say() throws (rate limit, invalid_auth, channel_not_found), this must
1033
+ // still run so finishProgress() doesn't send a "Done" card for a run that
1034
+ // both errored and failed to notify the user (matches reaction_added).
1035
+ progress.reportError();
1036
+ // Wrap the error notification in its own try/catch: a failed say()
1037
+ // (rate limit, invalid_auth, channel_not_found) must not escape the
1038
+ // handler and skip the finally block's finishProgress().
1039
+ try {
1040
+ await say({ text: formatRunErrorForSlack(err), thread_ts: replyTs });
1041
+ } catch (sayErr) {
1042
+ console.error("[slack] failed to post error notification:", sayErr);
1043
+ }
1044
+ } finally {
1045
+ // See finishProgress()'s doc comment.
1046
+ await finishProgress(
1047
+ progress,
1048
+ threadStatusTracker.exit(sessionKey),
1049
+ wasSuppressed,
1050
+ );
1051
+ unregisterStream(progress);
1052
+ }
1053
+ });
1054
+
1055
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
1056
+ app.event("reaction_added", async ({ event, client }: any) => {
1057
+ const ev = event as {
1058
+ reaction: string;
1059
+ item: { type: string; channel: string; ts: string };
1060
+ item_user: string;
1061
+ user: string;
1062
+ };
1063
+
1064
+ if (ev.item.type !== "message") return;
1065
+ if (!botUserId || ev.item_user !== botUserId) return;
1066
+ if (!ev.item.channel.startsWith("D")) return;
1067
+
1068
+ const gateMsg = gateNotice(trialExpiryRef, accountStatusRef);
1069
+ if (gateMsg) {
1070
+ await client.chat.postMessage({
1071
+ channel: ev.item.channel,
1072
+ text: gateMsg,
1073
+ });
1074
+ return;
1075
+ }
1076
+
1077
+ if (
1078
+ await shouldRejectSlackSender(
1079
+ ev.user,
1080
+ client,
1081
+ resolveUserEmailFn,
1082
+ membershipRef,
1083
+ )
1084
+ ) {
1085
+ return;
1086
+ }
1087
+
1088
+ const sessionKey = getThreadKey(ev.item.channel, ev.item.ts);
1089
+ const name = await resolveUserFn(ev.user, client).catch(() => ev.user);
1090
+ const prompt = `[${name} reacted with :${ev.reaction}: to your message]`;
1091
+
1092
+ // Gated above (`ev.item.channel.startsWith("D")`) — this handler only
1093
+ // ever fires for DMs, so recipient_team_id/recipient_user_id are never
1094
+ // fetched or needed here (STS-2.1).
1095
+ const progress = new SlackProgress({
1096
+ client,
1097
+ channel: ev.item.channel,
1098
+ threadTs: ev.item.ts,
1099
+ isDM: true,
1100
+ });
1101
+ // enter() is ALWAYS called — see startProgress()'s doc comment for
1102
+ // what it gates (STS2-1.1 AC #5).
1103
+ await startProgress(progress, threadStatusTracker.enter(sessionKey));
1104
+ // Registers the stream so agent_session_stopped can look this instance
1105
+ // up (AGS-1.1) — see the app.message handler's identical comment above.
1106
+ registerStream(progress);
1107
+
1108
+ // Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
1109
+ // in the finally block can title the stream's completion card "Ack"
1110
+ // rather than the generic "Done" when the reply was suppressed.
1111
+ let wasSuppressed = false;
1112
+ try {
1113
+ const runResult = await runner(prompt, sessionKey, progress.onProgress, {
1114
+ SLACK_CHANNEL_ID: ev.item.channel,
1115
+ SLACK_THREAD_TS: ev.item.ts,
1116
+ });
1117
+ if (runResult.streamIncomplete) {
1118
+ // Clean process exit, but the stream never emitted a terminal
1119
+ // `result` event — treat this the same as a genuine failure rather
1120
+ // than silently posting nothing and logging it as a normal
1121
+ // markers-only response (see CSU-1.1 review).
1122
+ throw new ClaudeRunError(
1123
+ "claude stream ended without a terminal result event",
1124
+ undefined,
1125
+ "stream incomplete — no terminal result event",
1126
+ runResult.sessionId,
1127
+ runResult.modelUsage,
1128
+ );
1129
+ }
1130
+ const { result, usage, totalCostUsd, modelUsage } = runResult;
1131
+ await chatTokenReporter.recordSession(usage, totalCostUsd, modelUsage);
1132
+ const { cleaned, markers } = parseMarkers(result);
1133
+
1134
+ const isSilent = markers.some((m) => m.type === "silent");
1135
+ wasSuppressed = isSilent;
1136
+ if (isSilent) {
1137
+ console.log("[slack] reaction_added: silent response — not posting");
1138
+ return;
1139
+ }
1140
+
1141
+ let postResult: unknown;
1142
+ if (cleaned.trim().length > 0) {
1143
+ const delivered = await progress.deliverContent(cleaned);
1144
+ if (!delivered) {
1145
+ const blocks = blocksConverter(cleaned);
1146
+ if (blocks) {
1147
+ try {
1148
+ postResult = await client.chat.postMessage({
1149
+ channel: ev.item.channel,
1150
+ text: blocks.text,
1151
+ blocks: blocks.blocks,
1152
+ });
1153
+ } catch (err: unknown) {
1154
+ if (isInvalidBlocksError(err)) {
1155
+ console.warn(
1156
+ "[slack] invalid_blocks — retrying with plain text",
1157
+ );
1158
+ postResult = await client.chat
1159
+ .postMessage({
1160
+ channel: ev.item.channel,
1161
+ text: blocks.text || formatter(cleaned),
1162
+ })
1163
+ .catch((err2: unknown) => {
1164
+ console.warn(
1165
+ "[slack] reaction_added: postMessage failed:",
1166
+ err2,
1167
+ );
1168
+ return undefined;
1169
+ });
1170
+ } else {
1171
+ console.warn(
1172
+ "[slack] reaction_added: postMessage failed:",
1173
+ err,
1174
+ );
1175
+ postResult = undefined;
1176
+ }
1177
+ }
1178
+ } else {
1179
+ postResult = await client.chat
1180
+ .postMessage({
1181
+ channel: ev.item.channel,
1182
+ text: formatter(cleaned),
1183
+ })
1184
+ .catch((err: unknown) => {
1185
+ console.warn(
1186
+ "[slack] reaction_added: postMessage failed:",
1187
+ err,
1188
+ );
1189
+ return undefined;
1190
+ });
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ const postedTs = (postResult as { ts?: string } | undefined)?.ts;
1196
+ await dispatchMarkers(markers, {
1197
+ client,
1198
+ channel: ev.item.channel,
1199
+ postedTs,
1200
+ threadTs: ev.item.ts,
1201
+ synthesizeSpeechFn,
1202
+ postedText: cleaned,
1203
+ voiceConfig,
1204
+ });
1205
+ } catch (err) {
1206
+ console.error("[slack] reaction_added error:", err);
1207
+ // Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) — a
1208
+ // UI-state signal only, not a second log path.
1209
+ progress.reportError();
1210
+ } finally {
1211
+ // See finishProgress()'s doc comment.
1212
+ await finishProgress(
1213
+ progress,
1214
+ threadStatusTracker.exit(sessionKey),
1215
+ wasSuppressed,
1216
+ );
1217
+ unregisterStream(progress);
1218
+ }
1219
+ });
1220
+
1221
+ // Slack's native Stop/session-cancellation event for the Agent Sessions
1222
+ // API (AGS-1.1). Without subscribing to this, the agent has no way to know
1223
+ // when Slack has externally halted a stream, so it keeps trying to
1224
+ // append/finalize into a stream Slack already discarded server-side
1225
+ // (message_not_in_streaming_state) — which cascades into a permanently
1226
+ // stuck "is working" status, since finish()'s stopStream() call fails with
1227
+ // no fallback to reset it. Mirrors openclaw's applySlackStreamStop /
1228
+ // markSlackStreamsStopped pattern (extensions/slack/src/streaming.ts,
1229
+ // monitor/events/agent.ts).
1230
+ //
1231
+ // biome-ignore lint/suspicious/noExplicitAny: Bolt callback params
1232
+ app.event("agent_session_stopped", async ({ event, client }: any) => {
1233
+ const ev = event as { channel?: string; streaming_message_ts?: string };
1234
+
1235
+ // Look up the matching live stream and mark it externally-stopped —
1236
+ // any further appendStream/stopStream call against it becomes a no-op
1237
+ // instead of a failed API call. An unknown/already-cleared channel:ts
1238
+ // (stream already closed via its own success/failure path, or a
1239
+ // duplicate delivery of the same event) is a safe no-op — never throws.
1240
+ // Also recovers the run's actual thread_ts (distinct from the stream's
1241
+ // own streaming_message_ts) for the direct status reset below, when a
1242
+ // match was found. On a registry miss threadTs stays undefined — we
1243
+ // deliberately do NOT fall back to ev.streaming_message_ts, which is the
1244
+ // stream's own message ts, a distinct value the Agent Sessions API does
1245
+ // not accept as a thread identifier (see SlackProgress.getThreadTs()).
1246
+ let threadTs: string | undefined;
1247
+ if (ev.channel && ev.streaming_message_ts) {
1248
+ const key = `${ev.channel}:${ev.streaming_message_ts}`;
1249
+ const progress = liveStreams.get(key);
1250
+ if (progress) {
1251
+ threadTs = progress.getThreadTs();
1252
+ progress.markExternallyStopped();
1253
+ liveStreams.delete(key);
1254
+ }
1255
+ }
1256
+
1257
+ // Independent of the stream's own close path: a direct status reset,
1258
+ // since it's a separate API call from the stream itself and should
1259
+ // succeed even when the stream is already dead server-side (the actual
1260
+ // fix for the stuck "is working" status — matches openclaw's own
1261
+ // handler comment: "Recover stale processing if the turn's earlier
1262
+ // active write failed. An unauthorized Stop may clear the indicator
1263
+ // cosmetically until the still-running turn ends.").
1264
+ //
1265
+ // On a registry miss (duplicate/late event for an already-cleared run) we
1266
+ // still fire the reset so the "is working" indicator can't stick, but omit
1267
+ // thread_ts entirely rather than send the wrong (streaming_message_ts)
1268
+ // value.
1269
+ if (ev.channel) {
1270
+ try {
1271
+ await client.agents.sessions.setStatus({
1272
+ channel_id: ev.channel,
1273
+ status: "active",
1274
+ ...(threadTs !== undefined ? { thread_ts: threadTs } : {}),
1275
+ });
1276
+ } catch (err) {
1277
+ console.warn(
1278
+ "[slack] agent_session_stopped: setStatus(active) failed:",
1279
+ err instanceof Error ? err.message : String(err),
1280
+ );
1281
+ }
1282
+ }
1283
+ });
1284
+
1285
+ return app;
1286
+ }