@stratusagent/channel-slack 0.14.1 → 0.14.2

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
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, HostRefusalError, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/core';
7
7
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
8
+ import { textingParts } from "./texts.js";
8
9
  /**
9
10
  * The longest display name the model is shown. Slack caps profile names
10
11
  * well under this; the bound is for a profile that is not a name.
@@ -116,6 +117,66 @@ const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
116
117
  * is reported by name rather than as the whole window running out.
117
118
  */
118
119
  const DEFAULT_STOP_WAIT_MS = 10_000;
120
+ /**
121
+ * How long an approval prompt waits for the words its turn wrote before the
122
+ * call to land first (card 950). Long enough for an ordinary post, short
123
+ * enough that a slow or refusing Slack never keeps the question from an
124
+ * approver: the turn is parked on that answer.
125
+ */
126
+ const DEFAULT_APPROVAL_PROMPT_WAIT_MS = 5_000;
127
+ /**
128
+ * How long a task works before its progress message opens (card 949): long
129
+ * enough that a quick answer, a lookup or two, is just its reply; short
130
+ * enough that a task taking minutes shows what it is doing while it does.
131
+ */
132
+ const DEFAULT_PROGRESS_AFTER_MS = 10_000;
133
+ /** Room kept for a progress message's first line and its `…` when its notes are trimmed. */
134
+ const PROGRESS_HEADER_ROOM = 200;
135
+ /**
136
+ * The most a progress message holds under its first line. Words longer than
137
+ * this are content, not a step — an analysis written before a tool call —
138
+ * and are posted whole as messages of their own, as before card 949, rather
139
+ * than cut to fit and lost.
140
+ */
141
+ const PROGRESS_NOTE_ROOM = SLACK_MAX_MESSAGE_CHARS - PROGRESS_HEADER_ROOM;
142
+ const stepCount = (steps) => `${steps} ${steps === 1 ? 'step' : 'steps'}`;
143
+ /** A tool's name as code, which no backtick of its own can close early. */
144
+ const toolCode = (toolName) => `\`${escapeSlackText(toolName.replace(/`/g, ''))}\``;
145
+ /**
146
+ * The first line of a progress message: what the turn is doing, from the
147
+ * tool calls it made and is making. Words the model wrote are under it, as
148
+ * notes; this line is the part that cannot claim more than happened.
149
+ */
150
+ const progressHeader = (state) => {
151
+ switch (state.end) {
152
+ case 'done':
153
+ return `✓ Done · ${stepCount(state.steps)}`;
154
+ case 'stopped':
155
+ return `■ Stopped after ${stepCount(state.steps)}`;
156
+ case 'failed':
157
+ return `✗ Failed after ${stepCount(state.steps)}`;
158
+ default:
159
+ break;
160
+ }
161
+ if (state.waitingOn !== undefined) {
162
+ return `⏸ Waiting for approval to run ${toolCode(state.waitingOn)}`;
163
+ }
164
+ const latest = [...state.running.values()].at(-1);
165
+ if (latest !== undefined) {
166
+ return `⚙ Working · step ${state.steps}: ${toolCode(latest)}`;
167
+ }
168
+ return `⚙ Working · ${stepCount(state.steps)} done`;
169
+ };
170
+ /** Resolves when `work` settles or after `ms`, whichever is first; never rejects. */
171
+ const settledWithin = (work, ms) => new Promise((resolve) => {
172
+ const timer = setTimeout(resolve, ms);
173
+ timer.unref?.();
174
+ const done = () => {
175
+ clearTimeout(timer);
176
+ resolve();
177
+ };
178
+ work.then(done, done);
179
+ });
119
180
  /** What a turn that produced no text puts in its message, wherever it is posted from. */
120
181
  const NO_REPLY_TEXT = '(no reply)';
121
182
  /**
@@ -447,6 +508,40 @@ class ReplyRenderer {
447
508
  * once that is known, so the thread's other agents hear it in its place.
448
509
  */
449
510
  onInterim;
