allaboard 0.5.0 → 0.6.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 (53) hide show
  1. package/README.md +9 -7
  2. package/dist/agent/claude/backend.js +73 -2
  3. package/dist/agent/claude/capabilities.js +4 -0
  4. package/dist/agent/claude/module.js +55 -0
  5. package/dist/agent/claude/translate.js +6 -2
  6. package/dist/agent/claude/trust.js +22 -0
  7. package/dist/chat/seam.js +3 -0
  8. package/dist/chat/slack/app/app.js +52 -4
  9. package/dist/chat/slack/app/delete-thread.js +90 -0
  10. package/dist/chat/slack/app/guards.js +44 -0
  11. package/dist/chat/slack/app/home-controls.js +8 -1
  12. package/dist/chat/slack/channels.js +23 -5
  13. package/dist/chat/slack/delete-thread.js +37 -0
  14. package/dist/chat/slack/delete.js +13 -7
  15. package/dist/chat/slack/home.js +194 -35
  16. package/dist/chat/slack/module.js +64 -14
  17. package/dist/chat/slack/onboarding.js +159 -0
  18. package/dist/chat/slack/reply/blocks.js +27 -11
  19. package/dist/chat/slack/reply/sinks.js +3 -1
  20. package/dist/chat/slack/tag.js +317 -0
  21. package/dist/chat/slack/texts.js +8 -0
  22. package/dist/chat/slack/thread.js +62 -2
  23. package/dist/chat/slack/tokens.js +15 -0
  24. package/dist/cli.js +43 -4
  25. package/dist/core/cleanup.js +66 -3
  26. package/dist/core/commands.js +40 -0
  27. package/dist/core/config.js +19 -11
  28. package/dist/core/files/listing.js +19 -0
  29. package/dist/core/flows/bind.js +29 -6
  30. package/dist/core/flows/flows.js +2 -0
  31. package/dist/core/flows/gone.js +39 -0
  32. package/dist/core/flows/inbound.js +3 -0
  33. package/dist/core/flows/messages.js +4 -2
  34. package/dist/core/flows/status.js +98 -48
  35. package/dist/core/flows/words.js +80 -1
  36. package/dist/core/guide.js +2 -0
  37. package/dist/core/port/cleanup.js +1 -0
  38. package/dist/core/port.js +1 -0
  39. package/dist/core/sessions/directory.js +47 -8
  40. package/dist/core/sessions/manager.js +32 -17
  41. package/dist/core/sessions/quote.js +57 -0
  42. package/dist/core/sessions/session.js +324 -35
  43. package/dist/core/sessions/title.js +98 -0
  44. package/dist/core/state.js +25 -0
  45. package/dist/core/texts.js +58 -3
  46. package/dist/core/wizard.js +306 -0
  47. package/dist/daemon.js +23 -11
  48. package/dist/host/program.js +22 -7
  49. package/dist/host/prompter.js +61 -0
  50. package/dist/module-setup.js +6 -0
  51. package/dist/version.js +4 -0
  52. package/package.json +5 -2
  53. package/slack-app-manifest.json +2 -1
