@stratusagent/channel-slack 0.3.0 → 0.6.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/dist/index.js CHANGED
@@ -85,12 +85,32 @@ class ReplyRenderer {
85
85
  return;
86
86
  }
87
87
  if (event.type === 'tool.called') {
88
+ // Also a turn boundary. On the kernel path `provider.response` has
89
+ // already marked one, and marking it twice costs nothing; on a
90
+ // provider that hosts its own loop there is no provider.response at
91
+ // all, so this is the only thing between the text before a tool and
92
+ // the text after it — without it they fuse in the live message.
93
+ this.turnBreakPending = true;
88
94
  this.toolLine = `⚙ ${event.call.toolName}…`;
89
95
  this.scheduleEdit();
90
96
  return;
91
97
  }
98
+ if (event.type === 'tool.denied') {
99
+ // Denial settles a call that never reached tool.called, so nothing
100
+ // else clears the status line or marks the boundary for it — the
101
+ // message would keep claiming a refused tool is running, and the
102
+ // model's follow-up would fuse with the text before the attempt.
103
+ this.toolLine = undefined;
104
+ this.turnBreakPending = true;
105
+ this.scheduleEdit();
106
+ return;
107
+ }
92
108
  if (event.type === 'tool.completed') {
93
109
  this.toolLine = undefined;
110
+ // Also a boundary, and not only for symmetry: a call rejected before
111
+ // execution settles as tool.completed without ever having emitted
112
+ // tool.called, so this is the only mark that attempt leaves.
113
+ this.turnBreakPending = true;
94
114
  // Clear the visible status promptly: the next provider turn may be
95
115
  // non-streaming or slow to its first delta, and the message must not
96
116
  // claim a finished tool is still running that whole time.
@@ -251,6 +271,102 @@ const lastAssistantReply = (session) => {
251
271
  }
252
272
  return '(no reply)';
253
273
  };
