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,171 @@
1
+ /**
2
+ * The daemon's two Slack clients on one token, with the daemon's retry policy:
3
+ *
4
+ * - every call that Slack rate limited (HTTP 429) is sent again after the `Retry-After` Slack
5
+ * gave, up to 3 times and while its pauses add up to no more than `RATE_LIMIT_WAIT_SECONDS`
6
+ * (60): a pause that would pass them is not taken, and the call is thrown as rate limited.
7
+ * That is safe for every method: a rate limited call never ran. Once the
8
+ * daemon stops (`ClientOptions.stopped`) such a call is thrown as it is and the pause of one
9
+ * that waits ends: Slack names the length of that pause, and a stop does not wait for it.
10
+ * Each rate limit is logged with its method, its `Retry-After` and the send it answered.
11
+ * - a call that failed on the connection (a reset, a refused or unreachable host: a
12
+ * `WebAPIRequestError` that is not a timeout) is sent again once, after a backoff pause.
13
+ * `sharedClient` does so for every method, which is safe for a post that is an approval, a
14
+ * question or a notice. `repliesClient`, the one a reply is written with, does so for every
15
+ * method but the four of `CREATING_METHODS`: a start, an append, a stop and a post are not
16
+ * idempotent, a connection can reset after Slack applied the call, and a second send would
17
+ * duplicate a message or its text, or stop a stream twice. The sink reads the thread back
18
+ * instead, and adopts what landed.
19
+ * - a call Slack answered with HTTP 500 or 503 (`SERVER_ERROR_STATUSES`) is sent again as one
20
+ * that failed on the connection is: once, after the same pause, by the same client for the
21
+ * same methods. The statuses and the one retry are those of slack-sdk's
22
+ * `ServerErrorRetryHandler` (`slack_sdk/http_retry/builtin_handlers.py`, read 2026-10-11).
23
+ * The sink reads an HTTP status as a call that did not run (`unknownOutcome` in `errors.ts`),
24
+ * which would let the four creating calls be sent again too; they are left out, since whether
25
+ * Slack can answer 500 or 503 after it applied a call was not measured.
26
+ * - nothing else is sent again: Slack answering with an error (`WebAPIPlatformError`), any other
27
+ * HTTP status, a timeout (a timed out call may have run).
28
+ *
29
+ * `@slack/web-api` 8.2.0 retries every failure or none: `WebClient.makeRequest` ends in
30
+ * `pRetry(task, this.retryConfig)`, with no option per method and none per error. So both clients
31
+ * are built with the two documented options that switch its retries off (`SENT_ONCE`), which
32
+ * makes the library report each failure once, as a typed error, and `PolicyClient.apiCall` applies
33
+ * the policy around `WebClient.apiCall`. A retry counts toward one budget per call, as slack-sdk's
34
+ * `RetryState.current_attempt` did: a connection or server error retry is allowed while no retry
35
+ * was made, a rate limit retry while fewer than 3 were.
36
+ *
37
+ * Differences from slack-sdk: a 429 with no valid `Retry-After` is thrown as the library's plain
38
+ * error at once (slack-sdk waited 1 to 2 seconds and retried; Slack always sends the header); and
39
+ * the pause before a retry is taken from the injected `Clock`, so a test crosses it without
40
+ * waiting. `files.uploadV2` is passed through untouched: it is not a Slack method but the
41
+ * library's three step upload, whose `files.getUploadURLExternal` and
42
+ * `files.completeUploadExternal` come back through `apiCall` and are retried there, one by one.
43
+ */
44
+ import { WebAPIHTTPError, WebAPIRateLimitedError, WebAPIRequestError, WebClient, } from "@slack/web-api";
45
+ import { systemClock } from "../../../clock.js";
46
+ import { getLogger } from "../../../log.js";
47
+ import { silentLogger } from "../quiet-logger.js";
48
+ export const logger = getLogger("allaboard.chat.slack.reply.clients");
49
+ /**
50
+ * The four calls that create or grow a message: the replies client never sends one again after a
51
+ * failed connection or a server error.
52
+ */
53
+ export const CREATING_METHODS = [
54
+ "chat.startStream",
55
+ "chat.appendStream",
56
+ "chat.stopStream",
57
+ "chat.postMessage",
58
+ ];
59
+ // What slack-sdk's client waited for an answer by default (`AsyncWebClient(timeout=30)`). The
60
+ // library's default is no timeout at all (`WebClientOptions.timeout`, 0), and a pass of the sink
61
+ // waits for its call to answer.
62
+ export const REQUEST_TIMEOUT_MS = 30_000;
63
+ /** The two options that make the library report each failure once, whatever else it is built with. */
64
+ export const SENT_ONCE = {
65
+ retryConfig: { retries: 0 },
66
+ rejectRateLimitedCalls: true,
67
+ };
68
+ // `AsyncRateLimitErrorRetryHandler(max_retry_count=3)`, and the default of slack-sdk's connection
69
+ // handler and of its server error one: the one retry of a call covers both kinds of failure.
70
+ export const RATE_LIMIT_RETRIES = 3;
71
+ // The most of Slack's `Retry-After` pauses one call waits out in all, the jitter left out (issue
72
+ // #236): the sink sends a reply's calls holding the reply's lock, which a `!stop` waits for. The
73
+ // one value measured, on 2026-10-12 and 2026-10-13, was 10 seconds.
74
+ export const RATE_LIMIT_WAIT_SECONDS = 60;
75
+ export const CONNECTION_RETRIES = 1;
76
+ /** The HTTP statuses a call is sent again after, as after a failed connection. */
77
+ export const SERVER_ERROR_STATUSES = [500, 503];
78
+ // `BackoffRetryIntervalCalculator(backoff_factor=0.5)`: 0.5 * 2^attempt seconds, plus a jitter.
79
+ const BACKOFF_FACTOR = 0.5;
80
+ /** A request that failed before an answer, and not by the client's own timeout. */
81
+ function failedOnConnection(error) {
82
+ return (error instanceof WebAPIRequestError &&
83
+ error.original.name !== "TimeoutError" &&
84
+ error.original.name !== "AbortError");
85
+ }
86
+ /** Slack answered with a status that says its side failed, with no word on the call itself. */
87
+ function failedOnServer(error) {
88
+ return error instanceof WebAPIHTTPError && SERVER_ERROR_STATUSES.includes(error.statusCode);
89
+ }
90
+ /** A `WebClient` that sends each call by the policy at the top of this file. */
91
+ class PolicyClient extends WebClient {
92
+ #notRetried;
93
+ #clock;
94
+ #random;
95
+ #stopped;
96
+ constructor(token, notRetried, options) {
97
+ const { clock, random, stopped, ...rest } = options;
98
+ super(token, { timeout: REQUEST_TIMEOUT_MS, ...rest, ...SENT_ONCE, logger: silentLogger() });
99
+ this.#notRetried = new Set(notRetried);
100
+ this.#clock = clock ?? systemClock;
101
+ this.#random = random ?? Math.random;
102
+ this.#stopped = stopped;
103
+ }
104
+ /**
105
+ * Seconds to wait before sending the call again, or null when it is not sent again. `waited` is
106
+ * the sum of the `Retry-After` pauses the call already took.
107
+ */
108
+ #pause(method, error, retries, waited) {
109
+ if (error instanceof WebAPIRateLimitedError) {
110
+ return retries < RATE_LIMIT_RETRIES && waited + error.retryAfter <= RATE_LIMIT_WAIT_SECONDS
111
+ ? error.retryAfter + this.#random()
112
+ : null;
113
+ }
114
+ if ((failedOnConnection(error) || failedOnServer(error)) &&
115
+ retries < CONNECTION_RETRIES &&
116
+ !this.#notRetried.has(method)) {
117
+ return BACKOFF_FACTOR * 2 ** retries + this.#random();
118
+ }
119
+ return null;
120
+ }
121
+ async apiCall(method, options = {}) {
122
+ if (method === "files.uploadV2")
123
+ return super.apiCall(method, options);
124
+ let waited = 0;
125
+ for (let retries = 0;; retries += 1) {
126
+ try {
127
+ return await super.apiCall(method, options);
128
+ }
129
+ catch (error) {
130
+ if (error instanceof WebAPIRateLimitedError) {
131
+ // The method and Slack's own number, never the call's arguments: which methods are
132
+ // rate limited, and for how long, is what issue #236 waits for.
133
+ logger.info(`Slack rate limited ${method}: Retry-After ${error.retryAfter} s, ` +
134
+ `send ${retries + 1} of ${RATE_LIMIT_RETRIES + 1}`);
135
+ }
136
+ const pause = this.#pause(method, error, retries, waited);
137
+ if (pause === null)
138
+ throw error;
139
+ if (error instanceof WebAPIRateLimitedError) {
140
+ // The one pause whose length Slack names, and the one a stop may leave: see
141
+ // `ClientOptions.stopped`.
142
+ if (this.#stopped?.aborted)
143
+ throw error;
144
+ waited += error.retryAfter;
145
+ try {
146
+ await this.#clock.sleep(pause, this.#stopped);
147
+ }
148
+ catch (stop) {
149
+ if (this.#stopped?.aborted)
150
+ throw error;
151
+ throw stop;
152
+ }
153
+ }
154
+ else {
155
+ await this.#clock.sleep(pause);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ /** The shared client: connection, server error and rate limit retries for every call. */
162
+ export function sharedClient(token, options = {}) {
163
+ return new PolicyClient(token, [], options);
164
+ }
165
+ /**
166
+ * The replies client: the shared client's policy, except that `CREATING_METHODS` are not resent
167
+ * on a connection failure or a server error.
168
+ */
169
+ export function repliesClient(token, options = {}) {
170
+ return new PolicyClient(token, CREATING_METHODS, options);
171
+ }
@@ -0,0 +1,125 @@
1
+ /** What a failed Slack call says, in words that are safe to log and to decide on. */
2
+ import { WebAPIHTTPError, WebAPIPlatformError, WebAPIRateLimitedError, WebAPIRequestError, } from "@slack/web-api";
3
+ import { getLogger } from "../../../log.js";
4
+ import { errorName } from "../../../values.js";
5
+ // The sink's lines all go under one logger name. `sinks.ts` imports this logger and, through
6
+ // `export *`, its callers get the `Logger` type from here.
7
+ export const logger = getLogger("allaboard.chat.slack.reply.sinks");
8
+ // chat.update errors that refuse the content itself (reference, read 2026-09-25): a plain retry
9
+ // can pass where the blocks did not. A transient error such as `ratelimited` is not one.
10
+ export const REFUSED_CONTENT = new Set([
11
+ "invalid_blocks",
12
+ "invalid_blocks_format",
13
+ "msg_too_long",
14
+ "invalid_arguments",
15
+ ]);
16
+ // Slack's answers about a stream's state (measured 2026-09-28): it is over, or still open.
17
+ export const NOT_STREAMING = "message_not_in_streaming_state";
18
+ // The one refusal of an append's content measured on `chat.appendStream` (2026-10-01, slack-sdk
19
+ // 3.44.1), and the one a `chat.update` of the same message was seen to pass.
20
+ export const TOO_LONG = "msg_too_long";
21
+ export const STILL_STREAMING = "streaming_state_conflict";
22
+ /** The system's code for a request that never got an answer (`ECONNRESET`), else the error's name. */
23
+ function requestFailure(original) {
24
+ let cause = original;
25
+ for (let depth = 0; depth < 4 && cause instanceof Error; depth += 1) {
26
+ const code = cause.code;
27
+ if (typeof code === "string" && code)
28
+ return code;
29
+ cause = cause.cause;
30
+ }
31
+ return original.name;
32
+ }
33
+ /** Slack's error code, or the error's type: never the request or its content. */
34
+ export function describe(error) {
35
+ if (error instanceof WebAPIPlatformError)
36
+ return String(error.data.error);
37
+ // The code Slack gives a call past its rate limit (HTTP 429).
38
+ if (error instanceof WebAPIRateLimitedError)
39
+ return "ratelimited";
40
+ if (error instanceof WebAPIHTTPError)
41
+ return `http_${error.statusCode}`;
42
+ if (error instanceof WebAPIRequestError)
43
+ return requestFailure(error.original);
44
+ return errorName(error);
45
+ }
46
+ // What Slack says of a message whose blocks, once it has translated the markdown ones, pass 50
47
+ // (measured 2026-10-08 on `chat.update` and `chat.postMessage`, slack-sdk 3.45.0).
48
+ export const TOO_MANY_BLOCKS = "no more than 50 items allowed";
49
+ export const JSON_POINTER = /\[json-pointer:([^\]]*)\]/g;
50
+ /** The sentences Slack adds to a refused write, which say what in the payload it refused. */
51
+ export function refusalNotes(error) {
52
+ if (!(error instanceof WebAPIPlatformError))
53
+ return [];
54
+ const notes = error.data.response_metadata?.messages ?? [];
55
+ return notes.map((note) => String(note));
56
+ }
57
+ export function tooManyBlocks(error) {
58
+ return refusalNotes(error).some((note) => note.includes(TOO_MANY_BLOCKS));
59
+ }
60
+ /**
61
+ * `describe`, with where in the payload Slack pointed and whether it counted too many blocks:
62
+ * paths and a fixed phrase, never Slack's own sentence, which can quote a value.
63
+ */
64
+ export function describeRefusal(error) {
65
+ let words = describe(error);
66
+ const where = new Set();
67
+ for (const note of refusalNotes(error)) {
68
+ for (const match of note.matchAll(JSON_POINTER))
69
+ where.add(match[1] ?? "");
70
+ }
71
+ if (where.size > 0)
72
+ words += ` at ${[...where].sort().join(", ")}`;
73
+ if (tooManyBlocks(error))
74
+ words += ", over 50 blocks once translated";
75
+ return words;
76
+ }
77
+ // What Slack answers about a channel or a message it no longer has: the only refusals taken as
78
+ // final by the Home tab and by the crash repair. Any other failure (a rate limit, a server
79
+ // error, the network) is no answer.
80
+ export const GONE = new Set([
81
+ "channel_not_found",
82
+ "message_not_found",
83
+ "thread_not_found",
84
+ ]);
85
+ /** Whether Slack answered that the channel or the message is not there any more. */
86
+ export function gone(error) {
87
+ return error instanceof WebAPIPlatformError && GONE.has(describe(error));
88
+ }
89
+ /**
90
+ * Whether a failed call may have been applied: Slack answering with an error says it was not
91
+ * (an error code, a rate limit, an HTTP status), anything else (a reset, a timeout) says nothing.
92
+ */
93
+ export function unknownOutcome(error) {
94
+ return !(error instanceof WebAPIPlatformError ||
95
+ error instanceof WebAPIRateLimitedError ||
96
+ error instanceof WebAPIHTTPError);
97
+ }
98
+ /**
99
+ * Delete a request (an approval, a question, a hold) once it is decided or stale:
100
+ * `message_not_found` counts as done, as everywhere else this project deletes one. Shared by the
101
+ * session, the handlers and the crash repair, so the one behaviour lives in one place. False
102
+ * when Slack did not delete it and did not say it is gone.
103
+ */
104
+ export async function deleteRequest(slack, request, options = {}) {
105
+ const log = options.logger ?? logger;
106
+ const { channel, ts } = request;
107
+ try {
108
+ await slack.chat.delete({ channel, ts });
109
+ }
110
+ catch (error) {
111
+ if (describe(error) !== "message_not_found") {
112
+ log.warning(`could not remove a request in ${channel}: ${describe(error)}`);
113
+ return false;
114
+ }
115
+ }
116
+ // Issue #71: a message ts is its post time in epoch seconds, so the age of a request when it
117
+ // goes is read from the log with no timer kept for it. Ids and a duration only.
118
+ const now = (options.now ?? (() => Date.now() / 1000))();
119
+ const posted = Number(ts);
120
+ const age = ts.trim() === "" || Number.isNaN(posted)
121
+ ? "age unknown"
122
+ : `${(now - posted).toFixed(1)}s after it was posted`;
123
+ log.info(`removed a request in ${channel}: message ${ts}, ${age}`);
124
+ return true;
125
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Third-party text shown as written in Slack's two markup languages. A leaf module, so the
3
+ * footer, the sinks, the approvals and the lists can all use it without an import cycle.
4
+ */
5
+ // The characters that start inline formatting in a markdown block, each escapable with a backslash
6
+ // (Slack's markdown block reference, read 2026-09-25). `#`, `+`, `-`, `.` and `!` act only at the
7
+ // start of a line. `~` is not in the reference's list, but `~~` renders as strikethrough (measured
8
+ // live 2026-09-25); `<` stays text there.
9
+ const MARKDOWN_INLINE = /([\\`*_{}[\]()&~])/g;
10
+ /** Slack's mrkdwn reads `&`, `<` and `>` as markup; a tool's title is shown as written. */
11
+ export function mrkdwnEscape(text) {
12
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13
+ }
14
+ /** Text for inside a markdown block's own formatting, shown as written. */
15
+ export function markdownEscape(text) {
16
+ return text.replace(MARKDOWN_INLINE, "\\$1");
17
+ }
18
+ /**
19
+ * Model-written text for mrkdwn, shown as written: `&`, `<` and `>` escaped, so no
20
+ * `<url|label>` can hide what it links, and a zero-width space after each backtick, so no
21
+ * run of three can close a code block.
22
+ */
23
+ export function shownAsWritten(text) {
24
+ return mrkdwnEscape(text).replaceAll("`", "`​");
25
+ }
@@ -0,0 +1,86 @@
1
+ /** The budget of writes every reply in the process shares. */
2
+ import { monotonicClock } from "../../../clock.js";
3
+ import { Mutex } from "../../../core/port.js";
4
+ import { getLogger } from "../../../log.js";
5
+ export const logger = getLogger("allaboard.chat.slack.reply.limiter");
6
+ // chat.update is Tier 3, "50+ per minute" per app (chat.update reference, read 2026-09-28): 40
7
+ // per 60 s plus a burst of 5, worst case 45 in one window, a real margin under the documented
8
+ // floor. Paced evenly (a token bucket, not a sliding window) past the burst, so a busy minute is
9
+ // a steady trickle rather than every reply racing through the budget together and then freezing
10
+ // until it resets.
11
+ export const UPDATE_LIMIT = 40;
12
+ export const UPDATE_WINDOW_SECONDS = 60.0;
13
+ // How many writes the budget lets through at once before pacing kicks in: enough for a reply
14
+ // that just started to show its first few lines without waiting on threads that were already busy.
15
+ export const UPDATE_BURST = 5;
16
+ // A write that waited this long for its turn is logged (issue #203): one token takes
17
+ // `UPDATE_WINDOW_SECONDS / UPDATE_LIMIT` (1.5 s) to come back, so a wait this long is a line of
18
+ // writes ahead, which is when the budget delays what the owner sees. The number is the
19
+ // project's own.
20
+ export const UPDATE_WAIT_LOGGED_SECONDS = 5.0;
21
+ /**
22
+ * One instance shared by every `ReplySink` in the process, so their chat.update writes stay
23
+ * under Slack's app-wide budget: a token bucket refilling at `limit` tokens per `window` seconds
24
+ * (evenly, one token every `window / limit`), holding at most `burst` at once. A caller that has
25
+ * to wait keeps its place in line, since it holds the lock for as long as it waits, so the next
26
+ * caller queues up behind it. A retry the Slack client makes under the hood, inside one
27
+ * `chat.update` call, spends no extra token here: the limiter only gates the call itself, not
28
+ * what the client does while it is in flight.
29
+ */
30
+ export class UpdateLimiter {
31
+ /** Tokens regained per second. */
32
+ rate;
33
+ burst;
34
+ clock;
35
+ log;
36
+ tokens;
37
+ checked;
38
+ lock = new Mutex();
39
+ constructor(options = {}) {
40
+ const limit = options.limit ?? UPDATE_LIMIT;
41
+ const window = options.window ?? UPDATE_WINDOW_SECONDS;
42
+ this.rate = limit / window;
43
+ this.burst = options.burst ?? UPDATE_BURST;
44
+ this.clock = options.clock ?? monotonicClock;
45
+ this.log = options.logger ?? logger;
46
+ this.tokens = this.burst;
47
+ this.checked = this.clock.time();
48
+ }
49
+ /**
50
+ * Waits until a token is available, then spends it. A signal that aborts gives the place up.
51
+ * A wait of `UPDATE_WAIT_LOGGED_SECONDS` or more, the line of callers ahead included, is logged.
52
+ */
53
+ async acquire(signal) {
54
+ const asked = this.clock.time();
55
+ const release = await this.lock.acquire(signal);
56
+ try {
57
+ for (;;) {
58
+ const now = this.clock.time();
59
+ this.tokens = Math.min(this.burst, this.tokens + (now - this.checked) * this.rate);
60
+ this.checked = now;
61
+ if (this.tokens >= 1) {
62
+ this.tokens -= 1;
63
+ const waited = now - asked;
64
+ if (waited >= UPDATE_WAIT_LOGGED_SECONDS) {
65
+ this.log.info(`a write waited ${waited.toFixed(1)} s for the shared budget of updates`);
66
+ }
67
+ return;
68
+ }
69
+ await this.clock.sleep((1 - this.tokens) / this.rate, signal);
70
+ }
71
+ }
72
+ finally {
73
+ release();
74
+ }
75
+ }
76
+ /**
77
+ * Give back a token `acquire` spent on a write that, once inside the caller's own lock, turned
78
+ * out not to be needed after all (the reply caught up to what it now shows while this one
79
+ * waited its turn): capped at `burst`, as a token earned by waiting would be. Never takes the
80
+ * lock: a concurrent `acquire` can hold it for as long as its own wait takes, and this must
81
+ * land at once regardless; `acquire` always rereads the tokens fresh on its own next pass.
82
+ */
83
+ async refund() {
84
+ this.tokens = Math.min(this.burst, this.tokens + 1);
85
+ }
86
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Claude's markdown as Slack reads it: how many blocks it makes of a text and where to cut one,
3
+ * and the text with its markers gone, for a notification's banner.
4
+ *
5
+ * The patterns follow Python's regular expression rules where the two engines differ: Python's
6
+ * `\s` and `\w` are Unicode (`PY_SPACE`, `WORD`), its `.` stops at `\n` only, and its `^` under
7
+ * `re.M` starts a line after `\n` only (JavaScript's also after `\r`, U+2028 and U+2029).
8
+ */
9
+ import { blank, PY_SPACE, strip } from "../../../text.js";
10
+ import { len } from "./chars.js";
11
+ import { mrkdwnEscape } from "./escape.js";
12
+ const S = `[${PY_SPACE}]`;
13
+ // Python's `\w` on text: a letter or a digit of any script, or `_`.
14
+ const WORD = "[\\p{L}\\p{N}_]";
15
+ // Slack stores a `markdown` block, and a stream's `markdown_text`, as several blocks: a header
16
+ // per heading, a table per table, a divider per rule, and rich text for each run of anything
17
+ // else between them. A write by `chat.update` or `chat.postMessage` is refused when the message
18
+ // passes 50 of them; a stream is not, and the update that follows its stop is (measured
19
+ // 2026-10-08, slack-sdk 3.45.0). Quotes, lists, images, bold lines and code blocks stay in the
20
+ // rich text around them, and a `#` inside a code block is not a heading.
21
+ // Read as CommonMark and GFM define them where Slack was not measured (a heading underlined
22
+ // with `=`, a fence longer than three marks, a rule with spaces in it): a shape counted that
23
+ // Slack keeps in its rich text only ends a message early.
24
+ // Sticky, so that it reads a heading at an offset of a whole text as well as at a line's start.
25
+ const HEADING = new RegExp(` {0,3}#{1,6}(?:${S}|$)`, "y");
26
+ const UNDERLINE = new RegExp(`^ {0,3}=+${S}*$`);
27
+ const RULE = new RegExp(`^ {0,3}([-*_])(?:${S}*\\1){2,}${S}*$`);
28
+ const TABLE_CELL = /^:?-+:?$/;
29
+ const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/;
30
+ /** Whether a heading starts at `offset` of `text`. */
31
+ export function headingAt(text, offset) {
32
+ HEADING.lastIndex = offset;
33
+ return HEADING.test(text);
34
+ }
35
+ /** Python's `line.rstrip("\r")`. */
36
+ function rstripReturns(line) {
37
+ let end = line.length;
38
+ while (end > 0 && line.charCodeAt(end - 1) === 0x0d)
39
+ end -= 1;
40
+ return line.slice(0, end);
41
+ }
42
+ /**
43
+ * Whether `line` is a table's delimiter row: cells of dashes, each with an optional colon at
44
+ * either end, between pipes, the first and the last pipe optional.
45
+ *
46
+ * Read cell by cell, in time linear in the line. The agent writes the text, and one pattern for
47
+ * the whole row backtracks in cubic time on a long run of spaces (measured 2026-10-11, Node
48
+ * 26.5.1), on the event loop every thread shares.
49
+ */
50
+ function tableRule(line) {
51
+ let row = strip(line);
52
+ if (row.startsWith("|"))
53
+ row = row.slice(1);
54
+ if (row.endsWith("|"))
55
+ row = row.slice(0, -1);
56
+ return row.split("|").every((cell) => TABLE_CELL.test(strip(cell)));
57
+ }
58
+ /** Where each block starts that Slack makes of a markdown text, as offsets into it. */
59
+ export function markdownStarts(text) {
60
+ const starts = [];
61
+ const lines = text.split("\n");
62
+ let offset = 0;
63
+ let fence = "";
64
+ let running = false;
65
+ let table = false;
66
+ for (const [number, raw] of lines.entries()) {
67
+ const here = offset;
68
+ offset += raw.length + 1;
69
+ const line = rstripReturns(raw);
70
+ const mark = FENCE.exec(line);
71
+ const run = mark?.[1] ?? "";
72
+ const info = mark?.[2] ?? "";
73
+ if (fence) {
74
+ // Closed by a run of the same mark, at least as long, with nothing after it.
75
+ if (mark !== null && run.startsWith(fence) && blank(info))
76
+ fence = "";
77
+ }
78
+ else if (mark !== null && !info.includes("`")) {
79
+ fence = run;
80
+ table = false;
81
+ }
82
+ else {
83
+ if (blank(line)) {
84
+ table = false;
85
+ continue;
86
+ }
87
+ if (table && line.includes("|"))
88
+ continue;
89
+ table = false;
90
+ const following = rstripReturns(lines[number + 1] ?? "");
91
+ if (line.includes("|") && following.includes("|") && tableRule(following)) {
92
+ starts.push(here);
93
+ table = true;
94
+ running = false;
95
+ continue;
96
+ }
97
+ if (headingAt(line, 0) || RULE.test(line) || (running && UNDERLINE.test(line))) {
98
+ starts.push(here);
99
+ running = false;
100
+ continue;
101
+ }
102
+ }
103
+ if (!running) {
104
+ starts.push(here);
105
+ running = true;
106
+ }
107
+ }
108
+ return starts;
109
+ }
110
+ /** How many blocks a markdown text with words in it counts in a message. */
111
+ export function markdownBlocks(text) {
112
+ return Math.max(1, markdownStarts(text).length);
113
+ }
114
+ /**
115
+ * Where to cut a markdown text so that it makes at most `room` blocks, at a block's start no
116
+ * earlier than `floor` (what a stream was already sent); null when nothing is to cut. A heading
117
+ * is never the last block before the cut, nor the last of a text that fills the room: it opens
118
+ * the next message, with the text it heads.
119
+ */
120
+ export function markdownCut(text, room, floor = 0) {
121
+ const starts = markdownStarts(text);
122
+ let index = starts.length === room ? starts.length : null;
123
+ if (starts.length > room) {
124
+ index = null;
125
+ for (let i = room; i < starts.length; i += 1) {
126
+ if ((starts.at(i) ?? 0) >= floor) {
127
+ index = i;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (index === null)
133
+ return null;
134
+ for (;;) {
135
+ const before = starts[index - 1];
136
+ if (index <= 1 || before === undefined || before < floor || !headingAt(text, before))
137
+ break;
138
+ index -= 1;
139
+ }
140
+ return starts[index] ?? null;
141
+ }
142
+ // A pattern that reads ahead and then fails is tried again from every later start, which is
143
+ // quadratic on a long run of `[`, of blank lines or of `_` inside a word. So the three that read
144
+ // ahead also match what they read where no marker is (a `[` no link closes, space no marker
145
+ // follows, `_` between two letters), and the replacement puts that back as it was.
146
+ const LINK = /\[([^\]]*)(?:\](?:\([^)]*(\))?)?)?/g;
147
+ // `(?<![^\n])` is Python's `^` under `re.M`; `\p{Nd}` its `\d`.
148
+ const LINE_MARKER = new RegExp(`(?<![^\\n])${S}*((?:#+|>|[-+*]|\\p{Nd}+\\.)${S}+)?`, "gu");
149
+ const PAIRED = /\*\*|__|~~|`+/g;
150
+ const EMPHASIS = new RegExp(`(?<!${WORD})[*_]+|[*_]+(?!${WORD})|(_+)`, "gu");
151
+ /**
152
+ * The markdown markers of `text` removed, nothing escaped, in a time linear in the text: a
153
+ * banner is cut from a paragraph as long as Claude wrote it.
154
+ */
155
+ export function stripMarkdown(text) {
156
+ let plain = text.replace(LINK, (all, label, closed) => closed ? label : all);
157
+ plain = plain.replace(LINE_MARKER, (all, marker) => marker ? "" : all);
158
+ plain = plain.replace(PAIRED, "");
159
+ plain = plain.replace(EMPHASIS, (_all, kept) => kept ?? "");
160
+ return strip(plain);
161
+ }
162
+ /**
163
+ * A paragraph of Claude's markdown as the plain `text` of a notification: the markers gone
164
+ * (headings, quotes, list bullets, emphasis, code ticks, link targets) and `&`, `<`, `>`
165
+ * escaped, since Slack reads them as markup there too. With `limit` it is cut before it is
166
+ * escaped, so the result holds at most that many characters and no half an entity.
167
+ */
168
+ export function bannerText(paragraph, options = {}) {
169
+ const plain = stripMarkdown(paragraph);
170
+ const limit = options.limit;
171
+ if (limit === undefined)
172
+ return mrkdwnEscape(plain);
173
+ const out = [];
174
+ let size = 0;
175
+ // By code point: an emoji is one character of the limit.
176
+ for (const char of plain) {
177
+ const piece = mrkdwnEscape(char);
178
+ const cost = len(piece);
179
+ if (size + cost > limit)
180
+ break;
181
+ out.push(piece);
182
+ size += cost;
183
+ }
184
+ return out.join("");
185
+ }
186
+ // A `<` with what follows it up to the next `|` or `>`, and the label when a `|...>` comes
187
+ // next. The stretch with no label is matched too, so that the search goes on after it: no `<`
188
+ // inside it opens a link either, since it would stop at the same `|` or `>`.
189
+ const LABELLED = /<[^|>]*(?:\|([^>]*)>)?/g;
190
+ const WORDS = new RegExp(`${WORD}+`, "gu");
191
+ /**
192
+ * `text` with each `<target|label>` replaced by its label: what `/<[^|>]*\|([^>]*)>/g` and
193
+ * `"$1"` give, in a time linear in the text (issue #241: that pattern starts again at every `<`
194
+ * of a run, and reads to the end each time). A link ends at a `>`, so the text after the last
195
+ * one is left unread, and every `|` in what is read has a `>` after it.
196
+ */
197
+ function labelsOnly(text) {
198
+ const end = text.lastIndexOf(">") + 1;
199
+ const read = text.slice(0, end).replace(LABELLED, (whole, label) => label ?? whole);
200
+ return read + text.slice(end);
201
+ }
202
+ /**
203
+ * The words of a text with the markup, the punctuation and the spacing gone: what two
204
+ * renderings of one text (Claude's markdown and Slack's converted read-back of it, with `*b*`
205
+ * for `**b**`, `<url|label>` for a link, `•` for a bullet) have in common.
206
+ */
207
+ export function plainWords(text) {
208
+ const words = stripMarkdown(labelsOnly(text)).match(WORDS) ?? [];
209
+ return words.join(" ").toLowerCase();
210
+ }