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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- 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
|
+
}
|