274
+ const APPROVAL_ACTIONS = {
275
+ stratus_approve_once: 'once',
276
+ stratus_approve_always: 'always',
277
+ stratus_deny: 'deny',
278
+ };
279
+ /**
280
+ * How an approval request reads once it is settled. `always` deliberately
281
+ * says "this session" out loud: the button is the shortest path to widening
282
+ * what an agent does unattended, and an approver should see the scope they
283
+ * granted in the record of granting it.
284
+ */
285
+ const OUTCOME_TEXT = {
286
+ 'decided:once': 'Allowed once',
287
+ 'decided:always': 'Allowed for the rest of this session',
288
+ 'decided:deny': 'Denied',
289
+ 'timeout:deny': 'Expired without an answer — denied',
290
+ // Covers both endings that reach here: the turn was aborted, and the
291
+ // daemon shut down with the request still outstanding.
292
+ 'cancelled:deny': 'Cancelled before anyone answered — denied',
293
+ 'undeliverable:deny': 'Could not be put to an approver — denied',
294
+ };
295
+ /** Comfortably inside Slack's 3000-character section limit. */
296
+ const APPROVAL_INPUT_LIMIT = 1400;
297
+ /** One line in a notification; the blocks carry the readable version. */
298
+ const APPROVAL_SUMMARY_LIMIT = 160;
299
+ /**
300
+ * Slack's three markup characters. Tool input is written by a model, so it
301
+ * reaches this message as untrusted text: unescaped, `<@U123>` becomes a
302
+ * real mention and `<!channel>` a real broadcast, letting an agent ping a
303
+ * workspace through the very prompt asking whether to trust it. Escaping is
304
+ * applied even inside a code block, where clients disagree about whether
305
+ * that markup still resolves.
306
+ */
307
+ const escapeSlackText = (text) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
308
+ /**
309
+ * What the approver is actually being asked to allow.
310
+ *
311
+ * Without this the prompt names only the tool, and for anything whose
312
+ * danger lives in its arguments — the shell tool this engine exists for —
313
+ * `ls` and a destructive command produce an identical message. Approving
314
+ * something you cannot see is not approval.
315
+ *
316
+ * Truncation is announced rather than silent for the same reason: a
317
+ * decision made on a partial view should at least know it is partial.
318
+ */
319
+ const renderInvocation = (input) => {
320
+ if (Object.keys(input).length === 0) {
321
+ return { summary: '', truncated: false };
322
+ }
323
+ const rendered = JSON.stringify(input, null, 2);
324
+ const truncated = rendered.length > APPROVAL_INPUT_LIMIT;
325
+ const oneLine = JSON.stringify(input);
326
+ return {
327
+ detail: truncated ? rendered.slice(0, APPROVAL_INPUT_LIMIT) : rendered,
328
+ summary: oneLine.length > APPROVAL_SUMMARY_LIMIT
329
+ ? `${oneLine.slice(0, APPROVAL_SUMMARY_LIMIT)}…`
330
+ : oneLine,
331
+ truncated,
332
+ };
333
+ };
334
+ const approvalBlocks = (agentName, toolName, risk, requestId, input) => {
335
+ const invocation = renderInvocation(input);
336
+ return [
337
+ {
338
+ type: 'section',
339
+ text: {
340
+ type: 'mrkdwn',
341
+ text: `*${escapeSlackText(agentName)}* wants to run \`${escapeSlackText(toolName)}\` (${risk}).`,
342
+ },
343
+ },
344
+ ...(invocation.detail
345
+ ? [{
346
+ type: 'section',
347
+ text: { type: 'mrkdwn', text: `\`\`\`\n${escapeSlackText(invocation.detail)}\n\`\`\`` },
348
+ }]
349
+ : []),
350
+ ...(invocation.truncated
351
+ ? [{
352
+ type: 'context',
353
+ elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
354
+ }]
355
+ : []),
356
+ {
357
+ type: 'actions',
358
+ // The request id rides on every button rather than in the message
359
+ // reference: Slack gives the value back verbatim, so the gateway is
360
+ // asked about the request that was actually rendered, never one
361
+ // re-derived from a channel and timestamp.
362
+ elements: [
363
+ { type: 'button', action_id: 'stratus_approve_once', text: { type: 'plain_text', text: 'Allow once' }, value: requestId },
364
+ { type: 'button', action_id: 'stratus_approve_always', text: { type: 'plain_text', text: 'Always allow' }, value: requestId, style: 'primary' },
365
+ { type: 'button', action_id: 'stratus_deny', text: { type: 'plain_text', text: 'Deny' }, value: requestId, style: 'danger' },
366
+ ],
367
+ },
368
+ ];
369
+ };
254
370
  /**
255
371
  * The Slack channel adapter. One Slack app per agent — Slack has no way to
256
372
  * give a single bot several identities with real avatars, presence, and
@@ -269,6 +385,9 @@ export const createSlackChannelAdapter = (options) => {
269
385
  const createSocket = options.createSocketClient ?? defaultSocketClient;
270
386
  const createWeb = options.createWebClient ?? defaultWebClient;
271
387
  const connections = [];
388
+ // Every agent this adapter was asked to carry, connected or not — the
389
+ // difference between "mine and broken" and "not mine".
390
+ const configuredAgents = new Set(options.agents.map((agent) => agent.agentId));
272
391
  // Renderers queue per session: two messages in one thread share a
273
392
  // session id, and the gateway serializes their turns — so events route
274
393
  // to the FIRST registered renderer (the running turn), never a later
@@ -284,8 +403,38 @@ export const createSlackChannelAdapter = (options) => {
284
403
  // Socket Mode redelivers on missed acks; a slow turn must not run twice.
285
404
  const seenEvents = new Set();
286
405
  const seenOrder = [];
406
+ // Rendered approval requests, keyed by the gateway's request id — the
407
+ // same id the buttons carry back.
408
+ const approvalPosts = new Map();
409
+ // Requests whose message is mid-post. A request can settle — expire, or
410
+ // its turn be cancelled — while the postMessage that announces it is
411
+ // still in flight, and the retraction would then find nothing to retract
412
+ // and leave live-looking buttons behind forever. Resolutions arriving in
413
+ // that window are held here and applied once the post lands. Bounded by
414
+ // construction: an id is only ever in this set while one HTTP call is
415
+ // outstanding.
416
+ const rendering = new Set();
417
+ const resolvedWhileRendering = new Map();
287
418
  let unsubscribe;
288
419
  let gatewayRef;
420
+ // Everything the adapter owes Slack goes through here, so stop() drains
421
+ // it: an approval question or its retraction must reach the workspace
422
+ // before the sockets close, or a message keeps offering buttons that
423
+ // nothing is listening for.
424
+ const track = (work) => {
425
+ // Also the rejection boundary, because this is where the work stops
426
+ // being anyone's to await: the caller hands it over and returns to the
427
+ // event loop, so a rejection past this point is attached to nothing
428
+ // and takes the daemon down under Node's default. One call site below
429
+ // remembers to catch before handing over, which is exactly the kind of
430
+ // rule that holds until the next call site forgets it -- so it lives
431
+ // here instead, where there is one of it.
432
+ const settled = work.catch((error) => {
433
+ warn(`slack: background work failed: ${error instanceof Error ? error.message : String(error)}`);
434
+ });
435
+ inflight.add(settled);
436
+ void settled.finally(() => inflight.delete(settled));
437
+ };
289
438
  const alreadySeen = (key) => {
290
439
  if (seenEvents.has(key)) {
291
440
  return true;
@@ -329,6 +478,317 @@ export const createSlackChannelAdapter = (options) => {
329
478
  }
330
479
  return result.trim();
331
480
  };
481
+ const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
482
+ /**
483
+ * Renders one parked call as buttons and remembers where it went.
484
+ *
485
+ * A request this adapter cannot route is DENIED here rather than left to
486
+ * expire: with no approver configured, or no conversation to ask in,
487
+ * waiting out the timeout tells the agent nothing it will not be told
488
+ * immediately, and holds the turn (and the thread it is answering) open
489
+ * for the whole window to get there.
490
+ */
491
+ const renderApprovalRequest = async (event) => {
492
+ const gateway = gatewayRef;
493
+ if (!gateway) {
494
+ return;
495
+ }
496
+ const connection = connectionFor(event.agentId);
497
+ if (!connection) {
498
+ // Two different situations, and only one of them is this adapter's
499
+ // to answer.
500
+ //
501
+ // An agent it was configured for but could not connect (a failed
502
+ // auth, a dead app token) is its own: it was supposed to carry that
503
+ // agent's approvals and cannot, so it says so rather than letting
504
+ // every gated call wait out the timeout.
505
+ //
506
+ // An agent it was never configured for is NOT. Approval requests are
507
+ // a broadcast, and another channel may be the one that serves that
508
+ // agent — denying here would let Slack refuse a question somebody
509
+ // else was going to ask. `stratus serve` reports agents that no
510
+ // channel can ask for, at startup, where the whole roster is visible.
511
+ if (configuredAgents.has(event.agentId)) {
512
+ warn(`slack: ${event.agentId} has no live connection; denying ${event.call.toolName}`);
513
+ gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
514
+ }
515
+ return;
516
+ }
517
+ const approvers = new Set((connection.config.approvers ?? []).filter((id) => id.length > 0));
518
+ const metadata = event.metadata ?? {};
519
+ const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
520
+ ? metadata.slackChannel
521
+ : undefined;
522
+ const channel = sameChannel ?? connection.config.approvalChannel;
523
+ const thread = sameChannel && typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
524
+ // Undeliverable, not decided: nobody was asked, so filing this next to
525
+ // the denials somebody actually made would make the audit record lie
526
+ // about which is which.
527
+ const decline = (reason) => {
528
+ warn(`slack: ${reason}; denying ${event.call.toolName} for ${event.agentId}`);
529
+ gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
530
+ };
531
+ if (approvers.size === 0) {
532
+ decline(`no approvers configured for ${event.agentId}`);
533
+ return;
534
+ }
535
+ if (!channel) {
536
+ decline(`no conversation to ask in for ${event.agentId}`);
537
+ return;
538
+ }
539
+ const agentName = gateway.agents().find((agent) => agent.id === event.agentId)?.name ?? event.agentId;
540
+ // The notification preview is all some approvers see before deciding
541
+ // whether to open the thread, so it carries the arguments too — just
542
+ // the short form.
543
+ const invocationSummary = renderInvocation(event.call.input).summary;
544
+ // The post is the only awaited step, and the only window in which the
545
+ // request can settle behind this function's back — so it is the only
546
+ // step `rendering` covers, and nothing decides anything until it is
547
+ // over and the two possible outcomes can be told apart.
548
+ rendering.add(event.requestId);
549
+ let post;
550
+ let failure;
551
+ try {
552
+ const posted = await connection.web.chat.postMessage({
553
+ channel,
554
+ // Fallback text for notifications and clients that do not render
555
+ // blocks — an approval nobody can read is an approval nobody gives.
556
+ text: escapeSlackText(`${agentName} wants to run ${event.call.toolName} (${event.risk})`
557
+ + `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
558
+ ...(thread ? { thread_ts: thread } : {}),
559
+ blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input),
560
+ });
561
+ post = posted.ts
562
+ ? {
563
+ connection,
564
+ channel: posted.channel ?? channel,
565
+ ts: posted.ts,
566
+ ...(thread ? { thread } : {}),
567
+ agentName,
568
+ toolName: event.call.toolName,
569
+ risk: event.risk,
570
+ approvers,
571
+ }
572
+ : undefined;
573
+ failure = post ? undefined : `Slack accepted the approval request for ${event.agentId} without a timestamp`;
574
+ }
575
+ catch (error) {
576
+ failure = `could not post an approval request to ${channel} (${error instanceof Error ? error.message : String(error)})`;
577
+ }
578
+ rendering.delete(event.requestId);
579
+ if (post) {
580
+ approvalPosts.set(event.requestId, post);
581
+ }
582
+ const settledMeanwhile = resolvedWhileRendering.get(event.requestId);
583
+ if (settledMeanwhile) {
584
+ resolvedWhileRendering.delete(event.requestId);
585
+ // Already decided — never decline it a second time; just take the
586
+ // buttons off the message that just appeared.
587
+ if (post) {
588
+ await retractApprovalRequest(settledMeanwhile);
589
+ }
590
+ return;
591
+ }
592
+ if (failure) {
593
+ decline(failure);
594
+ }
595
+ };
596
+ /**
597
+ * Retracts the buttons once a request is settled, however it settled.
598
+ * Every ending goes through here — a click, the timeout, a cancelled turn
599
+ * — so a message can never keep offering a decision that no longer has
600
+ * anywhere to land.
601
+ */
602
+ /**
603
+ * Rewrites a prompt left behind by a daemon that is gone, using the
604
+ * click's own coordinates.
605
+ *
606
+ * The index of posted requests is in-memory and keyed by request id, so
607
+ * a new process starts empty and has no way to find the messages its
608
+ * predecessor posted. An interaction payload carries the channel and
609
+ * timestamp of the message it came from, which is the one handle on such
610
+ * a message that survives — so the click that discovers the prompt is
611
+ * dead is also what retires it. Unclicked orphans stay as they are;
612
+ * clearing those needs the posts to be durable, which is a larger change
613
+ * than this and belongs to both billing paths equally.
614
+ */
615
+ const offersDecision = (blocks) => (blocks ?? []).some((block) => block.type === 'actions');
616
+ const retireOrphanedPrompt = async (connection, args) => {
617
+ const channel = args.body?.channel?.id;
618
+ const ts = args.body?.message?.ts;
619
+ if (!channel || !ts) {
620
+ return;
621
+ }
622
+ // The message decides, not a memory of it. Every ending rewrites this
623
+ // message into a plain section, so buttons still on it mean nothing
624
+ // has been written there yet — and no buttons means something has, and
625
+ // overwriting it would replace a real outcome and the person who made
626
+ // it. Reading the click's own copy answers that with no bookkeeping to
627
+ // disagree with, and no second API call to ask.
628
+ if (!offersDecision(args.body?.message?.blocks)) {
629
+ return;
630
+ }
631
+ const text = 'This request is no longer pending — the daemon that asked it is no longer running.';
632
+ try {
633
+ await connection.web.chat.update({
634
+ channel,
635
+ ts,
636
+ text,
637
+ blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }],
638
+ });
639
+ }
640
+ catch (error) {
641
+ warn(`slack: could not retire an orphaned approval request: ${error instanceof Error ? error.message : String(error)}`);
642
+ }
643
+ };
644
+ const retractApprovalRequest = async (event) => {
645
+ const post = approvalPosts.get(event.requestId);
646
+ if (!post) {
647
+ // Still being posted: hold the outcome so the message it is about to
648
+ // create does not keep offering a decision that is already made.
649
+ if (rendering.has(event.requestId)) {
650
+ resolvedWhileRendering.set(event.requestId, event);
651
+ }
652
+ return;
653
+ }
654
+ approvalPosts.delete(event.requestId);
655
+ const outcome = OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
656
+ const by = event.actor ? ` by <@${event.actor}>` : '';
657
+ const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
658
+ try {
659
+ await post.connection.web.chat.update({
660
+ channel: post.channel,
661
+ ts: post.ts,
662
+ text,
663
+ blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }],
664
+ });
665
+ }
666
+ catch (error) {
667
+ warn(`slack: could not update a resolved approval request: ${error instanceof Error ? error.message : String(error)}`);
668
+ }
669
+ };
670
+ /**
671
+ * Says so in the thread when a turn fails with nobody rendering it.
672
+ *
673
+ * The intake path reports a failure through the renderer it opened, so a
674
+ * turn this process started is always answered. A turn it did NOT start
675
+ * has no renderer: one the startup sweep failed because the last daemon
676
+ * died mid-turn, or one whose recovery could not be finished. Those are
677
+ * exactly the cases where the person is still looking at the thread,
678
+ * and silence there reads as an agent that simply never replied.
679
+ *
680
+ * The routing has to come from the durable session, because the only
681
+ * in-memory copy died with the process that had it.
682
+ */
683
+ const reportUnrenderedFailure = async (event) => {
684
+ const gateway = gatewayRef;
685
+ if (!gateway?.sessionRouting) {
686
+ return;
687
+ }
688
+ let routing;
689
+ try {
690
+ routing = await gateway.sessionRouting(event.sessionId);
691
+ }
692
+ catch (error) {
693
+ // A store that cannot answer is not a reason to lose the daemon, and
694
+ // the turn is already failed — there is nothing here to salvage
695
+ // beyond saying why the thread stayed quiet.
696
+ warn(`slack: could not read the routing for a failed turn: ${error instanceof Error ? error.message : String(error)}`);
697
+ return;
698
+ }
699
+ const metadata = routing?.metadata;
700
+ // Another surface's session, or one with no conversation to speak
701
+ // into. Not this adapter's to answer either way.
702
+ if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
703
+ return;
704
+ }
705
+ const connection = connectionFor(routing.agentId);
706
+ if (!connection) {
707
+ return;
708
+ }
709
+ const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
710
+ try {
711
+ await connection.web.chat.postMessage({
712
+ channel: metadata.slackChannel,
713
+ text: `Something went wrong: ${event.error}`,
714
+ ...(thread ? { thread_ts: thread } : {}),
715
+ });
716
+ }
717
+ catch (error) {
718
+ warn(`slack: could not report a failed turn: ${error instanceof Error ? error.message : String(error)}`);
719
+ }
720
+ };
721
+ const handleInteractive = async (connection, args) => {
722
+ // Ack first and unconditionally: Slack retries an unacked interaction,
723
+ // and a redelivered click on a request that is no longer pending would
724
+ // read as a second, rejected decision.
725
+ await args.ack();
726
+ const gateway = gatewayRef;
727
+ const action = args.body?.actions?.[0];
728
+ const answer = action?.action_id ? APPROVAL_ACTIONS[action.action_id] : undefined;
729
+ const requestId = action?.value;
730
+ if (!gateway || !answer || !requestId) {
731
+ return;
732
+ }
733
+ const post = approvalPosts.get(requestId);
734
+ const clicker = args.body?.user?.id;
735
+ // An unknown request predates this daemon or was already settled; the
736
+ // click is answered where it was made, not silently dropped.
737
+ if (!post) {
738
+ await tellClicker(connection, args, 'That approval request is no longer pending.');
739
+ // And the message itself is corrected, so the next person to read it
740
+ // is not offered a decision nothing is waiting for — unless it is
741
+ // one still being posted, which is live with the index simply not
742
+ // caught up yet. Slack can show a message before postMessage
743
+ // resolves here, so a fast click lands inside that window, and
744
+ // stripping its buttons would strand the turn until the approval
745
+ // times out: the record written when the post lands does not put
746
+ // them back. Whether anything has already been written on the
747
+ // message is `retireOrphanedPrompt`'s own question, answered from
748
+ // the message rather than from a memory of it.
749
+ if (!rendering.has(requestId)) {
750
+ await retireOrphanedPrompt(connection, args);
751
+ }
752
+ return;
753
+ }
754
+ // A different agent's app relaying a click would bypass the approver
755
+ // set bound to the request, since each app carries its own.
756
+ if (post.connection !== connection) {
757
+ return;
758
+ }
759
+ if (!clicker || !post.approvers.has(clicker)) {
760
+ // The request stays pending: someone who may not decide clicking is
761
+ // not a decision, and consuming the request would let any channel
762
+ // member deny an agent's work.
763
+ await tellClicker(connection, args, `You are not an approver for ${post.agentName}, so that decision was not recorded.`);
764
+ return;
765
+ }
766
+ // The retraction rides on tool.approval-resolved, which the gateway
767
+ // emits from resolveApproval — so a decision and the message showing it
768
+ // cannot disagree, and the timeout path retracts through the same code.
769
+ if (!gateway.resolveApproval({ requestId, answer, actor: clicker })) {
770
+ await tellClicker(connection, args, 'That approval request is no longer pending.');
771
+ }
772
+ };
773
+ const tellClicker = async (connection, args, text) => {
774
+ const user = args.body?.user?.id;
775
+ const channel = args.body?.channel?.id;
776
+ if (!user || !channel) {
777
+ return;
778
+ }
779
+ const thread = args.body?.message?.thread_ts;
780
+ try {
781
+ await connection.web.chat.postEphemeral({
782
+ channel,
783
+ user,
784
+ text,
785
+ ...(thread ? { thread_ts: thread } : {}),
786
+ });
787
+ }
788
+ catch (error) {
789
+ warn(`slack: could not send an ephemeral notice: ${error instanceof Error ? error.message : String(error)}`);
790
+ }
791
+ };
332
792
  const handleInbound = async (connection, args) => {
333
793
  // Ack immediately: Slack redelivers unacked envelopes, and a turn can
334
794
  // outlive the ack window many times over.
@@ -396,7 +856,15 @@ export const createSlackChannelAdapter = (options) => {
396
856
  sessionId,
397
857
  agentId: connection.config.agentId,
398
858
  userMessage,
399
- metadata: { channel: 'slack', team, slackChannel: event.channel, slackUser: userId },
859
+ metadata: {
860
+ channel: 'slack',
861
+ team,
862
+ slackChannel: event.channel,
863
+ slackUser: userId,
864
+ // Carried so a mid-turn approval question is asked in the thread
865
+ // the turn belongs to rather than at the top of a busy channel.
866
+ ...(thread ? { slackThread: thread } : {}),
867
+ },
400
868
  });
401
869
  return { renderer, turn };
402
870
  });
@@ -448,6 +916,39 @@ export const createSlackChannelAdapter = (options) => {
448
916
  async start(gateway) {
449
917
  gatewayRef = gateway;
450
918
  unsubscribe = gateway.bus.subscribe((event) => {
919
+ // Approval traffic is not part of a reply: it gets its own message
920
+ // with its own lifetime, and routing it through the running turn's
921
+ // renderer would fold a question into the answer being streamed.
922
+ if (event.type === 'tool.approval-requested') {
923
+ track(renderApprovalRequest(event));
924
+ return;
925
+ }
926
+ if (event.type === 'tool.approval-resolved') {
927
+ track(retractApprovalRequest(event));
928
+ return;
929
+ }
930
+ // A failure with no renderer is not a turn this process is
931
+ // serving — it is one the startup sweep or a failed recovery
932
+ // closed out. Nothing downstream would say anything about it.
933
+ //
934
+ // A non-empty queue is not proof the failure belongs to a turn
935
+ // this adapter dispatched, only that it has one for this session.
936
+ // A renderer is queued at intake, before the gateway starts the
937
+ // turn it belongs to, so a message arriving while a recovery is
938
+ // still ahead of it on the session chain leaves the recovery's
939
+ // failure looking rendered when it is not. Suppressing is the
940
+ // conservative half of that trade: the alternative reports every
941
+ // ordinary failure twice.
942
+ //
943
+ // Telling them apart needs a turn identifier, which `StratusEvent`
944
+ // does not carry — the same gap 05's WS envelope closes with
945
+ // `{ sessionId, turnId, event }`. The routing a line below has it
946
+ // too: recovery events are already delivered to whichever renderer
947
+ // is at the head of the queue, whether or not it is theirs.
948
+ if (event.type === 'session.failed' && !renderers.get(event.sessionId)?.length) {
949
+ track(reportUnrenderedFailure(event));
950
+ return;
951
+ }
451
952
  if ('sessionId' in event) {
452
953
  renderers.get(event.sessionId)?.[0]?.onEvent(event);
453
954
  }
@@ -478,6 +979,11 @@ export const createSlackChannelAdapter = (options) => {
478
979
  };
479
980
  socket.on('app_mention', onEvent);
480
981
  socket.on('message', onEvent);
982
+ socket.on('interactive', (args) => {
983
+ track(handleInteractive(connection, args).catch((error) => {
984
+ warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
985
+ }));
986
+ });
481
987
  await socket.start();
482
988
  connections.push(connection);
483
989
  log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
@@ -489,15 +995,26 @@ export const createSlackChannelAdapter = (options) => {
489
995
  }
490
996
  },
491
997
  async stop() {
492
- // Stop intake first: with the sockets down no new event can slip
493
- // into `inflight` after the drain snapshot below, so nothing posts
494
- // to Slack after stop() returns.
998
+ // Stop intake first, so no new INBOUND event can slip into
999
+ // `inflight` after the drain snapshot below.
1000
+ //
1001
+ // That is narrower than it used to claim. The snapshot is taken
1002
+ // once, and this unsubscribes from the bus only after it, so a turn
1003
+ // still finishing can emit an event whose subscriber tracks work
1004
+ // this drain never waits for — a retraction posted after stop()
1005
+ // returns, or dropped with the process. Draining until `inflight`
1006
+ // stays empty would close it, and would make stop() wait on turns
1007
+ // the gateway drains after this call rather than before; worth doing
1008
+ // deliberately, not as a side effect.
495
1009
  await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
496
1010
  await Promise.allSettled([...inflight]);
497
1011
  unsubscribe?.();
498
1012
  unsubscribe = undefined;
499
1013
  gatewayRef = undefined;
500
1014
  connections.length = 0;
1015
+ approvalPosts.clear();
1016
+ rendering.clear();
1017
+ resolvedWhileRendering.clear();
501
1018
  },
502
1019
  };
503
1020
  };