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,1069 @@
1
+ /**
2
+ * The app's Home tab: the owner's index of sessions. Two lines per thread that holds a session,
3
+ * grouped by channel, each group and each session ordered by the thread's last reply, with a link
4
+ * that opens the thread: a channel lists its threads by when they started, and Slack's
5
+ * Threads view by unread replies (Help Center, "Use threads to organize discussions", read
6
+ * 2026-10-01), so neither finds the thread worked in last.
7
+ *
8
+ * A card is built from what the daemon already keeps, what Claude Code knows and what Slack shows:
9
+ * the thread and its root's reaction from `state.json`, the title from the session's transcript,
10
+ * as `!resume` shows it, and from Slack the thread's number of replies and the time of its last
11
+ * one, the same the channel shows under the root. Nothing is stored for the page: the filters the
12
+ * owner chooses live in memory and start again at their defaults with the daemon.
13
+ *
14
+ * `conversations.replies` with the root's `ts` and `limit=1` returns the root alone, carrying
15
+ * `reply_count`, `latest_reply` and `reactions` (measured 2026-10-01 on a free workspace with
16
+ * `groups:history`, slack-sdk 3.44.1; a deleted root answers `thread_not_found`). The root's own
17
+ * reaction fills in the status of a thread that ended before the daemon kept it.
18
+ *
19
+ * `views.publish` (docs.slack.dev/reference/methods/views.publish, read 2026-10-01) takes no scope
20
+ * and may be called at any time, with no event from the owner ("Home tab updates can happen when a
21
+ * user isn't interacting with Slack or the app", docs.slack.dev/surfaces/app-home), so the page is
22
+ * rewritten when the index changes, with no event subscribed. A view holds 100 blocks. Ages are
23
+ * Slack's own `{ago}` date token (docs.slack.dev/messaging/formatting-message-text, seen rendered
24
+ * in a Home view on 2026-10-01), so they do not go stale between two publishes.
25
+ *
26
+ * What reaches the app from the page is a `block_actions` payload per use of a control
27
+ * (the app's listeners own them): a filter, which carries the state of every control in
28
+ * `view.state.values`, and the **New thread** link button, which Slack follows itself and still
29
+ * reports (button element reference, read 2026-10-01). A session's **Open** is a plain link in its
30
+ * line of details, which reports nothing: the permalink of the thread's last reply, which Slack
31
+ * opens the thread on (seen in the Mac app and on iOS, 2026-10-05), or of its root while it has
32
+ * none. **New thread** is the documented deep link to the channel
33
+ * (docs.slack.dev/interactivity/deep-linking; it opened the channel in the desktop app on
34
+ * 2026-10-01): the owner's own top-level message there starts the session, so the thread is one
35
+ * the owner started and its replies notify.
36
+ *
37
+ * How the waits are made:
38
+ * - `publish` runs under a FIFO `Mutex`, so the publishes run one at a time and each is built
39
+ * after the one before it landed. A publish is given up through an `AbortSignal` that
40
+ * `publish` looks at before each Slack call and before `views.publish`: a call in flight is
41
+ * awaited and its answer used, never cut short, and a cancelled publish never sends its page.
42
+ * - The debounced loop and the retry are each a `Task`; the time limit in `close` is a race
43
+ * against the clock's sleep that aborts the publish when the sleep wins.
44
+ * - `asking` is a `Semaphore` of `THREADS_AT_ONCE` places.
45
+ * - Every wait goes through the injected `Clock`.
46
+ */
47
+ import { createHash } from "node:crypto";
48
+ import { systemClock } from "../../clock.js";
49
+ import { Cancelled, Mutex, named, oneLine, Semaphore, TITLE_LIMIT, texts, } from "../../core/port.js";
50
+ import { getLogger } from "../../log.js";
51
+ import { setupGuide } from "../../version.js";
52
+ import { contextBlock, noticeText, plainTextObject } from "./reply/blocks.js";
53
+ import { take } from "./reply/chars.js";
54
+ import { describe, gone } from "./reply/errors.js";
55
+ import { mrkdwnEscape, shownAsWritten } from "./reply/escape.js";
56
+ import { REACTIONS, Status } from "./reply/status.js";
57
+ import { Task } from "./reply/tasks.js";
58
+ import { ID_SHOWN } from "./resume.js";
59
+ import * as slackTexts from "./texts.js";
60
+ import { updateButton, withChangelog } from "./update.js";
61
+ export const logger = getLogger("allaboard.chat.slack.home");
62
+ export const HOME_BLOCKS = 100;
63
+ // What a channel shows while no filter is chosen; a filter shows every session it matches.
64
+ export const PER_CHANNEL = 5;
65
+ // A turn changes its root's reaction several times in a row (⏳, ✋ at an approval, ⏳ again):
66
+ // one publish covers the burst.
67
+ export const DEBOUNCE_SECONDS = 2.0;
68
+ // How long a stop waits for its last publish: the page must never hold the daemon's exit.
69
+ export const CLOSE_SECONDS = 10.0;
70
+ // How many threads Slack is asked about at once on the first publish of a run.
71
+ export const THREADS_AT_ONCE = 8;
72
+ export const NOT_ENABLED = "not_enabled";
73
+ // How long after a page that lacked an answer the next one is tried.
74
+ export const RETRY_SECONDS = 60.0;
75
+ // A select menu holds 100 options (select menu reference): "All channels" and 99 channels.
76
+ export const CHANNEL_OPTIONS = 99;
77
+ export const FILTERS_BLOCK = "home_filters";
78
+ export const SEARCH_BLOCK = "home_search";
79
+ export const CHANNEL_ACTION = "home_channel";
80
+ export const STATUS_ACTION = "home_status";
81
+ export const DATE_ACTION = "home_date";
82
+ export const SEARCH_ACTION = "home_search_text";
83
+ export const SHOW_ALL_ACTION = "home_show_all";
84
+ export const NEW_THREAD_ACTION = "home_new_thread";
85
+ export const EDIT_ACTION = "home_edit";
86
+ export const DELETE_ACTION = "home_delete";
87
+ export const CLEAN_ACTION = "home_clean";
88
+ export const HOME_UPDATE_ACTION = "home_update";
89
+ // How many lines about deletes that did not end the page shows at once, the latest ones.
90
+ export const NOTICES_SHOWN = 5;
91
+ // What the Edit button carries: the mode a click asks for, so a click sent twice asks the same.
92
+ export const EDIT_ON = "edit";
93
+ export const EDIT_OFF = "done";
94
+ // A confirmation dialog's text holds 300 characters (confirmation dialog object reference).
95
+ export const CONFIRM_TEXT = 300;
96
+ export const FILTER_ACTIONS = [CHANNEL_ACTION, STATUS_ACTION, DATE_ACTION, SEARCH_ACTION];
97
+ // A blank row between two sessions: a context line that holds a zero-width space, since Slack
98
+ // sets the distance between blocks itself and takes no empty text.
99
+ export const SPACER = "​";
100
+ // A session is a title, a line of details and the blank row above it.
101
+ export const CARD_BLOCKS = 3;
102
+ export const ALL = "all";
103
+ export const SEARCH_LIMIT = 80;
104
+ // A select option's text holds 75 characters (option object reference).
105
+ export const OPTION_TEXT = 75;
106
+ // The reaction names a session can show and their words, in the order the status menu lists.
107
+ const WORDS = new Map([
108
+ [Status.WAITING, slackTexts.HOME_WAITING],
109
+ [Status.WORKING, slackTexts.HOME_WORKING],
110
+ [Status.DONE, slackTexts.HOME_ENDED],
111
+ [Status.ERROR, slackTexts.HOME_ERROR],
112
+ ]);
113
+ // A session in one of these states is not offered for deleting: its thread is in use.
114
+ const IN_USE = new Set([Status.WAITING, Status.WORKING]);
115
+ /** The reaction that shows a stored status; null for none. */
116
+ function reactionOf(status) {
117
+ return status === null ? null : REACTIONS[status];
118
+ }
119
+ export const LAST_48 = "48h";
120
+ export const TODAY = "today";
121
+ export const YESTERDAY = "yesterday";
122
+ export const LAST_7 = "7";
123
+ export const LAST_30 = "30";
124
+ // How far back each rolling period reaches, in seconds.
125
+ const REACH = new Map([
126
+ [LAST_48, 48 * 3600],
127
+ [LAST_7, 7 * 86400],
128
+ [LAST_30, 30 * 86400],
129
+ ]);
130
+ const DATES = new Map([
131
+ [LAST_48, slackTexts.HOME_LAST_48],
132
+ [TODAY, slackTexts.HOME_TODAY],
133
+ [YESTERDAY, slackTexts.HOME_YESTERDAY],
134
+ [LAST_7, slackTexts.HOME_LAST_7],
135
+ [LAST_30, slackTexts.HOME_LAST_30],
136
+ ]);
137
+ /** Python's `float(text)` for what a root carries; it throws for anything that is no number. */
138
+ function pyFloat(value) {
139
+ if (typeof value === "number")
140
+ return value;
141
+ const text = String(value).trim();
142
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) {
143
+ throw new TypeError("not a number");
144
+ }
145
+ return Number(text);
146
+ }
147
+ /** Python's `int(value)` for a count: the number cut toward zero, or a string of digits. */
148
+ function pyInt(value) {
149
+ if (typeof value === "number" && Number.isFinite(value))
150
+ return Math.trunc(value);
151
+ if (typeof value === "string" && /^\s*[+-]?\d+\s*$/.test(value))
152
+ return Number(value);
153
+ throw new TypeError("not a whole number");
154
+ }
155
+ /**
156
+ * Read a root message as `conversations.replies` returns it. A root nobody replied to has no
157
+ * `reply_count` and no `latest_reply`. Throws on a root in a shape that cannot be read.
158
+ */
159
+ export function threadFacts(root) {
160
+ const latest = root.latest_reply;
161
+ const reactions = Array.isArray(root.reactions) ? root.reactions : [];
162
+ const names = reactions
163
+ .filter((r) => typeof r === "object" && r !== null && !Array.isArray(r))
164
+ .map((r) => String(r.name));
165
+ return {
166
+ replies: root.reply_count ? pyInt(root.reply_count) : 0,
167
+ latestReply: latest ? Math.trunc(pyFloat(latest)) : null,
168
+ latestTs: latest ? String(latest) : null,
169
+ reaction: names.find((name) => WORDS.has(name)) ?? null,
170
+ };
171
+ }
172
+ /** A `HomeFilter` with every field not given at its default. */
173
+ export function homeFilter(fields = {}) {
174
+ return { channel: null, status: null, date: LAST_48, search: "", ...fields };
175
+ }
176
+ /**
177
+ * Whether a channel, a status or a search is chosen: the page then shows every session that
178
+ * matches. The period alone keeps the page's shape, each channel cut to its newest.
179
+ */
180
+ export function narrowed(filter) {
181
+ return Boolean(filter.channel || filter.status || filter.search);
182
+ }
183
+ /** The day of the machine's own calendar an instant falls on, as a day number. */
184
+ function localDay(epochSeconds) {
185
+ const at = new Date(epochSeconds * 1000);
186
+ return Math.floor(Date.UTC(at.getFullYear(), at.getMonth(), at.getDate()) / 86_400_000);
187
+ }
188
+ /**
189
+ * A search key: upper case, then lower case, so that `ß` and the final sigma meet their other
190
+ * spelling. It is not full Unicode case folding (a few hundred scalar values fold differently,
191
+ * `ẞ` stays `ß`), which is enough here: the title and the query go through this same function.
192
+ */
193
+ function casefold(text) {
194
+ return text.toUpperCase().toLowerCase();
195
+ }
196
+ /**
197
+ * Whether `row` passes every chosen filter. The date is the thread's last reply: `today` and
198
+ * `yesterday` are calendar days of the machine's own time zone, each instant read with the
199
+ * offset in force at that instant (a day a clock change makes 23 or 25 hours long stays one day);
200
+ * the others reach back from now.
201
+ */
202
+ export function matches(filter, row, now) {
203
+ if (filter.channel && row.channelId !== filter.channel)
204
+ return false;
205
+ if (filter.status && row.status !== filter.status)
206
+ return false;
207
+ if (filter.search && !casefold(row.title).includes(casefold(filter.search)))
208
+ return false;
209
+ if (filter.date === TODAY || filter.date === YESTERDAY) {
210
+ const days = localDay(now.getTime() / 1000) - localDay(row.lastActivity);
211
+ return days === (filter.date === TODAY ? 0 : 1);
212
+ }
213
+ const reach = filter.date === null ? undefined : REACH.get(filter.date);
214
+ if (reach !== undefined)
215
+ return now.getTime() / 1000 - row.lastActivity <= reach;
216
+ return true;
217
+ }
218
+ function isObject(value) {
219
+ return typeof value === "object" && value !== null && !Array.isArray(value);
220
+ }
221
+ /**
222
+ * The filter a use of a control leaves chosen, from the `view.state.values` its payload carries:
223
+ * every control's state rides on it, so nothing is tracked per control. Untrusted like every
224
+ * click, and never throwing on a shape Slack does not send: a status or a date that is not one of
225
+ * the page's own keeps the current one, and the channel is checked by `Home.publish`, which knows
226
+ * the page's own.
227
+ */
228
+ export function readFilter(values, current) {
229
+ // Found by action id, whatever the block: the blocks' ids change with the choice.
230
+ const controls = new Map();
231
+ if (isObject(values)) {
232
+ for (const block of Object.values(values)) {
233
+ if (isObject(block))
234
+ for (const [id, control] of Object.entries(block))
235
+ controls.set(id, control);
236
+ }
237
+ }
238
+ const picked = (actionId, nowChosen, known) => {
239
+ const control = controls.get(actionId);
240
+ if (!isObject(control))
241
+ return nowChosen;
242
+ const option = control.selected_option;
243
+ const value = isObject(option) ? option.value : undefined;
244
+ if (value === ALL)
245
+ return null;
246
+ if (typeof value !== "string" || (known !== null && !known.has(value)))
247
+ return nowChosen;
248
+ return value;
249
+ };
250
+ let search = current.search;
251
+ const box = controls.get(SEARCH_ACTION);
252
+ if (isObject(box)) {
253
+ const typed = box.value;
254
+ search = typeof typed === "string" ? oneLine(typed, SEARCH_LIMIT) : "";
255
+ }
256
+ return {
257
+ channel: picked(CHANNEL_ACTION, current.channel, null),
258
+ status: picked(STATUS_ACTION, current.status, WORDS),
259
+ date: picked(DATE_ACTION, current.date, DATES),
260
+ search,
261
+ };
262
+ }
263
+ /** Slack's date markup; the fallback is what a client that cannot render it shows. */
264
+ function dateToken(epoch, token) {
265
+ const iso = new Date(epoch * 1000).toISOString();
266
+ const fallback = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
267
+ return `<!date^${epoch}^{${token}}|${fallback}>`;
268
+ }
269
+ function linkButton(text, url, actionId) {
270
+ return { type: "button", action_id: actionId, text: { type: "plain_text", text }, url };
271
+ }
272
+ /** A menu that starts on `chosen`, or on its `ALL` option when nothing is chosen. */
273
+ function select(actionId, options, chosen) {
274
+ const built = options.map(([text, value]) => ({
275
+ text: { type: "plain_text", text },
276
+ value,
277
+ }));
278
+ const byValue = new Map(built.map((option) => [option.value, option]));
279
+ // A choice the menu does not hold (a channel Slack did not answer about) reads as "all".
280
+ const initial = byValue.get(chosen || ALL) ?? byValue.get(ALL);
281
+ return { type: "static_select", action_id: actionId, options: built, initial_option: initial };
282
+ }
283
+ function controlsOf(channels, chosen) {
284
+ // Slack keeps what a control shows for as long as its block keeps its id, whatever
285
+ // `initial_option` a later page carries (seen 2026-10-01: after a restart the menus still
286
+ // showed the choices of the run before). An id that follows the choice makes the controls
287
+ // show what the page was built with: a restart, Show all and a dropped channel included.
288
+ const choice = JSON.stringify([chosen.channel, chosen.status, chosen.date, chosen.search]);
289
+ const mark = createHash("sha256").update(choice).digest("hex").slice(0, 8);
290
+ const search = {
291
+ type: "plain_text_input",
292
+ action_id: SEARCH_ACTION,
293
+ placeholder: { type: "plain_text", text: slackTexts.HOME_SEARCH_HINT },
294
+ dispatch_action_config: { trigger_actions_on: ["on_enter_pressed"] },
295
+ };
296
+ if (chosen.search)
297
+ search.initial_value = chosen.search;
298
+ return [
299
+ {
300
+ type: "actions",
301
+ block_id: `${FILTERS_BLOCK}:${mark}`,
302
+ elements: [
303
+ select(CHANNEL_ACTION, [
304
+ [slackTexts.HOME_ALL_CHANNELS, ALL],
305
+ ...[...channels]
306
+ .slice(0, CHANNEL_OPTIONS)
307
+ .map(([id, name]) => [oneLine(name, OPTION_TEXT), id]),
308
+ ], chosen.channel),
309
+ select(STATUS_ACTION, [
310
+ [slackTexts.HOME_ALL_STATUSES, ALL],
311
+ ...[...WORDS].map(([name, word]) => [capitalize(word), name]),
312
+ ], chosen.status),
313
+ select(DATE_ACTION, [
314
+ ...[...DATES].map(([key, text]) => [text, key]),
315
+ [slackTexts.HOME_ANY_TIME, ALL],
316
+ ], chosen.date),
317
+ ],
318
+ },
319
+ {
320
+ type: "input",
321
+ block_id: `${SEARCH_BLOCK}:${mark}`,
322
+ dispatch_action: true,
323
+ label: { type: "plain_text", text: slackTexts.HOME_SEARCH_LABEL },
324
+ element: search,
325
+ },
326
+ ];
327
+ }
328
+ /** Python's `str.capitalize()`: the first character upper case, the rest lower case. */
329
+ function capitalize(text) {
330
+ const [first = "", ...rest] = Array.from(text);
331
+ return first.toUpperCase() + rest.join("").toLowerCase();
332
+ }
333
+ function section(text) {
334
+ return { type: "section", text: { type: "mrkdwn", text } };
335
+ }
336
+ function context(text) {
337
+ return { ...contextBlock(text) };
338
+ }
339
+ function deletingLine(count, names) {
340
+ return count === 1
341
+ ? named(slackTexts.HOME_DELETING_ONE, names)
342
+ : texts.fill(named(slackTexts.HOME_DELETING_MANY, names), { count });
343
+ }
344
+ function cleaningLine(count, names) {
345
+ return count === 1
346
+ ? named(slackTexts.HOME_CLEANING_ONE, names)
347
+ : texts.fill(named(slackTexts.HOME_CLEANING_MANY, names), { count });
348
+ }
349
+ /**
350
+ * The line above the sessions. It carries the Edit button when threads can be deleted: a context
351
+ * block holds no button (context block reference), so the line is a section then.
352
+ */
353
+ function header(time, editing, canEdit) {
354
+ const text = texts.fill(slackTexts.HOME_HEADER, { time });
355
+ if (!canEdit)
356
+ return context(text);
357
+ const button = {
358
+ type: "button",
359
+ action_id: EDIT_ACTION,
360
+ text: plainTextObject(editing ? slackTexts.HOME_DONE : slackTexts.HOME_EDIT),
361
+ value: editing ? EDIT_OFF : EDIT_ON,
362
+ };
363
+ if (editing)
364
+ button.style = "primary";
365
+ return { type: "section", text: { type: "mrkdwn", text }, accessory: button };
366
+ }
367
+ /**
368
+ * The update line under the header, in the phase of the update: the offer with its link and an
369
+ * Update button that asks through Slack's own dialog (this page shows no message that says what
370
+ * changes first), the two states that wait, and a failure with the button again.
371
+ */
372
+ function updateLine(line) {
373
+ const { installed, latest, phase } = line;
374
+ const button = () => updateButton(HOME_UPDATE_ACTION, latest, { installed });
375
+ switch (phase) {
376
+ case "available":
377
+ return {
378
+ type: "section",
379
+ text: {
380
+ type: "mrkdwn",
381
+ text: withChangelog(texts.fill(texts.UPDATE_HOME_AVAILABLE, { latest, installed }), line.changelog),
382
+ },
383
+ accessory: button(),
384
+ };
385
+ case "installing":
386
+ return section(texts.fill(texts.UPDATE_INSTALLING, { latest }));
387
+ case "restarting":
388
+ return section([texts.fill(texts.UPDATE_INSTALLED, { latest }), line.note]
389
+ .filter((x) => x !== null)
390
+ .join("\n"));
391
+ case "failed":
392
+ return {
393
+ type: "section",
394
+ text: { type: "mrkdwn", text: noticeText(line.failure ?? "") },
395
+ accessory: button(),
396
+ };
397
+ }
398
+ }
399
+ /** A channel's Clean up, with a confirmation dialog that says what it deletes. */
400
+ function cleanButton(channelId, channelName) {
401
+ const room = CONFIRM_TEXT - Array.from(texts.fill(slackTexts.HOME_CLEAN_TEXT, { channel: "" })).length;
402
+ return {
403
+ type: "button",
404
+ action_id: CLEAN_ACTION,
405
+ text: plainTextObject(slackTexts.HOME_CLEAN),
406
+ value: channelId,
407
+ confirm: {
408
+ title: plainTextObject(slackTexts.HOME_CLEAN_TITLE),
409
+ text: plainTextObject(texts.fill(slackTexts.HOME_CLEAN_TEXT, { channel: oneLine(channelName, room) })),
410
+ confirm: plainTextObject(slackTexts.HOME_CLEAN_CONFIRM),
411
+ deny: plainTextObject(slackTexts.HOME_DELETE_DENY),
412
+ style: "danger",
413
+ },
414
+ };
415
+ }
416
+ /**
417
+ * A channel's name, with how many of its threads are being deleted beside it: the rows that say
418
+ * so can be among those the channel does not show. Its button is New thread, and in edit mode
419
+ * Clean up, which gives way to a note while the channel is being cleaned.
420
+ */
421
+ function channelHeader(teamId, channelId, options) {
422
+ const { editing, deleting = 0, name = "", canClean = false, cleaning = false } = options;
423
+ let text = `*<#${channelId}>*`;
424
+ if (cleaning)
425
+ text += ` ${slackTexts.HOME_CHANNEL_CLEANING}`;
426
+ if (deleting === 1)
427
+ text += ` ${slackTexts.HOME_CHANNEL_DELETING_ONE}`;
428
+ else if (deleting) {
429
+ text += ` ${texts.fill(slackTexts.HOME_CHANNEL_DELETING_MANY, { count: deleting })}`;
430
+ }
431
+ const head = { type: "section", text: { type: "mrkdwn", text } };
432
+ if (!editing) {
433
+ head.accessory = linkButton(slackTexts.HOME_NEW_THREAD, `slack://channel?team=${teamId}&id=${channelId}`, NEW_THREAD_ACTION);
434
+ }
435
+ else if (canClean && !cleaning) {
436
+ head.accessory = cleanButton(channelId, name);
437
+ }
438
+ return head;
439
+ }
440
+ function repliesOf(row) {
441
+ return row.replies === 1
442
+ ? slackTexts.HOME_REPLY
443
+ : texts.fill(slackTexts.HOME_REPLIES, { count: row.replies });
444
+ }
445
+ /**
446
+ * The Delete of a session's row, with Slack's own confirmation dialog, which names the thread.
447
+ * The dialog shows before the click is sent (confirmation dialog object reference).
448
+ */
449
+ function deleteButton(row, channelName, names) {
450
+ const replies = repliesOf(row);
451
+ const naming = row.replies ? slackTexts.HOME_DELETE_NAMED : slackTexts.HOME_DELETE_NAMED_BARE;
452
+ const warning = named(slackTexts.HOME_DELETE_TEXT, names);
453
+ let room = CONFIRM_TEXT - Array.from(warning).length;
454
+ room -= Array.from(texts.fill(naming, { title: "", channel: channelName, replies })).length;
455
+ const title = oneLine(row.title, Math.max(room, 1));
456
+ return {
457
+ type: "button",
458
+ action_id: DELETE_ACTION,
459
+ style: "danger",
460
+ text: plainTextObject(slackTexts.HOME_DELETE),
461
+ value: `${row.channelId}:${row.threadTs}`,
462
+ confirm: {
463
+ title: plainTextObject(slackTexts.HOME_DELETE_TITLE),
464
+ text: plainTextObject(texts.fill(naming, { title, channel: channelName, replies }) + warning),
465
+ confirm: plainTextObject(slackTexts.HOME_DELETE_CONFIRM),
466
+ deny: plainTextObject(slackTexts.HOME_DELETE_DENY),
467
+ style: "danger",
468
+ },
469
+ };
470
+ }
471
+ function card(row, channelName, editing, names) {
472
+ const icon = row.status ? `:${row.status}: ` : "";
473
+ const title = shownAsWritten(oneLine(row.title, TITLE_LIMIT));
474
+ const when = row.replies ? slackTexts.HOME_LAST_REPLY : slackTexts.HOME_STARTED;
475
+ let word = null;
476
+ if (row.deleting)
477
+ word = slackTexts.HOME_DELETING;
478
+ else if (row.status)
479
+ word = WORDS.get(row.status) ?? null;
480
+ const details = [
481
+ word,
482
+ row.replies ? repliesOf(row) : null,
483
+ texts.fill(when, { when: dateToken(row.lastActivity, "ago") }),
484
+ // Open is a link in the small line, not a button: a button sits at the far right of the
485
+ // title's row, and a row that carries one cannot be small (the maintainer, 2026-10-01).
486
+ // A reply's permalink carries a query (`?thread_ts=…&cid=…`), and mrkdwn reads `&` as markup.
487
+ `<${mrkdwnEscape(row.permalink)}|${slackTexts.HOME_OPEN}>`,
488
+ ];
489
+ const head = section(`${icon}*${title}*`);
490
+ if (editing && !row.deleting && !(row.status !== null && IN_USE.has(row.status))) {
491
+ head.accessory = deleteButton(row, channelName, names);
492
+ }
493
+ return [head, context(details.filter((d) => d).join(" · "))];
494
+ }
495
+ /**
496
+ * The Home tab's view. `rows` are newest first and `channels` maps each bound channel Slack still
497
+ * has to its name. Only the sessions of the chosen period are shown. With no channel, status or
498
+ * search chosen, every channel is a group, the ones with sessions first by their newest, each
499
+ * showing its `PER_CHANNEL` newest and a button to see them all (which chooses that channel);
500
+ * otherwise only what matches, with no such cut. Never past Slack's 100 blocks: the page says
501
+ * when it stops short. With `canEdit` the header line carries Edit; in `editing` each session
502
+ * that is not in use carries Delete and no channel carries New thread. `notices` are the lines
503
+ * the deletes and clean-ups that did not end left, under the header; `deleting` is how many
504
+ * threads are being deleted or wait for it, said in a line of full size under the header, since a
505
+ * row that says so can be one of those a channel does not show.
506
+ */
507
+ export function homeView(rows, channels, options, names) {
508
+ const { teamId, chosen, now } = options;
509
+ const { canEdit = false, deleting = 0, canClean = false } = options;
510
+ const cleaning = options.cleaning ?? new Set();
511
+ const editing = (options.editing ?? false) && canEdit;
512
+ const blocks = [
513
+ ...controlsOf(channels, chosen),
514
+ header(dateToken(Math.trunc(now.getTime() / 1000), "time"), editing, canEdit),
515
+ ...(options.update ? [updateLine(options.update)] : []),
516
+ ...(deleting ? [section(deletingLine(deleting, names))] : []),
517
+ ...(cleaning.size > 0 ? [section(cleaningLine(cleaning.size, names))] : []),
518
+ ...[...(options.notices ?? [])].slice(0, NOTICES_SHOWN).map(context),
519
+ ];
520
+ if (channels.size === 0)
521
+ return { type: "home", blocks: [...blocks, context(slackTexts.HOME_EMPTY)] };
522
+ const groups = new Map();
523
+ const group = (channelId) => {
524
+ let found = groups.get(channelId);
525
+ if (found === undefined) {
526
+ found = [];
527
+ groups.set(channelId, found);
528
+ }
529
+ return found;
530
+ };
531
+ for (const row of rows) {
532
+ if (matches(chosen, row, now))
533
+ group(row.channelId).push(row);
534
+ }
535
+ if (chosen.channel)
536
+ group(chosen.channel);
537
+ else if (!narrowed(chosen)) {
538
+ for (const channelId of channels.keys())
539
+ group(channelId);
540
+ }
541
+ if (groups.size === 0)
542
+ blocks.push(context(slackTexts.HOME_NO_MATCH));
543
+ const withSessions = new Set(rows.map((row) => row.channelId));
544
+ // Counted over every session of the channel, shown or not, whatever the filters.
545
+ const going = new Map();
546
+ for (const row of rows) {
547
+ if (row.deleting)
548
+ going.set(row.channelId, (going.get(row.channelId) ?? 0) + 1);
549
+ }
550
+ const wanted = new Map([...groups].map(([id, found]) => [id, narrowed(chosen) ? found : found.slice(0, PER_CHANNEL)]));
551
+ let shown = 0;
552
+ for (const [channelId, found] of groups) {
553
+ const cards = wanted.get(channelId) ?? [];
554
+ // One block is kept for the line that says the page stops short; a group is a divider, a
555
+ // header, `CARD_BLOCKS` a card at most, and one closing block at most.
556
+ const room = Math.floor((HOME_BLOCKS - 1 - blocks.length - 3) / CARD_BLOCKS);
557
+ if (room < Math.min(1, cards.length) || HOME_BLOCKS - 1 - blocks.length < 3)
558
+ break;
559
+ const name = channels.get(channelId) ?? "";
560
+ blocks.push({ type: "divider" }, channelHeader(teamId, channelId, {
561
+ editing,
562
+ deleting: going.get(channelId) ?? 0,
563
+ name,
564
+ canClean,
565
+ cleaning: cleaning.has(channelId),
566
+ }));
567
+ cards.slice(0, room).forEach((row, index) => {
568
+ if (index)
569
+ blocks.push(context(SPACER));
570
+ blocks.push(...card(row, name, editing, names));
571
+ });
572
+ shown += Math.min(cards.length, room);
573
+ if (cards.length > room)
574
+ break;
575
+ if (found.length === 0) {
576
+ // A channel with sessions, none of them in the period or under the filters.
577
+ const hidden = withSessions.has(channelId);
578
+ blocks.push(context(hidden ? slackTexts.HOME_NO_MATCH : slackTexts.HOME_NO_SESSIONS));
579
+ }
580
+ else if (found.length > cards.length) {
581
+ const showAll = {
582
+ type: "button",
583
+ action_id: SHOW_ALL_ACTION,
584
+ text: {
585
+ type: "plain_text",
586
+ text: texts.fill(slackTexts.HOME_SHOW_ALL, { count: found.length }),
587
+ },
588
+ value: channelId,
589
+ };
590
+ blocks.push({ type: "actions", elements: [showAll] });
591
+ }
592
+ }
593
+ // Said only when a session is left out: channels with none can fall off the end unsaid.
594
+ let wantedCards = 0;
595
+ for (const cards of wanted.values())
596
+ wantedCards += cards.length;
597
+ if (shown < wantedCards)
598
+ blocks.push(context(texts.fill(slackTexts.HOME_MORE, { rows: shown })));
599
+ return { type: "home", blocks };
600
+ }
601
+ /** A root that `conversations.replies` did not return among its messages. */
602
+ class RootNotReturned extends Error {
603
+ constructor() {
604
+ super("the root is not among the messages");
605
+ this.name = "RootNotReturned";
606
+ }
607
+ }
608
+ const keyOf = (channelId, threadTs) => `${channelId}:${threadTs}`;
609
+ /**
610
+ * Publishes the owner's Home tab. `request` asks for a publish soon and returns at once (a state
611
+ * write calls it, naming the threads it changed); `choose` sets the filters and publishes now (a
612
+ * control was used); `publish` builds the page and sends it, one at a time, and never throws: the
613
+ * page must never break a turn. Always published to the configured owner, whoever opens the app.
614
+ * `not_enabled` (the Home tab is off in the Slack app's settings) is logged once and ends the
615
+ * publishing for this run. Any other failure is logged by its code, and a page that Slack did not
616
+ * fully answer for is tried again after `RETRY_SECONDS`.
617
+ */
618
+ export class Home {
619
+ slack;
620
+ owner;
621
+ team;
622
+ state;
623
+ textNames;
624
+ sessionsOf;
625
+ deleteThread;
626
+ cleanChannel;
627
+ updateOf;
628
+ debounce;
629
+ clock;
630
+ // Kept in memory like the filters: a restart starts out of edit mode.
631
+ editing = false;
632
+ // The filters as they were when edit mode was entered: Done brings them back.
633
+ beforeEdit = null;
634
+ // What each delete or clean-up that did not end left to say, by the thread (`channel:thread`)
635
+ // or the channel it was for, the latest first: one's result never erases another's. A new try
636
+ // at the same one, and leaving or entering edit mode, clear it.
637
+ notices = new Map();
638
+ // Each listed thread's title, for the line that names it.
639
+ labels = new Map();
640
+ // The threads a delete was asked for and has not ended: Slack's rate limit makes one take from
641
+ // seconds to minutes, and the page says so from the click on.
642
+ deleting = new Set();
643
+ // The channels a clean-up is on its way for.
644
+ cleaning = new Set();
645
+ task = null;
646
+ // Whether the debounced loop is running: set the moment it ends, which `Task.done` is a turn
647
+ // of the microtask queue late in saying.
648
+ settling = false;
649
+ retry = null;
650
+ dirty = false;
651
+ off = false;
652
+ // Whether the page being built lacks something Slack gave no answer about.
653
+ incomplete = false;
654
+ current = homeFilter();
655
+ // One publish at a time, each built inside the lock: the one that lands last was built last,
656
+ // so a filter just chosen is never overwritten by an older page.
657
+ publishing = new Mutex();
658
+ asking = new Semaphore(THREADS_AT_ONCE);
659
+ // Each thread's permalink, with the `ts` of the message it opens: the thread's last reply, or
660
+ // its root while it has none. Asked again only once that `ts` changed. A null link: Slack no
661
+ // longer has that message (`GONE`).
662
+ links = new Map();
663
+ // A channel's name, asked once per run. Null: Slack no longer has the channel, or the bot left
664
+ // it (`GONE`): the channel and its threads are left out of the page.
665
+ names = new Map();
666
+ // What Slack showed of each thread's root. Null: the root is gone (`GONE`).
667
+ facts = new Map();
668
+ // The threads a state write changed since their root was read: read again at the next
669
+ // publish, which every turn causes at its start and at its end.
670
+ stale = new Set();
671
+ constructor(slack, options) {
672
+ this.slack = slack;
673
+ this.owner = options.ownerUserId;
674
+ this.team = options.teamId;
675
+ this.state = options.state;
676
+ this.textNames = options.names;
677
+ this.sessionsOf = options.sessionsOf;
678
+ this.deleteThread = options.delete;
679
+ this.cleanChannel = options.clean;
680
+ this.updateOf = options.update;
681
+ this.debounce = options.debounce ?? DEBOUNCE_SECONDS;
682
+ this.clock = options.clock ?? systemClock;
683
+ }
684
+ /** The filters the page is built with. */
685
+ get chosen() {
686
+ return this.current;
687
+ }
688
+ /** Ask for a publish soon; `changed` are the (channel, thread) a state write touched. */
689
+ request(changed = []) {
690
+ if (this.off)
691
+ return;
692
+ for (const ref of changed)
693
+ this.stale.add(keyOf(ref.channel, ref.thread));
694
+ this.dirty = true;
695
+ if (!this.settling) {
696
+ this.settling = true;
697
+ this.task = new Task((signal) => this.publishWhenSettled(signal));
698
+ }
699
+ }
700
+ async publishWhenSettled(signal) {
701
+ try {
702
+ while (this.dirty && !this.off) {
703
+ await this.clock.sleep(this.debounce, signal);
704
+ this.dirty = false;
705
+ await this.publish(signal);
706
+ }
707
+ }
708
+ finally {
709
+ this.settling = false;
710
+ }
711
+ }
712
+ async requestLater(signal) {
713
+ await this.clock.sleep(RETRY_SECONDS, signal);
714
+ this.request();
715
+ }
716
+ /**
717
+ * Set the filters and publish at once. A channel that is not one of the page's own (not bound,
718
+ * or gone from Slack) is no filter: `publish` drops it.
719
+ */
720
+ async choose(chosen) {
721
+ this.current = chosen;
722
+ await this.publish();
723
+ }
724
+ /**
725
+ * Enter or leave edit mode and publish at once. Edit mode is a parenthesis: a filter chosen
726
+ * inside it (Show all on the channel being cleaned, most often) lasts while it does, and
727
+ * leaving it brings back the filters the page had on entering.
728
+ */
729
+ async edit(on) {
730
+ const wanted = on && this.deleteThread !== undefined;
731
+ if (wanted && !this.editing) {
732
+ this.beforeEdit = this.current;
733
+ }
734
+ else if (!wanted && this.editing && this.beforeEdit !== null) {
735
+ this.current = this.beforeEdit;
736
+ this.beforeEdit = null;
737
+ }
738
+ this.editing = wanted;
739
+ this.notices.clear();
740
+ await this.publish();
741
+ }
742
+ /**
743
+ * Delete a thread the page lists. Publishes at once with the row marked as being deleted, then
744
+ * again when the delete ended: without the row, or with the line that says why it is still
745
+ * there. A second click on the same thread while it runs does nothing. The page stays in edit
746
+ * mode.
747
+ */
748
+ async delete(channelId, threadTs) {
749
+ const key = keyOf(channelId, threadTs);
750
+ if (this.deleteThread === undefined || !this.editing || this.deleting.has(key))
751
+ return;
752
+ this.deleting.add(key);
753
+ this.notices.delete(key);
754
+ await this.publish();
755
+ let notice;
756
+ try {
757
+ notice = await this.deleteThread(channelId, threadTs);
758
+ }
759
+ finally {
760
+ this.deleting.delete(key);
761
+ }
762
+ if (notice) {
763
+ const label = this.labels.get(key);
764
+ this.say(key, label ? `“${label}”: ${notice}` : notice);
765
+ }
766
+ // A delete that stopped half way changed the thread: its root is read again.
767
+ this.stale.add(key);
768
+ await this.publish();
769
+ }
770
+ /** Keep a notice under `name`, ahead of the ones already kept. */
771
+ say(name, notice) {
772
+ this.notices = new Map([
773
+ [name, notice],
774
+ ...[...this.notices].filter(([kept]) => kept !== name),
775
+ ]);
776
+ }
777
+ /**
778
+ * Clean up a bound channel, as `delete` deletes a thread: the page says so at once, and again
779
+ * when it ended; a second click while it runs does nothing.
780
+ */
781
+ async clean(channelId) {
782
+ if (this.cleanChannel === undefined || !this.editing || this.cleaning.has(channelId))
783
+ return;
784
+ if (!this.state.channels().includes(channelId))
785
+ return; // the value of a click is untrusted
786
+ this.cleaning.add(channelId);
787
+ this.notices.delete(channelId);
788
+ await this.publish();
789
+ let notice;
790
+ try {
791
+ notice = await this.cleanChannel(channelId);
792
+ }
793
+ finally {
794
+ this.cleaning.delete(channelId);
795
+ }
796
+ if (notice) {
797
+ const label = this.names.get(channelId);
798
+ this.say(channelId, label ? `#${label}: ${notice}` : notice);
799
+ }
800
+ await this.publish();
801
+ }
802
+ /**
803
+ * Publish what a pending request still owed, then stop: the page a stop leaves behind shows the
804
+ * sessions as the stop left them. Gives up after `CLOSE_SECONDS`.
805
+ */
806
+ async close() {
807
+ const [task, retry] = [this.task, this.retry];
808
+ this.task = null;
809
+ this.retry = null;
810
+ // Waiting out its debounce or cut in the middle of a publish: owed either way.
811
+ const owed = this.dirty || this.settling;
812
+ for (const pending of [task, retry]) {
813
+ if (pending !== null) {
814
+ pending.cancel();
815
+ await pending.settled();
816
+ }
817
+ }
818
+ if (owed) {
819
+ const limit = new AbortController();
820
+ const publishing = this.publish(limit.signal).catch(() => undefined);
821
+ const expired = this.clock.sleep(CLOSE_SECONDS, limit.signal).catch(() => undefined);
822
+ // A Slack that does not answer is not waited for: the publish is told to stop and the
823
+ // daemon goes on, since a stop never hangs on the page.
824
+ await Promise.race([publishing, expired]);
825
+ limit.abort(new Cancelled());
826
+ }
827
+ this.off = true;
828
+ }
829
+ /** Build the page and send it. Never throws, unless `signal` aborted it. */
830
+ async publish(signal) {
831
+ if (this.off)
832
+ return;
833
+ const release = await this.publishing.acquire(signal);
834
+ try {
835
+ this.incomplete = false;
836
+ try {
837
+ await this.publishPage(signal);
838
+ }
839
+ catch (error) {
840
+ if (signal?.aborted)
841
+ throw signal.reason;
842
+ const code = describe(error);
843
+ if (code === NOT_ENABLED) {
844
+ this.off = true;
845
+ logger.warning(`the Home tab is not enabled in the Slack app (see ${setupGuide(slackTexts.SETUP_SLACK_APP)}): ` +
846
+ "the session index is not published this run");
847
+ return;
848
+ }
849
+ logger.warning(`could not publish the session index: ${code}`);
850
+ this.incomplete = true;
851
+ }
852
+ if (this.incomplete && (this.retry === null || this.retry.done)) {
853
+ this.retry = new Task((retrySignal) => this.requestLater(retrySignal));
854
+ }
855
+ }
856
+ finally {
857
+ release();
858
+ }
859
+ }
860
+ async publishPage(signal) {
861
+ const bound = this.state.channels();
862
+ const channels = await this.channelsOf(bound, signal);
863
+ if (bound.length > 0 && channels.size === 0 && this.incomplete) {
864
+ return; // Slack answered about no channel: the page stays as it is until it does
865
+ }
866
+ const rows = await this.rowsOf(channels, signal);
867
+ // Untrusted when it came from a click, and a chosen channel can go away. One Slack merely
868
+ // did not answer about is still the owner's choice.
869
+ const chosen = this.current.channel;
870
+ if (chosen && (!bound.includes(chosen) || this.names.get(chosen) === null)) {
871
+ this.current = { ...this.current, channel: null };
872
+ }
873
+ const view = homeView(rows, channels, {
874
+ teamId: this.team,
875
+ chosen: this.current,
876
+ now: new Date(this.clock.time() * 1000),
877
+ canEdit: this.deleteThread !== undefined,
878
+ editing: this.editing,
879
+ notices: [...this.notices.values()],
880
+ deleting: this.deleting.size,
881
+ canClean: this.cleanChannel !== undefined,
882
+ cleaning: new Set(this.cleaning),
883
+ update: this.updateOf?.() ?? null,
884
+ }, this.textNames());
885
+ signal?.throwIfAborted();
886
+ // `@slack/types` declares this shape as `HomeView`.
887
+ await this.slack.views.publish({
888
+ user_id: this.owner,
889
+ view: view,
890
+ });
891
+ }
892
+ /** Every bound channel Slack still has, with its name, in the order they were bound. */
893
+ async channelsOf(bound, signal) {
894
+ const found = new Map();
895
+ // A channel forgotten and bound again later is asked about again.
896
+ for (const unbound of [...this.names.keys()]) {
897
+ if (!bound.includes(unbound))
898
+ this.names.delete(unbound);
899
+ }
900
+ for (const channelId of bound) {
901
+ signal?.throwIfAborted();
902
+ if (!this.names.has(channelId)) {
903
+ try {
904
+ const info = await this.slack.conversations.info({ channel: channelId });
905
+ const name = info.channel?.name;
906
+ if (name === undefined)
907
+ throw new TypeError("the channel has no name");
908
+ this.names.set(channelId, String(name));
909
+ }
910
+ catch (error) {
911
+ logger.warning(`could not read channel ${channelId}: ${describe(error)}`);
912
+ if (!gone(error)) {
913
+ this.incomplete = true;
914
+ continue;
915
+ }
916
+ this.names.set(channelId, null);
917
+ }
918
+ }
919
+ const name = this.names.get(channelId);
920
+ if (name !== null && name !== undefined)
921
+ found.set(channelId, name);
922
+ }
923
+ return found;
924
+ }
925
+ /**
926
+ * One row per thread that holds a session in one of `channels` and that Slack still has (a
927
+ * thread whose root is gone cannot be opened: it is left out), last reply first.
928
+ */
929
+ async rowsOf(channels, signal) {
930
+ const threads = this.state.threads();
931
+ // What is kept per thread goes with the thread: a pruned one leaves nothing behind.
932
+ const existing = new Set(threads.map(([channelId, threadTs]) => keyOf(channelId, threadTs)));
933
+ for (const key of [...this.stale])
934
+ if (!existing.has(key))
935
+ this.stale.delete(key);
936
+ for (const cache of [this.links, this.facts]) {
937
+ for (const key of [...cache.keys()])
938
+ if (!existing.has(key))
939
+ cache.delete(key);
940
+ }
941
+ const held = threads.filter(([channelId, , thread]) => thread.sessionId !== null && channels.has(channelId));
942
+ const titles = await this.titlesOf(new Set(held.map(([, , thread]) => thread.directory)));
943
+ const seen = await Promise.all(held.map(([channelId, threadTs]) => this.inSlack(channelId, threadTs, signal)));
944
+ const rows = [];
945
+ held.forEach(([channelId, threadTs, thread], index) => {
946
+ const [link, facts] = seen[index];
947
+ if (link === null || facts === null)
948
+ return;
949
+ const sessionId = String(thread.sessionId);
950
+ rows.push({
951
+ channelId,
952
+ threadTs,
953
+ title: titles.get(sessionId) ||
954
+ texts.fill(slackTexts.HOME_UNTITLED, { id: take(sessionId, ID_SHOWN) }),
955
+ // ❌ over a kept ⏳ or ✋ (an answer that never reached Slack) shows ❌. A thread that
956
+ // ended before the daemon kept its status shows the root's reaction.
957
+ status: reactionOf(thread.ended) ?? reactionOf(thread.status) ?? facts.reaction,
958
+ replies: facts.replies,
959
+ lastActivity: facts.latestReply || Math.trunc(pyFloat(threadTs)),
960
+ permalink: link,
961
+ deleting: this.deleting.has(keyOf(channelId, threadTs)),
962
+ });
963
+ });
964
+ rows.sort((a, b) => b.lastActivity - a.lastActivity);
965
+ this.labels = new Map(rows.map((row) => [
966
+ keyOf(row.channelId, row.threadTs),
967
+ shownAsWritten(oneLine(row.title, TITLE_LIMIT)),
968
+ ]));
969
+ return rows;
970
+ }
971
+ /**
972
+ * The title Claude Code gives each session of `directories`, by session id. A folder that
973
+ * cannot be listed is logged and skipped: its sessions show their id.
974
+ */
975
+ async titlesOf(directories) {
976
+ const found = new Map();
977
+ for (const directory of directories) {
978
+ try {
979
+ const listed = await this.sessionsOf(directory);
980
+ for (const session of listed)
981
+ found.set(session.id, session.title);
982
+ }
983
+ catch (error) {
984
+ logger.warning(`could not list a folder's sessions for the session index: ${describe(error)}`);
985
+ }
986
+ }
987
+ return found;
988
+ }
989
+ /**
990
+ * A thread's permalink and what Slack shows of its root; either is null for a thread the page
991
+ * leaves out.
992
+ */
993
+ async inSlack(channelId, threadTs, signal) {
994
+ const release = await this.asking.acquire();
995
+ try {
996
+ signal?.throwIfAborted();
997
+ const facts = await this.root(channelId, threadTs);
998
+ if (facts === null)
999
+ return [null, null];
1000
+ signal?.throwIfAborted();
1001
+ return [await this.permalink(channelId, threadTs, facts.latestTs ?? threadTs), facts];
1002
+ }
1003
+ finally {
1004
+ release();
1005
+ }
1006
+ }
1007
+ async root(channelId, threadTs) {
1008
+ const key = keyOf(channelId, threadTs);
1009
+ if (this.facts.has(key) && !this.stale.has(key))
1010
+ return this.facts.get(key) ?? null;
1011
+ let facts;
1012
+ try {
1013
+ const answer = await this.slack.conversations.replies({
1014
+ channel: channelId,
1015
+ ts: threadTs,
1016
+ limit: 1,
1017
+ });
1018
+ const messages = Array.isArray(answer.messages) ? answer.messages : [];
1019
+ const root = messages.find((m) => isObject(m) && String(m.ts) === threadTs);
1020
+ if (!isObject(root))
1021
+ throw new RootNotReturned();
1022
+ facts = threadFacts(root);
1023
+ }
1024
+ catch (error) {
1025
+ // A root Slack returns in a shape this cannot read counts as no answer too.
1026
+ logger.warning(`could not read thread ${channelId}/${threadTs}: ${describe(error)}`);
1027
+ if (gone(error)) {
1028
+ this.stale.delete(key);
1029
+ this.facts.set(key, null);
1030
+ return null;
1031
+ }
1032
+ // No answer: what was read before stands, and the thread is asked about again.
1033
+ this.incomplete = true;
1034
+ return this.facts.get(key) ?? null;
1035
+ }
1036
+ this.stale.delete(key);
1037
+ this.facts.set(key, facts);
1038
+ return facts;
1039
+ }
1040
+ /**
1041
+ * The permalink that opens the thread on `messageTs`: Slack scrolls a thread to the reply its
1042
+ * permalink names (seen in the Mac app and on iOS, 2026-10-05).
1043
+ */
1044
+ async permalink(channelId, threadTs, messageTs) {
1045
+ const key = keyOf(channelId, threadTs);
1046
+ const kept = this.links.get(key);
1047
+ if (kept !== undefined && kept[0] === messageTs)
1048
+ return kept[1];
1049
+ let link = null;
1050
+ try {
1051
+ const answer = await this.slack.chat.getPermalink({
1052
+ channel: channelId,
1053
+ message_ts: messageTs,
1054
+ });
1055
+ if (answer.permalink === undefined)
1056
+ throw new TypeError("no permalink");
1057
+ link = String(answer.permalink);
1058
+ }
1059
+ catch (error) {
1060
+ logger.warning(`could not get a permalink for ${channelId}/${messageTs}: ${describe(error)}`);
1061
+ if (!gone(error)) {
1062
+ this.incomplete = true;
1063
+ return null;
1064
+ }
1065
+ }
1066
+ this.links.set(key, [messageTs, link]);
1067
+ return link;
1068
+ }
1069
+ }