@@ -0,0 +1,317 @@
1
+ /**
2
+ * The tag of a thread, shown on its root message and kept at hand by a pin: `!tag <name>` puts
3
+ * an inline-code `[name]` and a space at the start of the root, replacing a tag already there,
4
+ * and pins the root; `!untag` removes the prefix and, from a root that carried a tag, the pin (a
5
+ * pin the owner made by hand on an untagged root is left alone).
6
+ *
7
+ * The root is the owner's own message, and `chat.update` changes only "messages posted by the
8
+ * authenticated user" (docs.slack.dev/reference/methods/chat.update, read 2026-10-19), so the
9
+ * edit and the pin both go through the owner's user token (`SLACK_USER_TOKEN`, scopes
10
+ * `chat:write` and `pins:write`); the bot only reads the root. Measured on a dev workspace on
11
+ * 2026-10-19: that token edits and pins the owner's root; no `pinned_item` message is posted;
12
+ * no push reaches iOS for any of the four actions; the edit reaches the daemon as
13
+ * `message_changed`, which `app/guards.ts` drops, so no prompt is sent; `conversations.replies`
14
+ * with the bot's `groups:history` returns `pinned_to` and `pinned_info` on a pinned root; Slack's
15
+ * search finds the tag word an edit added. Not tried: a root with real formatting or a file.
16
+ *
17
+ * The tag is read back from the root's own blocks (`rootMarks`, which the Home tab uses too), so
18
+ * `state.json` keeps nothing of it: the first element of the first `rich_text_section` is a text
19
+ * element in code style whose text is `[name]` for a name that satisfies `TAG_NAME`, followed by
20
+ * an element that is exactly a space.
21
+ *
22
+ * The title of a thread (`titleThread`) replaces the whole root: one `rich_text` block with one
23
+ * section, the tag's two elements when the root carries a tag, then a bold text element with the
24
+ * title. The owner's own words are gone from the root once it has a title,
25
+ * so there is no title line to tell apart from them and nothing to replace selectively. The tag
26
+ * is read first (`rootMarks`) and kept, so `!tag` and `!untag` work on a titled root as on any
27
+ * other. Tag and title edits of one root are serialized, since each reads the message and writes
28
+ * it back whole. Titling needs `chat:write` alone: no pin is involved.
29
+ *
30
+ * `pins.add` answers `already_pinned` for a pinned item (docs.slack.dev/reference/methods/pins.add,
31
+ * read 2026-10-19); `pins.remove` answers `not_pinned` ("The item isn't pinned to the channel") or
32
+ * `no_pin` ("The pin couldn't be found", docs.slack.dev/reference/methods/pins.remove, read
33
+ * 2026-10-19). Both mean the pin is already as asked, so neither is a failure.
34
+ */
35
+ import { isDeepStrictEqual } from "node:util";
36
+ import { TAG_NAME } from "../../core/port.js";
37
+ import { isRecord } from "../../values.js";
38
+ import { ChatError } from "../seam.js";
39
+ import { slackUnescape } from "./app/messages.js";
40
+ import { isTombstone } from "./channels.js";
41
+ import { describe, GONE } from "./reply/errors.js";
42
+ import { mrkdwnEscape } from "./reply/escape.js";
43
+ // What Slack answers when a root can never be read or edited again by this token: the edit window
44
+ // of the workspace closed, the message cannot be updated, the message, its thread or its channel
45
+ // is gone, or the token lacks a scope or a membership.
46
+ const REFUSED_FOR_GOOD = new Set([
47
+ ...GONE,
48
+ "edit_window_closed",
49
+ "cant_update_message",
50
+ "not_in_channel",
51
+ "missing_scope",
52
+ ]);
53
+ /** `step`, with a refusal that cannot change made a `permanent` `ChatError`. */
54
+ async function permanently(step) {
55
+ try {
56
+ return await step();
57
+ }
58
+ catch (error) {
59
+ const code = describe(error);
60
+ // The same call is refused again at any later turn: say so, so the core stops asking.
61
+ if (REFUSED_FOR_GOOD.has(code))
62
+ throw new ChatError(code, { cause: error, permanent: true });
63
+ throw error;
64
+ }
65
+ }
66
+ const ALREADY_PINNED = "already_pinned";
67
+ const NOT_PINNED = new Set(["not_pinned", "no_pin"]);
68
+ // The inline-code prefix of a root's `text`, which Slack writes with backticks.
69
+ const TEXT_PREFIX = /^`\[([^\]`]*)\]` ?/;
70
+ /** The name inside a text element that is an inline-code `[name]`, null for any other element. */
71
+ function nameOf(element) {
72
+ if (!isRecord(element) || element.type !== "text" || typeof element.text !== "string") {
73
+ return null;
74
+ }
75
+ const style = element.style;
76
+ if (!isRecord(style) || style.code !== true || Object.keys(style).length !== 1)
77
+ return null;
78
+ const inner = /^\[(.*)\]$/s.exec(element.text)?.[1];
79
+ return inner !== undefined && TAG_NAME.test(inner) ? inner : null;
80
+ }
81
+ /** Whether `element` is exactly one space, with no style. */
82
+ function isSpace(element) {
83
+ return (isRecord(element) && element.type === "text" && element.text === " " && !("style" in element));
84
+ }
85
+ /** The first `rich_text` block of a root's blocks, or undefined. */
86
+ function richText(blocks) {
87
+ return blocks.find((block) => isRecord(block) && block.type === "rich_text");
88
+ }
89
+ /** The elements of the first section of a root's first `rich_text` block, or null. */
90
+ function leadingElements(blocks) {
91
+ const elements = richText(blocks)?.elements;
92
+ const section = Array.isArray(elements) ? elements[0] : undefined;
93
+ if (!isRecord(section) || section.type !== "rich_text_section")
94
+ return null;
95
+ return Array.isArray(section.elements) ? section.elements : null;
96
+ }
97
+ /**
98
+ * The tag a root message carries and whether it is pinned, as `conversations.replies` returns it.
99
+ * A root with no blocks at all is read from its text.
100
+ */
101
+ export function rootMarks(root) {
102
+ const pinned = Array.isArray(root.pinned_to) && root.pinned_to.length > 0;
103
+ if (Array.isArray(root.blocks)) {
104
+ return { tag: nameOf(leadingElements(root.blocks)?.[0]), pinned };
105
+ }
106
+ const prefix = typeof root.text === "string" ? TEXT_PREFIX.exec(root.text)?.[1] : undefined;
107
+ return { tag: prefix !== undefined && TAG_NAME.test(prefix) ? prefix : null, pinned };
108
+ }
109
+ /** `[name]` in code style and a space: what a tagged root starts with. */
110
+ function tagElements(tag) {
111
+ return [
112
+ { type: "text", text: `[${tag}]`, style: { code: true } },
113
+ { type: "text", text: " " },
114
+ ];
115
+ }
116
+ /** `blocks` with their leading tag taken off and `tag`, when there is one, put in its place. */
117
+ export function retaggedBlocks(blocks, tag) {
118
+ const copy = structuredClone([...blocks]);
119
+ const rich = richText(copy);
120
+ if (rich === undefined) {
121
+ return tag === null
122
+ ? copy
123
+ : [
124
+ {
125
+ type: "rich_text",
126
+ elements: [{ type: "rich_text_section", elements: tagElements(tag) }],
127
+ },
128
+ ...copy,
129
+ ];
130
+ }
131
+ const elements = Array.isArray(rich.elements) ? rich.elements : [];
132
+ rich.elements = elements;
133
+ const section = elements[0];
134
+ if (isRecord(section) &&
135
+ section.type === "rich_text_section" &&
136
+ Array.isArray(section.elements)) {
137
+ const inner = section.elements;
138
+ const tagged = nameOf(inner[0]) !== null;
139
+ if (tagged)
140
+ inner.splice(0, isSpace(inner[1]) ? 2 : 1);
141
+ if (tag !== null)
142
+ inner.unshift(...tagElements(tag));
143
+ // The tag may have had a section of its own (a root that starts with a list or a code
144
+ // block), or a block of its own (a root with no rich_text block): taking it off takes those
145
+ // with it, so a tag and an untag leave the blocks as they were.
146
+ if (tagged && inner.length === 0) {
147
+ elements.splice(0, 1);
148
+ if (elements.length === 0)
149
+ copy.splice(copy.indexOf(rich), 1);
150
+ }
151
+ }
152
+ else if (tag !== null) {
153
+ // The root starts with a list or a code block: the tag gets a line of its own.
154
+ elements.unshift({ type: "rich_text_section", elements: tagElements(tag) });
155
+ }
156
+ return copy;
157
+ }
158
+ /**
159
+ * The root as a titled one reads: a single `rich_text` block holding `tag`, when there is one,
160
+ * and `title` in bold. The block keeps the id of the root's first `rich_text` block, so that a
161
+ * title already shown rewrites to the very blocks the root holds and nothing is sent.
162
+ */
163
+ export function retitledBlocks(blocks, title, tag) {
164
+ const id = richText(blocks)?.block_id;
165
+ return [
166
+ {
167
+ type: "rich_text",
168
+ ...(typeof id === "string" && { block_id: id }),
169
+ elements: [
170
+ {
171
+ type: "rich_text_section",
172
+ elements: [
173
+ ...(tag === null ? [] : tagElements(tag)),
174
+ { type: "text", text: title, style: { bold: true } },
175
+ ],
176
+ },
177
+ ],
178
+ },
179
+ ];
180
+ }
181
+ /** The text fallback of a titled root: `` `[tag]` *title* `` or `*title*`. */
182
+ export function retitledText(title, tag) {
183
+ // Slack's control characters in a message's text (docs.slack.dev/reference/surfaces/formatting);
184
+ // the tag is written where `retaggedText` writes it.
185
+ return retaggedText(`*${mrkdwnEscape(title)}*`, tag);
186
+ }
187
+ /** `text` with its leading `` `[name]` `` taken off and `tag`, when there is one, put in its place. */
188
+ export function retaggedText(text, tag) {
189
+ const prefix = TEXT_PREFIX.exec(text);
190
+ const body = prefix !== null && TAG_NAME.test(prefix[1] ?? "") ? text.slice(prefix[0].length) : text;
191
+ return tag === null ? body : `\`[${tag}]\` ${body}`;
192
+ }
193
+ /** Edits the root of a thread with the owner's own token: its tag and pin, its title line. */
194
+ export class RootEditor {
195
+ #bot;
196
+ #owner;
197
+ // The last edit of each root still running or queued, by channel and thread.
198
+ #tails = new Map();
199
+ /** `bot` reads the root; `owner` is the client of the owner's user token and writes. */
200
+ constructor(bot, owner) {
201
+ this.#bot = bot;
202
+ this.#owner = owner;
203
+ }
204
+ /**
205
+ * Puts `tag` on the root of `thread` and pins it, or with a null `tag` takes the tag away and, when the root carried one, the pin. Each
206
+ * step is skipped when the root is already as asked, so a repeat is harmless. Rejects with a
207
+ * `ChatError` carrying Slack's code when a call was refused.
208
+ */
209
+ tag(channel, thread, tag) {
210
+ return this.#serially(channel, thread, async () => {
211
+ const root = await this.#root(channel, thread);
212
+ const marks = rootMarks(root);
213
+ await this.#edit(channel, thread, root, {
214
+ text: (text) => retaggedText(text, tag),
215
+ blocks: (blocks) => retaggedBlocks(blocks, tag),
216
+ });
217
+ if (tag !== null && !marks.pinned)
218
+ await this.#pin(channel, thread);
219
+ // Only a root that carried a tag is unpinned: a pin the owner made by hand stays.
220
+ if (tag === null && marks.pinned && marks.tag !== null)
221
+ await this.#unpin(channel, thread);
222
+ });
223
+ }
224
+ /**
225
+ * Rewrites the root of `thread` to its tag, when it has one, and `title` in bold: the owner's
226
+ * own words leave the root. In one queued task it reads the root once, hands `keep` the words
227
+ * the root held in the form the agent receives a message (`slackUnescape` of the root's `text`,
228
+ * the tag left out) and waits for it, and only then edits; when `keep` throws nothing is edited.
229
+ * Nothing is sent when the root already reads as asked. Rejects with a `ChatError` carrying
230
+ * Slack's code when a call was refused, `permanent` when the refusal cannot change.
231
+ */
232
+ title(channel, thread, title, keep) {
233
+ // One line: a newline in the title would end the bold element's line early.
234
+ const line = title.replace(/\s+/g, " ").trim();
235
+ return this.#serially(channel, thread, async () => {
236
+ const root = await permanently(() => this.#root(channel, thread));
237
+ const { tag } = rootMarks(root);
238
+ if (keep !== undefined) {
239
+ const text = typeof root.text === "string" ? root.text : "";
240
+ await keep(slackUnescape(retaggedText(text, null)));
241
+ }
242
+ await permanently(() => this.#edit(channel, thread, root, {
243
+ text: () => retitledText(line, tag),
244
+ blocks: (blocks) => retitledBlocks(blocks, line, tag),
245
+ }));
246
+ });
247
+ }
248
+ /** Runs `work` after the edits of the same root asked before it, and wraps its failure. */
249
+ #serially(channel, thread, work) {
250
+ const key = `${channel}/${thread}`;
251
+ const run = (this.#tails.get(key) ?? Promise.resolve()).then(async () => {
252
+ try {
253
+ await work();
254
+ }
255
+ catch (error) {
256
+ if (error instanceof ChatError)
257
+ throw error;
258
+ throw new ChatError(describe(error), { cause: error });
259
+ }
260
+ });
261
+ const tail = run.then(() => { }, () => { });
262
+ this.#tails.set(key, tail);
263
+ void tail.then(() => {
264
+ if (this.#tails.get(key) === tail)
265
+ this.#tails.delete(key);
266
+ });
267
+ return run;
268
+ }
269
+ async #root(channel, thread) {
270
+ const answer = await this.#bot.conversations.replies({ channel, ts: thread, limit: 1 });
271
+ const messages = Array.isArray(answer.messages) ? answer.messages : [];
272
+ const root = messages.find((message) => isRecord(message) && String(message.ts) === thread);
273
+ if (!isRecord(root))
274
+ throw new ChatError("root_not_returned");
275
+ // A root deleted while replies remain is returned as a tombstone (measured 2026-10-20): it is
276
+ // gone, and nothing of it is kept or edited. The same refusal `conversations.replies` gives
277
+ // once the last reply is deleted too.
278
+ if (isTombstone(root)) {
279
+ throw new ChatError("thread_not_found", { permanent: true });
280
+ }
281
+ return root;
282
+ }
283
+ /** Rewrites the root's text and blocks; nothing is sent when they would not change. */
284
+ async #edit(channel, thread, root, rewrite) {
285
+ const text = typeof root.text === "string" ? root.text : "";
286
+ const nextText = rewrite.text(text);
287
+ const hasBlocks = Array.isArray(root.blocks);
288
+ const nextBlocks = hasBlocks ? rewrite.blocks(root.blocks) : null;
289
+ if (nextText === text && (!hasBlocks || isDeepStrictEqual(nextBlocks, root.blocks)))
290
+ return;
291
+ await this.#owner.chat.update({
292
+ channel,
293
+ ts: thread,
294
+ text: nextText,
295
+ // A root with no blocks is shown from its text, which a block would replace.
296
+ ...(nextBlocks !== null && { blocks: nextBlocks }),
297
+ });
298
+ }
299
+ async #pin(channel, thread) {
300
+ try {
301
+ await this.#owner.pins.add({ channel, timestamp: thread });
302
+ }
303
+ catch (error) {
304
+ if (describe(error) !== ALREADY_PINNED)
305
+ throw error;
306
+ }
307
+ }
308
+ async #unpin(channel, thread) {
309
+ try {
310
+ await this.#owner.pins.remove({ channel, timestamp: thread });
311
+ }
312
+ catch (error) {
313
+ if (!NOT_PINNED.has(describe(error)))
314
+ throw error;
315
+ }
316
+ }
317
+ }
@@ -120,6 +120,7 @@ export const HOME_OPEN = "Open";
120
120
  export const HOME_NEW_THREAD = "New thread";
