@stratusagent/channel-slack 0.11.4 → 0.11.5

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/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
6
6
  import { channelSessionKey, } from '@stratusagent/channels';
7
+ import { toSlackMrkdwn } from "./mrkdwn.js";
7
8
  /**
8
9
  * The longest display name the model is shown. Slack caps profile names
9
10
  * well under this; the bound is for a profile that is not a name.
@@ -51,6 +52,15 @@ const THREAD_HANDOVER_DEPTH = 32;
51
52
  */
52
53
  const MAX_UNRENDERED_FILES = 20;
53
54
  const PLACEHOLDER_TEXT = '…';
55
+ /** What the loading status says while a turn works, and while it runs a tool. */
56
+ const THINKING_STATUS = 'is thinking…';
57
+ const toolStatus = (toolName) => `is running ${toolName}…`;
58
+ /**
59
+ * How often a loading status is set again while its turn runs. Slack drops
60
+ * one after two minutes with no message, and a long turn — a tool, an
61
+ * approval wait — is exactly the one whose status must not vanish.
62
+ */
63
+ const STATUS_REFRESH_MS = 60_000;
54
64
  /**
55
65
  * How long an agent that judges stays attentive after it last answered a
56
66
  * message that addressed it: this many messages, or this many minutes,
@@ -156,10 +166,13 @@ const uploadPlace = (result, channel) => {
156
166
  return undefined;
157
167
  };
158
168
  /**
159
- * Renders one turn's reply into Slack with the placeholder-then-edit
160
- * pattern: post `…` immediately, fold streaming deltas and tool status
161
- * lines into throttled edits, and finalize with the authoritative reply
162
- * (split across messages when it outgrows one).
169
+ * Renders one turn's reply into Slack, one of two ways. Streaming (the
170
+ * `stream` reply mode): post `…` immediately, fold streaming deltas and
171
+ * tool status lines into throttled edits, and finalize with the
172
+ * authoritative reply. Otherwise (`final`, the default): no placeholder —
173
+ * Slack's loading status says the agent is working, and the finished reply
174
+ * is posted once, so the notification carries the answer rather than a
175
+ * `…`. Either way a reply that outgrows one message is split.
163
176
  */
