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,2403 @@
1
+ /**
2
+ * Write a rendered reply to Slack as a native stream: `chat.startStream` with the reply's first
3
+ * content, `chat.appendStream` as it grows, `chat.stopStream` at its end, in the session's thread.
4
+ * A stream in a thread the owner started notifies once, when it stops, with its first text as the
5
+ * banner, and never at its start (measured 2026-09-29). Slack closes a stream 5 minutes after it
6
+ * started (measured), so a reply that runs longer stops its stream at STREAM_SECONDS and goes on in
7
+ * the same message with `chat.update`, which never notifies; its end then posts a closing message.
8
+ * Every `chat.appendStream` and `chat.update` also waits its turn on an `UpdateLimiter` shared by
9
+ * every reply in the process: a token bucket that paces writes evenly under the app's own budget,
10
+ * rather than letting several busy threads exhaust it together and then freeze until it resets.
11
+ *
12
+ * The client is the replies client of `clients.ts` (`repliesClient`), and the sink relies on its
13
+ * policy: a rate limited call is sent again by the client, a call that failed on the connection
14
+ * is sent again once EXCEPT the four that create or grow a message (start, append, stop, post),
15
+ * which are never sent twice, since Slack may have applied them. The sink reads the thread back
16
+ * for those and adopts what landed. Constructed with another client, the sink would post or
17
+ * append twice after a reset.
18
+ *
19
+ * How a sink is stopped. Whoever stops a sink (`!stop`, a close, a restart)
20
+ * follows the same rule:
21
+ *
22
+ * 1. A pass is never cancelled. A pass is `flush`: it takes the lock, brings every message to
23
+ * the model and ends the reply if it has ended. Once asked for it runs to its end, whoever
24
+ * stops waiting for it, and the next pass queues behind it on the lock. `@slack/web-api` cannot
25
+ * abort a call in flight (the only signal `WebClient.makeRequest` gives `fetch` is the client's
26
+ * own timeout), and a call given up after Slack took it loses a message's ts or posts an
27
+ * ending twice. For that reason no wait inside a pass (the lock, the limiter, a Slack call, a
28
+ * read-back) takes a signal. One pause inside a Slack call does end early, at the daemon's
29
+ * stop: the one the client makes after Slack rate limited the call (`ClientOptions.stopped`
30
+ * in `clients.ts`). Nothing is in flight then, and Slack did not apply the call it answered
31
+ * `ratelimited`: the pass gets that failure, as it does when the retries run out.
32
+ * 2. What can be cancelled is a wait outside a pass, and it is cancelled with an `AbortSignal`:
33
+ * the debounce before a write, the pause before the end's one retry, the stream's 280 seconds
34
+ * (each a `Task` with its own signal, aborted by `Task.cancel`), and a caller's own wait for a
35
+ * pass. `finish`, `closeOut`, `waitLanded` and `settle` take an optional signal: aborting it
36
+ * rejects that call with the signal's reason and leaves the pass running. To stop a sink,
37
+ * abort the signal you gave it, then `settle()` to wait for what is in flight.
38
+ * 3. A cancelled wait rejects with the signal's reason; a `Task` aborts with `Cancelled`. A task
39
+ * that was cancelled while its shielded pass ran sees `signal.aborted` when the pass returns
40
+ * and does nothing more.
41
+ * 4. The lock is `Mutex`: first come, first served.
42
+ * 5. State is explicit, never carried by a task being alive: `pending` is null whenever no
43
+ * debounce waits (cleared by the task itself as it returns, and by whoever cancels it), and
44
+ * `landedValue` says whether and how the end resolved.
45
+ */
46
+ import { isDeepStrictEqual } from "node:util";
47
+ import { systemClock } from "../../../clock.js";
48
+ import { cancellable, Fold, formatDuration, Mutex, shownPreview } from "../../../core/port.js";
49
+ import { blank, strip } from "../../../text.js";
50
+ import { formatFooter } from "../footer.js";
51
+ import { asideBlock, BANNER_LIMIT, BLOCKS_LIMIT, blocksCount, blocksSizes, blockText, CARD_TEXT_FIELDS, cardAddition, cardBlock, cardChunk, cardFields, contextBlock, isRunningList, MESSAGE_LIMIT, PREVIEW_CUT, plainLines, plainText, previewBlocks, previewContainers, runningListBlock, split, TERMINAL, ZERO_WIDTH_SPACE, } from "./blocks.js";
52
+ import { at, lastNewline, len, lstripNewlines, rstripNewlines, stripNewlines, take, } from "./chars.js";
53
+ import { logger as defaultLogger, describe, describeRefusal, NOT_STREAMING, REFUSED_CONTENT, STILL_STREAMING, TOO_LONG, tooManyBlocks, unknownOutcome, } from "./errors.js";
54
+ import { mrkdwnEscape } from "./escape.js";
55
+ import { bannerText, markdownBlocks, markdownCut, plainWords } from "./markdown.js";
56
+ import { positionOf, ThreadPlaces } from "./places.js";
57
+ import { Task } from "./tasks.js";
58
+ /** What `chat.delete` answers for a message that is not there: it counts as deleted. */
59
+ const MESSAGE_NOT_FOUND = "message_not_found";
60
+ export const DEBOUNCE_SECONDS = 1.0;
61
+ // The final write has no next rewrite to fix it: one that fails for any reason but its content
62
+ // is tried once more after this pause.
63
+ export const FINAL_RETRY_SECONDS = 10.0;
64
+ // A stream is closed by Slack 5 minutes after `chat.startStream` (measured 2026-09-28: refused at
65
+ // 300.3 s and 305 s); stopped by the daemon at this age, it leaves a margin for the round trip.
66
+ export const STREAM_SECONDS = 280.0;
67
+ // A stream past this age is over, whatever the daemon did (refused at 300.3 s).
68
+ export const STREAM_LIFE = 300.0;
69
+ // A running command's card shows how long it has run from this age (the terminal shows seconds
70
+ // from the first; a card is rewritten, so the first number waits for the first refresh) and is
71
+ // refreshed at this interval while the stream lives. After the stream has stopped nothing
72
+ // refreshes it: the seconds are read again whenever the message is rewritten.
73
+ export const RUNNING_REFRESH_SECONDS = 30.0;
74
+ // How far back a create of unknown outcome is looked for: the attempt's own clock, less this.
75
+ export const ADOPT_SKEW_SECONDS = 2.0;
76
+ // How much of a message's first words is compared to know it is the one that was lost.
77
+ export const ADOPT_WORDS = 40;
78
+ /** `a <= b`: by part, then by offset. */
79
+ function upTo(a, b) {
80
+ return a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]);
81
+ }
82
+ function sameCursor(a, b) {
83
+ if (a === null || b === null)
84
+ return a === b;
85
+ return a[0] === b[0] && a[1] === b[1];
86
+ }
87
+ /** A preview piece of a tool, as a key: (part, piece). */
88
+ function pieceKey(index, piece) {
89
+ return `${index}:${piece}`;
90
+ }
91
+ class Text {
92
+ text;
93
+ /** A line of the daemon's own: never the banner while Claude has words. */
94
+ notice;
95
+ /** A notice that says how a reply was cut short (`endingCursor`). */
96
+ ending;
97
+ /** A line about the work (a task's end): grey once the message is written by an update. */
98
+ aside;
99
+ /** Bumped when it changes: a message whose parts kept theirs is not rendered again. */
100
+ rev;
101
+ constructor(text, notice, ending, aside, rev) {
102
+ this.text = text;
103
+ this.notice = notice;
104
+ this.ending = ending;
105
+ this.aside = aside;
106
+ this.rev = rev;
107
+ }
108
+ }
109
+ /**
110
+ * The call as its card shows it: a call the terminal times (a shell command) that has run
111
+ * RUNNING_REFRESH_SECONDS reads `<title> · <duration>`.
112
+ */
113
+ function timedUpdate(update, now) {
114
+ if (update.startedAt === null || update.status !== "in_progress")
115
+ return update;
116
+ const ran = now - update.startedAt;
117
+ if (ran < RUNNING_REFRESH_SECONDS)
118
+ return update;
119
+ return { ...update, title: `${update.title} · ${formatDuration(ran)}` };
120
+ }
121
+ export class Tool {
122
+ /** The call as the card shows it: the title carries the seconds read at the last `stamp`. */
123
+ update;
124
+ /** The call as the core gave it. */
125
+ base;
126
+ rev = 0;
127
+ shownPieces = [];
128
+ sizes = [];
129
+ carded = false;
130
+ constructor(update, now) {
131
+ this.set(update, now);
132
+ }
133
+ /** The seconds of a timed call read at `now`; true when the card's title changed. */
134
+ stamp(now) {
135
+ const shown = timedUpdate(this.base, now);
136
+ if (shown.title === this.update.title)
137
+ return false;
138
+ this.update = shown;
139
+ return true;
140
+ }
141
+ /** Whether the call is one whose card shows seconds and it still runs. */
142
+ get timed() {
143
+ return this.base.startedAt !== null && this.base.status === "in_progress";
144
+ }
145
+ /** A new state of the call, its seconds read at `now`. What follows its card is worked out once, here. */
146
+ set(update, now) {
147
+ this.base = update;
148
+ this.update = timedUpdate(update, now);
149
+ const view = shownPreview(update);
150
+ if (view === null || !view.body) {
151
+ this.shownPieces = [];
152
+ }
153
+ else if (view.plain) {
154
+ // One context block, as it is drawn: a piece counts for what the message holds.
155
+ this.shownPieces = [plainLines(view.body, view.record ? view.title : null)];
156
+ }
157
+ else {
158
+ this.shownPieces = split(view.body).filter((chunk) => chunk !== "");
159
+ }
160
+ this.sizes = this.shownPieces.map(len);
161
+ if (view === null || (view.plain && !view.record) || this.shownPieces.length === 0) {
162
+ this.carded = true;
163
+ }
164
+ }
165
+ /**
166
+ * Whether the tool is its preview alone: an Edit or a Write that reached the reply already
167
+ * ended well. A state that needs a card (running, failed, stopped, a preview with no body)
168
+ * gives it one for good, since a stream cannot take a card back.
169
+ */
170
+ get cardless() {
171
+ return !this.carded;
172
+ }
173
+ /** Piece `piece` (1 is the first). */
174
+ piece(piece) {
175
+ return this.shownPieces[piece - 1] ?? "";
176
+ }
177
+ /** The characters of piece `piece`, counted in code points (`len` of `chars.ts`). */
178
+ pieceSize(piece) {
179
+ return this.sizes[piece - 1] ?? 0;
180
+ }
181
+ /**
182
+ * The elements of the tool: its card (counted even when `cardless` draws none, so a cursor
183
+ * reads the same either way), then its preview pieces.
184
+ */
185
+ get extent() {
186
+ return 1 + this.shownPieces.length;
187
+ }
188
+ }
189
+ /**
190
+ * Piece `index` (1 is the first) of a tool's preview as blocks. With no card: a collapsed
191
+ * container titled with the call's line. Under a card: a collapsed container for a diff, code
192
+ * blocks for a new file's first lines, a context block for lines of words (a question's
193
+ * answers), each indented under its card and shown as written.
194
+ */
195
+ export function pieceBlocks(tool, index) {
196
+ const view = shownPreview(tool.update);
197
+ if (view === null)
198
+ throw new Error("a piece of a tool with no preview");
199
+ const body = tool.piece(index);
200
+ if (view.plain)
201
+ return [contextBlock(body)];
202
+ if (!pieceCollapsed(tool))
203
+ return previewBlocks(body);
204
+ if (tool.cardless) {
205
+ return previewContainers(view.title, body, {
206
+ subtitle: view.summary,
207
+ language: view.language,
208
+ asCode: true,
209
+ });
210
+ }
211
+ return previewContainers(view.summary, body);
212
+ }
213
+ /**
214
+ * Whether `pieceBlocks` draws the tool's preview as collapsed containers, whose text a
215
+ * `chat.update` does not count toward MESSAGE_LIMIT. A context block and code blocks (markdown)
216
+ * always count.
217
+ */
218
+ export function pieceCollapsed(tool) {
219
+ const view = shownPreview(tool.update);
220
+ if (view === null)
221
+ throw new Error("a piece of a tool with no preview");
222
+ return !view.plain && (tool.cardless || view.language === "diff");
223
+ }
224
+ /**
225
+ * The same piece for a stream: a `blocks` chunk (measured 2026-09-28 for a diff's container and
226
+ * 2026-09-29 for a markdown block, which reads back as rich text; a context block in one was
227
+ * seen drawn on the daemon on 2026-10-03).
228
+ */
229
+ export function pieceChunk(tool, index) {
230
+ return { type: "blocks", blocks: pieceBlocks(tool, index) };
231
+ }
232
+ /** What a stream still has to be told, and where the reply goes on if it does not all fit. */
233
+ class Plan {
234
+ /** Characters the message holds once these chunks are in. */
235
+ size;
236
+ /** Elements it holds. */
237
+ count;
238
+ /** What its cards count toward MESSAGE_LIMIT beside `size`. */
239
+ cardCost;
240
+ chunks = [];
241
+ /** Where the next message starts, if the rest does not fit. */
242
+ overflow = null;
243
+ /** Part to the offset sent up to. */
244
+ text = new Map();
245
+ pieces = new Set();
246
+ cards = new Map();
247
+ cardHeld = new Map();
248
+ /** Part to the blocks its text makes. */
249
+ textBlocks = new Map();
250
+ constructor(size, count, cardCost) {
251
+ this.size = size;
252
+ this.count = count;
253
+ this.cardCost = cardCost;
254
+ }
255
+ }
256
+ /**
257
+ * What the reply's last message showed when its end was written: nothing of it is removed by a
258
+ * late update.
259
+ */
260
+ class Held {
261
+ /** Part to the offset its text was shown up to. */
262
+ text = new Map();
263
+ cards = new Set();
264
+ pieces = new Set();
265
+ /** The blocks they take, a card or a piece counting one. */
266
+ blocks() {
267
+ return this.text.size + this.cards.size + this.pieces.size;
268
+ }
269
+ }
270
+ /** The characters of card text a plan's chunks carry. */
271
+ function planCardText(plan) {
272
+ let total = 0;
273
+ for (const chunk of plan.chunks) {
274
+ if (chunk.type !== "task_update")
275
+ continue;
276
+ for (const key of CARD_TEXT_FIELDS)
277
+ total += len(chunk[key] ?? "");
278
+ }
279
+ return total;
280
+ }
281
+ /**
282
+ * One Slack message of a reply: the span of the reply's model from `start` to the next message's
283
+ * start, first written as a stream (a reply's first message, and one that continues a stream
284
+ * that is still open) or as a post (one that continues a stopped message). While it streams,
285
+ * what it has been sent is remembered here, since a stream only grows.
286
+ */
287
+ class Message {
288
+ start;
289
+ mode;
290
+ ts = null;
291
+ streaming = false;
292
+ textSent = new Map();
293
+ piecesSent = new Set();
294
+ /** What each card was told. */
295
+ cards = new Map();
296
+ /** The details and the output Slack holds per card: those of every chunk sent, joined. */
297
+ cardHeld = new Map();
298
+ // Characters of card text (title, details, output) in every chunk sent, for the log, and
299
+ // what the cards count toward MESSAGE_LIMIT (`cardAddition`); `size` counts none of it.
300
+ cardText = 0;
301
+ cardCost = 0;
302
+ size = 0;
303
+ count = 0;
304
+ /** Part to the blocks its text makes. */
305
+ textBlocks = new Map();
306
+ // The blocks the message may hold: BLOCKS_LIMIT, until Slack refuses a write of it for
307
+ // counting more of them than the daemon did.
308
+ blocksRoom = BLOCKS_LIMIT;
309
+ // Stopped: whether what it shows is its stream as sent, which needs no write while the
310
+ // model still says the same; else `shown` is the blocks of its last post or update.
311
+ exact = false;
312
+ shown = null;
313
+ /** The footer it shows. */
314
+ footer = [];
315
+ deadline = null;
316
+ /** The refresh of a running command's seconds, while the stream lives (`refreshTimed`). */
317
+ refresh = null;
318
+ // An append whose outcome is unknown: the stream is no longer told anything, it is stopped
319
+ // and the message goes on by update, from the model.
320
+ blind = false;
321
+ // An update refused in turn left it showing less than the model (`short`), until an update
322
+ // passes: while a message is short the reply's end has not landed.
323
+ short = false;
324
+ /** When its stream started, by the clock: a stream is over at 5 minutes. */
325
+ started = 0;
326
+ // (span revision, end) of the last write of a stopped message that has a successor: while it
327
+ // holds, nothing in the message changed and it is not rendered again.
328
+ checked = null;
329
+ // What a stop of unknown outcome carried, when it was the end's own stop or carried blocks of
330
+ // ours (the list); the stream's own stop at STREAM_SECONDS records nothing. If the next stop
331
+ // finds the stream over, that stop is the one that landed.
332
+ stopUnknown = null;
333
+ // Slack refused an update of this message whose containers were not counted: from then on
334
+ // they count toward MESSAGE_LIMIT, as in a post.
335
+ containersCounted = false;
336
+ constructor(start, mode) {
337
+ this.start = start;
338
+ this.mode = mode;
339
+ }
340
+ }
341
+ /**
342
+ * One reply in a Slack thread, as a native stream: Claude's text as it is written, and task
343
+ * cards for its tools, updated in place, in the order things happen: one per run of calls
344
+ * (`core/reply/fold`), which a silent update folds into a line of counts once the stream has
345
+ * stopped at the reply's end. The stream starts with the first content (never a placeholder) and
346
+ * stops with the reply's end, the footer at the bottom.
347
+ * It stops on its own at STREAM_SECONDS, since Slack closes a stream at 5 minutes, and when
348
+ * Slack refuses an append as too long, which it would refuse again: from then on the same
349
+ * message grows by `chat.update`, and the end posts the reply's ending (the text after its last
350
+ * call) with the footer as a new message.
351
+ * A reply past MESSAGE_LIMIT or BLOCKS_LIMIT continues in a new message (a new stream while the
352
+ * message still streams, else a post). `finish` ends the body only: a task that outlives the turn
353
+ * keeps updating its own card after that, in place. Never throws for a write that fails: it is
354
+ * sent again with the next one, the final one once more after FINAL_RETRY_SECONDS.
355
+ */
356
+ export class ReplySink {
357
+ slack;
358
+ channel;
359
+ threadTs;
360
+ teamId;
361
+ userId;
362
+ botUserId;
363
+ limiter;
364
+ clock;
365
+ steady;
366
+ onOpenReply;
367
+ onWrite;
368
+ places;
369
+ logger;
370
+ debounceSeconds;
371
+ finalRetrySeconds;
372
+ /** A Slack write was attempted since `onWrite` was last called. */
373
+ wrote = false;
374
+ /** This sink's own entries in the open-replies list: the messages a crash would leave unfinished. */
375
+ tracked = new Set();
376
+ /** The end landed: nothing is left for a repair to close. */
377
+ ended = false;
378
+ /** The last message's body is whole, and only the closing message is owed. */
379
+ bodyLanded = false;
380
+ parts = [];
381
+ /** By the id of the card that shows them. */
382
+ tools = new Map();
383
+ /** Which cards show the calls: one per run of calls. */
384
+ fold = new Fold();
385
+ messages = [];
386
+ pending = null;
387
+ retry = null;
388
+ ending = null;
389
+ lock = new Mutex();
390
+ finished = false;
391
+ /** `rest` was called: the reply's messages are no longer streams. */
392
+ restRequested = false;
393
+ /** The agent's own words were written after the reply rested: a report turn's text. */
394
+ reportedSinceRest = false;
395
+ /**
396
+ * The session's footer, formatted, once it is drawn under this reply (`showFooter`), after its
397
+ * end. It stays on this reply for good.
398
+ */
399
+ footer = null;
400
+ /** The list of what still runs the thread's places last handed this reply; read once it rests. */
401
+ assigned = null;
402
+ /** Whether closeOut has run; a second call is a no-op. */
403
+ closedOut = false;
404
+ // How the reply ended, which says where its footer goes once one is drawn: "inline", on the
405
+ // stream's own stop (and on the message's updates after it); "moved", under the reply's ending
406
+ // in its last message (posted for it, or the continuation that opened on the line on how the
407
+ // reply ended); or "post", in a closing message that holds nothing else.
408
+ endMode = null;
409
+ // Set once the end has landed on Slack (`endLanded`): the reply's messages are then a fixed
410
+ // set, and a late update only edits them, keeping what its last message showed (`blocks`).
411
+ // Not `closedOut`, nor `endMode` alone: a close whose write failed still owes messages, and
412
+ // its retry must open them.
413
+ held = null;
414
+ /** The ts of the closing message, once posted. */
415
+ closing = null;
416
+ closingShown = [];
417
+ /** A write of the closing message failed: it may have landed, so what it shows is not known. */
418
+ closingUnsure = false;
419
+ /** The closing message was deleted: the footer, if one is drawn later, goes under the body. */
420
+ closingGone = false;
421
+ /**
422
+ * The closing message had nothing to show at the end: it is posted with the session's footer
423
+ * (`showFooter`), and that post is the push that says the session has ended.
424
+ */
425
+ closingDeferred = false;
426
+ /** The newest message of the thread when the closing message was put off (`newestAt`). */
427
+ deferredAt = null;
428
+ /** One more try of a write after the end, when a pass failed (the final write's retry). */
429
+ tailRetry = null;
430
+ /** `settle`'s own flush is running: no retry may outlive it. */
431
+ settling = false;
432
+ /** The retry of a write after the end is running its pass. */
433
+ retrying = false;
434
+ /** The retry in flight already logged that it gave up. */
435
+ gaveUpLogged = false;
436
+ /** The cut of an overtaken reply was logged: once is enough. */
437
+ cutWarned = false;
438
+ // Resolves once the reply is known to have ended on Slack (true) or its one retry failed too
439
+ // (false): what the session waits on before it shows a checkmark.
440
+ landed;
441
+ landedValue = null;
442
+ landedResolve = () => { };
443
+ // Bumped by `changed`: `later` compares it before and after a pass to notice a change that
444
+ // arrived while the pass wrote or waited its turn, and runs another pass for it.
445
+ version = 0;
446
+ rev = 0;
447
+ /** When the seconds of the running commands were last read while a stream lived. */
448
+ stampedAt = 0;
449
+ /** `slack` is the replies client (`repliesClient`), whatever the sink calls goes through it. */
450
+ constructor(slack, options) {
451
+ this.slack = slack;
452
+ this.channel = options.channel;
453
+ this.threadTs = options.threadTs;
454
+ this.teamId = options.teamId;
455
+ this.userId = options.userId;
456
+ this.botUserId = options.botUserId;
457
+ this.limiter = options.limiter;
458
+ this.clock = options.clock ?? systemClock;
459
+ this.steady = options.steady ?? this.clock;
460
+ this.onOpenReply = options.onOpenReply ?? null;
461
+ this.places = options.places ?? new ThreadPlaces();
462
+ this.places.join(this);
463
+ this.onWrite = options.onWrite ?? null;
464
+ this.logger = options.logger ?? defaultLogger;
465
+ this.debounceSeconds = options.debounceSeconds ?? DEBOUNCE_SECONDS;
466
+ this.finalRetrySeconds = options.finalRetrySeconds ?? FINAL_RETRY_SECONDS;
467
+ this.landed = new Promise((resolve) => {
468
+ this.landedResolve = resolve;
469
+ });
470
+ }
471
+ /**
472
+ * One transition of this sink's own entries in the thread's open-replies list: add `fresh`,
473
+ * or drop `old`; every other sink's entry is left alone. Best-effort: a failed state write is
474
+ * logged (ids only) and swallowed, since the reply itself must never fail over crash-repair
475
+ * bookkeeping. False then: the caller keeps its set as it was, or it would believe a ts no
476
+ * longer needs removing and orphan it in state.json forever, since nothing else ever asks to
477
+ * remove a ts this sink no longer remembers.
478
+ */
479
+ track(old, fresh) {
480
+ if (this.onOpenReply === null)
481
+ return true;
482
+ try {
483
+ this.onOpenReply(old, fresh);
484
+ }
485
+ catch (error) {
486
+ this.logger.warning(`could not update the open-reply tracking for ${this.channel}/${this.threadTs}: ` +
487
+ describe(error));
488
+ return false;
489
+ }
490
+ return true;
491
+ }
492
+ /**
493
+ * Whether a crash would leave the message unfinished: its stream still open, or a card of it
494
+ * still running (a stopped message stores such a card as an error until it is updated), or,
495
+ * for the reply's last message, the end not yet landed. Once the body is whole and only the
496
+ * closing message is owed, there is nothing of the answer left to fix.
497
+ */
498
+ keepsOpen(message, end) {
499
+ if (message.ts === null)
500
+ return false;
501
+ if (message.streaming)
502
+ return true;
503
+ // The list of what still runs is no more than a card a crash would leave running.
504
+ if (message.footer.some(isRunningList))
505
+ return true;
506
+ for (const { part, floor, ceil } of this.span(message.start, end)) {
507
+ if (!(part instanceof Tool))
508
+ continue;
509
+ const [hasCard] = ReplySink.toolElements(part, floor, ceil);
510
+ if (hasCard && !TERMINAL.includes(part.update.status))
511
+ return true;
512
+ }
513
+ return message === this.messages.at(-1) && !this.ended && !this.bodyLanded;
514
+ }
515
+ /**
516
+ * Bring this sink's open-reply entries to the messages that are unfinished now: one is added
517
+ * the moment it is written, and dropped once nothing is left for a repair to close.
518
+ */
519
+ retrack() {
520
+ const wanted = new Set();
521
+ for (const [index, message] of this.messages.entries()) {
522
+ const following = this.messages[index + 1];
523
+ const end = following === undefined ? null : following.start;
524
+ if (message.ts !== null && this.keepsOpen(message, end))
525
+ wanted.add(message.ts);
526
+ }
527
+ if (this.closing !== null && this.closingShown.some(isRunningList))
528
+ wanted.add(this.closing);
529
+ for (const ts of [...wanted].filter((ts) => !this.tracked.has(ts)).sort()) {
530
+ if (this.track(null, ts))
531
+ this.tracked.add(ts);
532
+ }
533
+ for (const ts of [...this.tracked].filter((ts) => !wanted.has(ts)).sort()) {
534
+ if (this.track(ts, null))
535
+ this.tracked.delete(ts);
536
+ }
537
+ }
538
+ /**
539
+ * The reply's end landed: from then on nothing is left open for a repair to close, but a card
540
+ * still running.
541
+ */
542
+ settleOpenReply() {
543
+ this.ended = true;
544
+ this.places.ended(this);
545
+ this.retrack();
546
+ }
547
+ /**
548
+ * Claude's words, or with `notice` a line of the daemon's own (never a banner while Claude
549
+ * has words). With `ending` that line says how the reply was cut short: a part of its own,
550
+ * never joined to an earlier notice, so it can move whole (`endingCursor`).
551
+ */
552
+ async text(markdown, options = {}) {
553
+ const ending = options.ending ?? false;
554
+ const notice = (options.notice ?? false) || ending;
555
+ const aside = options.aside ?? false;
556
+ if (!blank(markdown))
557
+ this.fold.text();
558
+ // The line a lost process writes (a notice that is an ending) is new text, and the last.
559
+ if (this.restRequested && (!notice || ending) && !blank(markdown))
560
+ this.reportedSinceRest = true;
561
+ const last = this.parts.at(-1);
562
+ if (last instanceof Text &&
563
+ last.notice === notice &&
564
+ last.ending === ending &&
565
+ last.aside === aside) {
566
+ last.text += markdown;
567
+ last.rev = this.nextRev();
568
+ }
569
+ else {
570
+ this.parts.push(new Text(markdown, notice, ending, aside, this.nextRev()));
571
+ }
572
+ this.changed();
573
+ }
574
+ /**
575
+ * A call's new state, on the cards that show it: a run of calls shares one card
576
+ * (`core/reply/fold`), a call with a view of its own has one.
577
+ */
578
+ async task(update) {
579
+ const cards = this.fold.task(update);
580
+ for (const card of cards) {
581
+ let tool = this.tools.get(card.id);
582
+ const now = this.reading();
583
+ if (tool === undefined) {
584
+ tool = new Tool(card, now);
585
+ this.tools.set(card.id, tool);
586
+ this.parts.push(tool);
587
+ }
588
+ else {
589
+ tool.set(card, now);
590
+ }
591
+ tool.rev = this.nextRev();
592
+ }
593
+ if (cards.length > 0) {
594
+ this.changed();
595
+ const last = this.messages.at(-1);
596
+ if (last !== undefined)
597
+ this.armRefresh(last);
598
+ }
599
+ }
600
+ /** Whether the reply's last message is a stream that is open. */
601
+ get live() {
602
+ return this.messages.at(-1)?.streaming === true;
603
+ }
604
+ /**
605
+ * The time a running command's seconds are read at. While the stream lives they move at the
606
+ * refresh alone, so that a card's title does not change with every piece of text; otherwise
607
+ * they are as of now.
608
+ */
609
+ reading() {
610
+ return this.live ? this.stampedAt : this.steady.time();
611
+ }
612
+ /** The tools whose card sits in `message`: the span a stream is told about. */
613
+ toolsOf(message) {
614
+ return this.span(message.start, null).flatMap(({ part }) => part instanceof Tool ? [part] : []);
615
+ }
616
+ /**
617
+ * Read the seconds of the running commands at `now`, those of `message`'s cards alone when it
618
+ * is given, else of every card; true when a card's title changed.
619
+ */
620
+ restamp(now, message) {
621
+ let moved = false;
622
+ const tools = message === undefined ? this.tools.values() : this.toolsOf(message);
623
+ for (const tool of tools) {
624
+ if (!tool.stamp(now))
625
+ continue;
626
+ tool.rev = this.nextRev();
627
+ moved = true;
628
+ }
629
+ return moved;
630
+ }
631
+ /** Start the refresh of the seconds on `message`, a stream that is open, if a command runs. */
632
+ armRefresh(message) {
633
+ if (!message.streaming || message.refresh !== null)
634
+ return;
635
+ if (!this.toolsOf(message).some((tool) => tool.timed))
636
+ return;
637
+ this.stampedAt = this.steady.time();
638
+ message.refresh = new Task((signal) => this.refreshTimed(message, signal));
639
+ }
640
+ /**
641
+ * Every RUNNING_REFRESH_SECONDS, while `message` streams and a timed command runs: read the
642
+ * seconds again and, when a title changed, send it. A wait outside a pass, cancelled with the
643
+ * stream (`gone`); the write is the debounced one every change makes.
644
+ */
645
+ async refreshTimed(message, signal) {
646
+ try {
647
+ while (message.streaming && this.toolsOf(message).some((tool) => tool.timed)) {
648
+ await this.clock.sleep(RUNNING_REFRESH_SECONDS, signal);
649
+ if (!message.streaming)
650
+ return;
651
+ this.stampedAt = this.steady.time();
652
+ if (this.restamp(this.stampedAt, message))
653
+ this.changed();
654
+ }
655
+ }
656
+ finally {
657
+ if (message.refresh?.signal === signal)
658
+ message.refresh = null;
659
+ }
660
+ }
661
+ nextRev() {
662
+ this.rev += 1;
663
+ return this.rev;
664
+ }
665
+ /** The position of the reply's last message in its thread, null while it has none. */
666
+ get lastAt() {
667
+ // A closing message that was deleted still marks where the reply sat: a reply never moves up.
668
+ const ts = this.closing ?? this.messages.findLast((message) => message.ts !== null)?.ts ?? null;
669
+ return ts === null ? null : positionOf(ts);
670
+ }
671
+ /** Whether nothing of the owner's and no other reply sits below this reply's last message. */
672
+ get last() {
673
+ return this.places.last(this);
674
+ }
675
+ /** Whether another reply has a message below this one. */
676
+ get overtaken() {
677
+ return this.places.overtaken(this);
678
+ }
679
+ /**
680
+ * Its turn's part has ended, it waits (`rest`) or its end has landed, and its last message is
681
+ * no stream: the message can be rewritten, and the footer shows under it. A reply that only
682
+ * finished is not at rest, whose end may still fail: it must not take the footer from a reply
683
+ * above it before it can draw one.
684
+ */
685
+ get resting() {
686
+ if (this.ended)
687
+ return true;
688
+ if (!this.finished || !this.restRequested)
689
+ return false;
690
+ const last = this.messages.at(-1);
691
+ // A stream not yet started is a stream: the first write opens it.
692
+ return !(last?.streaming === true || (last?.ts === null && last.mode === "stream"));
693
+ }
694
+ /**
695
+ * The list of what still runs this reply shows under its last message from now on, as the
696
+ * thread's places worked it out. A change is written unless the last message is a stream, which
697
+ * a stop ends (the reply's rest or its end).
698
+ */
699
+ assign(list) {
700
+ const moved = !isDeepStrictEqual(list, this.assigned);
701
+ this.assigned = list;
702
+ if (moved && this.messages.at(-1)?.streaming !== true)
703
+ this.changed();
704
+ }
705
+ /**
706
+ * The list taken off this reply's message now, not after the debounce: a reply below is about
707
+ * to create its first message (`ThreadPlaces.opens`). Through the lock, whatever the message
708
+ * shows now: a pass in flight may be drawing the list. A write that failed is tried again, as a
709
+ * debounced one would have been.
710
+ */
711
+ async unlist() {
712
+ if (this.assigned !== null)
713
+ return true;
714
+ this.pending?.cancel();
715
+ this.pending = null;
716
+ const ok = await this.flush();
717
+ if (!ok)
718
+ this.retryLater();
719
+ return ok;
720
+ }
721
+ /** `showFooterFormatted` with the footer as one line of mrkdwn. */
722
+ showFooter(footer, signal) {
723
+ return this.showFooterFormatted(formatFooter(footer, this.clock.time() * 1000), signal);
724
+ }
725
+ /**
726
+ * The session's footer under this reply's last message, by an edit, written now: the session
727
+ * has ended everything, and this reply holds its latest work. It stays on the reply, whatever
728
+ * is posted below it later. False when the write failed (one more try follows, as for any
729
+ * write after the end) or when the reply's end never landed: no message is known to hold it.
730
+ */
731
+ async showFooterFormatted(line, signal) {
732
+ if (this.landedValue !== true)
733
+ return false;
734
+ if (!line)
735
+ return true; // a footer that says nothing: nothing to draw
736
+ // The first footer drawn stays: a later draw of the same end (a forced end's, after a write
737
+ // the core saw fail) knows no more than it did.
738
+ this.footer ??= line;
739
+ this.pending?.cancel();
740
+ this.pending = null;
741
+ const ok = await cancellable(this.flush(), signal);
742
+ if (!ok)
743
+ this.retryLater();
744
+ return ok;
745
+ }
746
+ /**
747
+ * End the turn's part of the reply: what was written is sent now, not at the next debounce.
748
+ * A card still in progress is a task that outlives the turn: `task` keeps updating it after
749
+ * this. The stream stays open: it ends with `closeOut`, once the caller knows nothing more is
750
+ * coming, or stops with `rest`. A report that renders into the reply later is followed by
751
+ * another `finish`.
752
+ */
753
+ async finish(closing, signal) {
754
+ for (const update of closing)
755
+ await this.task(update);
756
+ this.finished = true;
757
+ // Before the pending write is cancelled: the flush below carries the change it schedules.
758
+ this.places.place();
759
+ this.pending?.cancel();
760
+ this.pending = null;
761
+ // What a card of a run of calls shows changes here, to its folded line: its message is
762
+ // rendered again even when nothing else in it changed.
763
+ for (const tool of this.tools.values()) {
764
+ if (tool.update.folded !== null)
765
+ tool.rev = this.nextRev();
766
+ }
767
+ await cancellable(this.flush(), signal);
768
+ }
769
+ /**
770
+ * The reply waits for work its turn started (see `Reply.rest`). The next pass stops a stream
771
+ * with no footer, then rewrites the message: one push, the stop's. Idempotent.
772
+ */
773
+ rest() {
774
+ const first = !this.restRequested;
775
+ this.restRequested = true;
776
+ // "Since it rested" is measured from the latest rest: an earlier report is old text.
777
+ this.reportedSinceRest = false;
778
+ // The reply may already be at rest (its stream gone): the placement says what it shows.
779
+ this.places.place();
780
+ // Called again while the stream is still open, the stop is tried again: it failed before.
781
+ if (first || this.messages.at(-1)?.streaming === true)
782
+ this.changed();
783
+ }
784
+ /**
785
+ * End the reply, once: stop its stream (one push), or, when the stream already stopped (at
786
+ * STREAM_SECONDS, or by `rest`), post its ending as a new message (the second push). The
787
+ * session's footer is not part of the end: `showFooter` adds it by an edit, once the session
788
+ * has ended everything. True when the reply ended on Slack (or nothing was owed); false when a
789
+ * write failed and one retry is scheduled: `waitLanded` then says how it ended. A second call
790
+ * is a no-op: the reply has already ended.
791
+ */
792
+ async closeOut(signal) {
793
+ if (this.closedOut)
794
+ return this.landedValue === true;
795
+ this.closedOut = true;
796
+ this.places.place();
797
+ // The end is its own task, shielded from the caller: a caller that stops waiting while Slack
798
+ // takes the write (a timer, a turn starting, a shutdown) must not leave `landed` unresolved
799
+ // with no retry, which no later `closeOut` could repair.
800
+ this.ending = new Task(() => this.endOut());
801
+ return cancellable(this.ending.result(), signal);
802
+ }
803
+ /**
804
+ * The end's first flush: whatever happens, `landed` resolves (an end that began always does),
805
+ * with the retry when Slack refused the write.
806
+ */
807
+ async endOut() {
808
+ let ok;
809
+ try {
810
+ ok = await this.flush();
811
+ }
812
+ catch (error) {
813
+ this.resolve(false);
814
+ throw error;
815
+ }
816
+ if (ok) {
817
+ this.resolve(true);
818
+ return true;
819
+ }
820
+ if (this.landedValue === null) {
821
+ // `settle` may have decided it already
822
+ this.retry = new Task((retry) => this.retryFinal(retry));
823
+ }
824
+ return false;
825
+ }
826
+ resolve(landed) {
827
+ if (landed)
828
+ this.settleOpenReply();
829
+ if (this.landedValue === null) {
830
+ this.landedValue = landed;
831
+ this.landedResolve(landed);
832
+ }
833
+ }
834
+ /** Whether the reply ends up on Slack: waits for the retry a failed end schedules. */
835
+ waitLanded(signal) {
836
+ return cancellable(this.landed, signal);
837
+ }
838
+ async retryFinal(signal) {
839
+ await this.clock.sleep(this.finalRetrySeconds, signal);
840
+ // Shielded: a shutdown's `settle` cancels this task, and a write given up after Slack took
841
+ // it would lose what it did.
842
+ const ok = await this.flush();
843
+ if (signal.aborted)
844
+ return;
845
+ this.resolve(ok);
846
+ }
847
+ /**
848
+ * Force this reply to its current, true form right now, cancelling any debounce still
849
+ * pending: a shutdown's very last chance. Cheap when nothing changed since the last write. A
850
+ * retry still waiting is tried now. True when the reply is on Slack (or nothing was owed): a
851
+ * shutdown that gets false has lost it.
852
+ */
853
+ async settle(signal) {
854
+ this.pending?.cancel();
855
+ this.pending = null;
856
+ if (this.ending !== null && !this.ending.done) {
857
+ // The end in flight may still schedule its retry: let it, so the cancel below sees it.
858
+ // `settled` hands back neither its error nor its cancellation: only this call's own
859
+ // signal ends the wait early.
860
+ await cancellable(this.ending.settled(), signal);
861
+ }
862
+ this.retry?.cancel();
863
+ this.tailRetry?.cancel();
864
+ // The flush below may fail a write that would start a retry; later changes retry as usual.
865
+ this.settling = true;
866
+ let ok;
867
+ try {
868
+ ok = await cancellable(this.flush(), signal);
869
+ }
870
+ finally {
871
+ this.settling = false;
872
+ }
873
+ if (this.closedOut && this.landedValue === null)
874
+ this.resolve(ok);
875
+ return ok || this.landedValue === true;
876
+ }
877
+ /**
878
+ * A background task or subagent can still update its own card after the reply itself ended
879
+ * (rare, and possibly during shutdown): debounced through `later`, like any other change,
880
+ * rather than flushed here and now, which would spend a limiter wait (real time, under a busy
881
+ * process's shared budget) inline on the caller, the SDK reader loop among them.
882
+ */
883
+ changed() {
884
+ // A message that is no stream is rewritten whole: the seconds of a running command are
885
+ // read again with it. A stream's wait for the refresh.
886
+ if (!this.live)
887
+ this.restamp(this.steady.time());
888
+ this.version += 1;
889
+ this.schedule();
890
+ }
891
+ schedule() {
892
+ if (this.pending === null)
893
+ this.pending = new Task((signal) => this.later(signal));
894
+ }
895
+ async later(signal) {
896
+ try {
897
+ for (;;) {
898
+ await this.clock.sleep(this.debounceSeconds, signal);
899
+ const version = this.version;
900
+ // Shielded: `finish` cancels a pending write, and a write given up after Slack took it
901
+ // would lose the message's ts. `finish` waits for the lock instead.
902
+ const ok = await this.flush();
903
+ if (signal.aborted)
904
+ return;
905
+ if (!ok) {
906
+ // A write after the end, or on a reply at rest, that failed is tried once more: with
907
+ // one long task no later change may come.
908
+ if (this.landedValue === true || this.resting)
909
+ this.retryLater();
910
+ return;
911
+ }
912
+ if (this.version === version)
913
+ return;
914
+ // The reply changed again while that call wrote or waited its turn, in a way it never
915
+ // saw: another debounce, then flush again, so it still catches up rather than waiting
916
+ // for the next unrelated event to notice. The debounce stays inside the loop: while
917
+ // text keeps streaming, the version moves during every round trip, and without it here
918
+ // a reply would be written after every round trip instead of at most once per
919
+ // DEBOUNCE_SECONDS.
920
+ }
921
+ }
922
+ finally {
923
+ // In the same step as the decision to return: a change that arrives next starts a
924
+ // debounce of its own.
925
+ if (this.pending?.signal === signal)
926
+ this.pending = null;
927
+ }
928
+ }
929
+ // The reply's model: the parts in order, cut into messages at cursors.
930
+ spanRev(message, end) {
931
+ return this.span(message.start, end).reduce((rev, { part }) => Math.max(rev, part.rev), 0);
932
+ }
933
+ hasContent() {
934
+ return this.parts.some((part) => part instanceof Tool || !blank(part.text));
935
+ }
936
+ /**
937
+ * The parts from `start` to `end` (the model's end when null), each with the first and last
938
+ * element the span takes of it.
939
+ */
940
+ span(start, end) {
941
+ const last = end === null ? this.parts.length : Math.min(this.parts.length, end[0] + 1);
942
+ const spanned = [];
943
+ for (let index = start[0]; index < last; index += 1) {
944
+ const part = this.parts[index];
945
+ if (part === undefined)
946
+ continue;
947
+ spanned.push({
948
+ index,
949
+ part,
950
+ floor: index === start[0] ? start[1] : 0,
951
+ ceil: end !== null && index === end[0] ? end[1] : null,
952
+ });
953
+ }
954
+ return spanned;
955
+ }
956
+ /** Whether a span takes a tool's card, if it has one, and which of its preview pieces. */
957
+ static toolElements(tool, floor, ceil) {
958
+ const extent = tool.extent;
959
+ const top = ceil === null ? extent : Math.min(extent, ceil);
960
+ const pieces = [];
961
+ for (let piece = Math.max(floor, 1); piece < top; piece += 1)
962
+ pieces.push(piece);
963
+ return [floor === 0 && top > 0 && !tool.cardless, pieces];
964
+ }
965
+ /** The footer below a divider; empty when there is none. A stream's stop carries only this. */
966
+ footerBlocks() {
967
+ return this.footer ? [{ type: "divider" }, contextBlock(this.footer)] : [];
968
+ }
969
+ /**
970
+ * What the reply shows under its last message: the list of what still runs, when this reply
971
+ * holds it, above the footer's divider. Not on a stream's stop, which Slack may take the cards
972
+ * of no more than the footer's blocks of (not measured): the edit that follows the stop adds it.
973
+ */
974
+ closingBlocks() {
975
+ // The list only from what the places handed this reply once it rests: a reply whose end is on
976
+ // its way would draw a second card beside the one its holder still shows.
977
+ const list = this.resting ? this.assigned : null;
978
+ return list === null ? this.footerBlocks() : [runningListBlock(list), ...this.footerBlocks()];
979
+ }
980
+ /**
981
+ * What a message shows under its body: only the reply's last, when the footer rode on its
982
+ * stream's stop or when it is the reply's ending, in a message of its own (`end`), or while the
983
+ * reply rests before its end (`rest`), where only the list of what still runs shows and the
984
+ * footer once it is drawn. A reply that only finished shows none: it is not at rest, and its
985
+ * end follows at once.
986
+ */
987
+ footerOf(message) {
988
+ if (message === this.messages.at(-1) &&
989
+ (this.endMode === "inline" ||
990
+ this.endMode === "moved" ||
991
+ this.closingGone ||
992
+ (this.endMode === null && this.resting))) {
993
+ return this.closingBlocks();
994
+ }
995
+ return [];
996
+ }
997
+ /**
998
+ * Where the reply's ending starts in its last message: the text Claude wrote after its last
999
+ * call, whole (a part of the model is never cut: half a list or a heading without its body is
1000
+ * no ending), with whatever follows it. Null when the message holds no such text from its
1001
+ * start, or would keep nothing of the answer before it (an answer that is text alone): the
1002
+ * ending is then the footer alone, unless the turn was cut short. The ending is then the
1003
+ * daemon's line on how the reply ended (`Text.ending`), with whatever follows it: the message
1004
+ * that notifies says so, where it would show empty or hold a footer alone. That line moves
1005
+ * from under a line of the daemon's too, which stays.
1006
+ */
1007
+ endingCursor(message) {
1008
+ const cursor = this.cutBefore(message, this.lastText(message, false));
1009
+ if (cursor !== null)
1010
+ return cursor;
1011
+ return this.cutBefore(message, this.lastText(message, true), true);
1012
+ }
1013
+ /**
1014
+ * The last part, from where `message` starts, that holds words of Claude's, or with `ending`
1015
+ * the daemon's line on how the reply was cut short; null when there is none.
1016
+ */
1017
+ lastText(message, ending) {
1018
+ for (let i = this.parts.length - 1; i >= message.start[0]; i -= 1) {
1019
+ const part = this.parts[i];
1020
+ if (part instanceof Text && (ending ? part.ending : !part.notice) && !blank(part.text)) {
1021
+ return i;
1022
+ }
1023
+ }
1024
+ return null;
1025
+ }
1026
+ /**
1027
+ * The cursor at the start of part `index`, when cutting `message` there leaves it something of
1028
+ * the answer, or with `notices` any line at all; null when the part opens the message or
1029
+ * nothing would stay.
1030
+ */
1031
+ cutBefore(message, index, notices = false) {
1032
+ if (index === null || upTo([index, 0], message.start))
1033
+ return null;
1034
+ const cursor = [index, 0];
1035
+ // Something of the answer must stay before it, and show: a card that draws, a preview, or
1036
+ // words of Claude's. A message left with a line
1037
+ // of the daemon's alone, or with nothing, is no answer: cutting Claude's text there would
1038
+ // move the whole answer out. The line on how the reply ended is no part of the answer, so
1039
+ // a line of the daemon's is enough to stay above it.
1040
+ for (const { part, floor, ceil } of this.span(message.start, cursor)) {
1041
+ if (part instanceof Tool) {
1042
+ const [hasCard, pieces] = ReplySink.toolElements(part, floor, ceil);
1043
+ if ((hasCard && this.cardBlocks(part.update).length > 0) || pieces.length > 0) {
1044
+ return cursor;
1045
+ }
1046
+ }
1047
+ else if ((notices || !part.notice) && !blank(part.text.slice(floor, ceil ?? undefined))) {
1048
+ return cursor;
1049
+ }
1050
+ }
1051
+ return null;
1052
+ }
1053
+ /**
1054
+ * Whether `message`, posted as a continuation, starts at the daemon's line on how the reply
1055
+ * ended, inside it or in what follows it: it rang when it was posted, and is the reply's
1056
+ * ending as it stands.
1057
+ */
1058
+ opensOnEnding(message) {
1059
+ let said = null;
1060
+ for (let i = this.parts.length - 1; i >= 0; i -= 1) {
1061
+ const part = this.parts[i];
1062
+ if (part instanceof Text && part.ending && !blank(part.text)) {
1063
+ said = i;
1064
+ break;
1065
+ }
1066
+ }
1067
+ return message.mode === "post" && said !== null && upTo([said, 0], message.start);
1068
+ }
1069
+ /**
1070
+ * The notification's text, plain: the first paragraph of Claude's own words in the span; else
1071
+ * the daemon's line on how the reply was cut short; else the first tool's title; else any
1072
+ * line of the daemon's. Never a daemon line while there are words of Claude's to show. Cut to
1073
+ * BANNER_LIMIT.
1074
+ */
1075
+ banner(span = null) {
1076
+ const parts = span ?? this.span([0, 0], null);
1077
+ const first = (words) => {
1078
+ const end = words.indexOf("\n\n");
1079
+ const paragraph = end === -1 ? words : words.slice(0, end);
1080
+ return bannerText(paragraph, { limit: BANNER_LIMIT }) || "…";
1081
+ };
1082
+ let notice = "";
1083
+ let said = "";
1084
+ for (const { part, floor, ceil } of parts) {
1085
+ if (!(part instanceof Text))
1086
+ continue;
1087
+ const words = strip(part.text.slice(floor, ceil ?? undefined));
1088
+ if (words && !part.notice)
1089
+ return first(words);
1090
+ notice = notice || words;
1091
+ said = said || (part.ending ? words : "");
1092
+ }
1093
+ if (said)
1094
+ return first(said);
1095
+ for (const { part } of parts) {
1096
+ if (part instanceof Tool) {
1097
+ return bannerText(cardFields(part.update).title, { limit: BANNER_LIMIT }) || "…";
1098
+ }
1099
+ }
1100
+ return first(notice);
1101
+ }
1102
+ // What a stream is told.
1103
+ /**
1104
+ * What the message's stream lacks: the cards that changed since they were sent, then what the
1105
+ * model has past what it was sent, in order, as far as the message holds.
1106
+ */
1107
+ plan(message) {
1108
+ const plan = new Plan(message.size, message.count, message.cardCost);
1109
+ for (const [toolId, sent] of message.cards) {
1110
+ const tool = this.tools.get(toolId);
1111
+ if (tool === undefined)
1112
+ continue;
1113
+ const chunk = cardChunk(tool.update);
1114
+ if (!isDeepStrictEqual(chunk, sent))
1115
+ ReplySink.planCard(message, plan, chunk);
1116
+ }
1117
+ for (const { index, part, floor } of this.span(message.start, null)) {
1118
+ const more = part instanceof Text
1119
+ ? this.planText(message, plan, index, part, floor)
1120
+ : this.planTool(message, plan, index, part, floor);
1121
+ if (!more)
1122
+ break;
1123
+ }
1124
+ return plan;
1125
+ }
1126
+ /**
1127
+ * Add a card's chunk to the plan as a stream takes it; false when the card is new and the
1128
+ * message has no room for it. A message that holds nothing yet takes any card.
1129
+ */
1130
+ static planCard(message, plan, chunk) {
1131
+ const toolId = chunk.id;
1132
+ const room = MESSAGE_LIMIT - plan.size - plan.cardCost;
1133
+ const [told, held, cost] = cardAddition(chunk, message.cards.get(toolId), message.cardHeld.get(toolId) ?? {}, room);
1134
+ if (!message.cards.has(toolId) && cost > room && plan.size + plan.cardCost > 0)
1135
+ return false;
1136
+ plan.chunks.push(told);
1137
+ plan.cards.set(toolId, chunk);
1138
+ plan.cardHeld.set(toolId, held);
1139
+ plan.cardCost += cost;
1140
+ return true;
1141
+ }
1142
+ /** Add the unsent tail of a text part to the plan; false when the plan is complete. */
1143
+ planText(message, plan, index, part, floor) {
1144
+ const sent = Math.max(message.textSent.get(index) ?? 0, floor);
1145
+ let tail = part.text.slice(sent);
1146
+ let lead = 0;
1147
+ if (plan.size === 0) {
1148
+ // a message opens on its words, not on the blank lines before them
1149
+ lead = tail.length - lstripNewlines(tail).length;
1150
+ tail = tail.slice(lead);
1151
+ }
1152
+ if (blank(tail)) {
1153
+ if (index === this.parts.length - 1 && !this.finished) {
1154
+ return false; // more of it may come: the blanks go with it
1155
+ }
1156
+ plan.text.set(index, part.text.length);
1157
+ return true;
1158
+ }
1159
+ const had = message.textBlocks.get(index) ?? 0;
1160
+ const others = plan.count - had; // the blocks of everything else in the message
1161
+ if (!had && others >= message.blocksRoom) {
1162
+ plan.overflow = [index, sent];
1163
+ return false;
1164
+ }
1165
+ // Characters, held against the limit; `fits` is where that many of them end in the tail.
1166
+ const room = Math.max(0, MESSAGE_LIMIT - plan.size - plan.cardCost);
1167
+ const fits = at(tail, room);
1168
+ let piece;
1169
+ let upto;
1170
+ if (fits >= tail.length || blank(tail.slice(fits))) {
1171
+ // All of it, or all but blanks, which go with what follows if anything does.
1172
+ piece = tail.slice(0, fits);
1173
+ upto = sent + lead + piece.length;
1174
+ }
1175
+ else {
1176
+ const cut = room > 0 ? lastNewline(tail, fits) : -1;
1177
+ const end = cut <= 0 ? fits : cut;
1178
+ piece = tail.slice(0, end);
1179
+ upto = sent + lead + end + (cut > 0 ? 1 : 0);
1180
+ plan.overflow = [index, upto];
1181
+ }
1182
+ // The part's text in this message, as the update that follows the stream will write it:
1183
+ // cut where the blocks Slack makes of it would pass the message's room.
1184
+ const whole = part.text.slice(floor, sent + lead + piece.length);
1185
+ const opening = floor + whole.length - lstripNewlines(whole).length;
1186
+ let words = stripNewlines(whole);
1187
+ // Text already sent can gain a block when its next line arrives (a line that turns out to
1188
+ // head a table): the cut is then the next block's start, never inside a line.
1189
+ const over = markdownCut(words, Math.max(1, message.blocksRoom - others), Math.max(0, sent + lead - opening));
1190
+ if (over !== null) {
1191
+ const stop = opening + over;
1192
+ piece = part.text.slice(sent + lead, stop);
1193
+ upto = stop;
1194
+ plan.overflow = [index, stop];
1195
+ words = stripNewlines(part.text.slice(opening, stop));
1196
+ }
1197
+ plan.text.set(index, upto);
1198
+ if (!blank(piece)) {
1199
+ // A line about the work is drawn small from its first chunk, as the update that follows
1200
+ // the stream writes it: drawn as text first, it would shrink once the stream stops. A
1201
+ // context block in a `blocks` chunk is drawn by Slack (seen 2026-10-03, `pieceChunk`).
1202
+ // A later piece of the same part gets an id of its own: no two blocks of a message share
1203
+ // one.
1204
+ plan.chunks.push(part.aside
1205
+ ? {
1206
+ type: "blocks",
1207
+ blocks: [
1208
+ asideBlock(mrkdwnEscape(stripNewlines(piece)), sent > floor ? `${index}-${sent}` : index),
1209
+ ],
1210
+ }
1211
+ : { type: "markdown_text", text: piece });
1212
+ plan.size += len(piece);
1213
+ const made = markdownBlocks(words);
1214
+ plan.textBlocks.set(index, made);
1215
+ plan.count = others + made;
1216
+ }
1217
+ return plan.overflow === null;
1218
+ }
1219
+ /**
1220
+ * Add a tool's card, if this message holds it and has not sent it, and the pieces of its
1221
+ * preview it has not sent; false when the plan is complete.
1222
+ */
1223
+ planTool(message, plan, index, tool, floor) {
1224
+ const update = tool.update;
1225
+ const [hasCard, pieces] = ReplySink.toolElements(tool, floor, null);
1226
+ if (hasCard && !message.cards.has(update.id) && !plan.cards.has(update.id)) {
1227
+ if (plan.count >= message.blocksRoom ||
1228
+ !ReplySink.planCard(message, plan, cardChunk(update))) {
1229
+ plan.overflow = [index, 0];
1230
+ return false;
1231
+ }
1232
+ plan.count += 1;
1233
+ }
1234
+ for (const piece of pieces) {
1235
+ if (message.piecesSent.has(pieceKey(index, piece)))
1236
+ continue;
1237
+ const size = tool.pieceSize(piece);
1238
+ if (plan.count >= message.blocksRoom || plan.size + plan.cardCost + size > MESSAGE_LIMIT) {
1239
+ plan.overflow = [index, piece];
1240
+ return false;
1241
+ }
1242
+ plan.chunks.push(pieceChunk(tool, piece));
1243
+ plan.pieces.add(pieceKey(index, piece));
1244
+ plan.size += size;
1245
+ plan.count += 1;
1246
+ }
1247
+ return true;
1248
+ }
1249
+ static sent(message, plan) {
1250
+ for (const [index, upto] of plan.text)
1251
+ message.textSent.set(index, upto);
1252
+ for (const piece of plan.pieces)
1253
+ message.piecesSent.add(piece);
1254
+ for (const [id, chunk] of plan.cards)
1255
+ message.cards.set(id, chunk);
1256
+ for (const [id, held] of plan.cardHeld)
1257
+ message.cardHeld.set(id, held);
1258
+ message.cardText += planCardText(plan);
1259
+ message.size = plan.size;
1260
+ message.count = plan.count;
1261
+ message.cardCost = plan.cardCost;
1262
+ for (const [index, made] of plan.textBlocks)
1263
+ message.textBlocks.set(index, made);
1264
+ }
1265
+ /**
1266
+ * Whether the message's stream, as sent, shows what the model says for its span: every card
1267
+ * as it is now, and ended (a card left in progress in a stopped stream is stored as an error
1268
+ * until it is updated: measured 2026-09-28), every preview piece, all the text. Never once
1269
+ * the body has ended, for a message with cards of a run of calls: the model then says the
1270
+ * folded line, which a stream cannot show; without `folding` that is let pass, and the answer
1271
+ * is whether the stream holds everything the span says.
1272
+ */
1273
+ streamShows(message, end, folding = true) {
1274
+ for (const [toolId, sent] of message.cards) {
1275
+ const update = this.tools.get(toolId)?.update;
1276
+ if (update === undefined)
1277
+ return false;
1278
+ if (folding && this.finished && update.folded !== null)
1279
+ return false;
1280
+ if (!TERMINAL.includes(update.status) || !isDeepStrictEqual(cardChunk(update), sent)) {
1281
+ return false;
1282
+ }
1283
+ }
1284
+ for (const { index, part, floor, ceil } of this.span(message.start, end)) {
1285
+ if (part instanceof Text) {
1286
+ // A line about the work reads grey once the body has ended, which a stream cannot
1287
+ // show: like a run's folded line.
1288
+ if (folding && this.finished && part.aside && !blank(part.text))
1289
+ return false;
1290
+ const sentTo = Math.max(message.textSent.get(index) ?? 0, floor);
1291
+ if (!blank(part.text.slice(sentTo, ceil ?? undefined)))
1292
+ return false;
1293
+ }
1294
+ else {
1295
+ const [hasCard, pieces] = ReplySink.toolElements(part, floor, ceil);
1296
+ if (hasCard && !message.cards.has(part.update.id))
1297
+ return false;
1298
+ if (pieces.some((piece) => !message.piecesSent.has(pieceKey(index, piece))))
1299
+ return false;
1300
+ }
1301
+ }
1302
+ return true;
1303
+ }
1304
+ // What a message shows once it is no longer a stream: blocks.
1305
+ /**
1306
+ * The blocks of the message's span: Claude's text as markdown, the tools' task cards
1307
+ * (`cardBlocks`), the preview blocks after a card. With no `end` (the reply's last message)
1308
+ * only as many as fit MESSAGE_LIMIT and BLOCKS_LIMIT, and where the reply goes on if it does
1309
+ * not all fit. Once the reply's end has landed nothing opens a message after it: what arrives
1310
+ * late is shown when it all fits, and else not at all, the last message keeping what it showed
1311
+ * (`Held`) and no cursor being returned. The blocks are counted for a `chat.update`, which
1312
+ * takes a collapsed container's text without counting it; `posting` counts it, as a
1313
+ * `chat.postMessage` does.
1314
+ */
1315
+ blocks(message, end, posting = false) {
1316
+ const [blocks, overflow] = this.render(message, end, null, posting);
1317
+ if (overflow === null || end !== null || this.held === null)
1318
+ return [blocks, overflow];
1319
+ return [this.render(message, null, this.held)[0], null];
1320
+ }
1321
+ /**
1322
+ * `blocks` for one reading of the span: with `held`, only what the message showed when the
1323
+ * reply's end landed, as the span is now, and the note for a preview of a card of it that was
1324
+ * left out, if a block is free for it.
1325
+ */
1326
+ render(message, end, held, posting = false) {
1327
+ const blocks = [];
1328
+ let size = 0;
1329
+ let count = 0; // the blocks as Slack counts them, `markdownBlocks` a text
1330
+ const limit = message.blocksRoom;
1331
+ const fixed = end !== null || held !== null;
1332
+ // A message whose span is fixed: what its cards and text take, and what previews took.
1333
+ const [baseBlocks, baseSize] = end !== null ? this.base(message, end) : [0, 0];
1334
+ const leftOut = end !== null ? this.cutPieces(message, end, baseBlocks, baseSize) : new Set();
1335
+ const noteRoom = held !== null && held.blocks() < BLOCKS_LIMIT;
1336
+ let noted = false;
1337
+ for (const { index, part, floor, ceil: spanCeil } of this.span(message.start, end)) {
1338
+ if (part instanceof Text) {
1339
+ const ceil = held !== null ? (held.text.get(index) ?? floor) : spanCeil;
1340
+ const raw = part.text.slice(floor, ceil ?? undefined);
1341
+ const lead = raw.length - lstripNewlines(raw).length;
1342
+ const words = stripNewlines(raw.slice(lead));
1343
+ if (!words)
1344
+ continue;
1345
+ if (!fixed && count >= limit)
1346
+ return [blocks, [index, floor + lead]];
1347
+ if (!fixed) {
1348
+ let stop = null;
1349
+ let skip = 0;
1350
+ // Where the characters the message still has room for end in the words.
1351
+ const fits = at(words, MESSAGE_LIMIT - size);
1352
+ if (fits < words.length) {
1353
+ const cut = fits > 0 ? lastNewline(words, fits) : -1;
1354
+ [stop, skip] = cut <= 0 ? [fits, 0] : [cut, 1];
1355
+ }
1356
+ const over = markdownCut(words, limit - count);
1357
+ if (over !== null && (stop === null || over < stop)) {
1358
+ // a line's start: the next message opens on it
1359
+ stop = over;
1360
+ skip = 0;
1361
+ }
1362
+ if (stop !== null) {
1363
+ const shown = words.slice(0, stop);
1364
+ if (stripNewlines(shown)) {
1365
+ blocks.push({ type: "markdown", text: rstripNewlines(shown) });
1366
+ }
1367
+ return [blocks, [index, floor + lead + stop + skip]];
1368
+ }
1369
+ }
1370
+ // A line about the work is small and grey, as the count of a run of calls is.
1371
+ blocks.push(part.aside ? asideBlock(mrkdwnEscape(words), index) : { type: "markdown", text: words });
1372
+ size += len(words);
1373
+ count += markdownBlocks(words);
1374
+ }
1375
+ else {
1376
+ const [hasCard, pieces] = ReplySink.toolElements(part, floor, spanCeil);
1377
+ if (hasCard && (held === null || held.cards.has(part.update.id))) {
1378
+ if (!fixed && count >= limit)
1379
+ return [blocks, [index, 0]];
1380
+ const card = this.cardBlocks(part.update);
1381
+ blocks.push(...card);
1382
+ count += card.length;
1383
+ }
1384
+ for (const piece of pieces) {
1385
+ const length = ReplySink.weight(message, part, piece, posting);
1386
+ if (held !== null && !held.pieces.has(pieceKey(index, piece))) {
1387
+ // Late: left out, with the note when its card was shown, once.
1388
+ if (noteRoom && !noted && held.cards.has(part.update.id)) {
1389
+ blocks.push(contextBlock(PREVIEW_CUT));
1390
+ noted = true;
1391
+ count += 1;
1392
+ }
1393
+ continue;
1394
+ }
1395
+ if (!fixed) {
1396
+ if (count + 1 > limit || size + length > MESSAGE_LIMIT) {
1397
+ return [blocks, [index, piece]];
1398
+ }
1399
+ }
1400
+ else if (leftOut.has(pieceKey(index, piece))) {
1401
+ // A preview that arrives after its card, in a message whose span is fixed and
1402
+ // full: it is cut, and says so once, rather than pass the limit (Slack would
1403
+ // refuse the whole update, the card with it).
1404
+ if (!noted) {
1405
+ blocks.push(contextBlock(PREVIEW_CUT));
1406
+ noted = true;
1407
+ count += 1;
1408
+ }
1409
+ continue;
1410
+ }
1411
+ const shown = pieceBlocks(part, piece);
1412
+ if (!fixed && count + blocksCount(shown) > limit)
1413
+ return [blocks, [index, piece]];
1414
+ blocks.push(...shown);
1415
+ size += length;
1416
+ count += blocksCount(shown);
1417
+ }
1418
+ }
1419
+ }
1420
+ return [blocks, null];
1421
+ }
1422
+ /**
1423
+ * A tool's card, or once the reply's body has ended what a card of a run of calls folds to: a
1424
+ * line of counts, as the channel model drew it, or nothing.
1425
+ */
1426
+ cardBlocks(update) {
1427
+ if (!this.finished || update.folded === null)
1428
+ return [cardBlock(update)];
1429
+ return update.folded ? [contextBlock(mrkdwnEscape(update.folded))] : [];
1430
+ }
1431
+ /**
1432
+ * What the message shows of the model as it is now: as much of it as fits. Words can reach
1433
+ * the model while the end is being written; past the limits they were never shown, and
1434
+ * holding them would have every later edit of the message refused.
1435
+ */
1436
+ hold(message) {
1437
+ const held = new Held();
1438
+ const fits = this.render(message, null, null)[1];
1439
+ for (const { index, part, floor, ceil } of this.span(message.start, fits)) {
1440
+ if (part instanceof Text) {
1441
+ if (stripNewlines(part.text.slice(floor, ceil ?? undefined))) {
1442
+ held.text.set(index, ceil === null ? part.text.length : ceil);
1443
+ }
1444
+ }
1445
+ else {
1446
+ const [hasCard, pieces] = ReplySink.toolElements(part, floor, ceil);
1447
+ if (hasCard)
1448
+ held.cards.add(part.update.id);
1449
+ for (const piece of pieces)
1450
+ held.pieces.add(pieceKey(index, piece));
1451
+ }
1452
+ }
1453
+ return held;
1454
+ }
1455
+ /**
1456
+ * The characters a preview piece counts toward MESSAGE_LIMIT in a message: its text, unless
1457
+ * it is drawn as collapsed containers, the message is written by `chat.update` and Slack has
1458
+ * not refused an update of it for them.
1459
+ */
1460
+ static weight(message, tool, piece, posting) {
1461
+ if (!posting && !message.containersCounted && pieceCollapsed(tool))
1462
+ return 0;
1463
+ return tool.pieceSize(piece);
1464
+ }
1465
+ /**
1466
+ * The preview pieces a fixed span has no room for, once the text and the cards, and the one
1467
+ * note that says pieces were left out, have theirs. Pieces are taken in order.
1468
+ */
1469
+ cutPieces(message, end, baseBlocks, baseSize) {
1470
+ let cut = new Set();
1471
+ // a note is owed as soon as one piece is cut
1472
+ for (const reserved of [0, 1]) {
1473
+ cut = new Set();
1474
+ let usedBlocks = 0;
1475
+ let usedSize = 0;
1476
+ for (const { index, part, floor, ceil } of this.span(message.start, end)) {
1477
+ if (!(part instanceof Tool))
1478
+ continue;
1479
+ for (const piece of ReplySink.toolElements(part, floor, ceil)[1]) {
1480
+ const length = ReplySink.weight(message, part, piece, false);
1481
+ if (baseBlocks + usedBlocks + 1 + reserved > message.blocksRoom ||
1482
+ baseSize + usedSize + length > MESSAGE_LIMIT) {
1483
+ cut.add(pieceKey(index, piece));
1484
+ }
1485
+ else {
1486
+ usedBlocks += 1;
1487
+ usedSize += length;
1488
+ }
1489
+ }
1490
+ }
1491
+ if (cut.size === 0)
1492
+ break;
1493
+ }
1494
+ return cut;
1495
+ }
1496
+ /**
1497
+ * The blocks and characters the text and the cards of a fixed span take: what is left of the
1498
+ * limits is what its previews may.
1499
+ */
1500
+ base(message, end) {
1501
+ let blocks = 0;
1502
+ let size = 0;
1503
+ for (const { part, floor, ceil } of this.span(message.start, end)) {
1504
+ if (part instanceof Text) {
1505
+ const words = stripNewlines(part.text.slice(floor, ceil ?? undefined));
1506
+ if (words) {
1507
+ blocks += markdownBlocks(words);
1508
+ size += len(words);
1509
+ }
1510
+ }
1511
+ else if (ReplySink.toolElements(part, floor, ceil)[0]) {
1512
+ blocks += 1;
1513
+ }
1514
+ }
1515
+ return [blocks, size];
1516
+ }
1517
+ /**
1518
+ * Bring a stopped message to what the model says, with a `chat.update` when it shows something
1519
+ * else: (written, where the reply goes on past the message). An update never notifies
1520
+ * (measured 2026-09-29). Nothing is written for a message whose stream already shows the
1521
+ * model. An update refused for content that held a container, which counted for nothing, is
1522
+ * tried once more with the containers counted, as a post counts them. With `split`, for a
1523
+ * caller that opens the next message at the cursor returned: an update Slack refuses for too
1524
+ * many blocks is tried again with less of the span (`tighten`); `room` is the room the message
1525
+ * had before the first of those tries.
1526
+ */
1527
+ async updateStep(message, end, split = false, room = 0) {
1528
+ const ts = message.ts;
1529
+ if (ts === null)
1530
+ throw new Error("an update of a message that was never written");
1531
+ let [blocks, overflow] = this.blocks(message, end);
1532
+ let footer = this.footerOf(message);
1533
+ blocks = [...blocks, ...footer];
1534
+ if (blocks.length === 0 || this.current(message, blocks, footer, end))
1535
+ return [true, overflow];
1536
+ try {
1537
+ await this.limiter.acquire();
1538
+ // A change can arrive while this write waits its turn: send what the reply looks like
1539
+ // right now rather than the snapshot taken before the wait.
1540
+ let fresh;
1541
+ [fresh, overflow] = this.blocks(message, end);
1542
+ footer = this.footerOf(message);
1543
+ fresh = [...fresh, ...footer];
1544
+ if (fresh.length === 0 || this.current(message, fresh, footer, end)) {
1545
+ await this.limiter.refund(); // caught up: no write follows
1546
+ return [true, overflow];
1547
+ }
1548
+ blocks = fresh;
1549
+ this.wrote = true;
1550
+ await this.slack.chat.update({
1551
+ channel: this.channel,
1552
+ ts,
1553
+ text: this.banner(this.span(message.start, end)),
1554
+ blocks,
1555
+ });
1556
+ }
1557
+ catch (error) {
1558
+ const code = describe(error);
1559
+ const [text, elements, cards] = blocksSizes(blocks);
1560
+ this.logger.warning(`chat.update failed (${describeRefusal(error)}) with text ${text}, ` +
1561
+ `elements ${elements}, cards ${cards}`);
1562
+ if (code === STILL_STREAMING) {
1563
+ // The daemon's stop never reached Slack: stopped now, the next write passes.
1564
+ await this.stop(message, null, end);
1565
+ }
1566
+ else if (REFUSED_CONTENT.has(code)) {
1567
+ if (!message.containersCounted && blocks.some((block) => block.type === "container")) {
1568
+ message.containersCounted = true;
1569
+ return this.updateStep(message, end, split, room);
1570
+ }
1571
+ if (split && end === null && this.held === null && tooManyBlocks(error)) {
1572
+ const before = room || message.blocksRoom;
1573
+ if (ReplySink.tighten(message, blocks)) {
1574
+ return this.updateStep(message, end, true, before);
1575
+ }
1576
+ // Refused down to one block: nothing was written, so nothing is cut.
1577
+ message.blocksRoom = before;
1578
+ [blocks, overflow] = this.blocks(message, end);
1579
+ blocks = [...blocks, ...footer];
1580
+ }
1581
+ // The message already shows what it showed: never replaced by a plainer one. The
1582
+ // change is dropped; the next one is tried. Nothing else would say the message is
1583
+ // short of the model, so that is kept until an update passes. A stream never
1584
+ // rewritten (`shown` is null) that was sent all of its span lacks only how the update
1585
+ // would have drawn it, its cards folded: it is not short.
1586
+ const whole = message.shown === null && this.streamShows(message, end, false);
1587
+ message.shown = blocks;
1588
+ message.exact = false;
1589
+ message.footer = footer;
1590
+ message.short = message.short || !whole;
1591
+ return [true, overflow];
1592
+ }
1593
+ // The write may have landed with its answer lost: what the message shows is not known any
1594
+ // more, and the next pass writes it again rather than find it equal to what it believes.
1595
+ message.shown = null;
1596
+ message.exact = false;
1597
+ return [false, null];
1598
+ }
1599
+ message.shown = blocks;
1600
+ message.exact = false;
1601
+ message.footer = footer;
1602
+ message.short = false;
1603
+ return [true, overflow];
1604
+ }
1605
+ /**
1606
+ * Slack counted more blocks in a write of the message than the daemon did: halve the room the
1607
+ * message has, so that its next write holds less and the reply goes on in a new message.
1608
+ * False, with the room as it was, when one block was already too many.
1609
+ */
1610
+ static tighten(message, blocks) {
1611
+ if (message.blocksRoom <= 1)
1612
+ return false;
1613
+ message.blocksRoom = Math.max(1, Math.floor(Math.min(message.blocksRoom, blocksCount(blocks)) / 2));
1614
+ return true;
1615
+ }
1616
+ /** Whether the message already shows `blocks`. */
1617
+ current(message, blocks, footer, end) {
1618
+ if (message.exact) {
1619
+ return isDeepStrictEqual(message.footer, footer) && this.streamShows(message, end);
1620
+ }
1621
+ return message.shown !== null && isDeepStrictEqual(message.shown, blocks);
1622
+ }
1623
+ /**
1624
+ * Slack refused the blocks of a message not yet posted: without this one retry it would never
1625
+ * show, so it is posted as text alone. Never used on a message that shows something already,
1626
+ * which a plainer form would replace.
1627
+ */
1628
+ async writePlain(message, blocks) {
1629
+ let posted;
1630
+ try {
1631
+ this.wrote = true;
1632
+ posted = await this.slack.chat.postMessage({
1633
+ channel: this.channel,
1634
+ thread_ts: this.threadTs,
1635
+ text: plainText(blocks),
1636
+ unfurl_links: false,
1637
+ unfurl_media: false,
1638
+ });
1639
+ }
1640
+ catch (error) {
1641
+ this.logger.warning(`could not write a reply to Slack as plain text: ${describe(error)}`);
1642
+ return false;
1643
+ }
1644
+ message.ts = String(posted.ts);
1645
+ this.places.moved();
1646
+ message.shown = [];
1647
+ message.exact = false;
1648
+ message.footer = [];
1649
+ this.retrack();
1650
+ return true;
1651
+ }
1652
+ /**
1653
+ * Post the message that continues a stopped one, with the blocks of its span as a post takes
1654
+ * them, then bring it to what an update takes: a container's text counts toward a post's limit
1655
+ * and not toward an update's, so what the post left out of its span reaches the same message
1656
+ * and no further one is opened for it.
1657
+ */
1658
+ async postStep(message, split = false, created) {
1659
+ let [blocks, overflow] = this.blocks(message, null, true);
1660
+ const footer = this.footerOf(message);
1661
+ blocks = [...blocks, ...footer];
1662
+ if (blocks.length === 0)
1663
+ return [true, overflow];
1664
+ const attempted = this.clock.time();
1665
+ const banner = this.banner(this.span(message.start, null));
1666
+ let posted;
1667
+ try {
1668
+ // Claude's text can carry a link built to leak data when Slack fetches it for a preview:
1669
+ // no previews for anything the daemon posts.
1670
+ this.wrote = true;
1671
+ posted = await this.slack.chat.postMessage({
1672
+ channel: this.channel,
1673
+ thread_ts: this.threadTs,
1674
+ text: banner,
1675
+ blocks,
1676
+ unfurl_links: false,
1677
+ unfurl_media: false,
1678
+ });
1679
+ }
1680
+ catch (error) {
1681
+ const [text, elements, cards] = blocksSizes(blocks);
1682
+ this.logger.warning(`chat.postMessage failed (${describeRefusal(error)}) with text ${text}, ` +
1683
+ `elements ${elements}, cards ${cards}`);
1684
+ if (split && tooManyBlocks(error) && ReplySink.tighten(message, blocks)) {
1685
+ return this.postStep(message, true, created);
1686
+ }
1687
+ if (unknownOutcome(error)) {
1688
+ const ts = await this.adopt(attempted, false, take(plainWords(banner), ADOPT_WORDS));
1689
+ if (ts !== null) {
1690
+ message.ts = ts;
1691
+ this.places.moved();
1692
+ created?.();
1693
+ message.shown = blocks;
1694
+ message.footer = footer;
1695
+ this.retrack();
1696
+ return [true, overflow];
1697
+ }
1698
+ }
1699
+ if (this.closedOut &&
1700
+ REFUSED_CONTENT.has(describe(error)) &&
1701
+ (await this.writePlain(message, blocks))) {
1702
+ return [true, overflow];
1703
+ }
1704
+ return [false, null];
1705
+ }
1706
+ message.ts = String(posted.ts);
1707
+ this.places.moved();
1708
+ // The message sits where it sits: the edit that follows waits for the budget without
1709
+ // holding the thread's other replies back.
1710
+ created?.();
1711
+ message.shown = blocks;
1712
+ message.footer = footer;
1713
+ this.retrack();
1714
+ return this.updateStep(message, null, split);
1715
+ }
1716
+ /**
1717
+ * The start of what a stream's first chunks say, as plain words: empty when there are none to
1718
+ * compare (then nothing is adopted). A stream's `text` reads back with a card's title and with
1719
+ * a container's title in it (recorded 2026-09-28, slack-sdk 3.44.1).
1720
+ */
1721
+ static firstWords(plan) {
1722
+ const first = plan.chunks[0];
1723
+ let words = "";
1724
+ if (first === undefined) {
1725
+ words = "";
1726
+ }
1727
+ else if (first.type === "blocks") {
1728
+ const block = first.blocks[0];
1729
+ if (block !== undefined) {
1730
+ words = (block.type === "container" ? block.title.text : "") || blockText(block);
1731
+ }
1732
+ }
1733
+ else if (first.type === "markdown_text") {
1734
+ words = first.text;
1735
+ }
1736
+ else {
1737
+ words = first.title;
1738
+ }
1739
+ return take(plainWords(words), ADOPT_WORDS);
1740
+ }
1741
+ /**
1742
+ * A create that failed on the connection may have landed. Read the thread back, and return
1743
+ * the ts of the daemon's own message newer than the attempt that carries `probe` (a stream:
1744
+ * `streaming_state`, and the start of its text), or null. Writing again first would make it
1745
+ * twice.
1746
+ */
1747
+ async adopt(attempted, stream, probe) {
1748
+ let found;
1749
+ try {
1750
+ const read = await this.slack.conversations.replies({
1751
+ channel: this.channel,
1752
+ ts: this.threadTs,
1753
+ oldest: (attempted - ADOPT_SKEW_SECONDS).toFixed(6),
1754
+ limit: 200,
1755
+ });
1756
+ found = read.messages;
1757
+ }
1758
+ catch (error) {
1759
+ this.logger.warning(`could not read the thread back for a lost write: ${describe(error)}`);
1760
+ return null;
1761
+ }
1762
+ const known = new Set(this.messages.map((message) => message.ts));
1763
+ known.add(this.closing);
1764
+ for (const item of Array.isArray(found) ? found : []) {
1765
+ if (typeof item !== "object" || item === null)
1766
+ continue;
1767
+ const message = item;
1768
+ const ts = String(message.ts);
1769
+ if (ts === this.threadTs || known.has(ts) || message.user !== this.botUserId)
1770
+ continue;
1771
+ if (stream && !("streaming_state" in message))
1772
+ continue;
1773
+ if (probe && plainWords(String(message.text ?? "")).includes(probe))
1774
+ return ts;
1775
+ }
1776
+ return null;
1777
+ }
1778
+ /**
1779
+ * Tell the message's stream what it lacks, starting it with the first content: (written,
1780
+ * where the reply goes on past the message).
1781
+ */
1782
+ async streamStep(message) {
1783
+ let plan = this.plan(message);
1784
+ if (plan.chunks.length === 0)
1785
+ return [true, plan.overflow];
1786
+ if (message.ts === null) {
1787
+ const attempted = this.clock.time();
1788
+ let streamTs;
1789
+ try {
1790
+ this.wrote = true;
1791
+ const started = await this.slack.chat.startStream({
1792
+ channel: this.channel,
1793
+ thread_ts: this.threadTs,
1794
+ recipient_team_id: this.teamId,
1795
+ recipient_user_id: this.userId,
1796
+ chunks: plan.chunks,
1797
+ task_display_mode: "timeline",
1798
+ });
1799
+ streamTs = String(started.ts);
1800
+ }
1801
+ catch (error) {
1802
+ const unknown = unknownOutcome(error);
1803
+ this.logger.warning(`could not start a reply's stream: ${describe(error)}` +
1804
+ (unknown
1805
+ ? " (outcome unknown: the thread is read back for a stream it may have made)"
1806
+ : ""));
1807
+ const ts = unknown ? await this.adopt(attempted, true, ReplySink.firstWords(plan)) : null;
1808
+ if (ts === null)
1809
+ return [false, null];
1810
+ streamTs = ts; // it landed: this is that stream
1811
+ }
1812
+ message.ts = streamTs;
1813
+ this.places.moved();
1814
+ message.streaming = true;
1815
+ message.started = attempted;
1816
+ ReplySink.sent(message, plan);
1817
+ this.retrack();
1818
+ message.deadline = new Task((signal) => this.expire(message, signal));
1819
+ this.armRefresh(message);
1820
+ return [true, plan.overflow];
1821
+ }
1822
+ try {
1823
+ await this.limiter.acquire();
1824
+ // A change can arrive while this write waits its turn: tell the stream what it lacks
1825
+ // right now, not the snapshot taken before the wait.
1826
+ plan = this.plan(message);
1827
+ if (plan.chunks.length === 0) {
1828
+ await this.limiter.refund(); // caught up: no write follows
1829
+ return [true, plan.overflow];
1830
+ }
1831
+ this.wrote = true;
1832
+ await this.slack.chat.appendStream({
1833
+ channel: this.channel,
1834
+ ts: message.ts,
1835
+ chunks: plan.chunks,
1836
+ });
1837
+ }
1838
+ catch (error) {
1839
+ const code = describe(error);
1840
+ if (code === TOO_LONG) {
1841
+ // Slack's cap follows what it stores, which the plan only estimates, and it would
1842
+ // refuse the same append again. The stream is stopped bare, as at STREAM_SECONDS, and
1843
+ // the message goes on by update, from the model; the end then posts the closing
1844
+ // message. Only this code: any other refusal stays a failed write, which the session
1845
+ // shows.
1846
+ const cards = new Set([...message.cards.keys(), ...plan.cards.keys()]).size;
1847
+ this.logger.warning(`chat.appendStream refused (${code}) with text ${plan.size}, elements ${plan.count}, ` +
1848
+ `cards ${cards}, card text sent ${message.cardText} and ${planCardText(plan)} more ` +
1849
+ "in this append: the stream is stopped and the message goes on by update");
1850
+ if ((await this.stop(message, null, null)) === "failed")
1851
+ return [false, null];
1852
+ return this.updateStep(message, null, true);
1853
+ }
1854
+ this.logger.warning(`could not write a reply to Slack: ${code}`);
1855
+ if (code === NOT_STREAMING) {
1856
+ // Slack ended the stream first: the message goes on by update.
1857
+ this.gone(message);
1858
+ // A rest stop that landed with its answer lost: the reply is at rest from here on.
1859
+ if (this.restRequested)
1860
+ this.places.place();
1861
+ return this.updateStep(message, null, true);
1862
+ }
1863
+ if (unknownOutcome(error)) {
1864
+ // An append is not idempotent: sent again it may show twice. The stream is told
1865
+ // nothing more; the message is stopped and goes on by update, from the model.
1866
+ this.logger.warning("a stream append's outcome is unknown: the message goes on by update");
1867
+ message.blind = true;
1868
+ }
1869
+ return [false, null];
1870
+ }
1871
+ ReplySink.sent(message, plan);
1872
+ return [true, plan.overflow];
1873
+ }
1874
+ /** The message's stream is over, however it ended. */
1875
+ gone(message) {
1876
+ message.streaming = false;
1877
+ // Its own deadline may be what stopped it: past its waits, a cancel changes nothing there.
1878
+ message.deadline?.cancel();
1879
+ message.deadline = null;
1880
+ message.refresh?.cancel();
1881
+ message.refresh = null;
1882
+ }
1883
+ /**
1884
+ * Stop the message's stream, with `blocks` at its bottom: "stopped", "gone" (Slack had ended
1885
+ * it already) or "failed". A stop notifies; what the stream still lacks is left for the
1886
+ * update that follows.
1887
+ */
1888
+ async stop(message, blocks, end) {
1889
+ const ts = message.ts;
1890
+ if (ts === null)
1891
+ throw new Error("a stop of a stream that never started");
1892
+ let footer = blocks !== null && blocks.length > 0 ? blocks : null;
1893
+ let result = "stopped";
1894
+ try {
1895
+ this.wrote = true;
1896
+ await this.slack.chat.stopStream({
1897
+ channel: this.channel,
1898
+ ts,
1899
+ ...(footer === null ? {} : { blocks: footer }),
1900
+ });
1901
+ }
1902
+ catch (error) {
1903
+ if (describe(error) !== NOT_STREAMING) {
1904
+ this.logger.warning(`could not stop a reply's stream: ${describe(error)}`);
1905
+ // The end's stop carries no footer (the session draws it later): it is ours all the same.
1906
+ const ours = footer !== null || this.closedOut;
1907
+ if (unknownOutcome(error) && ours && message.stopUnknown === null) {
1908
+ message.stopUnknown = [...(footer ?? [])];
1909
+ }
1910
+ return "failed";
1911
+ }
1912
+ if (message.stopUnknown !== null && !this.pastLife(message)) {
1913
+ // The stop that failed on the connection is the one that landed, with what it carried.
1914
+ footer = message.stopUnknown;
1915
+ result = "stopped";
1916
+ }
1917
+ else {
1918
+ // Over with no stop of ours behind it, or past the stream's life, when Slack's own end
1919
+ // cannot be told from our stop landing: the end posts a closing message of its own.
1920
+ result = "gone";
1921
+ }
1922
+ message.stopUnknown = null;
1923
+ }
1924
+ this.gone(message);
1925
+ message.stopUnknown = null;
1926
+ message.exact = this.streamShows(message, end);
1927
+ message.footer = result === "stopped" ? [...(footer ?? [])] : [];
1928
+ if (end === null && this.restRequested)
1929
+ this.places.place();
1930
+ return result;
1931
+ }
1932
+ pastLife(message) {
1933
+ return this.clock.time() - message.started >= STREAM_LIFE;
1934
+ }
1935
+ /**
1936
+ * STREAM_SECONDS after the stream started: stop it before Slack does, and go on by update.
1937
+ * The stop notifies (accepted); the updates after it never do.
1938
+ */
1939
+ async expire(message, signal) {
1940
+ await this.clock.sleep(STREAM_SECONDS, signal);
1941
+ const release = await this.lock.acquire(signal);
1942
+ try {
1943
+ if (!message.streaming)
1944
+ return;
1945
+ if ((await this.stop(message, null, null)) === "failed") {
1946
+ return; // Slack ends it at 5 minutes: the next append finds it over
1947
+ }
1948
+ // The message is rewritten from the model now, with no stream to wait for.
1949
+ this.restamp(this.steady.time());
1950
+ await this.sync();
1951
+ }
1952
+ finally {
1953
+ release();
1954
+ }
1955
+ }
1956
+ // The one place a reply is written.
1957
+ /**
1958
+ * Write what changed; false when a write failed and the reply is not as it should be. A write
1959
+ * can take real time (the limiter, a round trip), during which the reply can change again:
1960
+ * what is written is what the reply looks like right now, never the snapshot taken before the
1961
+ * wait, so a wait never drops a change. `later` runs this again when `version` moved during
1962
+ * it. A pass: never cancelled (see the top of this file).
1963
+ */
1964
+ /** Whether a message of the reply, or its closing message, still shows the list. */
1965
+ showsList() {
1966
+ return (this.messages.some((message) => message.footer.some(isRunningList)) ||
1967
+ this.closingShown.some(isRunningList));
1968
+ }
1969
+ async flush() {
1970
+ const release = await this.lock.acquire();
1971
+ try {
1972
+ const ok = await this.sync();
1973
+ if (ok && this.closedOut)
1974
+ this.settleOpenReply();
1975
+ if (ok && this.assigned === null && !this.showsList())
1976
+ this.places.unlisted(this);
1977
+ return ok;
1978
+ }
1979
+ finally {
1980
+ release();
1981
+ }
1982
+ }
1983
+ /**
1984
+ * Bring every message of the reply to the model, then, once the reply has ended, end it, and
1985
+ * follow which messages a crash would leave unfinished. The lock is held.
1986
+ */
1987
+ async sync() {
1988
+ try {
1989
+ return await this.syncMessages();
1990
+ }
1991
+ finally {
1992
+ this.retrack();
1993
+ if (this.wrote) {
1994
+ this.wrote = false;
1995
+ this.onWrite?.();
1996
+ }
1997
+ }
1998
+ }
1999
+ async syncMessages() {
2000
+ if (this.messages.length === 0) {
2001
+ if (!this.hasContent())
2002
+ return this.closedOut ? this.end() : true;
2003
+ this.messages.push(new Message([0, 0], this.restRequested ? "post" : "stream"));
2004
+ }
2005
+ for (let i = 0; i + 1 < this.messages.length; i += 1) {
2006
+ const message = this.messages[i];
2007
+ const following = this.messages[i + 1];
2008
+ if (message === undefined || following === undefined || message.ts === null)
2009
+ continue;
2010
+ const rev = this.spanRev(message, following.start);
2011
+ if (message.checked !== null &&
2012
+ message.checked.rev === rev &&
2013
+ sameCursor(message.checked.end, following.start)) {
2014
+ continue; // nothing in it changed since it was last brought to the model
2015
+ }
2016
+ if (!(await this.updateStep(message, following.start))[0])
2017
+ return false;
2018
+ message.checked = { rev, end: following.start };
2019
+ }
2020
+ for (;;) {
2021
+ const message = this.messages.at(-1);
2022
+ if (message === undefined)
2023
+ return false;
2024
+ if (message.streaming && (message.blind || this.restRequested)) {
2025
+ // Told nothing more, or the reply rests: stopped now (with the footer, if the reply has
2026
+ // ended or rests), and the update below writes the whole message from the model.
2027
+ const footer = this.closedOut || this.restRequested ? this.footerBlocks() : null;
2028
+ const result = await this.stop(message, footer, null);
2029
+ if (result === "failed") {
2030
+ // A refused rest keeps appending to the stream, which the next pass or the 280 seconds
2031
+ // stop again; a stream told nothing more, or one the end stops, is not appended to.
2032
+ if (message.blind || this.closedOut)
2033
+ return false;
2034
+ // No change may come before then: one more try after the pause, as for a write on a
2035
+ // reply at rest.
2036
+ if (this.retrying)
2037
+ this.warnGaveUp();
2038
+ else
2039
+ this.retryLater();
2040
+ }
2041
+ else {
2042
+ if (this.closedOut && result === "stopped")
2043
+ this.endMode = "inline";
2044
+ if (message.blind)
2045
+ message.exact = false;
2046
+ }
2047
+ }
2048
+ let step;
2049
+ const fresh = message.ts === null;
2050
+ if (message.ts === null) {
2051
+ // The reply's first message: the list leaves the reply above it before it is created.
2052
+ const opened = this.messages.length === 1 ? await this.places.opens(this) : null;
2053
+ // A message is created with the thread's places held, after the decision that it may be.
2054
+ const free = await this.creating();
2055
+ try {
2056
+ if (this.messages.length > 1 && this.places.overtaken(this)) {
2057
+ // A continuation planned before another reply wrote below, not yet posted: it
2058
+ // would land under that reply. The message before it is the reply's last again.
2059
+ this.messages.pop();
2060
+ this.warnCut();
2061
+ continue;
2062
+ }
2063
+ step =
2064
+ message.mode === "stream"
2065
+ ? await this.streamStep(message)
2066
+ : await this.postStep(message, true, free);
2067
+ }
2068
+ finally {
2069
+ free();
2070
+ opened?.();
2071
+ }
2072
+ }
2073
+ else if (message.streaming) {
2074
+ step = await this.streamStep(message);
2075
+ }
2076
+ else {
2077
+ step = await this.updateStep(message, null, true);
2078
+ }
2079
+ const [ok, overflow] = step;
2080
+ if (!ok)
2081
+ return false;
2082
+ const previous = this.messages.at(-2);
2083
+ if (fresh &&
2084
+ previous !== undefined &&
2085
+ previous.ts !== null &&
2086
+ previous.footer.length > 0 &&
2087
+ message.ts !== null) {
2088
+ // The continuation is written: the message before it is no longer the reply's last, and
2089
+ // was written as if it were, with the footer and the list. Neither is in two messages of
2090
+ // a reply at rest.
2091
+ if (!(await this.updateStep(previous, message.start))[0])
2092
+ return false;
2093
+ }
2094
+ if (overflow === null)
2095
+ break;
2096
+ if (this.places.overtaken(this)) {
2097
+ // Another reply is below: a message opened now would sit below it. What does not fit
2098
+ // is cut, as a card's text is.
2099
+ this.warnCut();
2100
+ break;
2101
+ }
2102
+ if (sameCursor(overflow, message.start)) {
2103
+ this.logger.error("a reply's message cannot hold its first element");
2104
+ return false;
2105
+ }
2106
+ const mode = message.streaming ? "stream" : "post";
2107
+ if (message.streaming && (await this.stop(message, null, overflow)) === "failed") {
2108
+ return false;
2109
+ }
2110
+ this.messages.push(new Message(overflow, mode));
2111
+ }
2112
+ if (!this.closedOut)
2113
+ return true;
2114
+ // The end is written either way; a message left short of the model makes it one that did
2115
+ // not land, which the session shows.
2116
+ const ended = await this.end();
2117
+ const last = this.messages.at(-1);
2118
+ if (this.held === null && last !== undefined && this.endLanded())
2119
+ this.held = this.hold(last);
2120
+ return ended && !this.messages.some((message) => message.short);
2121
+ }
2122
+ /**
2123
+ * The reply's end, on Slack: the footer on the last stream's stop; once the stream is over,
2124
+ * under the reply's ending, posted as a new message, or in a closing message of its own when
2125
+ * there is no ending to move (`endingCursor`). A continuation that opened on the line on how
2126
+ * the reply ended is the ending as it stands (`opensOnEnding`): nothing is posted, and the
2127
+ * footer goes under it by edit.
2128
+ */
2129
+ async end() {
2130
+ const message = this.messages.at(-1);
2131
+ if (message === undefined)
2132
+ return true; // nothing was ever shown: nothing to end
2133
+ if (this.endMode === "inline" || this.endMode === "moved") {
2134
+ // The stream's stop carried the footer, or the ending posted as the reply's last message
2135
+ // does: a later change edits that message (`syncMessages`).
2136
+ return true;
2137
+ }
2138
+ if (message.streaming) {
2139
+ const result = await this.stop(message, this.footerBlocks(), null);
2140
+ if (result === "failed")
2141
+ return false;
2142
+ if (result === "stopped")
2143
+ this.endMode = "inline";
2144
+ // A stream cannot fold its cards: the update that follows its stop does, silently. The
2145
+ // reply has ended whatever becomes of that write: one that fails is tried once more with
2146
+ // the next pass, and the cards stay if that fails too.
2147
+ if (!(await this.updateStep(message, null))[0]) {
2148
+ this.version += 1;
2149
+ this.schedule();
2150
+ }
2151
+ if (result === "stopped")
2152
+ return true;
2153
+ }
2154
+ // Everything below may create a message, and is decided with the thread's places held.
2155
+ const free = await this.creating();
2156
+ try {
2157
+ return await this.endCreating(message, free);
2158
+ }
2159
+ finally {
2160
+ free();
2161
+ }
2162
+ }
2163
+ /**
2164
+ * Takes the thread's places lock, for the decision to create a message and the call that
2165
+ * creates it. Returns the release, which may be called more than once: the creator calls it as
2166
+ * soon as the message's `ts` is set, and the caller's `finally` calls it again. Edits never
2167
+ * hold it, since an edit does not change where a message sits.
2168
+ */
2169
+ async creating() {
2170
+ const release = await this.places.lock.acquire();
2171
+ let held = true;
2172
+ return () => {
2173
+ if (!held)
2174
+ return;
2175
+ held = false;
2176
+ release();
2177
+ };
2178
+ }
2179
+ /**
2180
+ * A reply at rest that is no longer the last thing in the thread and was written no words of
2181
+ * the agent's since it rested: its ending would only be its old text again.
2182
+ */
2183
+ leftBehind() {
2184
+ return this.restRequested && !this.reportedSinceRest && !this.last;
2185
+ }
2186
+ /** The part of `end` that posts: the ending, or the closing message. */
2187
+ async endCreating(message, free) {
2188
+ const owed = this.endMode === null || (this.endMode === "post" && this.closing === null);
2189
+ // A closing message put off is not posted below anything written in the thread since.
2190
+ const passed = this.closingDeferred && (this.places.newestAt ?? 0) > (this.deferredAt ?? 0);
2191
+ if (owed && (this.places.overtaken(this) || this.leftBehind() || passed)) {
2192
+ // Another reply has a message below this one, or this one rested, was left behind by the
2193
+ // owner's and has nothing to report, or its closing message was put off and something was
2194
+ // written below since (the owner's message, a line of the daemon's): nothing is posted (it
2195
+ // would land under that message, or repeat old text as the newest), and the footer, once
2196
+ // drawn, goes under this message by a silent edit.
2197
+ this.endMode = "inline";
2198
+ this.closingDeferred = false;
2199
+ free();
2200
+ return (await this.updateStep(message, null))[0];
2201
+ }
2202
+ if (this.endMode === null) {
2203
+ const cursor = this.endingCursor(message);
2204
+ if (cursor !== null) {
2205
+ // The reply's ending, with the footer, as a new message: the one that notifies, so the
2206
+ // notification says how the work ended. Posted first, then taken out of the message it
2207
+ // grew in: for a moment it shows twice, never nowhere.
2208
+ const ending = new Message(cursor, "post");
2209
+ this.messages.push(ending);
2210
+ this.endMode = "moved"; // the footer goes under it, in the same post
2211
+ try {
2212
+ await this.postStep(ending, false, free);
2213
+ }
2214
+ finally {
2215
+ if (ending.ts === null) {
2216
+ // Not posted: as if never tried, so the next pass posts it before it shortens
2217
+ // anything.
2218
+ this.messages.pop();
2219
+ this.endMode = null;
2220
+ }
2221
+ }
2222
+ free();
2223
+ if (this.endMode === null)
2224
+ return false;
2225
+ // its stream said more than its span
2226
+ message.exact = false;
2227
+ message.checked = null;
2228
+ return (await this.updateStep(message, cursor))[0];
2229
+ }
2230
+ if (this.opensOnEnding(message)) {
2231
+ // A full message pushed the line into this one, which notified with it: a closing
2232
+ // message after it would ring again with nothing to show. The footer, if there is
2233
+ // one, goes under it by a silent edit.
2234
+ this.endMode = "moved";
2235
+ free();
2236
+ return (await this.updateStep(message, null))[0];
2237
+ }
2238
+ }
2239
+ this.endMode = "post";
2240
+ this.bodyLanded = true; // every message is written: only the closing message is owed
2241
+ if (!(await this.writeClosing(free)))
2242
+ return false;
2243
+ // A message that rested showed the footer: the closing message holds it now.
2244
+ return message.footer.length === 0 || (await this.updateStep(message, null))[0];
2245
+ }
2246
+ /**
2247
+ * Delete the closing message that held the footer alone. The reply stays ended: a delete that
2248
+ * fails leaves the footer where it is, is logged, and is tried once more after
2249
+ * `finalRetrySeconds`. A message Slack no longer has counts as deleted.
2250
+ */
2251
+ async deleteClosing(ts, created) {
2252
+ created?.(); // a delete creates nothing: another reply may create meanwhile
2253
+ try {
2254
+ await this.limiter.acquire();
2255
+ this.wrote = true;
2256
+ await this.slack.chat.delete({ channel: this.channel, ts });
2257
+ }
2258
+ catch (error) {
2259
+ if (describe(error) !== MESSAGE_NOT_FOUND) {
2260
+ this.logger.warning(`could not delete a reply's closing message: ${describe(error)}`);
2261
+ if (this.retrying)
2262
+ this.warnGaveUp();
2263
+ else
2264
+ this.retryLater();
2265
+ return true;
2266
+ }
2267
+ }
2268
+ this.closingGone = true;
2269
+ this.closingShown = [];
2270
+ this.places.moved();
2271
+ return true;
2272
+ }
2273
+ /**
2274
+ * One more try after `finalRetrySeconds` of a write that follows the end: the final write has
2275
+ * no next rewrite to fix it. A try in flight is not followed by another.
2276
+ */
2277
+ retryLater() {
2278
+ if (this.tailRetry !== null || this.settling)
2279
+ return;
2280
+ const task = new Task(async (signal) => {
2281
+ try {
2282
+ await this.clock.sleep(this.finalRetrySeconds, signal);
2283
+ // Shielded like the final write: a pass given up after Slack took it would lose its result.
2284
+ this.retrying = true;
2285
+ this.gaveUpLogged = false;
2286
+ if (!(await this.flush()))
2287
+ this.warnGaveUp();
2288
+ }
2289
+ finally {
2290
+ this.retrying = false;
2291
+ if (this.tailRetry === task)
2292
+ this.tailRetry = null;
2293
+ }
2294
+ });
2295
+ this.tailRetry = task;
2296
+ }
2297
+ /** The retry of a write after the end failed too: the next change of the reply writes it. */
2298
+ warnGaveUp() {
2299
+ // Once per retry: its stop and its append may both fail.
2300
+ if (this.gaveUpLogged)
2301
+ return;
2302
+ this.gaveUpLogged = true;
2303
+ this.logger.warning("could not bring a reply's footer to the thread once it rested or ended, tried twice: " +
2304
+ "the next change of the reply writes it");
2305
+ }
2306
+ /** Logs once that an overtaken reply shows less than it said. */
2307
+ warnCut() {
2308
+ if (this.cutWarned)
2309
+ return;
2310
+ this.cutWarned = true;
2311
+ this.logger.warning("a reply that another reply sits below shows less than it said: a message opened now " +
2312
+ "would land below that reply");
2313
+ }
2314
+ /**
2315
+ * Whether the reply's end is on Slack: the footer rode on the last stream's stop ("inline",
2316
+ * set after a stop that landed) or sits in the ending posted as the last message ("moved",
2317
+ * reset when that post fails), or the closing message was posted. A continuation that is the
2318
+ * ending as it stands is "moved" too, from before its footer is written: it rang when it was
2319
+ * posted, and an edit that fails is written by the next pass. Not `endMode === "post"`, which
2320
+ * `end` sets before the closing message is written.
2321
+ */
2322
+ endLanded() {
2323
+ return (this.endMode === "inline" ||
2324
+ this.endMode === "moved" ||
2325
+ this.closing !== null ||
2326
+ this.closingDeferred);
2327
+ }
2328
+ /**
2329
+ * Post the closing message of a reply whose stream stopped early and that has no ending to
2330
+ * move into a message of its own (`end`), or bring it to the list and the footer as they
2331
+ * stand: it stays once posted, since it is what notified. Its text is Claude's own words, as a
2332
+ * banner: never a line of the daemon's. With nothing to show it is not posted yet: the
2333
+ * session's footer posts it (`closingDeferred`). One posted that is left with nothing else
2334
+ * holds one zero-width space.
2335
+ */
2336
+ async writeClosing(created) {
2337
+ // Deleted: the footer, if one is drawn later, goes under the body (`footerOf`).
2338
+ if (this.closingGone)
2339
+ return true;
2340
+ let blocks = this.closingBlocks();
2341
+ // A list that is no longer assigned leaves the message with nothing: it goes.
2342
+ if (blocks.length === 0 && this.closing !== null && this.closingShown.some(isRunningList)) {
2343
+ return this.deleteClosing(this.closing, created);
2344
+ }
2345
+ if (blocks.length === 0 && this.closing === null) {
2346
+ // Nothing to show yet: posted with the session's footer, never empty.
2347
+ if (!this.closingDeferred)
2348
+ this.deferredAt = this.places.newestAt;
2349
+ this.closingDeferred = true;
2350
+ created?.();
2351
+ return true;
2352
+ }
2353
+ this.closingDeferred = false;
2354
+ if (blocks.length === 0)
2355
+ blocks = [contextBlock(ZERO_WIDTH_SPACE)];
2356
+ const attempted = this.clock.time();
2357
+ try {
2358
+ if (this.closing === null) {
2359
+ this.wrote = true;
2360
+ const posted = await this.slack.chat.postMessage({
2361
+ channel: this.channel,
2362
+ thread_ts: this.threadTs,
2363
+ text: this.banner(),
2364
+ blocks,
2365
+ unfurl_links: false,
2366
+ unfurl_media: false,
2367
+ });
2368
+ this.closing = String(posted.ts);
2369
+ this.places.moved();
2370
+ created?.();
2371
+ }
2372
+ else if (this.closingUnsure || !isDeepStrictEqual(blocks, this.closingShown)) {
2373
+ // An edit never notifies (measured 2026-09-27): the text stays as posted.
2374
+ created?.();
2375
+ await this.limiter.acquire();
2376
+ this.wrote = true;
2377
+ await this.slack.chat.update({
2378
+ channel: this.channel,
2379
+ ts: this.closing,
2380
+ text: this.banner(),
2381
+ blocks,
2382
+ });
2383
+ }
2384
+ }
2385
+ catch (error) {
2386
+ this.logger.warning(`could not write a reply's closing message: ${describe(error)}`);
2387
+ if (this.closing !== null)
2388
+ this.closingUnsure = true;
2389
+ if (this.closing === null && unknownOutcome(error)) {
2390
+ this.closing = await this.adopt(attempted, false, take(plainWords(this.banner()), ADOPT_WORDS));
2391
+ if (this.closing !== null) {
2392
+ this.places.moved();
2393
+ this.closingShown = blocks;
2394
+ return true;
2395
+ }
2396
+ }
2397
+ return false;
2398
+ }
2399
+ this.closingShown = blocks;
2400
+ this.closingUnsure = false;
2401
+ return true;
2402
+ }
2403
+ }