121
121
  export const HOME_EDIT = "Edit";
122
122
  export const HOME_DONE = "Done";
123
+ export const HOME_REFRESH = "Refresh";
123
124
  export const HOME_DELETE = "Delete";
124
125
  // Bold, behind the hourglass the other two notes carry: mrkdwn has no colour, and the plain
125
126
  // word was missed among the row's details.
@@ -156,6 +157,12 @@ export const HOME_DELETE_REFUSED = naming("{chat} refused to delete {count} of t
156
157
  export const HOME_CLEAN_REFUSED = naming("{chat} refused to delete {count} of that channel's messages (`cant_delete_message`): your " +
157
158
  "workspace does not let you delete them. Every other one is gone.");
158
159
  export const HOME_DELETE_BUSY = "Not deleted: that thread is working or waiting for you.";
160
+ // The same two ends as the Home tab's, told in the thread `!delete-thread` was sent in.
161
+ export const THREAD_DELETE_REFUSED = naming("{chat} refused to delete {count} of this thread's messages (`cant_delete_message`): they are " +
162
+ "not yours or the bot's, or your workspace does not let you delete them. Every other " +
163
+ "message is gone.");
164
+ export const THREAD_DELETE_FAILED = "Could not delete every message of this thread (`{error}`). Send `!delete-thread` again to " +
165
+ "continue.";
159
166
  export const HOME_DELETE_FAILED = "Could not delete every message of that thread (`{error}`). Delete it again to continue.";
160
167
  export const HOME_SHOW_ALL = "Show all {count}";
161
168
  export const HOME_UNTITLED = "Session {id}";
@@ -169,6 +176,7 @@ export const HOME_ENDED = "ended";
169
176
  export const HOME_ERROR = "error";
170
177
  export const HOME_ALL_CHANNELS = "All channels";
171
178
  export const HOME_ALL_STATUSES = "All statuses";
179
+ export const HOME_ALL_TAGS = "All tags";
172
180
  export const HOME_ANY_TIME = "Any time";
173
181
  export const HOME_LAST_48 = "Last 48 hours";
174
182
  export const HOME_TODAY = "Today";
@@ -30,7 +30,14 @@ import { REACTIONS, StatusReaction, ThreadStatus } from "./reply/status.js";
30
30
  import { answeredBlocks, approvalBlocks, questionBlocks } from "./requests.js";
31
31
  import { setupBlocks } from "./setup.js";
32
32
  export const logger = getLogger("allaboard.chat.slack.thread");
33
- /** Slack draws every part of the chat seam. */
33
+ /** The emoji of each root reaction (`REACTIONS`), as a character. */
34
+ const STATUS_MARKS = {
35
+ working: "⏳",
36
+ waiting: "✋",
37
+ done: "✅",
38
+ error: "❌",
39
+ };
40
+ /** What Slack draws of the chat seam whatever the configuration. */
34
41
  const CAPABILITIES = {
35
42
  nativeStreaming: true,
36
43
  reactions: true,
@@ -40,7 +47,7 @@ const CAPABILITIES = {
40
47
  /** The Slack provider: one `SlackThread` per thread, one `SlackChannel` per channel. */
41
48
  export class SlackChat {
42
49
  name = "Slack";
43
- capabilities = CAPABILITIES;
50
+ capabilities;
44
51
  /**
45
52
  * The bot of the workspace, and the app-level token of the connection when there is one: the
46
53
  * two things a copied `.env` can leave the same, each of which makes Socket Mode spread events
@@ -49,6 +56,12 @@ export class SlackChat {
49
56
  identities;
50
57
  /** Present when the provider holds the Home tab (`SlackApp.home`). */
51
58
  index;
59
+ /** Present when the owner's user token is configured (`SlackChatOptions.roots`, `canTag`). */
60
+ tagThread;
61
+ /** Present when the owner's user token is configured (`SlackChatOptions.roots`). */
62
+ titleThread;
63
+ /** Present when the owner's user token is configured (`SlackApp.deleteThread`). */
64
+ deleteThread;
52
65
  options;
53
66
  stopping = new AbortController();
54
67
  built = null;
@@ -60,6 +73,12 @@ export class SlackChat {
60
73
  named = null;
61
74
  constructor(options) {
62
75
  this.options = options;
76
+ this.capabilities = {
77
+ ...CAPABILITIES,
78
+ threadTags: options.roots !== undefined && options.canTag === true,
79
+ threadTitles: options.roots !== undefined,
80
+ threadDelete: options.app?.deleteThread !== undefined,
81
+ };
63
82
  this.identities = [
64
83
  `${options.identity.teamId}:${options.identity.botUserId}`,
65
84
  ...(options.app !== undefined ? [options.app.appKey] : []),
@@ -67,6 +86,34 @@ export class SlackChat {
67
86
  const home = options.app?.home;
68
87
  if (home !== undefined)
69
88
  this.index = { refresh: (changed) => home.request(changed) };
89
+ const roots = options.roots;
90
+ // The root may have changed even when a later step failed: the Home reads it again.
91
+ const rereading = async (ref, edit) => {
92
+ try {
93
+ await edit();
94
+ }
95
+ finally {
96
+ this.index?.refresh([ref]);
97
+ }
98
+ };
99
+ if (roots !== undefined && options.canTag === true) {
100
+ this.tagThread = (ref, tag) => rereading(ref, () => roots.tag(ref.channel, ref.thread, tag));
101
+ }
102
+ if (roots !== undefined) {
103
+ this.titleThread = async (ref, title, keep) => {
104
+ // A title resolving after the stop began touches neither Slack nor the state.
105
+ if (this.stopped.aborted)
106
+ return;
107
+ await rereading(ref, () => roots.title(ref.channel, ref.thread, title, keep));
108
+ };
109
+ }
110
+ if (options.app?.deleteThread !== undefined) {
111
+ this.deleteThread = async (ref) => {
112
+ if (this.built === null)
113
+ throw new Error("the Slack app is not started");
114
+ await this.built.askDelete(ref.channel, ref.thread);
115
+ };
116
+ }
70
117
  }
71
118
  /**
72
119
  * The `chat.update` budget every reply in the process draws from, so an edit made outside a
@@ -161,6 +208,13 @@ export class SlackChat {
161
208
  });
162
209
  }
163
210
  escape(text, markup) {
211
+ // `words` is the owner's own text, drawn in a markdown block as written: measured 2026-10-20,
212
+ // the block turns `<!here>` into a broadcast and `<@U…>` into a mention, as they were in the
213
+ // owner's message, and keeps `&&` and code spans as typed, where an entity or a backslash
214
+ // escape shows literally inside code. Its only reader is `!status`, which only the owner sees
215
+ // and which never pushes (`ownerOnly`), so a mention in it reaches no one.
216
+ if (markup === "words")
217
+ return text;
164
218
  return markup === "notice" ? mrkdwnEscape(text) : markdownEscape(text);
165
219
  }
166
220
  // Slack's channel syntax in a notice. Whether a markdown block renders it was not confirmed
@@ -168,6 +222,11 @@ export class SlackChat {
168
222
  channelName(channel, _markup) {
169
223
  return `<#${channel}>`;
170
224
  }
225
+ // The character of each reaction a root carries (`REACTIONS`): written as such, as the
226
+ // bypass mark of the same lines is, rather than as a `:name:` a reference may not render.
227
+ statusMark(status) {
228
+ return STATUS_MARKS[status];
229
+ }
171
230
  openedAt(ref) {
172
231
  // A Slack ts is the post time in epoch seconds.
173
232
  const seconds = ref.thread.trim() === "" ? Number.NaN : Number(ref.thread);
@@ -203,6 +262,7 @@ export class SlackChat {
203
262
  state: core.state,
204
263
  uploads: app.uploads,
205
264
  home: app.home,
265
+ ...(app.deleteThread !== undefined && { deleteThread: app.deleteThread }),
206
266
  receiver: app.receiver,
207
267
  ...(steady !== undefined && { clock: steady }),
208
268
  ...(app.fetch !== undefined && { fetch: app.fetch }),
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The kind of each Slack token, told by its prefix. The one rule for the daemon's start and for
3
+ * `allaboard setup`: a swapped pair is named before Slack is called.
4
+ */
5
+ /** A token variable, the prefix of its kind and the sentence that names it. */
6
+ export const TOKEN_KINDS = {
7
+ SLACK_BOT_TOKEN: { prefix: "xoxb-", message: "the Bot User OAuth Token (xoxb-...)" },
8
+ SLACK_APP_TOKEN: { prefix: "xapp-", message: "the app-level token (xapp-...)" },
9
+ SLACK_USER_TOKEN: { prefix: "xoxp-", message: "the User OAuth Token (xoxp-...)" },
10
+ };
11
+ /** The sentence that says `token` is not the kind `variable` takes, or null when it is. */
12
+ export function wrongKind(variable, token) {
13
+ const kind = TOKEN_KINDS[variable];
14
+ return token.startsWith(kind.prefix) ? null : `${variable} must be ${kind.message}`;
15
+ }
package/dist/cli.js CHANGED
@@ -9,8 +9,10 @@ import { homedir } from "node:os";
9
9
  import { createInterface } from "node:readline/promises";
10
10
  import { configDirOf, DEFAULT_INSTANCE, INSTANCE_VARIABLE, parseInstanceName, } from "./core/instance.js";
11
11
  import { UpdateUnderWay, updateLock } from "./core/lock.js";
12
+ import { answerWizard } from "./core/wizard.js";
12
13
  import { installVersion, isNewer, latestVersion, updatableCopy, } from "./host/npm.js";
13
14
  import { programOf } from "./host/program.js";
15
+ import { askTerminal, chooseTerminal } from "./host/prompter.js";
14
16
  import { runAsync, runCommand } from "./host/run.js";
15
17
  import { Launchd } from "./host/service/launchd.js";
16
18
  import { loadedOn } from "./host/service/listing.js";
@@ -19,11 +21,13 @@ import { changelogOf, installedVersion } from "./version.js";
19
21
  export const USAGE = [
20
22
  "usage: allaboard [--instance <name>] run the daemon",
21
23
  ` allaboard service <${SERVICE_ACTIONS.join("|")}> [--instance <name>]`,
24
+ " allaboard setup [--instance <name>]",
22
25
  " allaboard update [--check] [--yes]",
23
26
  " allaboard --version | --help",
24
27
  "",
25
28
  `--instance picks one of the machine's daemons; ${INSTANCE_VARIABLE} does the same when the flag`,
26
29
  "is absent. `service list` shows every instance and takes no --instance.",
30
+ "`setup` asks for the tokens and writes the instance's `.env`; it refuses a file that exists.",
27
31
  "`update` installs the latest version of the copy that `npm install -g` replaces and restarts",
28
32
  "the services that run it, asking first unless --yes; --check only prints the versions.",
29
33
  ].join("\n");
@@ -96,6 +100,12 @@ export function parseArguments(args, env = {}) {
96
100
  const [first, second, ...rest] = positional;
97
101
  if (first === "update")
98
102
  return parseUpdate([second, ...rest], flag);
103
+ if (first === "setup") {
104
+ if (second !== undefined)
105
+ return invalid(`unknown argument ${JSON.stringify(second)}`);
106
+ const instance = instanceOf(flag, env);
107
+ return "kind" in instance ? instance : { kind: "setup", instance };
108
+ }
99
109
  if (first === undefined) {
100
110
  const instance = instanceOf(flag, env);
101
111
  return "kind" in instance ? instance : { kind: "run", instance };
@@ -123,6 +133,10 @@ export function parseArguments(args, env = {}) {
123
133
  }
124
134
  return invalid(`unknown argument ${JSON.stringify(first)}`);
125
135
  }
136
+ /** The commands the agents in `agents` declare. */
137
+ function executablesOf(agents) {
138
+ return Object.values(agents).flatMap((agent) => agent.executable ?? []);
139
+ }
126
140
  export const machineHost = {
127
141
  service: (instance) => process.platform === "darwin"
128
142
  ? new Launchd({
@@ -133,13 +147,14 @@ export const machineHost = {
133
147
  run: runCommand,
134
148
  })
135
149
  : null,
136
- program: () => programOf(process.argv[1] ?? "", process.env.PATH ?? "", process.execPath),
150
+ program: (agentExecutables) => programOf(process.argv[1] ?? "", process.env.PATH ?? "", process.execPath, agentExecutables),
137
151
  };
138
152
  /**
139
153
  * Answers an invocation that does not run the daemon, and returns the exit status: 0, 2 for
140
154
  * arguments it does not know (with the usage on stderr), or the service action's own status.
155
+ * `agents` are the registry's, whose commands the service's `PATH` finds.
141
156
  */
142
- export function answer(invocation, output, host = machineHost) {
157
+ export function answer(invocation, output, agents, host = machineHost) {
143
158
  switch (invocation.kind) {
144
159
  case "version":
145
160
  output.out(`${installedVersion()}\n`);
@@ -157,7 +172,7 @@ export function answer(invocation, output, host = machineHost) {
157
172
  return 1;
158
173
  }
159
174
  const report = invocation.action === "install"
160
- ? manager.install(host.program())
175
+ ? manager.install(host.program(executablesOf(agents)))
161
176
  : manager[invocation.action]();
162
177
  for (const line of report.out)
163
178
  output.out(`${line}\n`);
@@ -167,6 +182,29 @@ export function answer(invocation, output, host = machineHost) {
167
182
  }
168
183
  }
169
184
  }
185
+ export const machineSetupHost = {
186
+ ...machineHost,
187
+ home: homedir(),
188
+ // A getter, as in `machineUpdateHost`.
189
+ get interactive() {
190
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
191
+ },
192
+ ask: askTerminal,
193
+ choose: chooseTerminal,
194
+ };
195
+ /**
196
+ * Answers `allaboard setup` with `modules`, the chats and agents the registry holds, which the
197
+ * owner picks from. Returns the wizard's exit status.
198
+ */
199
+ export function answerSetup(invocation, modules, output, host = machineSetupHost) {
200
+ const manager = host.service(invocation.instance);
201
+ return answerWizard(invocation.instance, modules, output, {
202
+ home: host.home,
203
+ interactive: host.interactive,
204
+ prompter: { ask: host.ask, choose: host.choose },
205
+ installService: manager === null ? null : () => manager.install(host.program(executablesOf(modules.agents))),
206
+ });
207
+ }
170
208
  export const machineUpdateHost = {
171
209
  ...machineHost,
172
210
  run: runAsync,
@@ -256,7 +294,8 @@ export async function answerUpdate(invocation, output, host = machineUpdateHost)
256
294
  output.out(`allaboard ${running} · latest ${latest.version}\n`);
257
295
  return 0;
258
296
  }
259
- const updatable = await updatableCopy(host.run, host.program().main, running);
297
+ // Only the entry is read: an update installs no service.
298
+ const updatable = await updatableCopy(host.run, host.program([]).main, running);
260
299
  if ("refusal" in updatable)
261
300
  return failure(output, notUpdated(updatable.refusal));
262
301
  const latest = await latestVersion(host.run);