allaboard 0.0.0-stage → 0.5.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,278 @@
1
+ /**
2
+ * Deleting from Slack what the Home tab's edit mode names: a session's whole thread, or what
3
+ * sits in a channel outside every thread (the commands typed there and the bot's own messages).
4
+ *
5
+ * `chat.delete` with a bot token "may delete only messages posted by that bot", and with a user
6
+ * token the messages that user can delete (docs.slack.dev/reference/methods/chat.delete, read
7
+ * 2026-10-05). A session's thread is started by the owner, so its root and the owner's replies
8
+ * need the owner's own user token (`SLACK_USER_TOKEN`, user scope `chat:write`): the bot's
9
+ * messages are deleted with the bot token, every other one with the owner's. Without that token
10
+ * there is no deleter, and the page offers no delete.
11
+ *
12
+ * The replies go first and the root last: the reference does not say what a root deleted under
13
+ * its replies leaves behind. `chat.delete` is Tier 3 (50+ per minute); both clients retry a rate
14
+ * limit after the time Slack asks for, which is what a delete's time is made of (measured
15
+ * 2026-10-05 on a free workspace: 20 to 40 seconds a thread, with a rate limit met every few
16
+ * calls), so threads are deleted one at a time. A delete that stops half way leaves the thread
17
+ * in `state.json`, so asking again continues with the messages that are left. The Claude Code
18
+ * session is not touched: its transcript stays, and `!resume` lists it again once no thread
19
+ * holds it.
20
+ *
21
+ * Cleaning up a channel reads its history (`conversations.history`, Tier 3, cursor pagination,
22
+ * read 2026-10-05) and deletes the messages that are no thread and belong to no thread: "Detect
23
+ * a threaded message by looking for a `thread_ts` value in the message object", and a parent
24
+ * keeps it "even if all its replies have been deleted" (docs.slack.dev/messaging/retrieving-
25
+ * messages, read 2026-10-05). In a channel bound to a folder every message the owner sends
26
+ * outside a thread is a prompt or a word for the daemon, so one with no reply is a leftover
27
+ * whoever wrote it: the owner's and the bot's both go. What stays: a thread that has a reply, a
28
+ * message with a `subtype` (Slack's own lines), anyone else's message, and a message
29
+ * `state.json` holds as a thread whose first reply has not come yet. A message that carries
30
+ * `thread_ts` is deleted only after `conversations.replies` on it, with `limit=1`, returned its
31
+ * root with no `reply_count` (a root nobody replied to has none, measured 2026-10-01): the
32
+ * reference does not say what a parent's count reads in the channel's history once its replies
33
+ * are gone, and a thread taken for a leftover would lose its root.
34
+ */
35
+ import { fill, Mutex, named } from "../../core/port.js";
36
+ import { getLogger } from "../../log.js";
37
+ import { isRecord } from "../../values.js";
38
+ import { describe } from "./reply/errors.js";
39
+ import * as slackTexts from "./texts.js";
40
+ export const logger = getLogger("allaboard.chat.slack.delete");
41
+ /** How many messages one `conversations.replies` page is asked for. */
42
+ export const PAGE = 200;
43
+ export const MESSAGE_NOT_FOUND = "message_not_found";
44
+ export const CANT_DELETE = "cant_delete_message";
45
+ export const THREAD_NOT_FOUND = "thread_not_found";
46
+ /** The messages of a `conversations.*` answer, `[]` for an answer with none. */
47
+ function messagesOf(answer) {
48
+ const messages = isRecord(answer) ? answer.messages : undefined;
49
+ return Array.isArray(messages) ? messages.filter(isRecord) : [];
50
+ }
51
+ /** The cursor of the next page; null on the last one. */
52
+ function nextCursor(answer) {
53
+ const metadata = isRecord(answer) ? answer.response_metadata : undefined;
54
+ const cursor = isRecord(metadata) ? metadata.next_cursor : undefined;
55
+ return typeof cursor === "string" && cursor !== "" ? cursor : null;
56
+ }
57
+ /** A message's `ts`; a message without one is a failure. */
58
+ function tsOf(message) {
59
+ if (message.ts === undefined)
60
+ throw new Error("a message without a ts");
61
+ return String(message.ts);
62
+ }
63
+ /** Deletes the threads the Home tab's Delete names, and cleans the channels it names. */
64
+ export class ThreadDeleter {
65
+ #bot;
66
+ #owner;
67
+ #botUserId;
68
+ #ownerUserId;
69
+ #state;
70
+ #names;
71
+ #release;
72
+ #free;
73
+ // One thread at a time: two deletes at once would share Slack's rate limit, and each would
74
+ // use up its retries on the other's calls.
75
+ #oneAtATime = new Mutex();
76
+ constructor(bot, owner, options) {
77
+ this.#bot = bot;
78
+ this.#owner = owner;
79
+ this.#botUserId = options.botUserId;
80
+ this.#ownerUserId = options.ownerUserId;
81
+ this.#state = options.state;
82
+ this.#names = options.names;
83
+ this.#release = options.release;
84
+ this.#free = options.free;
85
+ }
86
+ /**
87
+ * Delete every message of a thread that holds a session and forget the thread. Returns null
88
+ * once the thread is gone (or was not one of the daemon's), else the line that says why it is
89
+ * still there. Never throws.
90
+ */
91
+ async delete(channelId, threadTs) {
92
+ if (this.#state.thread(channelId, threadTs) === null) {
93
+ return null; // not a thread of the daemon's: nothing a click may delete
94
+ }
95
+ return this.#oneAtATime.run(() => this.#delete(channelId, threadTs));
96
+ }
97
+ async #delete(channelId, threadTs) {
98
+ if (this.#state.thread(channelId, threadTs) === null) {
99
+ return null; // deleted while this call waited its turn
100
+ }
101
+ try {
102
+ // From here until `free`, the thread is held: a message sent in it while its messages go
103
+ // finds no session, and none is rebuilt from the entry `state.json` still has
104
+ // (`SessionManager.release`).
105
+ if (!(await this.#release(channelId, threadTs)))
106
+ return slackTexts.HOME_DELETE_BUSY;
107
+ const refused = await this.#empty(channelId, threadTs);
108
+ if (refused > 0) {
109
+ // The root stays while a reply does, so the thread can still be opened.
110
+ return fill(named(slackTexts.HOME_DELETE_REFUSED, this.#names()), { count: refused });
111
+ }
112
+ this.#state.removeThread(channelId, threadTs);
113
+ }
114
+ catch (error) {
115
+ logger.warning(`could not delete ${channelId}/${threadTs}: ${describe(error)}`);
116
+ return fill(slackTexts.HOME_DELETE_FAILED, { error: describe(error) });
117
+ }
118
+ finally {
119
+ this.#free(channelId, threadTs);
120
+ }
121
+ logger.info(`deleted thread ${channelId}/${threadTs}`);
122
+ return null;
123
+ }
124
+ /**
125
+ * Delete the thread's replies, then its root. The thread is read again after each pass, so a
126
+ * message that arrived meanwhile goes too, and the root goes only once a read shows no reply
127
+ * left: a delete never leaves replies under a deleted root. Returns how many messages Slack
128
+ * refused to delete; the root stays when it is not zero.
129
+ */
130
+ async #empty(channelId, threadTs) {
131
+ const handled = new Set();
132
+ const refused = new Set();
133
+ let messages;
134
+ for (;;) {
135
+ messages = await this.#messages(channelId, threadTs);
136
+ const replies = [...messages].filter(([ts]) => ts !== threadTs && !handled.has(ts));
137
+ if (replies.length === 0)
138
+ break;
139
+ for (const [ts, author] of replies) {
140
+ handled.add(ts);
141
+ if (!(await this.#remove(channelId, ts, author)))
142
+ refused.add(ts);
143
+ }
144
+ }
145
+ const root = refused.size > 0 ? undefined : messages.get(threadTs);
146
+ if (root !== undefined && !(await this.#remove(channelId, threadTs, root))) {
147
+ refused.add(threadTs);
148
+ }
149
+ return refused.size;
150
+ }
151
+ /**
152
+ * Delete one message with its author's token; anyone else's is tried with the owner's, which
153
+ * deletes what the owner may delete in Slack. False when Slack refuses this very message
154
+ * (`cant_delete_message`): the caller goes on and counts it. A message already gone is
155
+ * deleted; any other failure throws.
156
+ */
157
+ async #remove(channelId, ts, author) {
158
+ const client = author === this.#botUserId ? this.#bot : this.#owner;
159
+ try {
160
+ await client.chat.delete({ channel: channelId, ts });
161
+ }
162
+ catch (error) {
163
+ if (describe(error) === CANT_DELETE)
164
+ return false;
165
+ if (describe(error) !== MESSAGE_NOT_FOUND)
166
+ throw error;
167
+ }
168
+ return true;
169
+ }
170
+ /**
171
+ * Delete what sits in a bound channel outside every thread: the owner's messages and the
172
+ * bot's that have no reply. Returns null when done (or for a channel that is not bound), else
173
+ * the line that says why it stopped. Never throws.
174
+ */
175
+ async clean(channelId) {
176
+ if (!this.#state.channels().includes(channelId)) {
177
+ return null; // not a channel of the daemon's: nothing a click may clean
178
+ }
179
+ return this.#oneAtATime.run(async () => {
180
+ let loose = new Map();
181
+ let refused = 0;
182
+ try {
183
+ loose = await this.#loose(channelId);
184
+ for (const [ts, author] of loose) {
185
+ if (!(await this.#remove(channelId, ts, author)))
186
+ refused += 1;
187
+ }
188
+ }
189
+ catch (error) {
190
+ logger.warning(`could not clean up ${channelId}: ${describe(error)}`);
191
+ return fill(slackTexts.HOME_CLEAN_FAILED, { error: describe(error) });
192
+ }
193
+ logger.info(`cleaned up ${channelId}: ${loose.size} messages, ${refused} refused`);
194
+ return refused > 0
195
+ ? fill(named(slackTexts.HOME_CLEAN_REFUSED, this.#names()), { count: refused })
196
+ : null;
197
+ });
198
+ }
199
+ /** The channel's messages a clean-up deletes, `ts` to author, read page by page. */
200
+ async #loose(channelId) {
201
+ const found = new Map();
202
+ let cursor = null;
203
+ for (;;) {
204
+ const page = await this.#bot.conversations.history({
205
+ channel: channelId,
206
+ limit: PAGE,
207
+ ...(cursor === null ? {} : { cursor }),
208
+ });
209
+ for (const message of messagesOf(page)) {
210
+ const ts = tsOf(message);
211
+ const author = String(message.user || "");
212
+ const threaded = message.thread_ts;
213
+ if (message.subtype || (threaded && String(threaded) !== ts)) {
214
+ continue; // Slack's own line; or a thread's reply shown in the channel
215
+ }
216
+ if (author !== this.#botUserId && author !== this.#ownerUserId)
217
+ continue;
218
+ if (this.#state.thread(channelId, ts) !== null) {
219
+ continue; // a thread of the daemon's with no reply yet
220
+ }
221
+ if (threaded && (await this.#hasReplies(channelId, ts)))
222
+ continue; // a thread
223
+ found.set(ts, author);
224
+ }
225
+ cursor = nextCursor(page);
226
+ if (cursor === null)
227
+ return found;
228
+ }
229
+ }
230
+ /**
231
+ * Whether a message that carries `thread_ts` still has a reply, asked of the thread itself.
232
+ * True as well for one that is gone meanwhile: there is nothing to delete.
233
+ */
234
+ async #hasReplies(channelId, ts) {
235
+ let answer;
236
+ try {
237
+ answer = await this.#bot.conversations.replies({ channel: channelId, ts, limit: 1 });
238
+ }
239
+ catch (error) {
240
+ if (describe(error) === THREAD_NOT_FOUND)
241
+ return true;
242
+ throw error;
243
+ }
244
+ const messages = messagesOf(answer);
245
+ const root = messages.find((message) => String(message.ts) === ts);
246
+ // No root in the answer is no proof of an empty thread: keep the message.
247
+ return root === undefined || Boolean(root.reply_count) || messages.length > 1;
248
+ }
249
+ /**
250
+ * Every message of the thread, its `ts` to its author's user id, read page by page (cursor
251
+ * pagination, `response_metadata.next_cursor`). Empty when the root is gone.
252
+ */
253
+ async #messages(channelId, threadTs) {
254
+ const found = new Map();
255
+ let cursor = null;
256
+ for (;;) {
257
+ let page;
258
+ try {
259
+ page = await this.#bot.conversations.replies({
260
+ channel: channelId,
261
+ ts: threadTs,
262
+ limit: PAGE,
263
+ ...(cursor === null ? {} : { cursor }),
264
+ });
265
+ }
266
+ catch (error) {
267
+ if (describe(error) === THREAD_NOT_FOUND)
268
+ return found;
269
+ throw error;
270
+ }
271
+ for (const message of messagesOf(page))
272
+ found.set(tsOf(message), String(message.user || ""));
273
+ cursor = nextCursor(page);
274
+ if (cursor === null)
275
+ return found;
276
+ }
277
+ }
278
+ }
@@ -0,0 +1,43 @@
1
+ /** The footer as one line of Slack mrkdwn. */
2
+ import { basename } from "node:path";
3
+ import { footerFields, valueWithDetail, } from "../../core/port.js";
4
+ import { mrkdwnEscape } from "./reply/escape.js";
5
+ // The footer's fields that come before the folder.
6
+ const SESSION = ["model", "effort"];
7
+ /** A field as the footer writes it: the label in bold (`*ctx* 15%`, the owner's choice). */
8
+ function short(field) {
9
+ const { key, value } = field;
10
+ switch (key) {
11
+ case "model":
12
+ case "changes":
13
+ return value;
14
+ case "effort":
15
+ return `*effort* ${valueWithDetail(field)}`;
16
+ case "branch":
17
+ return mrkdwnEscape(value);
18
+ case "tokens":
19
+ return `${value} *tok*`;
20
+ case "context":
21
+ return `*ctx* ${value}`;
22
+ case "session_limit":
23
+ return `*5h* ${value}`;
24
+ case "week_limit":
25
+ return `*7d* ${value}`;
26
+ }
27
+ }
28
+ /**
29
+ * One line: bypass, model and effort, the channel's folder, then branch, changes, tokens,
30
+ * context and limits (the owner's order).
31
+ */
32
+ export function formatFooter(data, now) {
33
+ const fields = footerFields(data, now);
34
+ const parts = data.bypass ? ["⚡ bypass"] : [];
35
+ parts.push(...fields.filter((f) => SESSION.includes(f.key)).map((f) => short(f)));
36
+ // Its name alone, the project's: the whole path is on `!status`'s Directory line.
37
+ const name = data.folder === null ? "" : basename(data.folder);
38
+ if (name) {
39
+ parts.push(mrkdwnEscape(name));
40
+ }
41
+ parts.push(...fields.filter((f) => !SESSION.includes(f.key)).map((f) => short(f)));
42
+ return parts.join(" · ");
43
+ }
@@ -0,0 +1,37 @@
1
+ import { texts } from "../../core/port.js";
2
+ import * as slackTexts from "./texts.js";
3
+ /** The same-folder hold's question as Slack blocks. */
4
+ export const HOLD_CONTINUE = "hold_continue";
5
+ export const HOLD_CANCEL = "hold_cancel";
6
+ /**
7
+ * The question in the thread: a `mrkdwn` section (`link` in mrkdwn's own `<url|label>` form, same
8
+ * as approvals and the resume picker use for their own buttons) plus Continue and Cancel. Only
9
+ * Continue is `primary`: one button of a set, and never `danger` for an answer that destroys
10
+ * nothing (button element reference, docs.slack.dev, read 2026-10-07).
11
+ */
12
+ export function holdBlocks(holdId, link) {
13
+ return [
14
+ {
15
+ type: "section",
16
+ text: { type: "mrkdwn", text: texts.fill(texts.HOLD_QUESTION, { link }) },
17
+ },
18
+ {
19
+ type: "actions",
20
+ elements: [
21
+ {
22
+ type: "button",
23
+ action_id: HOLD_CONTINUE,
24
+ value: holdId,
25
+ style: "primary",
26
+ text: { type: "plain_text", text: slackTexts.HOLD_CONTINUE_BUTTON },
27
+ },
28
+ {
29
+ type: "button",
30
+ action_id: HOLD_CANCEL,
31
+ value: holdId,
32
+ text: { type: "plain_text", text: slackTexts.HOLD_CANCEL_BUTTON },
33
+ },
34
+ ],
35
+ },
36
+ ];
37
+ }