164
177
  class ReplyRenderer {
165
178
  /**
@@ -225,6 +238,47 @@ class ReplyRenderer {
225
238
  * had nothing to add is a message the decision cannot take back.
226
239
  */
227
240
  lazy;
241
+ /**
242
+ * Whether this turn streams into a placeholder. When it does not, there
243
+ * is never a placeholder: `finalize` finds none and posts the reply as
244
+ * messages of its own, and a handover has nothing to hand over and says
245
+ * so (`untaken`), so the recovery's reply is posted ahead of this one
246
+ * the ordinary way — both already the paths for a placeholder that
247
+ * could not be opened.
248
+ */
249
+ streaming;
250
+ /**
251
+ * The thread the loading status belongs to: the reply's thread, or the
252
+ * message itself in a DM, whose reply has no thread to key one on.
253
+ */
254
+ statusThread;
255
+ /** Shared by a connection's renderers, so a status Slack refuses is said once, not per message. */
256
+ statusWarned;
257
+ loading = false;
258
+ /**
259
+ * Status calls, in order, so the last one Slack applies is the last one
260
+ * sent — and so `finalize` can wait for them: a status that reached
261
+ * Slack after the reply would stand over a finished turn until Slack's
262
+ * own timeout, since the post that clears it came first.
263
+ */
264
+ statusChain = Promise.resolve();
265
+ /**
266
+ * The turn this one was queued behind has posted everything it will
267
+ * (see `posted`). A streaming turn holds its place with the placeholder
268
+ * it opened at intake; a `final` one has no placeholder, so it waits for
269
+ * this before putting text or files in the thread — or a quick answer
270
+ * would land above the slow one ahead of it.
271
+ */
272
+ after;
273
+ settle;
274
+ /** Resolves once this turn has put in the thread everything it will. */
275
+ posted = new Promise((resolve) => {
276
+ this.settle = resolve;
277
+ });
278
+ statusText;
279
+ statusTimer;
280
+ /** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
281
+ runningTool;
228
282
  web;
229
283
  channel;
230
284
  threadTs;
@@ -239,6 +293,83 @@ class ReplyRenderer {
239
293
  this.warn = warn;
240
294
  this.lazy = options.lazy ?? false;
241
295
  this.now = options.now ?? Date.now;
296
+ this.streaming = options.streaming ?? true;
297
+ this.statusThread = options.statusThread ?? threadTs;
298
+ this.statusWarned = options.statusWarned ?? { value: false };
299
+ this.after = options.after ?? Promise.resolve();
300
+ }
301
+ /**
302
+ * Show Slack's loading status for this turn, and keep it up until the
303
+ * turn is finalized. Not for a streaming turn, which has its placeholder,
304
+ * nor for one nobody asked for: "is thinking…" over a turn that may
305
+ * decide to say nothing is the interruption silence exists to avoid.
306
+ */
307
+ showLoading(head) {
308
+ if (this.streaming || this.lazy || this.loading || this.finalized) {
309
+ return;
310
+ }
311
+ this.loading = true;
312
+ // A thread has one status per app, so a turn queued behind another
313
+ // must not publish its own: it would overwrite what the running turn
314
+ // says it is doing. It publishes when it reaches the head (`beginTurn`,
315
+ // or `refreshLoading` once the turn ahead has posted).
316
+ if (head) {
317
+ this.publishLoading();
318
+ }
319
+ }
320
+ publishLoading() {
321
+ if (this.statusTimer || this.finalized) {
322
+ return;
323
+ }
324
+ this.setStatus(this.statusText ?? THINKING_STATUS);
325
+ this.statusTimer = setInterval(() => this.refreshLoading(), STATUS_REFRESH_MS);
326
+ this.statusTimer.unref?.();
327
+ }
328
+ /**
329
+ * Set the status again. Slack clears a thread's status whenever the app
330
+ * posts in it, so a reply or file from the turn ahead of this one — or
331
+ * this turn's own upload — takes it down while this turn is still
332
+ * working; the adapter calls this once that post has landed.
333
+ */
334
+ refreshLoading() {
335
+ if (!this.loading || this.finalized) {
336
+ return;
337
+ }
338
+ if (!this.statusTimer) {
339
+ // Queued until now: this is the renderer at the head at last.
340
+ this.publishLoading();
341
+ return;
342
+ }
343
+ this.setStatus(this.statusText ?? THINKING_STATUS);
344
+ }
345
+ stopLoading() {
346
+ if (this.statusTimer) {
347
+ clearInterval(this.statusTimer);
348
+ this.statusTimer = undefined;
349
+ }
350
+ this.loading = false;
351
+ }
352
+ setStatus(text) {
353
+ const setStatus = this.web.assistant?.threads.setStatus;
354
+ if (!setStatus || this.statusThread === undefined) {
355
+ return;
356
+ }
357
+ this.statusText = text;
358
+ const thread = this.statusThread;
359
+ // Not awaited by the caller: the turn never waits on a status. Only
360
+ // `finalize` does, for the ordering reason on `statusChain`. A refusal
361
+ // — a workspace or plan without it, a token Slack will not honour for
362
+ // it — is said once and then left alone; the reply still arrives,
363
+ // just without the status ahead of it.
364
+ this.statusChain = this.statusChain
365
+ .then(() => setStatus.call(this.web.assistant?.threads, { channel_id: this.channel, thread_ts: thread, status: text }))
366
+ .then(() => undefined, (error) => {
367
+ if (!this.statusWarned.value) {
368
+ this.statusWarned.value = true;
369
+ this.warn(`slack: could not show a loading status (${error instanceof Error ? error.message : String(error)}); `
370
+ + 'replies still post when they are ready, without one');
371
+ }
372
+ });
242
373
  }
243
374
  /**
244
375
  * The gateway has begun this renderer's turn. Whatever was streamed here
@@ -255,8 +386,17 @@ class ReplyRenderer {
255
386
  }
256
387
  this.buffer = '';
257
388
  this.toolLine = undefined;
389
+ this.runningTool = undefined;
258
390
  this.turnBreakPending = false;
259
391
  this.generation += 1;
392
+ // Queued until now, this turn publishes its status as it starts; one
393
+ // already showing may be showing a tool the turn ahead of it ran.
394
+ if (this.loading && !this.statusTimer) {
395
+ this.publishLoading();
396
+ }
397
+ else if (this.loading && this.statusText !== THINKING_STATUS) {
398
+ this.setStatus(THINKING_STATUS);
399
+ }
260
400
  }
261
401
  async open() {
262
402
  const posted = await this.web.chat.postMessage({
@@ -305,6 +445,7 @@ class ReplyRenderer {
305
445
  // the text after it — without it they fuse in the live message.
306
446
  this.turnBreakPending = true;
307
447
  this.toolLine = `⚙ ${event.call.toolName}…`;
448
+ this.runningTool = event.call.toolName;
308
449
  this.scheduleEdit();
309
450
  return;
310
451
  }
@@ -314,12 +455,14 @@ class ReplyRenderer {
314
455
  // message would keep claiming a refused tool is running, and the
315
456
  // model's follow-up would fuse with the text before the attempt.
316
457
  this.toolLine = undefined;
458
+ this.runningTool = undefined;
317
459
  this.turnBreakPending = true;
318
460
  this.scheduleEdit();
319
461
  return;
320
462
  }
321
463
  if (event.type === 'tool.completed') {
322
464
  this.toolLine = undefined;
465
+ this.runningTool = undefined;
323
466
  // Also a boundary, and not only for symmetry: a call rejected before
324
467
  // execution settles as tool.completed without ever having emitted
325
468
  // tool.called, so this is the only mark that attempt leaves.
@@ -353,8 +496,10 @@ class ReplyRenderer {
353
496
  // it, and an upload that then waited for that handover would be a
354
497
  // cycle nothing breaks (see `queueEdit`).
355
498
  const handover = this.handover;
499
+ const after = this.streaming ? undefined : this.after;
356
500
  this.uploadChain = this.uploadChain
357
501
  .then(() => handover)
502
+ .then(() => after)
358
503
  // Read as file DATA inside the chain: the Web API takes contents,
359
504
  // not a path, and a missing file surfaces as this upload's own
360
505
  // failure instead of an unhandled stream error.
@@ -377,6 +522,9 @@ class ReplyRenderer {
377
522
  ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
378
523
  });
379
524
  this.uploaded = true;
525
+ // The upload is a post of the app's in this thread, which takes
526
+ // the status down with it while the turn is still working.
527
+ this.refreshLoading();
380
528
  const placed = uploadPlace(result, this.channel) ?? before;
381
529
  if (this.uploadedAt === undefined || placed > this.uploadedAt) {
382
530
  this.uploadedAt = placed;
@@ -389,6 +537,15 @@ class ReplyRenderer {
389
537
  return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
390
538
  }
391
539
  scheduleEdit() {
540
+ if (!this.streaming) {
541
+ // Nothing to edit: what the turn is doing goes into the status line,
542
+ // and only once the turn is this renderer's own (see `beginTurn`).
543
+ const status = this.runningTool !== undefined ? toolStatus(this.runningTool) : THINKING_STATUS;
544
+ if (this.loading && this.statusTimer && this.turnStarted && !this.finalized && status !== this.statusText) {
545
+ this.setStatus(status);
546
+ }
547
+ return;
548
+ }
392
549
  // Not gated on having a placeholder: during a handover there is none
393
550
  // for a moment, and the edit reads the fresh one when its turn comes.
394
551
  if (this.pendingEdit) {
@@ -469,8 +626,25 @@ class ReplyRenderer {
469
626
  * refused sentence is still the last thing said, for the thread rule.
470
627
  */
471
628
  async finalize(reply) {
629
+ try {
630
+ return await this.finalizeInOrder(reply);
631
+ }
632
+ finally {
633
+ this.settle();
634
+ }
635
+ }
636
+ async finalizeInOrder(reply) {
472
637
  await this.handover;
638
+ // Shown, not merely wanted: a turn still queued behind another never
639
+ // published one, and a clear from it would take down the running
640
+ // turn's status instead.
641
+ const hadStatus = this.statusTimer !== undefined;
642
+ this.stopLoading();
473
643
  this.finalized = true;
644
+ if (!this.streaming) {
645
+ await this.after;
646
+ await this.statusChain;
647
+ }
474
648
  if (this.pendingEdit) {
475
649
  clearTimeout(this.pendingEdit);
476
650
  this.pendingEdit = undefined;
@@ -528,6 +702,15 @@ class ReplyRenderer {
528
702
  this.placeText(this.ref?.ts);
529
703
  }
530
704
  }
705
+ // A reply posted clears the status itself; one Slack refused would
706
+ // leave "is thinking…" standing over a turn that has ended.
707
+ if (hadStatus && !landed) {
708
+ this.setStatus('');
709
+ // Awaited: the turn queued behind this one re-shows its own status
710
+ // once this renderer is done, and a clear that reached Slack after
711
+ // that would take the next turn's status down with it.
712
+ await this.statusChain;
713
+ }
531
714
  return this.outcome(published, landed || this.edited || this.uploaded || stranded);
532
715
  }
533
716
  placeText(ts) {
@@ -714,343 +897,6 @@ const safeCutIndex = (text, index) => {
714
897
  const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
715
898
  ? text
716
899
  : `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
717
- /**
718
- * Where the run of exactly `length` backticks that closes an open one
719
- * begins, or -1. Exactly: a longer run is not a closer, which is the whole
720
- * point of writing a span as ``code with a ` in it`` and a fence as ````
721
- * when the block itself contains ```.
722
- */
723
- const closingBacktickRun = (text, from, length, limit) => {
724
- const run = '`'.repeat(length);
725
- for (let at = text.indexOf(run, from); at !== -1 && at + length <= limit; at = text.indexOf(run, at + 1)) {
726
- if (text[at - 1] !== '`' && text[at + length] !== '`') {
727
- return at;
728
- }
729
- }
730
- return -1;
731
- };
732
- /**
733
- * The text as alternating prose and code — even indices prose, odd code.
734
- *
735
- * Code is the one place a reader means the characters themselves: `**` in a
736
- * shell snippet is the snippet, so nothing below rewrites it. A run of N
737
- * backticks opens code and the next run of exactly N closes it, which is
738
- * the same rule for a span and for a fence and is why a longer delimiter
739
- * can carry a shorter one inside it.
740
- *
741
- * A span may close on a later line, and is protected the whole way. That
742
- * looks wasteful — two stray backticks paragraphs apart become one long
743
- * "span" whose contents are left unconverted — and it was briefly changed
744
- * to end a span at its line for exactly that reason. That was wrong, and
745
- * the reason is the rule this whole file answers to: what Slack does with
746
- * the message decides, not what Markdown says. Slack pairs those backticks
747
- * too, so the text between them is what it renders as code, and rewriting
748
- * a `**bold**` in there would alter the contents of somebody's snippet.
749
- * Leaving prose unconverted is a cosmetic loss; changing what a reader is
750
- * told is code is not, so the doubt resolves toward protecting more.
751
- *
752
- * A fence with no closer runs to the end on purpose: a model that forgets
753
- * to close one, and every partially streamed block on its way to being
754
- * closed, would otherwise have its contents rewritten as prose. One or two
755
- * unmatched backticks are the opposite case — literal text, since "use the
756
- * ` character" must not swallow what follows.
757
- */
758
- const proseAndCode = (text) => {
759
- const segments = [];
760
- let prose = 0;
761
- let cursor = 0;
762
- while (cursor < text.length) {
763
- const open = text.indexOf('`', cursor);
764
- if (open === -1) {
765
- break;
766
- }
767
- let length = 1;
768
- while (text[open + length] === '`') {
769
- length += 1;
770
- }
771
- const close = closingBacktickRun(text, open + length, length, text.length);
772
- if (close === -1) {
773
- if (length < 3) {
774
- cursor = open + length;
775
- continue;
776
- }
777
- segments.push(text.slice(prose, open), text.slice(open));
778
- return segments;
779
- }
780
- segments.push(text.slice(prose, open), text.slice(open, close + length));
781
- prose = close + length;
782
- cursor = prose;
783
- }
784
- segments.push(text.slice(prose));
785
- return segments;
786
- };
787
- /**
788
- * The spellings that differ, applied to prose only, in an order that matters.
789
- *
790
- * Single-asterisk emphasis goes first and is barred from crossing a `*`, so
791
- * `**bold**` cannot be read as an italic run that happens to begin with one.
792
- * Bold then rewrites what is left, and its output is never rescanned.
793
- *
794
- * Each delimiter has to hug its content — `3 * 4 * 5` is arithmetic, not
795
- * emphasis — and none may span a line, so one stray marker cannot italicize
796
- * the rest of a message.
797
- */
798
- const MRKDWN_REWRITES = [
799
- // Both at once, before either rule can take half of it and leave the
800
- // other half as literal asterisks. Its output is why the italic rule
801
- // below refuses content that already opens with an underscore.
802
- [/\*\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*\*/g, '*_$1_*'],
803
- [/(^|[^*])\*([^\s*_][^*\n]*[^\s*]|[^\s*_])\*(?!\*)/g, '$1_$2_'],
804
- [/\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*/g, '*$1*'],
805
- [/(^|[^_])__([^\s_][^_\n]*[^\s_]|[^\s_])__(?!_)/g, '$1*$2*'],
806
- [/~~([^\s~][^~\n]*[^\s~]|[^\s~])~~/g, '~$1~'],
807
- // The one construct Slack spells backwards. A scheme is required: a
808
- // bare `[1](2)` in prose is not a link anybody meant to follow.
809
- [/\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^()\s]+)\)/g, '<$2|$1>'],
810
- ];
811
- /**
812
- * `## Heading`, which mrkdwn has no spelling for at all.
813
- *
814
- * The optional closing hashes have to be spaced off the text, as Markdown
815
- * requires: without that, `# C#` is a heading whose name loses its last
816
- * character.
817
- *
818
- * What they and any trailing spaces cover is captured rather than dropped,
819
- * because dropping is only right when they are the heading's own syntax. A
820
- * heading that opens a code span reaches its end inside one, and there the
821
- * same characters are the snippet: `` # show `value # `` has a hash that
822
- * belongs to whoever wrote the code.
823
- */
824
- const MARKDOWN_HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)((?:[ \t]+#+)?[ \t]*)$/gm;
825
- const convertInline = (segment) => {
826
- let converted = segment;
827
- for (const [pattern, replacement] of MRKDWN_REWRITES) {
828
- converted = converted.replace(pattern, replacement);
829
- }
830
- return converted;
831
- };
832
- /**
833
- * Headings, found on the text's own lines rather than inside each prose
834
- * fragment.
835
- *
836
- * A fragment is not a line: inline code cuts one in two, and the halves
837
- * begin wherever the code ended. Matched per fragment, `^` then lied in
838
- * both directions — the tail of `` `status` # not a heading `` looked like a
839
- * line of its own and lost its hash, and ``## Run `npm test` now`` was only
840
- * ever half a line, so only the half before the code was wrapped.
841
- *
842
- * So the whole text is matched instead, with the code spans it now contains
843
- * passed in: a `#` whose line begins inside one is a comment in somebody's
844
- * snippet, not a heading. A heading that merely *contains* code is a
845
- * heading, and its code goes along untouched.
846
- *
847
- * Emphasis inside is already converted by here, so the line is only wrapped
848
- * when nothing in it can pair with the wrapper. Slack has one bold
849
- * delimiter and no way to nest it: wrapping `*Run* the thing` again yields
850
- * `**Run* the thing*`, which it renders as literal asterisks rather than as
851
- * anything bold. A heading that already carries emphasis therefore keeps
852
- * exactly the emphasis it has — `## **Title**` is bold by now and needs
853
- * nothing more, and `# glob *.ts` keeps an asterisk that was never a marker
854
- * at all. An unbolded heading reads fine; a heading full of stray asterisks
855
- * does not, and one with characters removed to make room is worse than both.
856
- *
857
- * An asterisk inside a code span is not one of those: Slack does not read
858
- * markup there, so it cannot pair with anything, and ``## Match `*.ts` files``
859
- * is bolded like any other heading.
860
- *
861
- * Both walks are cursors rather than searches: `replace` reports matches in
862
- * increasing offset, and the spans were collected in that order too, so
863
- * neither has to be looked at twice. The same work as a scan per heading,
864
- * without its quadratic on a reply that is mostly headings and snippets.
865
- */
866
- /** What could pair with a heading's wrapper, or with something out of sight. */
867
- const PAIRABLE_MARKER = /[*`]/g;
868
- const convertHeadings = (text, code) => {
869
- let span = 0;
870
- const startsInside = (position, from) => position >= (code[from]?.[0] ?? Number.POSITIVE_INFINITY);
871
- return text.replace(MARKDOWN_HEADING, (line, body, tail, offset) => {
872
- while (span < code.length && (code[span]?.[1] ?? 0) <= offset) {
873
- span += 1;
874
- }
875
- // A `#` whose line begins inside a span is a comment in somebody's code.
876
- if (startsInside(offset, span)) {
877
- return line;
878
- }
879
- const heading = body.trim();
880
- if (heading.length === 0) {
881
- return line;
882
- }
883
- // Where everything this would drop from the line's end begins: the
884
- // closing hashes and spaces the pattern captured, and whatever else
885
- // `trimEnd` would take with them. The two are not the same set — the
886
- // pattern knows only spaces and tabs, while trimming also takes a
887
- // no-break space, an ideographic space, and the rest of what
888
- // ECMAScript counts as whitespace — and the difference is exactly
889
- // where a character goes missing.
890
- //
891
- // Dropping any of it is the heading's own syntax being removed, but
892
- // only while it is the heading's: a line that opens a span reaches its
893
- // end inside one, and there the same characters are somebody's
894
- // snippet. Nothing on this line is safe to strip then, so it is left
895
- // exactly as written. (The front needs no such care: the body starts
896
- // where the hashes stop, which this line has already been shown to
897
- // reach in prose.)
898
- const dropped = tail.length + (body.length - body.trimEnd().length);
899
- if (dropped > 0) {
900
- const droppedAt = offset + line.length - dropped;
901
- let over = span;
902
- while (over < code.length && (code[over]?.[1] ?? 0) <= droppedAt) {
903
- over += 1;
904
- }
905
- if (startsInside(droppedAt, over)) {
906
- return line;
907
- }
908
- }
909
- // Anything left on the line that a delimiter could pair with, walked
910
- // from where the heading cursor already stands rather than from the
911
- // beginning. An asterisk is the obvious one — it would pair with the
912
- // wrapper. A backtick counts too: one this side found no partner for is
913
- // a literal character here, but Slack parses the message itself and may
914
- // pair it with a backtick further down, and the wrapper's closing `*`
915
- // would then be written inside what Slack reads as code, where it is
916
- // ignored — leaving the opening one with nothing to close it. Both are
917
- // spent characters inside a span, which is why the ranges are consulted
918
- // rather than the text alone.
919
- let within = span;
920
- for (const marker of line.matchAll(PAIRABLE_MARKER)) {
921
- const position = offset + (marker.index ?? 0);
922
- while (within < code.length && (code[within]?.[1] ?? 0) <= position) {
923
- within += 1;
924
- }
925
- if (!startsInside(position, within)) {
926
- return heading;
927
- }
928
- }
929
- // Both markers have to land in prose, not just the opening one. A fence
930
- // opened on this line — by a model mid-answer, or by a block still being
931
- // streamed — runs past its end, so the closing `*` would be written
932
- // inside the code and ignored there, leaving the opening one unpaired.
933
- // The line began in prose or this never ran, so it is the end that is
934
- // in question: where the marker would go, one past the line's last
935
- // character, and not that character itself — a span that closes exactly
936
- // at the line's end leaves the marker just outside it, which is fine.
937
- const closer = offset + line.length;
938
- while (within < code.length && (code[within]?.[1] ?? 0) <= closer) {
939
- within += 1;
940
- }
941
- return startsInside(closer, within) ? heading : `*${heading}*`;
942
- });
943
- };
944
- /**
945
- * A stand-in for one code segment while the prose around it is rewritten.
946
- *
947
- * Emphasis was converted one prose fragment at a time until this existed,
948
- * and a fragment is not a run: inline code cuts one in two, so the `**`
949
- * opening a bold run and the `**` closing it land in different fragments
950
- * whenever a snippet sits between them, and neither half ever finds its
951
- * partner. ``**the `fs.read` tool**`` reached a reader as four literal
952
- * asterisks around a snippet, and so did every link, italic and
953
- * strikethrough written the same way. It is the defect #142 found in
954
- * headings, in the rule right next to it, and it has the same answer:
955
- * rewrite the whole text, with code held out of the rewrite rather than
956
- * cut out of the text.
957
- *
958
- * A mask names its segment rather than merely standing in for one, because
959
- * a rewrite is allowed to move it: the link rule writes its two captures
960
- * back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
961
- * snippet to the label and the label's to the URL, and a snippet that
962
- * arrives somewhere else is the same defect as one that is rewritten. Named,
963
- * it comes back to the place holding its own name. No rewrite can damage
964
- * the name itself: none of the delimiters they look for appear in it, and
965
- * every rule copies what it matched through to its replacement.
966
- *
967
- * A mask also carries whether its segment holds whitespace, because three
968
- * rules refuse to cross it — a destination is `[^()\s]+`, a label and an
969
- * emphasis run stop at a newline — and a name alone has none to stop them.
970
- * `[label](https://host/`a b`)` is not a destination and was never meant to
971
- * convert; masked shapelessly it did, and came back rearranged into a link
972
- * Slack cannot render. Only whitespace is carried, and only ever so a rule
973
- * refuses: a `*` in a snippet must still not act like one. Both ends of a
974
- * mask stay the mask character, so a run that has to hug its content still
975
- * hugs it — a segment opens and closes with a backtick, so that is not a
976
- * lie about its ends.
977
- *
978
- * The character is chosen against the text rather than fixed, because a
979
- * reply that already contained it would have a snippet spliced in where
980
- * the writer's own character stood — the one thing this conversion must
981
- * never do. A reply holding all 6400 of the private-use characters has no
982
- * mask to be given and is left in Markdown instead: prose that reads as it
983
- * was written is a cosmetic loss, and a snippet spliced somewhere else is
984
- * not.
985
- */
986
- const maskFor = (text) => {
987
- for (let point = 0xe000; point <= 0xf8ff; point += 1) {
988
- const candidate = String.fromCharCode(point);
989
- if (!text.includes(candidate)) {
990
- return candidate;
991
- }
992
- }
993
- return undefined;
994
- };
995
- /**
996
- * Markdown as a model writes it, in the spelling Slack actually renders.
997
- *
998
- * Slack's mrkdwn is not Markdown, and the gap is not cosmetic: `**bold**`
999
- * reaches a reader as four literal asterisks, and `## Heading` as a line
1000
- * that starts with two hashes. Models write Markdown because that is what
1001
- * every other surface here renders, and a soul should not have to know
1002
- * which channel is carrying it, so the translation happens at the edge that
1003
- * does know.
1004
- *
1005
- * Only what differs is touched. Lists, block quotes, and inline code
1006
- * already mean in mrkdwn what they mean in Markdown; rewriting them would
1007
- * add ways to be wrong and fix nothing.
1008
- */
1009
- const toSlackMrkdwn = (text) => {
1010
- const mask = maskFor(text);
1011
- if (mask === undefined) {
1012
- return text;
1013
- }
1014
- const segments = proseAndCode(text);
1015
- const code = [];
1016
- let masked = '';
1017
- for (const [index, segment] of segments.entries()) {
1018
- if (index % 2 === 1) {
1019
- const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
1020
- masked += `${mask}${code.length}${shape}${mask}`;
1021
- code.push(segment);
1022
- continue;
1023
- }
1024
- masked += segment;
1025
- }
1026
- // A code segment that ends the text is the unterminated fence: every
1027
- // closed run is followed by a prose slice, even an empty one, so only an
1028
- // unclosed one leaves the segments ending on a code index. Named, not
1029
- // last: a rewrite may have moved it, and it is the segment that ran to
1030
- // the text's end when it was cut, wherever it now sits.
1031
- const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
1032
- // Where each code segment ends up in the converted text, which is not
1033
- // where it started: the prose around it changes length as it is
1034
- // rewritten, and the masks are the only record of where it went. Split
1035
- // on the names, so the pieces alternate prose and the name of the
1036
- // segment that follows it.
1037
- const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
1038
- const spans = [];
1039
- let converted = pieces[0] ?? '';
1040
- for (let piece = 1; piece < pieces.length; piece += 2) {
1041
- const named = Number(pieces[piece]);
1042
- const segment = code[named] ?? '';
1043
- // The unterminated fence has no end to be past, so nothing may be
1044
- // appended after it either — recording the end as it looks would say a
1045
- // marker placed at the text's end was safely outside.
1046
- const ends = named === unterminated
1047
- ? Number.POSITIVE_INFINITY
1048
- : converted.length + segment.length;
1049
- spans.push([converted.length, ends]);
1050
- converted += `${segment}${pieces[piece + 1] ?? ''}`;
1051
- }
1052
- return convertHeadings(converted, spans);
1053
- };
1054
900
  const splitForSlack = (text) => {
1055
901
  if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
1056
902
  return [text];
@@ -1457,7 +1303,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
1457
1303
  * Session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>`
1458
1304
  * (DMs: the DM channel id alone), so threads are resumable conversations
1459
1305
  * and two agents sharing a thread keep fully separate sessions. Replies
1460
- * stream via placeholder-then-edit, throttled for chat.update limits.
1306
+ * post once, finished, behind Slack's loading status — or, in the `stream`
1307
+ * reply mode, stream via placeholder-then-edit, throttled for chat.update
1308
+ * limits.
1461
1309
  *
1462
1310
  * A mention starts a thread conversation; inside one, the agent keeps
1463
1311
  * listening without being tagged again — see `couldBeFollowUp` and
@@ -1485,6 +1333,12 @@ export const createSlackChannelAdapter = (options) => {
1485
1333
  // to the FIRST registered renderer (the running turn), never a later
1486
1334
  // message's placeholder. Each handler removes exactly its own renderer.
1487
1335
  const renderers = new Map();
1336
+ /**
1337
+ * Per session, what the newest `final` reply has left to post. A `final`
1338
+ * turn holds no place in the thread until it posts, so each waits for
1339
+ * the one queued ahead of it — see `ReplyRenderer.after`.
1340
+ */
1341
+ const replyOrder = new Map();
1488
1342
  /**
1489
1343
  * Files a turn nobody here rendered produced — a recovery's screenshot,
1490
1344
  * say — keyed by session, waiting for the turn's outcome to be posted so
@@ -3068,9 +2922,28 @@ export const createSlackChannelAdapter = (options) => {
3068
2922
  };
3069
2923
  }
3070
2924
  // A turn nobody asked for opens its placeholder on its first text,
3071
- // if it ever has any — see `ReplyRenderer.lazy`.
3072
- const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, { lazy: judged, now });
3073
- if (!judged) {
2925
+ // if it ever has any — see `ReplyRenderer.lazy`. One the agent's
2926
+ // reply mode does not stream opens none at all, and shows the
2927
+ // loading status instead — see `ReplyRenderer.streaming`.
2928
+ const streaming = connection.config.replies === 'stream';
2929
+ const ahead = streaming ? undefined : replyOrder.get(sessionId);
2930
+ const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
2931
+ lazy: judged,
2932
+ now,
2933
+ streaming,
2934
+ statusThread: thread ?? event.ts,
2935
+ statusWarned: connection.statusWarned,
2936
+ ...(ahead ? { after: ahead } : {}),
2937
+ });
2938
+ if (!streaming) {
2939
+ replyOrder.set(sessionId, renderer.posted);
2940
+ void renderer.posted.then(() => {
2941
+ if (replyOrder.get(sessionId) === renderer.posted) {
2942
+ replyOrder.delete(sessionId);
2943
+ }
2944
+ });
2945
+ }
2946
+ if (!judged && streaming) {
3074
2947
  try {
3075
2948
  await renderer.open();
3076
2949
  }
@@ -3085,6 +2958,7 @@ export const createSlackChannelAdapter = (options) => {
3085
2958
  const queue = renderers.get(sessionId) ?? [];
3086
2959
  queue.push(renderer);
3087
2960
  renderers.set(sessionId, queue);
2961
+ renderer.showLoading(queue.length === 1);
3088
2962
  const turn = gateway.dispatch({
3089
2963
  sessionId,
3090
2964
  agentId: connection.config.agentId,
@@ -3165,6 +3039,9 @@ export const createSlackChannelAdapter = (options) => {
3165
3039
  // running, which the other may therefore still take.
3166
3040
  rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
3167
3041
  }
3042
+ // The reply just posted took the thread's status down with it, and a
3043
+ // turn queued behind this one is still working.
3044
+ renderers.get(sessionId)?.[0]?.refreshLoading();
3168
3045
  await heard;
3169
3046
  }
3170
3047
  else {
@@ -3173,6 +3050,7 @@ export const createSlackChannelAdapter = (options) => {
3173
3050
  // A file it posted before breaking is still the last thing said.
3174
3051
  rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
3175
3052
  }
3053
+ renderers.get(sessionId)?.[0]?.refreshLoading();
3176
3054
  }
3177
3055
  };
3178
3056
  return {
@@ -3189,7 +3067,9 @@ export const createSlackChannelAdapter = (options) => {
3189
3067
  return;
3190
3068
  }
3191
3069
  if (event.type === 'tool.approval-resolved') {
3192
- track(retractApprovalRequest(event));
3070
+ // The question was posted into the turn's thread, which took the
3071
+ // loading status down with it; the turn works on once answered.
3072
+ track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
3193
3073
  return;
3194
3074
  }
3195
3075
  // A turn ends here in one of two hands. One this adapter started
@@ -3260,12 +3140,18 @@ export const createSlackChannelAdapter = (options) => {
3260
3140
  // connection — never leaves the queued reply stalled behind it.
3261
3141
  if (event.type === 'session.failed' && !rendered) {
3262
3142
  const claim = head?.reserveHandover();
3263
- track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => claim?.release()));
3143
+ track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
3144
+ claim?.release();
3145
+ head?.refreshLoading();
3146
+ }));
3264
3147
  return;
3265
3148
  }
3266
3149
  if (event.type === 'session.completed' && !rendered) {
3267
3150
  const claim = head?.reserveHandover();
3268
- track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => claim?.release()));
3151
+ track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
3152
+ claim?.release();
3153
+ head?.refreshLoading();
3154
+ }));
3269
3155
  return;
3270
3156
  }
3271
3157
  if ('sessionId' in event) {
@@ -3300,6 +3186,7 @@ export const createSlackChannelAdapter = (options) => {
3300
3186
  socket: createSocket(config.appToken),
3301
3187
  botUserId,
3302
3188
  teamId,
3189
+ statusWarned: { value: false },
3303
3190
  });
3304
3191
  }
3305
3192
  catch (error) {