511
+ /**
512
+ * Card 949, `final` mode: a task says what it is about to do in a
513
+ * message of its own (its first words before a tool), then keeps ONE
514
+ * progress message up to date — a first line built from its tool events,
515
+ * and under it whatever else it wrote between tools — and posts its reply
516
+ * as a message of its own. Narration that used to be a post per step is
517
+ * an edit, so a long task is three messages, not twenty.
518
+ *
519
+ * Whether this turn's first words have gone out, so later words are notes.
520
+ */
521
+ firstWordsSaid = false;
522
+ /** Whether the progress message has been asked for; it may not have landed yet. */
523
+ progressOpen = false;
524
+ /** The progress message, once Slack took it. */
525
+ progressRef;
526
+ /** What the first line says, from this turn's own tool events only. */
527
+ progress = { steps: 0, running: new Map(), waitingOn: undefined, end: undefined };
528
+ /**
529
+ * The notes under that line, as sent: converted, oldest dropped past one
530
+ * message's room. `shown` once a write that held the note has landed: a
531
+ * note trimmed while its write was still queued never reached anyone.
532
+ */
533
+ progressNotes = [];
534
+ /** Whether older notes were dropped for room, so the message says so. */
535
+ progressNotesCut = false;
536
+ /** Whether the last thing this turn said is a note rather than a message of its own. */
537
+ lastSaidWasNote = false;
538
+ progressTimer;
539
+ progressOpenTimer;
540
+ lastProgressAt = 0;
541
+ turnStartedAt = 0;
542
+ /** How the turn ended, for the progress message: a stop or a failure says so before it finalizes. */
543
+ ending = 'done';
544
+ progressAfterMs;
450
545
  /** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
451
546
  taintedTo;
452
547
  turnBreakPending = false;
@@ -513,6 +608,12 @@ class ReplyRenderer {
513
608
  * could not be opened.
514
609
  */
515
610
  streaming;
611
+ /**
612
+ * Whether the reply, and what the turn says before a tool, are posted a
613
+ * paragraph to a message (`texts` mode). Never with `streaming`: there
614
+ * is one placeholder to stream into.
615
+ */
616
+ texts;
516
617
  /**
517
618
  * The thread the loading status belongs to: the reply's thread, or the
518
619
  * message itself in a DM, whose reply has no thread to key one on.
@@ -545,6 +646,13 @@ class ReplyRenderer {
545
646
  statusTimer;
546
647
  /** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
547
648
  runningTool;
649
+ /**
650
+ * Calls started and not yet settled, by call id, oldest first. Core runs
651
+ * a response's read-only calls together, so one can settle while another
652
+ * still runs: both lines name the newest call still running, and clear
653
+ * only once none is.
654
+ */
655
+ activeTools = new Map();
548
656
  web;
549
657
  /**
550
658
  * The one person in the conversation when this turn is a DM: whoever sent
@@ -567,11 +675,13 @@ class ReplyRenderer {
567
675
  this.lazy = options.lazy ?? false;
568
676
  this.now = options.now ?? Date.now;
569
677
  this.streaming = options.streaming ?? true;
678
+ this.texts = !this.streaming && options.texts === true;
570
679
  this.statusThread = options.statusThread ?? threadTs;
571
680
  this.statusWarned = options.statusWarned ?? { value: false };
572
681
  this.after = options.after ?? Promise.resolve();
573
682
  this.dmWith = options.dmWith;
574
683
  this.onInterim = options.onInterim;
684
+ this.progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
575
685
  }
576
686
  /**
577
687
  * Show Slack's loading status for this turn, and keep it up until the
@@ -662,6 +772,9 @@ class ReplyRenderer {
662
772
  * included, so this turn's placeholder starts empty.
663
773
  */
