allaboard 0.0.0-stage → 0.4.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * The Slack provider (`ChatProvider` of the chat seam) and one Slack thread as its session uses
3
+ * it (`ThreadChat`), composed from what the provider already has: a `ReplySink` per reply, the
4
+ * `StatusReaction` on the thread's root, the `ThreadStatus` line under its last message, and the
5
+ * request messages of `requests.ts`.
6
+ *
7
+ * What the thread adds to those parts is their wiring: every pass of a reply that wrote, and every
8
+ * notice, tells the status line (Slack clears a thread's status when the app replies); the
9
+ * seam's four statuses are the four reactions; an answered question is rewritten on the budget
10
+ * every reply draws from; and a failed post or rewrite crosses the seam as a `ChatError` named
11
+ * by Slack's code.
12
+ */
13
+ import { monotonicClock, systemClock } from "../../clock.js";
14
+ import { Cancelled, named, texts, } from "../../core/port.js";
15
+ import { getLogger } from "../../log.js";
16
+ import { ChatError, } from "../seam.js";
17
+ import { buildApp } from "./app/app.js";
18
+ import { threadKey } from "./app/order.js";
19
+ import { SlackChannel } from "./channel.js";
20
+ import { channelLookup, threadLookup } from "./channels.js";
21
+ import { holdBlocks } from "./hold.js";
22
+ import { crossing, deliver, deliverNotice } from "./notice.js";
23
+ import { endReply } from "./repair.js";
24
+ import { contextBlock, noticeText, split } from "./reply/blocks.js";
25
+ import { deleteRequest, describe } from "./reply/errors.js";
26
+ import { markdownEscape, mrkdwnEscape } from "./reply/escape.js";
27
+ import { ThreadPlaces } from "./reply/places.js";
28
+ import { ReplySink } from "./reply/sinks.js";
29
+ import { REACTIONS, StatusReaction, ThreadStatus } from "./reply/status.js";
30
+ import { answeredBlocks, approvalBlocks, questionBlocks } from "./requests.js";
31
+ import { setupBlocks } from "./setup.js";
32
+ export const logger = getLogger("allaboard.chat.slack.thread");
33
+ /** Slack draws every part of the chat seam. */
34
+ const CAPABILITIES = {
35
+ nativeStreaming: true,
36
+ reactions: true,
37
+ activityLine: true,
38
+ fileUpload: true,
39
+ };
40
+ /** The Slack provider: one `SlackThread` per thread, one `SlackChannel` per channel. */
41
+ export class SlackChat {
42
+ name = "Slack";
43
+ capabilities = CAPABILITIES;
44
+ /**
45
+ * The bot of the workspace, and the app-level token of the connection when there is one: the
46
+ * two things a copied `.env` can leave the same, each of which makes Socket Mode spread events
47
+ * across the connections of two daemons.
48
+ */
49
+ identities;
50
+ /** Present when the provider holds the Home tab (`SlackApp.home`). */
51
+ index;
52
+ options;
53
+ stopping = new AbortController();
54
+ built = null;
55
+ /**
56
+ * Where each thread's messages sit, by thread, while a thread object that opened a reply in it
57
+ * is open (`users`), and after that while a reply of it is registered.
58
+ */
59
+ places = new Map();
60
+ named = null;
61
+ constructor(options) {
62
+ this.options = options;
63
+ this.identities = [
64
+ `${options.identity.teamId}:${options.identity.botUserId}`,
65
+ ...(options.app !== undefined ? [options.app.appKey] : []),
66
+ ];
67
+ const home = options.app?.home;
68
+ if (home !== undefined)
69
+ this.index = { refresh: (changed) => home.request(changed) };
70
+ }
71
+ /**
72
+ * The `chat.update` budget every reply in the process draws from, so an edit made outside a
73
+ * reply (a handler's own) can share it.
74
+ */
75
+ get limiter() {
76
+ return this.options.limiter;
77
+ }
78
+ /**
79
+ * Aborts when the daemon stops. A listener still in flight then does nothing at its next
80
+ * step: the sessions are closed and the lock is released.
81
+ */
82
+ get stopped() {
83
+ return this.stopping.signal;
84
+ }
85
+ /** Tells every listener, and every call of the chat, that the daemon is stopping. */
86
+ halt() {
87
+ this.stopping.abort();
88
+ }
89
+ /** The names the texts take, the agent's and this chat's, made when first asked for. */
90
+ names() {
91
+ this.named ??= texts.namesOf(this.options.agent(), this);
92
+ return this.named;
93
+ }
94
+ /** What the setup posted for `requestId` said to draw, or undefined when it is no longer kept. */
95
+ drawnSetup(requestId) {
96
+ return drawnSetups.get(this.options)?.get(requestId);
97
+ }
98
+ thread(ref) {
99
+ return new SlackThread(ref.channel, ref.thread, this.options, () => this.names(), this.stopped, this.leaseOf(ref.channel, ref.thread));
100
+ }
101
+ /**
102
+ * The owner wrote a message at `ts` in a thread. Called by the listener for every owner
103
+ * message, before anything else of it is decided: a message the core later refuses still sits
104
+ * below the replies above it.
105
+ */
106
+ ownerPosted(channel, thread, ts) {
107
+ this.places.get(threadKey(channel, thread))?.places.ownerPosted(ts);
108
+ }
109
+ /**
110
+ * The thread's places: one object for every `SlackThread` of it, so that the replies of the
111
+ * thread's successive sessions see each other. A thread object takes it at its first reply and
112
+ * gives it back at its close (`ThreadLease`). It lives while one is open: a reply let go of
113
+ * by an owner message must still be found by the next one. After the last give-back it lives
114
+ * while a reply is registered with it.
115
+ */
116
+ leaseOf(channel, thread) {
117
+ const key = threadKey(channel, thread);
118
+ return {
119
+ peek: () => this.places.get(key)?.places,
120
+ take: () => {
121
+ const known = this.places.get(key);
122
+ if (known !== undefined) {
123
+ known.users += 1;
124
+ return known.places;
125
+ }
126
+ const made = new ThreadPlaces(() => {
127
+ const entry = this.places.get(key);
128
+ if (entry?.places === made && entry.users === 0)
129
+ this.places.delete(key);
130
+ });
131
+ this.places.set(key, { places: made, users: 1 });
132
+ return made;
133
+ },
134
+ give: () => {
135
+ const entry = this.places.get(key);
136
+ if (entry === undefined)
137
+ return;
138
+ entry.users -= 1;
139
+ if (entry.users === 0 && !entry.places.held)
140
+ this.places.delete(key);
141
+ },
142
+ };
143
+ }
144
+ /**
145
+ * The thread as a handler answers in it. A post of the app's clears the status line of the
146
+ * session that lives there, not of this object, which only posts: `written` sets that line
147
+ * again.
148
+ */
149
+ answering(ref, written) {
150
+ return new SlackThread(ref.channel, ref.thread, this.options, () => this.names(), this.stopped, this.leaseOf(ref.channel, ref.thread), written);
151
+ }
152
+ channel(channel) {
153
+ const { slack, identity, limiter, clock } = this.options;
154
+ return new SlackChannel(channel, {
155
+ slack,
156
+ ownerUserId: identity.ownerUserId,
157
+ limiter,
158
+ clock: clock ?? systemClock,
159
+ stopped: this.stopped,
160
+ names: () => this.names(),
161
+ });
162
+ }
163
+ escape(text, markup) {
164
+ return markup === "notice" ? mrkdwnEscape(text) : markdownEscape(text);
165
+ }
166
+ // Slack's channel syntax in a notice. Whether a markdown block renders it was not confirmed
167
+ // (the block's reference names neither mentions nor angle brackets), so `reference` gets the same.
168
+ channelName(channel, _markup) {
169
+ return `<#${channel}>`;
170
+ }
171
+ openedAt(ref) {
172
+ // A Slack ts is the post time in epoch seconds.
173
+ const seconds = ref.thread.trim() === "" ? Number.NaN : Number(ref.thread);
174
+ return Number.isNaN(seconds) ? null : seconds;
175
+ }
176
+ channelState(channel) {
177
+ return channelLookup(this.options.slack)(channel);
178
+ }
179
+ threadState(ref) {
180
+ return threadLookup(this.options.slack)(ref);
181
+ }
182
+ async openFile(ref, folder, target) {
183
+ if (this.built === null)
184
+ throw new Error("the Slack app is not started");
185
+ await this.built.openFile(ref.channel, ref.thread, folder, target);
186
+ }
187
+ async start(inbound) {
188
+ const { app, identity, steady } = this.options;
189
+ if (app === undefined)
190
+ throw new Error("this Slack provider was built with no app to open");
191
+ const core = app.core();
192
+ this.built = buildApp({
193
+ chat: this,
194
+ slack: this.options.slack,
195
+ config: app.config,
196
+ identity,
197
+ sessions: core.sessions,
198
+ approvals: core.approvals,
199
+ holds: core.holds,
200
+ names: this.names(),
201
+ inbound,
202
+ guard: app.guard,
203
+ state: core.state,
204
+ uploads: app.uploads,
205
+ home: app.home,
206
+ receiver: app.receiver,
207
+ ...(steady !== undefined && { clock: steady }),
208
+ ...(app.fetch !== undefined && { fetch: app.fetch }),
209
+ ...(app.now !== undefined && { now: app.now }),
210
+ ...(app.listings !== undefined && { listings: app.listings }),
211
+ });
212
+ await this.built.app.start();
213
+ logger.info(`connected to Slack workspace ${identity.teamId}`);
214
+ }
215
+ async stop() {
216
+ const built = this.built;
217
+ try {
218
+ await built?.app.stop();
219
+ }
220
+ finally {
221
+ // Even when the connection would not close: what listens must stop all the same.
222
+ this.halt();
223
+ // The timers of clips still waiting for a transcript would keep the process alive.
224
+ built?.close();
225
+ }
226
+ }
227
+ async close() {
228
+ await this.options.app?.home.close();
229
+ }
230
+ }
231
+ // The requests a provider withdrew lately, by channel and message, per provider (the options it
232
+ // was built over). A rewrite that waited for the budget while its message was withdrawn writes
233
+ // nothing.
234
+ const WITHDRAWN_KEPT = 64;
235
+ const withdrawn = new WeakMap();
236
+ // What the setups posted lately said to draw, by request id, per provider. A click that only
237
+ // redraws a setup (a model picked) draws the message again from this, since the hold it finds
238
+ // keeps the model list alone.
239
+ const SETUPS_KEPT = 64;
240
+ const drawnSetups = new WeakMap();
241
+ export class SlackThread {
242
+ channel;
243
+ threadTs;
244
+ options;
245
+ names;
246
+ slack;
247
+ clock;
248
+ // One reaction on the session's root message, which `threadTs` always is (a top-level owner
249
+ // message, or the root of a `!resume` thread, the owner's own message).
250
+ reaction;
251
+ /** Issues #83 and #95: Slack's status line at the bottom of the thread. */
252
+ line;
253
+ stopped;
254
+ lease;
255
+ /** The places of this thread, once a reply was opened in it and until the close. */
256
+ places = null;
257
+ onWritten;
258
+ /** The replies this object opened, with the places each is registered in. */
259
+ opened = [];
260
+ /**
261
+ * `written` stands for this object's own status line where something else holds it: the
262
+ * handlers answer through a thread of their own, and the session's line is the one a post
263
+ * clears.
264
+ */
265
+ constructor(channelId, threadTs, options, names, stopped, lease, written) {
266
+ this.channel = channelId;
267
+ this.threadTs = threadTs;
268
+ this.options = options;
269
+ this.names = names;
270
+ this.stopped = stopped;
271
+ this.lease = lease;
272
+ this.slack = options.slack;
273
+ this.clock = options.clock ?? systemClock;
274
+ this.reaction = new StatusReaction(this.slack, { channel: channelId, rootTs: threadTs });
275
+ this.line = new ThreadStatus(this.slack, {
276
+ channel: channelId,
277
+ threadTs,
278
+ clock: options.steady ?? options.clock ?? monotonicClock,
279
+ });
280
+ this.onWritten = written ?? (() => this.line.wrote());
281
+ }
282
+ openReply(onOpenMessage) {
283
+ const { identity } = this.options;
284
+ this.places ??= this.lease.take();
285
+ const places = this.places;
286
+ const reply = new ReplySink(this.options.replies, {
287
+ channel: this.channel,
288
+ threadTs: this.threadTs,
289
+ teamId: identity.teamId,
290
+ userId: identity.ownerUserId,
291
+ botUserId: identity.botUserId,
292
+ limiter: this.options.limiter,
293
+ clock: this.clock,
294
+ ...(this.options.steady !== undefined && { steady: this.options.steady }),
295
+ onOpenReply: onOpenMessage,
296
+ onWrite: () => this.line.wrote(),
297
+ places,
298
+ ...(this.options.debounceSeconds !== undefined && {
299
+ debounceSeconds: this.options.debounceSeconds,
300
+ }),
301
+ ...(this.options.finalRetrySeconds !== undefined && {
302
+ finalRetrySeconds: this.options.finalRetrySeconds,
303
+ }),
304
+ });
305
+ // A reply the places let go of needs no letting go at close, and must not stay reachable.
306
+ this.opened.splice(0, this.opened.length, ...this.opened.filter(([held, old]) => held.holds(old)));
307
+ this.opened.push([places, reply]);
308
+ return reply;
309
+ }
310
+ showRunning(list) {
311
+ // No reply was opened yet: there is nothing for the list to sit under.
312
+ this.places?.showRunning(list);
313
+ }
314
+ showStatus(status, signal) {
315
+ return this.reaction.show(REACTIONS[status], signal);
316
+ }
317
+ clearStatus() {
318
+ return this.reaction.clear();
319
+ }
320
+ settleStatus() {
321
+ return this.reaction.settle();
322
+ }
323
+ showActivity(text, afterName) {
324
+ this.line.show(text, afterName);
325
+ }
326
+ get activityRefused() {
327
+ return ThreadStatus.refused();
328
+ }
329
+ written() {
330
+ this.onWritten();
331
+ }
332
+ /**
333
+ * A message of the app's own posted in the thread outside a reply: the places learn where it
334
+ * sits, so that no reply posts its closing message below it later. Null for an ephemeral one.
335
+ */
336
+ postedOwn(ts) {
337
+ if (typeof ts === "string" && ts !== "")
338
+ (this.places ?? this.lease.peek())?.posted(ts);
339
+ }
340
+ async notice(text) {
341
+ // mrkdwn reads `&`, `<` and `>` as markup, and a notice can quote the owner's own message.
342
+ const shown = mrkdwnEscape(text);
343
+ try {
344
+ const posted = await this.slack.chat.postMessage({
345
+ channel: this.channel,
346
+ thread_ts: this.threadTs,
347
+ text: shown,
348
+ blocks: [contextBlock(noticeText(shown))],
349
+ unfurl_links: false,
350
+ unfurl_media: false,
351
+ });
352
+ this.postedOwn(posted.ts);
353
+ this.line.wrote(); // a post of the app's clears the thread's status
354
+ }
355
+ catch (error) {
356
+ logger.error(`could not post in ${this.channel}/${this.threadTs}: ${describe(error)}`);
357
+ }
358
+ }
359
+ async ask(requestId, request, title) {
360
+ let blocks;
361
+ switch (request.type) {
362
+ case "question":
363
+ blocks = questionBlocks(requestId, request.questions, this.names());
364
+ break;
365
+ case "permission":
366
+ blocks = approvalBlocks(requestId, request, this.names());
367
+ break;
368
+ case "hold":
369
+ blocks = holdBlocks(requestId, request.link);
370
+ break;
371
+ case "setup":
372
+ this.noteSetup(requestId, request);
373
+ blocks = setupBlocks(requestId, request, request.selection);
374
+ break;
375
+ }
376
+ let ts;
377
+ try {
378
+ const posted = await this.slack.chat.postMessage({
379
+ channel: this.channel,
380
+ thread_ts: this.threadTs,
381
+ text: title,
382
+ // Built to Block Kit's reference by the modules that make them: opaque to the library's types.
383
+ blocks: blocks,
384
+ unfurl_links: false,
385
+ unfurl_media: false,
386
+ });
387
+ ts = posted.ts;
388
+ }
389
+ catch (error) {
390
+ // A wait that was cancelled is no refusal of the chat: it crosses as it is.
391
+ if (error instanceof Cancelled)
392
+ throw error;
393
+ throw new ChatError(describe(error), { cause: error });
394
+ }
395
+ // The message is the only handle on its buttons: one Slack did not name cannot be removed.
396
+ if (typeof ts !== "string" || ts === "")
397
+ throw new ChatError("no_ts");
398
+ this.postedOwn(ts);
399
+ return ts;
400
+ }
401
+ async withdraw(request) {
402
+ // (sync) Noted before the first await: a rewrite waiting for the budget sees it.
403
+ this.noteWithdrawn(request);
404
+ return await deleteRequest(this.slack, { channel: this.channel, ts: request });
405
+ }
406
+ async keepAnswers(request, questions, answers) {
407
+ try {
408
+ // One more chat.update against the app's budget, which every reply draws from.
409
+ await this.options.limiter.acquire();
410
+ await this.slack.chat.update({
411
+ channel: this.channel,
412
+ ts: request,
413
+ text: named(texts.ANSWERED, this.names()),
414
+ blocks: answeredBlocks(questions, answers, this.names()),
415
+ });
416
+ }
417
+ catch (error) {
418
+ throw new ChatError(describe(error), { cause: error });
419
+ }
420
+ }
421
+ rewrite(message, text) {
422
+ return crossing(async () => {
423
+ await this.options.limiter.acquire();
424
+ // The wait can outlast the message (withdrawn by a `!stop`) and the daemon: nothing is
425
+ // left to edit then, and the edit would only be refused.
426
+ if (this.stopped.aborted || this.wasWithdrawn(message))
427
+ return;
428
+ await this.slack.chat.update({
429
+ channel: this.channel,
430
+ ts: message,
431
+ text,
432
+ blocks: [contextBlock(noticeText(text))],
433
+ });
434
+ });
435
+ }
436
+ noteSetup(requestId, request) {
437
+ const kept = drawnSetups.get(this.options) ?? new Map();
438
+ drawnSetups.set(this.options, kept);
439
+ const { models, efforts, bypassOffered } = request;
440
+ kept.set(requestId, { models, efforts, bypassOffered });
441
+ // Only a setup that is waiting right now can be redrawn: the oldest go.
442
+ for (const old of kept.keys()) {
443
+ if (kept.size <= SETUPS_KEPT)
444
+ break;
445
+ kept.delete(old);
446
+ }
447
+ }
448
+ noteWithdrawn(message) {
449
+ const kept = withdrawn.get(this.options) ?? new Set();
450
+ withdrawn.set(this.options, kept);
451
+ kept.add(`${this.channel}/${message}`);
452
+ // Only a rewrite that is waiting right now cares: the oldest go.
453
+ for (const old of kept) {
454
+ if (kept.size <= WITHDRAWN_KEPT)
455
+ break;
456
+ kept.delete(old);
457
+ }
458
+ }
459
+ wasWithdrawn(message) {
460
+ return withdrawn.get(this.options)?.has(`${this.channel}/${message}`) ?? false;
461
+ }
462
+ async tell(text, options = {}) {
463
+ if (this.stopped.aborted)
464
+ return;
465
+ try {
466
+ this.postedOwn(await deliverNotice(this.slack, this.options.identity.ownerUserId, this.place, text, {
467
+ ephemeral: !options.stays,
468
+ }));
469
+ // Slack clears a thread's status line when the app replies: the session's is set
470
+ // again, so `Working…` does not go for a minute after the answer to a word.
471
+ this.written();
472
+ }
473
+ catch (error) {
474
+ logger.warning(`could not reach the owner in ${this.channel}: ${describe(error)}`);
475
+ }
476
+ }
477
+ async say(markdown, options = {}) {
478
+ for (const chunk of split(markdown)) {
479
+ if (this.stopped.aborted)
480
+ return;
481
+ this.postedOwn(await crossing(() => deliver(this.slack, this.options.identity.ownerUserId, this.place, chunk, [{ type: "markdown", text: chunk }], { ephemeral: options.ownerOnly ?? false })));
482
+ this.written();
483
+ }
484
+ }
485
+ async acknowledge(message) {
486
+ if (this.stopped.aborted)
487
+ return;
488
+ try {
489
+ await this.slack.reactions.add({
490
+ channel: this.channel,
491
+ name: "white_check_mark",
492
+ timestamp: message,
493
+ });
494
+ }
495
+ catch (error) {
496
+ logger.warning(`could not react to a word in ${this.channel}: ${describe(error)}`);
497
+ }
498
+ }
499
+ async link(label, markup) {
500
+ let permalink;
501
+ try {
502
+ ({ permalink } = await this.slack.chat.getPermalink({
503
+ channel: this.channel,
504
+ message_ts: this.threadTs,
505
+ }));
506
+ if (typeof permalink !== "string")
507
+ throw new TypeError("no permalink");
508
+ }
509
+ catch (error) {
510
+ logger.warning(`could not get a permalink for ${this.channel}/${this.threadTs}: ${describe(error)}`);
511
+ return null;
512
+ }
513
+ // A notice is mrkdwn, which takes `<url|label>`; a reference is Markdown, `[label](url)`.
514
+ return markup === "notice" ? `<${permalink}|${label}>` : `[${label}](${permalink})`;
515
+ }
516
+ endReply(reply, note) {
517
+ return endReply(this.slack, this.options.limiter, this.place, reply, note);
518
+ }
519
+ async close() {
520
+ // The list that was just taken off a reply the session no longer tracks only debounced:
521
+ // nothing else would write it before the process ends.
522
+ await this.places?.settleListed();
523
+ // The replies this object opened are let go of, their end landed or not: the session that
524
+ // held them is over, and nothing else would drop them from the thread's places.
525
+ for (const [places, reply] of this.opened)
526
+ places.closed(reply);
527
+ this.opened.length = 0;
528
+ if (this.places !== null) {
529
+ this.places = null;
530
+ this.lease.give();
531
+ }
532
+ await this.line.close();
533
+ }
534
+ get place() {
535
+ return { channel: this.channel, threadTs: this.threadTs };
536
+ }
537
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The update of allaboard as Slack draws it (issue #361): the offer `!allaboard update` posts in a
3
+ * channel, and the button and dialog of the update line on the Home tab. The words are the
4
+ * core's; the click of either becomes a pick of the core (`app/update.ts`).
5
+ *
6
+ * The confirmation dialog object (docs.slack.dev/reference/block-kit/composition-objects/
7
+ * confirmation-dialog-object, read 2026-10-17): `title` holds 100 characters, `text` 300,
8
+ * `confirm` and `deny` 30 each, all `plain_text`; `style` is optional. A button's `value` holds
9
+ * 2,000 characters (button element reference): a version is far under it.
10
+ */
11
+ import { fill, sharedCopyLine, texts } from "../../core/port.js";
12
+ import { contextBlock, plainTextObject } from "./reply/blocks.js";
13
+ import { take } from "./reply/chars.js";
14
+ import { mrkdwnEscape } from "./reply/escape.js";
15
+ import * as slackTexts from "./texts.js";
16
+ // Block Kit's limits on a confirmation dialog: its title, its text, and each of its two buttons.
17
+ export const CONFIRM_TITLE_LIMIT = 100;
18
+ export const CONFIRM_TEXT_LIMIT = 300;
19
+ export const CONFIRM_BUTTON_LIMIT = 30;
20
+ /** The Update button of the offer a channel shows: its value is the version it installs. */
21
+ export const UPDATE_ACTION = "update_install";
22
+ /** The `block_id` of the offer's line, so a test and a rewrite can tell it from its notes. */
23
+ export const UPDATE_BLOCK = "update";
24
+ /** `line` followed by the link to the changelog at `url`, in mrkdwn. */
25
+ export function withChangelog(line, url) {
26
+ return fill(slackTexts.UPDATE_LINKED, {
27
+ line,
28
+ url: mrkdwnEscape(url),
29
+ label: texts.UPDATE_WHAT_CHANGED,
30
+ });
31
+ }
32
+ /**
33
+ * The button that installs `latest`, with Slack's own confirmation dialog when `confirm` gives
34
+ * the version that runs: the Home tab shows no message that says what changes first, the offer
35
+ * in a channel does.
36
+ */
37
+ export function updateButton(actionId, latest, confirm) {
38
+ const button = {
39
+ type: "button",
40
+ action_id: actionId,
41
+ text: plainTextObject(fill(texts.UPDATE_BUTTON, { latest })),
42
+ value: latest,
43
+ };
44
+ if (confirm !== null) {
45
+ button.confirm = {
46
+ title: plainTextObject(take(texts.UPDATE_CONFIRM_TITLE, CONFIRM_TITLE_LIMIT)),
47
+ text: plainTextObject(take(fill(texts.UPDATE_CONFIRM_TEXT, { installed: confirm.installed, latest }), CONFIRM_TEXT_LIMIT)),
48
+ confirm: plainTextObject(take(texts.UPDATE_CONFIRM_YES, CONFIRM_BUTTON_LIMIT)),
49
+ deny: plainTextObject(take(texts.UPDATE_CONFIRM_NO, CONFIRM_BUTTON_LIMIT)),
50
+ };
51
+ }
52
+ return button;
53
+ }
54
+ /**
55
+ * The offer: the line `title` with the link to what changed and the Update button, then what a
56
+ * click waits for, and the other instances that restart with this one when there are any. The
57
+ * message shows the change, so its button asks nothing more.
58
+ */
59
+ export function updateBlocks(list, title) {
60
+ const shared = sharedCopyLine(list.sharing);
61
+ return [
62
+ {
63
+ type: "section",
64
+ block_id: UPDATE_BLOCK,
65
+ text: { type: "mrkdwn", text: withChangelog(title, list.changelog) },
66
+ accessory: updateButton(UPDATE_ACTION, list.to, null),
67
+ },
68
+ contextBlock(texts.UPDATE_DRAIN),
69
+ ...(shared === null ? [] : [contextBlock(shared)]),
70
+ ];
71
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * An audio clip as a prompt: the transcript Slack makes of it, sent as the owner's text.
3
+ *
4
+ * Claude Code takes no audio (its Read tool refuses a binary file, measured 2026-10-09, CLI
5
+ * 2.1.292), and its own dictation is the microphone of an interactive session. So a clip becomes a
6
+ * prompt the way dictation fills the terminal's input: as text, before the session sees it. The
7
+ * daemon never transcribes: the text is the one Slack makes when the owner asks for it with
8
+ * `Generate transcript` on the clip (Slack help, "Record audio and video clips", read 2026-10-09).
9
+ *
10
+ * Measured on 2026-10-09 (Slack free plan, slack-sdk 3.45.0), three clips recorded in the iOS app:
11
+ *
12
+ * - a clip is a message with no text and one file whose `media_display_type` is `audio`
13
+ * (`mimetype` `audio/mp4`): a clip recorded in Slack has the `subtype` `slack_audio`, and the
14
+ * same recording uploaded as a file has none and gets a transcript all the same;
15
+ * - nothing tells the app when the clip is sent; while Slack makes the transcript it sends
16
+ * `file_change` for the file, four times in 15 seconds, and the last leaves
17
+ * `transcription.status` at `complete`. Only `complete` was seen as a status;
18
+ * - `transcription.preview.content` is the text and `has_more` was false, up to 67 characters;
19
+ * `vtt` is the whole transcript as WebVTT on Slack's file host, read with the bot token;
20
+ * - Slack picks the language itself (`transcription.locale`): of three clips spoken in Italian it
21
+ * heard one as `it-IT` and two as `en-GB`, and the text of those two was wrong.
22
+ */
23
+ import { strip } from "../../text.js";
24
+ // How long a clip waits for the owner to ask Slack for its transcript.
25
+ export const WAIT_SECONDS = 300.0;
26
+ // A transcript of five minutes of speech, the longest clip Slack records, is a few kilobytes.
27
+ export const VTT_LIMIT = 256 * 1024;
28
+ function record(value) {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value)
30
+ ? value
31
+ : null;
32
+ }
33
+ /**
34
+ * The audio a message is, when it carries that one file and nothing else: a clip recorded in
35
+ * Slack or an audio file, which Slack shows with the same player and transcript.
36
+ */
37
+ export function clip(files) {
38
+ const [only] = files;
39
+ if (files.length === 1 && only && record(only)?.media_display_type === "audio")
40
+ return only;
41
+ return null;
42
+ }
43
+ /** Whether Slack has finished the transcript of `file`. */
44
+ export function ready(file) {
45
+ return record(record(file)?.transcription)?.status === "complete";
46
+ }
47
+ /**
48
+ * The transcript when the file object holds all of it; null when Slack cut it (`has_more`) and
49
+ * the rest is in the file's `vtt`.
50
+ */
51
+ export function preview(file) {
52
+ const found = record(record(record(file)?.transcription)?.preview);
53
+ if (found === null || found.has_more)
54
+ return null;
55
+ return typeof found.content === "string" ? strip(found.content) : null;
56
+ }
57
+ // A tag of a WebVTT cue: from `<` to the next `>`, across line ends, or to the end of the cue when
58
+ // nothing closes it (WebVTT 1, the cue text tokenizer).
59
+ const TAG = /<[^>]*(?:>|$)/g;
60
+ /**
61
+ * The words of a WebVTT transcript as Slack writes it: a `WEBVTT` header, then cues of a timing
62
+ * line and text lines, the first of which can open with `- `. One line of text, without the tags
63
+ * WebVTT allows inside a cue (`<v Name>`, `<i>`), which Slack was not seen to write.
64
+ */
65
+ export function vttText(body) {
66
+ const words = [];
67
+ for (const block of body.replace(/^+/, "").replaceAll("\r\n", "\n").split("\n\n")) {
68
+ const lines = block
69
+ .split("\n")
70
+ .map(strip)
71
+ .filter((line) => line !== "");
72
+ const timing = lines.findIndex((line) => line.includes("-->"));
73
+ if (timing === -1)
74
+ continue; // the header, or a note
75
+ const cue = lines
76
+ .slice(timing + 1)
77
+ .join("\n")
78
+ .replace(TAG, "");
79
+ for (const text of cue.split("\n")) {
80
+ words.push(strip(text.startsWith("- ") ? text.slice(2) : text));
81
+ }
82
+ }
83
+ return words.filter((word) => word !== "").join(" ");
84
+ }