664
774
  beginTurn() {
775
+ if (!this.turnStarted) {
776
+ this.turnStartedAt = Date.now();
777
+ }
665
778
  this.turnStarted = true;
666
779
  if (this.pendingEdit) {
667
780
  clearTimeout(this.pendingEdit);
@@ -669,6 +782,7 @@ class ReplyRenderer {
669
782
  }
670
783
  this.buffer = '';
671
784
  this.interim = '';
785
+ this.activeTools.clear();
672
786
  this.toolLine = undefined;
673
787
  this.runningTool = undefined;
674
788
  this.turnBreakPending = false;
@@ -745,9 +859,16 @@ class ReplyRenderer {
745
859
  // all, so this is the only thing between the text before a tool and
746
860
  // the text after it — without it they fuse in the live message.
747
861
  this.turnBreakPending = true;
748
- this.toolLine = `⚙ ${event.call.toolName}…`;
749
- this.runningTool = event.call.toolName;
862
+ this.activeTools.set(event.call.id, event.call.toolName);
863
+ this.showActiveTool();
750
864
  this.flushInterim(ofThisTurn);
865
+ if (ofThisTurn && this.mayShowProgress()) {
866
+ this.progress.steps += 1;
867
+ this.progress.running.set(event.call.id, event.call.toolName);
868
+ this.progress.waitingOn = undefined;
869
+ this.openProgressInTime();
870
+ this.scheduleProgress();
871
+ }
751
872
  this.scheduleEdit();
752
873
  return;
753
874
  }
@@ -756,21 +877,27 @@ class ReplyRenderer {
756
877
  // else clears the status line or marks the boundary for it — the
757
878
  // message would keep claiming a refused tool is running, and the
758
879
  // model's follow-up would fuse with the text before the attempt.
759
- this.toolLine = undefined;
760
- this.runningTool = undefined;
880
+ this.activeTools.delete(event.call.id);
881
+ this.showActiveTool();
761
882
  this.turnBreakPending = true;
762
883
  // Words written before a call that was refused were still said
763
884
  // before a tool, and must not run into the next response's.
764
885
  this.flushInterim(ofThisTurn);
886
+ if (ofThisTurn) {
887
+ this.settleProgressStep(event.call.id);
888
+ }
765
889
  this.scheduleEdit();
766
890
  return;
767
891
  }
768
892
  if (event.type === 'tool.completed') {
769
- this.toolLine = undefined;
770
- this.runningTool = undefined;
893
+ this.activeTools.delete(event.result.callId);
894
+ this.showActiveTool();
771
895
  // Settled here too for a call rejected before it ran, which never
772
896
  // reached `tool.called`; after one that did, this is empty.
773
897
  this.flushInterim(ofThisTurn);
898
+ if (ofThisTurn) {
899
+ this.settleProgressStep(event.result.callId);
900
+ }
774
901
  // Also a boundary, and not only for symmetry: a call rejected before
775
902
  // execution settles as tool.completed without ever having emitted
776
903
  // tool.called, so this is the only mark that attempt leaves.
@@ -861,8 +988,18 @@ class ReplyRenderer {
861
988
  const text = this.interim.trim();
862
989
  this.interim = '';
863
990
  if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
864
- return;
991
+ return undefined;
865
992
  }
993
+ // Only words before the turn's first tool are a message of their own:
994
+ // after one, a step has run, and what follows is progress whether or
995
+ // not the turn opened with a line.
996
+ if (this.firstWordsSaid || this.progressOpen || this.progress.steps > 0) {
997
+ const note = toSlackMrkdwn(text);
998
+ if (note.length <= PROGRESS_NOTE_ROOM) {
999
+ return this.noteProgress(text, note);
1000
+ }
1001
+ }
1002
+ this.firstWordsSaid = true;
866
1003
  const handover = this.handover;
867
1004
  const after = this.after;
868
1005
  let heard = '';
@@ -877,7 +1014,7 @@ class ReplyRenderer {
877
1014
  .then(async () => {
878
1015
  const landed = [];
879
1016
  try {
880
- for (const chunk of messageChunks(text)) {
1017
+ for (const chunk of this.chunksOf(text)) {
881
1018
  const posted = await this.web.chat.postMessage({
882
1019
  channel: this.channel,
883
1020
  text: chunk,
@@ -894,12 +1031,13 @@ class ReplyRenderer {
894
1031
  // reply, where repeating a few lines beats losing the rest.
895
1032
  if (landed.length > 0) {
896
1033
  this.interimPosted = true;
897
- heard = landed.length === messageChunks(text).length ? text : landed.join('\n\n');
1034
+ heard = landed.length === this.chunksOf(text).length ? text : landed.join('\n\n');
898
1035
  }
899
1036
  settleLanded(landed.length > 0);
900
1037
  }
901
1038
  this.lastInterim = text;
902
1039
  this.interimSaid.push(text);
1040
+ this.lastSaidWasNote = false;
903
1041
  // A post of the app's in this thread takes the status down while
904
1042
  // the turn is still working; put it back.
905
1043
  this.refreshLoading();
@@ -908,6 +1046,230 @@ class ReplyRenderer {
908
1046
  // Settled whatever happened above, or the other agents' intake would
909
1047
  // wait on it for good. A second settle is a no-op.
910
1048
  .finally(() => settleLanded(false));
1049
+ return landedNow;
1050
+ }
1051
+ /**
1052
+ * Whether this turn keeps a progress message (card 949): `final` mode,
1053
+ * a turn somebody asked for, and its own — the same turns that post
1054
+ * their words before a tool. A streaming turn's placeholder already
1055
+ * shows its tool lines, and a turn nobody asked for must not speak
1056
+ * before it decides to.
1057
+ */
1058
+ mayShowProgress() {
1059
+ return !this.streaming && !this.lazy && this.turnStarted && !this.finalized;
1060
+ }
1061
+ /**
1062
+ * Words the turn wrote between tools after its first line: a note in the
1063
+ * progress message, opening it if need be, rather than a post of their
1064
+ * own. Heard by the thread's other agents and stripped from a reply that
1065
+ * repeats them, as a message of their own would be. A note Slack refused
1066
+ * is taken out again, so a later edit never shows words nobody heard.
1067
+ */
1068
+ noteProgress(text, converted) {
1069
+ this.progressOpen = true;
1070
+ this.clearProgressOpenTimer();
1071
+ const note = { text: converted };
1072
+ this.progressNotes.push(note);
1073
+ this.trimProgressNotes();
1074
+ let heard = '';
1075
+ let settleLanded;
1076
+ const landedNow = new Promise((resolve) => {
1077
+ settleLanded = resolve;
1078
+ });
1079
+ this.onInterim?.(() => heard, landedNow);
1080
+ // Said once a write that held it has landed, whichever write that was,
1081
+ // and only then. Its own write can land after a slow Slack let later
1082
+ // notes trim it out unseen, or be refused after an earlier write showed
1083
+ // it. Unseen, it is not said: the others do not hear it, and a reply
1084
+ // that repeats it keeps it.
1085
+ const settle = () => {
1086
+ if (!note.shown) {
1087
+ this.progressNotes = this.progressNotes.filter((kept) => kept !== note);
1088
+ settleLanded(false);
1089
+ return;
1090
+ }
1091
+ this.interimPosted = true;
1092
+ heard = text;
1093
+ this.lastInterim = text;
1094
+ this.interimSaid.push(text);
1095
+ this.lastSaidWasNote = true;
1096
+ settleLanded(true);
1097
+ };
1098
+ this.queueProgressWrite(settle, settle);
1099
+ return landedNow;
1100
+ }
1101
+ /**
1102
+ * Keep the notes to what one message holds with its first line: the
1103
+ * newest, since the oldest are what a reader has already seen. Past
1104
+ * that the message opens with `…`, and memory stays bounded however
1105
+ * long the task runs.
1106
+ */
1107
+ trimProgressNotes() {
1108
+ let size = this.progressNotes.reduce((total, note) => total + note.text.length + 2, 0);
1109
+ while (size > PROGRESS_NOTE_ROOM && this.progressNotes.length > 1) {
1110
+ size -= (this.progressNotes.shift()?.text.length ?? 0) + 2;
1111
+ this.progressNotesCut = true;
1112
+ }
1113
+ }
1114
+ progressText(omitLast = false) {
1115
+ const notes = omitLast ? this.progressNotes.slice(0, -1) : this.progressNotes;
1116
+ const parts = [progressHeader(this.progress), ...(this.progressNotesCut ? ['…'] : []), ...notes.map((note) => note.text)];
1117
+ return truncateForSlack(parts.join('\n\n'));
1118
+ }
1119
+ /**
1120
+ * Put the progress message as it stands now into the thread: posted the
1121
+ * first time, edited after. On the upload chain, behind the turn ahead
1122
+ * and any handover, so it lands below this turn's first line and above
1123
+ * its files and reply, and reads its state when it runs, not when asked.
1124
+ */
1125
+ queueProgressWrite(landed, refused) {
1126
+ const handover = this.handover;
1127
+ const after = this.after;
1128
+ this.uploadChain = this.uploadChain
1129
+ .then(() => handover)
1130
+ .then(() => after)
1131
+ .then(async () => {
1132
+ this.lastProgressAt = Date.now();
1133
+ const held = [...this.progressNotes];
1134
+ await this.writeProgress(this.progressText());
1135
+ for (const note of held) {
1136
+ note.shown = true;
1137
+ }
1138
+ landed?.();
1139
+ })
1140
+ .catch((error) => {
1141
+ refused?.();
1142
+ this.warn(`could not update the progress message: ${error instanceof Error ? error.message : String(error)}`);
1143
+ });
1144
+ }
1145
+ async writeProgress(text) {
1146
+ const ref = this.progressRef;
1147
+ if (ref) {
1148
+ await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
1149
+ return;
1150
+ }
1151
+ const posted = await this.web.chat.postMessage({
1152
+ channel: this.channel,
1153
+ text,
1154
+ ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
1155
+ });
1156
+ if (posted.ts) {
1157
+ this.progressRef = { channel: posted.channel ?? this.channel, ts: posted.ts };
1158
+ }
1159
+ this.placeText(posted.ts);
1160
+ // A post of the app's in this thread takes the status down while the
1161
+ // turn is still working; put it back.
1162
+ this.refreshLoading();
1163
+ }
1164
+ /** Edit the progress message for a step that started or settled, at most once per edit interval. */
1165
+ scheduleProgress() {
1166
+ if (!this.progressOpen || this.finalized || this.progressTimer) {
1167
+ return;
1168
+ }
1169
+ const delay = Math.max(0, this.editIntervalMs - (Date.now() - this.lastProgressAt));
1170
+ this.progressTimer = setTimeout(() => {
1171
+ this.progressTimer = undefined;
1172
+ if (!this.finalized) {
1173
+ this.queueProgressWrite();
1174
+ }
1175
+ }, delay);
1176
+ this.progressTimer.unref?.();
1177
+ }
1178
+ /**
1179
+ * Open the progress message once the turn has worked `progressAfterMs`:
1180
+ * now, if it has, or when it will have. Asked on a tool call, so there
1181
+ * is always a step to show; a quick answer ends first and opens none.
1182
+ */
1183
+ openProgressInTime() {
1184
+ if (this.progressOpen || this.progressOpenTimer) {
1185
+ return;
1186
+ }
1187
+ const open = () => {
1188
+ this.progressOpen = true;
1189
+ this.queueProgressWrite();
1190
+ };
1191
+ const wait = this.progressAfterMs - (Date.now() - this.turnStartedAt);
1192
+ if (wait <= 0) {
1193
+ open();
1194
+ return;
1195
+ }
1196
+ this.progressOpenTimer = setTimeout(() => {
1197
+ this.progressOpenTimer = undefined;
1198
+ if (!this.finalized && !this.progressOpen) {
1199
+ open();
1200
+ }
1201
+ }, wait);
1202
+ this.progressOpenTimer.unref?.();
1203
+ }
1204
+ /** A step of this turn's finished or was refused. */
1205
+ settleProgressStep(callId) {
1206
+ if (!this.mayShowProgress()) {
1207
+ return;
1208
+ }
1209
+ this.progress.running.delete(callId);
1210
+ this.progress.waitingOn = undefined;
1211
+ this.scheduleProgress();
1212
+ }
1213
+ /**
1214
+ * A call of this turn's is waiting on a person: the progress message says
1215
+ * so until the call runs or is refused. The prompt itself is a message
1216
+ * of its own, below it.
1217
+ */
1218
+ awaitingApproval(toolName, ofThisTurn) {
1219
+ if (!ofThisTurn || !this.mayShowProgress()) {
1220
+ return;
1221
+ }
1222
+ this.progress.waitingOn = toolName;
1223
+ this.scheduleProgress();
1224
+ }
1225
+ clearProgressOpenTimer() {
1226
+ if (this.progressOpenTimer) {
1227
+ clearTimeout(this.progressOpenTimer);
1228
+ this.progressOpenTimer = undefined;
1229
+ }
1230
+ }
1231
+ clearProgressTimers() {
1232
+ this.clearProgressOpenTimer();
1233
+ if (this.progressTimer) {
1234
+ clearTimeout(this.progressTimer);
1235
+ this.progressTimer = undefined;
1236
+ }
1237
+ }
1238
+ /**
1239
+ * The progress message's last edit: how the task ended, and every note
1240
+ * but one that became the reply. Resolves once Slack has answered; a
1241
+ * refusal leaves the last state that landed, and is logged.
1242
+ */
1243
+ async closeProgress(omitLast) {
1244
+ this.clearProgressTimers();
1245
+ const ref = this.progressRef;
1246
+ if (!ref) {
1247
+ return;
1248
+ }
1249
+ this.progress.end = this.ending;
1250
+ try {
1251
+ await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text: this.progressText(omitLast) });
1252
+ }
1253
+ catch (error) {
1254
+ this.warn(`could not finish the progress message: ${error instanceof Error ? error.message : String(error)}`);
1255
+ }
1256
+ }
1257
+ /**
1258
+ * Post what the model said before a call that is about to ask for
1259
+ * approval, and resolve once it has landed (or could not), or at once
1260
+ * when there is nothing to post (card 950).
1261
+ *
1262
+ * The prompt is posted before the call starts, so waiting for
1263
+ * `tool.called` put the question above the line saying what the agent
1264
+ * set out to do, and the approver answered without it. The same flush
1265
+ * `tool.called` would have done, only earlier: what it posts is stripped
1266
+ * from the reply as before, and the flush at `tool.called` finds nothing.
1267
+ */
1268
+ sayBeforeAsking(ofThisTurn) {
1269
+ if (this.finalized) {
1270
+ return undefined;
1271
+ }
1272
+ return this.flushInterim(ofThisTurn);
911
1273
  }
912
1274
  /**
913
1275
  * Everything this turn said in the thread, for its other agents to hear:
@@ -942,6 +1304,12 @@ class ReplyRenderer {
942
1304
  }
943
1305
  return rest === this.lastInterim ? '' : rest;
944
1306
  }
1307
+ /** Point both status lines at the newest call still running, or clear them. */
1308
+ showActiveTool() {
1309
+ const running = [...this.activeTools.values()].at(-1);
1310
+ this.runningTool = running;
1311
+ this.toolLine = running === undefined ? undefined : `⚙ ${running}…`;
1312
+ }
945
1313
  currentText() {
946
1314
  const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
947
1315
  return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
@@ -1051,6 +1419,7 @@ class ReplyRenderer {
1051
1419
  const hadStatus = this.statusTimer !== undefined;
1052
1420
  this.stopLoading();
1053
1421
  this.finalized = true;
1422
+ this.clearProgressTimers();
1054
1423
  if (!this.streaming) {
1055
1424
  await this.after;
1056
1425
  await this.statusChain;
@@ -1079,10 +1448,25 @@ class ReplyRenderer {
1079
1448
  // tool call) is not posted again, and a turn that said things along
1080
1449
  // the way is not followed by `(no reply)`.
1081
1450
  await this.uploadChain;
1451
+ // Words the turn ended on, written before its last tool call, are its
1452
+ // answer (card 949): a reply that only repeats them, or an empty one,
1453
+ // posts them as the reply, and the progress message lets them go. Left
1454
+ // as its last note, the answer would sit in the message that says how
1455
+ // the work went, above where anyone looks for it.
1456
+ let promoted = false;
1082
1457
  if (this.interimPosted && !this.ref) {
1083
- reply = this.unsaid(reply);
1084
- }
1458
+ const rest = this.unsaid(reply);
1459
+ promoted = rest.length === 0 && this.lastSaidWasNote && this.lastInterim !== undefined;
1460
+ reply = promoted && this.lastInterim !== undefined ? this.lastInterim : rest;
1461
+ }
1462
+ // Alongside the reply rather than ahead of it: an edit keeps its place,
1463
+ // and the answer should not wait on a round trip for a status line.
1464
+ // Words promoted to the reply are the exception: they leave the
1465
+ // progress message only once the reply holding them has landed, so a
1466
+ // reply Slack refuses does not take the answer out of the thread.
1467
+ const closed = promoted ? undefined : this.closeProgress(false);
1085
1468
  if (!this.ref && this.interimPosted && reply.trim().length === 0) {
1469
+ await (closed ?? this.closeProgress(false));
1086
1470
  // Nothing posts after this to take the status down, so it is cleared.
1087
1471
  if (hadStatus) {
1088
1472
  this.setStatus('');
@@ -1091,7 +1475,7 @@ class ReplyRenderer {
1091
1475
  return this.outcome(true, true);
1092
1476
  }
1093
1477
  const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
1094
- const chunks = messageChunks(text);
1478
+ const chunks = this.chunksOf(text);
1095
1479
  // No placeholder — a handover could not open a fresh one — and the
1096
1480
  // reply is a message of its own rather than nothing at all.
1097
1481
  const rest = this.ref ? chunks.slice(1) : chunks;
@@ -1121,6 +1505,7 @@ class ReplyRenderer {
1121
1505
  // avoid, so it is taken back like an abandoned attempt's. One Slack
1122
1506
  // will not let go stands, and a message the thread can see is the
1123
1507
  // last thing said whatever it says.
1508
+ await (closed ?? this.closeProgress(published));
1124
1509
  let stranded = false;
1125
1510
  if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
1126
1511
  stranded = !(await this.retract());
@@ -1142,6 +1527,10 @@ class ReplyRenderer {
1142
1527
  }
1143
1528
  return this.outcome(published, landed || this.edited || this.uploaded || stranded, !published);
1144
1529
  }
1530
+ /** This turn's text as the messages it is posted as, in this agent's reply mode. */
1531
+ chunksOf(text) {
1532
+ return messageChunks(text, this.texts ? 'texts' : undefined);
1533
+ }
1145
1534
  placeText(ts) {
1146
1535
  if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
1147
1536
  this.textAt = ts;
@@ -1197,6 +1586,7 @@ class ReplyRenderer {
1197
1586
  const hadStatus = this.statusTimer !== undefined;
1198
1587
  this.stopLoading();
1199
1588
  this.finalized = true;
1589
+ this.clearProgressTimers();
1200
1590
  // The barrier `finalizeInOrder` takes, for the same reason: the turn
1201
1591
  // queued behind this one waits on `posted`, and settling it before
1202
1592
  // the reply ahead has posted would let that turn's reply overtake it.
@@ -1232,6 +1622,7 @@ class ReplyRenderer {
1232
1622
  if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
1233
1623
  return this.finalize('');
1234
1624
  }
1625
+ this.ending = 'stopped';
1235
1626
  return this.finalize(`Stopped by <@${by}>.`);
1236
1627
  }
1237
1628
  /**
@@ -1257,6 +1648,8 @@ class ReplyRenderer {
1257
1648
  this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
1258
1649
  return this.finalize('');
1259
1650
  }
1651
+ // A spent budget is a limit working, not a fault: the task stopped.
1652
+ this.ending = refused ? 'stopped' : 'failed';
1260
1653
  return this.finalize(refused ? message : `Something went wrong: ${message}`);
1261
1654
  }
1262
1655
  /**
@@ -1493,8 +1886,12 @@ const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
1493
1886
  * Agent text as however many Slack messages it takes. Converted before it
1494
1887
  * is split, so a chunk is measured at the length it will be sent at, and so
1495
1888
  * no rewrite is ever asked to span a boundary.
1889
+ *
1890
+ * In `texts` mode a reply is first cut into the few messages it is texted
1891
+ * as, each then converted and sized on its own: the cuts never fall inside
1892
+ * code, and nothing else in the conversion crosses a line.
1496
1893
  */
1497
- const messageChunks = (text) => splitForSlack(toSlackMrkdwn(text));
1894
+ const messageChunks = (text, replies) => (replies === 'texts' ? textingParts(text) : [text]).flatMap((part) => splitForSlack(toSlackMrkdwn(part)));
1498
1895
  const APPROVAL_ACTIONS = {
1499
1896
  stratus_approve_once: 'once',
1500
1897
  stratus_approve_always: 'always',
@@ -2442,7 +2839,10 @@ const credentialModal = (post, requestId) => ({
2442
2839
  type: 'input',
2443
2840
  block_id: CREDENTIAL_BLOCK,
2444
2841
  label: { type: 'plain_text', text: `Value for ${post.name}` },
2445
- element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT },
2842
+ // Several lines: a one-line field flattens the line breaks of a pasted
2843
+ // key file, such as a PEM private key, and the key stops working.
2844
+ element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT, multiline: true },
2845
+ hint: { type: 'plain_text', text: 'Paste the whole value. A key file, such as a PEM private key, keeps its line breaks.' },
2446
2846
  },
2447
2847
  ],
2448
2848
  });
@@ -2545,6 +2945,8 @@ export const createSlackChannelAdapter = (options) => {
2545
2945
  const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
2546
2946
  const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
2547
2947
  const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
2948
+ const approvalPromptWaitMs = options.approvalPromptWaitMs ?? DEFAULT_APPROVAL_PROMPT_WAIT_MS;
2949
+ const progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
2548
2950
  const now = options.now ?? Date.now;
2549
2951
  const connections = [];
2550
2952
  // The agents this adapter carries: `options.agents` until a reload reads
@@ -2775,6 +3177,12 @@ export const createSlackChannelAdapter = (options) => {
2775
3177
  // outstanding.
2776
3178
  const rendering = new Set();
2777
3179
  const resolvedWhileRendering = new Map();
3180
+ // Requests whose prompt is waiting for its turn's line to land first
3181
+ // (card 950), and those of them settled in that wait: a request answered
3182
+ // or cancelled before anyone was asked has no prompt to post. Bounded the
3183
+ // same way: an id is here only for one bounded wait.
3184
+ const askingAfterLine = new Set();
3185
+ const settledBeforeAsked = new Set();
2778
3186
  let unsubscribe;
2779
3187
  let gatewayRef;
2780
3188
  // Set as `stop()` begins, so a reload still connecting an app leaves
@@ -4990,7 +5398,7 @@ export const createSlackChannelAdapter = (options) => {
4990
5398
  // last. `NO_REPLY_TEXT` is what a rendered turn with nothing to say
4991
5399
  // puts in its own message too. With nothing queued behind, there is no
4992
5400
  // order to keep and nothing to say, so the files go on their own.
4993
- const chunks = routing.reply ? messageChunks(routing.reply) : [];
5401
+ const chunks = routing.reply ? messageChunks(routing.reply, connection.config.replies) : [];
4994
5402
  const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
4995
5403
  let landed = [];
4996
5404
  const uploads = async () => {
@@ -6958,6 +7366,8 @@ export const createSlackChannelAdapter = (options) => {
6958
7366
  lazy: judged,
6959
7367
  now,
6960
7368
  streaming,
7369
+ progressAfterMs,
7370
+ texts: connection.config.replies === 'texts',
6961
7371
  statusThread: thread ?? event.ts,
6962
7372
  statusWarned: connection.statusWarned,
6963
7373
  ...(ahead ? { after: ahead } : {}),
@@ -7530,18 +7940,14 @@ export const createSlackChannelAdapter = (options) => {
7530
7940
  // reaches none of them, so a read stopping before it stops short.
7531
7941
  liveFrom = NOT_LIVE_YET;
7532
7942
  unsubscribe = gateway.bus.subscribe((event) => {
7533
- // Approval traffic is not part of a reply: it gets its own message
7534
- // with its own lifetime, and routing it through the running turn's
7535
- // renderer would fold a question into the answer being streamed.
7536
- if (event.type === 'tool.approval-requested') {
7537
- track(renderApprovalRequest(event));
7538
- return;
7539
- }
7540
7943
  if (event.type === 'credential.provided') {
7541
7944
  track(settleCredentialPost(event));
7542
7945
  return;
7543
7946
  }
7544
7947
  if (event.type === 'tool.approval-resolved') {
7948
+ if (askingAfterLine.has(event.requestId)) {
7949
+ settledBeforeAsked.add(event.requestId);
7950
+ }
7545
7951
  // The question was posted into the turn's thread, which took the
7546
7952
  // loading status down with it; the turn works on once answered.
7547
7953
  track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
@@ -7594,6 +8000,30 @@ export const createSlackChannelAdapter = (options) => {
7594
8000
  // turn from a renderer still waiting to start, and the renderer
7595
8001
  // keeps what it is handed, as it always has.
7596
8002
  const foreign = exact && !rendered;
8003
+ // Approval traffic is not part of a reply: it gets its own message
8004
+ // with its own lifetime, and routing it through the running turn's
8005
+ // renderer would fold a question into the answer being streamed.
8006
+ // What the turn wrote before the call goes out first, though, so
8007
+ // the approver reads the line saying what the agent is doing above
8008
+ // the question it leads to (card 950). The wait is bounded: a line
8009
+ // Slack is slow to take must not keep the question from anyone.
8010
+ if (event.type === 'tool.approval-requested') {
8011
+ head?.awaitingApproval(event.call.toolName, !foreign);
8012
+ const said = head?.sayBeforeAsking(!foreign);
8013
+ if (!said) {
8014
+ track(renderApprovalRequest(event));
8015
+ return;
8016
+ }
8017
+ askingAfterLine.add(event.requestId);
8018
+ track(settledWithin(said, approvalPromptWaitMs).then(async () => {
8019
+ askingAfterLine.delete(event.requestId);
8020
+ if (settledBeforeAsked.delete(event.requestId)) {
8021
+ return;
8022
+ }
8023
+ await renderApprovalRequest(event);
8024
+ }));
8025
+ return;
8026
+ }
7597
8027
  if (event.type === 'tool.completed' && event.result.ok && (foreign || !head)) {
7598
8028
  const produced = collectFilePaths(event.result);
7599
8029
  if (produced.length > 0) {