@stratusagent/channel-slack 0.12.5 → 0.13.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
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { lstat, mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
3
+ import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  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_TOTAL_BYTES, escapeControlCharacters, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
@@ -27,6 +27,17 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
27
27
  * same message, and the addressee record answers for every message after.
28
28
  */
29
29
  const COLD_VERDICT_CAPACITY = 256;
30
+ /**
31
+ * How many of one person's mentions one thread remembers (card 541). Only
32
+ * has to span the lag between two sockets, like `THREAD_HANDOVER_DEPTH`.
33
+ */
34
+ const SENDER_ADDRESSEE_DEPTH = 8;
35
+ /**
36
+ * How many people one thread remembers an addressee for. Past this the
37
+ * least recently heard is forgotten, and their next untagged reply goes to
38
+ * the thread's owner, as after a restart.
39
+ */
40
+ const THREAD_SENDER_CAPACITY = 64;
30
41
  /**
31
42
  * How many claims on one thread it remembers.
32
43
  *
@@ -44,6 +55,17 @@ const COLD_VERDICT_CAPACITY = 256;
44
55
  * socket stays behind for all of them.
45
56
  */
46
57
  const THREAD_HANDOVER_DEPTH = 32;
58
+ /**
59
+ * How far past this host's clock at `start()` claims count as seen live
60
+ * (card 562). Slack stamps messages by its own clock, so a host clock
61
+ * behind Slack's would date a message sent just before the restart as
62
+ * after it, and a read stopping there would look complete. Reads for
63
+ * messages in this first stretch are treated as stopping short, which
64
+ * costs a thread read or two, never a wrong placement.
65
+ */
66
+ const LIVE_FROM_MARGIN_MS = 60_000;
67
+ /** `liveFrom` while `start()` runs: later than any message. */
68
+ const NOT_LIVE_YET = '9999999999.999999';
47
69
  /**
48
70
  * How many agent mentions one thread admits in a row before a person has
49
71
  * to speak there again (card 513): enough for a question, an answer and a
@@ -69,17 +91,6 @@ const toolStatus = (toolName) => `is running ${toolName}…`;
69
91
  * approval wait — is exactly the one whose status must not vanish.
70
92
  */
71
93
  const STATUS_REFRESH_MS = 60_000;
72
- /**
73
- * How long an agent that judges stays attentive after it last answered a
74
- * message that addressed it: this many messages, or this many minutes,
75
- * whichever ends first. Judging costs
76
- * a model call per message, and two people talking for an hour is a
77
- * hundred of them for one "no" — so attention decays the way a person's
78
- * does. Outside the window a message is overheard for free, and a mention
79
- * re-arms it, which is what a mention has always meant.
80
- */
81
- const ATTENTION_MESSAGES = 8;
82
- const ATTENTION_MS = 15 * 60 * 1000;
83
94
  /**
84
95
  * A 5 MB image over a slow link is seconds, not minutes. Past this the
85
96
  * downloads are abandoned and the turn runs with the attachments named as
@@ -114,6 +125,33 @@ const EARLIER_CHANNEL_LIMIT = 20;
114
125
  const EARLIER_CHANNEL_READ = 100;
115
126
  /** The most pages read to find them: 1,000 messages. */
116
127
  const EARLIER_CHANNEL_PAGES = 10;
128
+ /** What a person who wrote during a restart is told, when it was kept. */
129
+ export const RESTARTING_REPLY_TEXT = "I'm restarting right now. I'll answer this as soon as I'm back.";
130
+ /** The same, for a daemon that is stopping rather than restarting. */
131
+ export const STOPPING_REPLY_TEXT = "I'm shutting down right now. I'll answer this when I'm started again.";
132
+ /**
133
+ * How old a kept message may be and still be answered at start: a daemon
134
+ * stopped for a week does not wake up to answer last week's questions.
135
+ */
136
+ const PENDING_INTAKE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
137
+ /**
138
+ * How long the start-up replay waits on Slack to read one kept message
139
+ * back. Past it, that message (and any kept after it in its conversation)
140
+ * is left for the next start rather than answered late; see `replayHolds`.
141
+ */
142
+ const REPLAY_READ_MAX_MS = 10_000;
143
+ /** The most messages kept at once; past it, a new one is refused, not kept. */
144
+ const PENDING_INTAKE_MAX = 200;
145
+ const isPendingIntake = (value) => {
146
+ if (typeof value !== 'object' || value === null) {
147
+ return false;
148
+ }
149
+ const entry = value;
150
+ const optionalString = (field) => field === undefined || typeof field === 'string';
151
+ return typeof entry.agentId === 'string' && typeof entry.team === 'string' && typeof entry.channel === 'string'
152
+ && typeof entry.ts === 'string' && typeof entry.user === 'string' && typeof entry.keptAt === 'number'
153
+ && optionalString(entry.threadTs) && optionalString(entry.channelType);
154
+ };
117
155
  // Lazy (and CJS-interoperable) so tests with injected fakes never load the
118
156
  // real SDK stack.
119
157
  const requireModule = createRequire(import.meta.url);
@@ -1046,19 +1084,19 @@ class ReplyRenderer {
1046
1084
  // that would take the next turn's status down with it.
1047
1085
  await this.statusChain;
1048
1086
  }
1049
- return this.outcome(published, landed || this.edited || this.uploaded || stranded);
1087
+ return this.outcome(published, landed || this.edited || this.uploaded || stranded, !published);
1050
1088
  }
1051
1089
  placeText(ts) {
1052
1090
  if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
1053
1091
  this.textAt = ts;
1054
1092
  }
1055
1093
  }
1056
- outcome(published, spoke) {
1094
+ outcome(published, spoke, refused = false) {
1057
1095
  const spokeAt = [this.textAt, this.uploadedAt]
1058
1096
  .filter((ts) => ts !== undefined)
1059
1097
  .sort()
1060
1098
  .at(-1);
1061
- return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}) };
1099
+ return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}), ...(refused ? { refused: true } : {}) };
1062
1100
  }
1063
1101
  /**
1064
1102
  * Take back the placeholder a turn nobody asked for opened and then had
@@ -2188,6 +2226,13 @@ const compareSlackTs = (left, right) => {
2188
2226
  }
2189
2227
  return Math.sign(Number(leftMicros.padEnd(6, '0').slice(0, 6)) - Number(rightMicros.padEnd(6, '0').slice(0, 6)));
2190
2228
  };
2229
+ /** What a Slack stop aborts a turn with, followed by the stopper's user id. */
2230
+ const SLACK_STOP_REASON = 'Stopped from Slack by ';
2231
+ /**
2232
+ * Who stopped a turn, read back from the error it failed with: the gateway
2233
+ * fails an aborted turn with the abort's reason verbatim.
2234
+ */
2235
+ const slackStopper = (error) => error?.startsWith(SLACK_STOP_REASON) ? error.slice(SLACK_STOP_REASON.length) || undefined : undefined;
2191
2236
  export const createSlackChannelAdapter = (options) => {
2192
2237
  const log = options.log ?? (() => { });
2193
2238
  const warn = options.warn ?? (() => { });
@@ -2283,6 +2328,26 @@ export const createSlackChannelAdapter = (options) => {
2283
2328
  * corrects it.
2284
2329
  */
2285
2330
  const threadAddressee = new Map();
2331
+ /**
2332
+ * Per thread whose record has been cut in the middle (`trimHandovers`),
2333
+ * the first claim kept after the cut. Who held the thread between its
2334
+ * first claim and that one is no longer known here (card 559): a message
2335
+ * from then is left to the sessions and a thread read, rather than
2336
+ * placed by the first claim, or the latest take, that outlived the cut.
2337
+ * Goes with the thread's record.
2338
+ */
2339
+ const threadTrimmedUntil = new Map();
2340
+ /**
2341
+ * Per thread the record was warmed from a thread read (#369), the last
2342
+ * message a read covered. A read stops at the message it places, so one
2343
+ * for a message from before this process started (delivered late, or a
2344
+ * replay after a restart) leaves out every take between that message and
2345
+ * the start (card 562). `liveFrom` is when every socket was up, plus a
2346
+ * margin for clock skew: claims since were seen live. Goes with the
2347
+ * thread's record.
2348
+ */
2349
+ const threadReadUntil = new Map();
2350
+ let liveFrom;
2286
2351
  /**
2287
2352
  * Threads whose owner this process can name (card 508): `true` for one
2288
2353
  * whose first message it saw, so the first claim in `threadAddressee` is
@@ -2293,6 +2358,67 @@ export const createSlackChannelAdapter = (options) => {
2293
2358
  * the addressee map; a lost entry costs a lookup.
2294
2359
  */
2295
2360
  const threadOrigins = new Map();
2361
+ /**
2362
+ * Per thread, the agent each person last addressed by name, and when
2363
+ * (card 541). An untagged reply from that person is that agent's —
2364
+ * `@Bea your turn`, then `and?`, is Bea's — ahead of the thread's owner,
2365
+ * who keeps every reply from someone who has not addressed anyone else.
2366
+ * Recorded by every socket from the message itself, before any refusal,
2367
+ * as a handover is, so the sockets agree. Bounded like the addressee map;
2368
+ * a lost entry, or a restart, falls back to the owner.
2369
+ */
2370
+ const senderAddressees = new Map();
2371
+ const rememberSenderAddressee = (key, sender, agentId, ts) => {
2372
+ const senders = senderAddressees.get(key) ?? new Map();
2373
+ // Kept in timestamp order, so a socket that is behind can still find the
2374
+ // mention that preceded the reply it is only now reaching. A late
2375
+ // delivery of an older mention slots in where it belongs.
2376
+ const history = (senders.get(sender) ?? []).filter((entry) => entry.ts !== ts);
2377
+ let at = history.length;
2378
+ while (at > 0 && history[at - 1].ts > ts) {
2379
+ at -= 1;
2380
+ }
2381
+ history.splice(at, 0, { agentId, ts });
2382
+ if (history.length > SENDER_ADDRESSEE_DEPTH) {
2383
+ history.splice(0, history.length - SENDER_ADDRESSEE_DEPTH);
2384
+ }
2385
+ // Most recently heard sender last, so the first key is the one to drop.
2386
+ senders.delete(sender);
2387
+ senders.set(sender, history);
2388
+ if (senders.size > THREAD_SENDER_CAPACITY) {
2389
+ const oldest = senders.keys().next();
2390
+ if (!oldest.done) {
2391
+ senders.delete(oldest.value);
2392
+ }
2393
+ }
2394
+ senderAddressees.delete(key);
2395
+ senderAddressees.set(key, senders);
2396
+ if (senderAddressees.size > THREAD_ADDRESSEE_CAPACITY) {
2397
+ const oldest = senderAddressees.keys().next();
2398
+ if (!oldest.done) {
2399
+ senderAddressees.delete(oldest.value);
2400
+ }
2401
+ }
2402
+ };
2403
+ /**
2404
+ * The agent `sender` last addressed in thread `key` before `ts`, if this
2405
+ * process saw it. A reply older than every mention still remembered for
2406
+ * that sender has nothing to go on, and falls back to the owner.
2407
+ */
2408
+ const senderAddresseeAt = (key, sender, ts) => {
2409
+ const history = senderAddressees.get(key)?.get(sender);
2410
+ if (!history) {
2411
+ return undefined;
2412
+ }
2413
+ for (let i = history.length - 1; i >= 0; i -= 1) {
2414
+ if (history[i].ts < ts) {
2415
+ return history[i].agentId;
2416
+ }
2417
+ }
2418
+ return undefined;
2419
+ };
2420
+ /** Whose an untagged reply from `sender` at `ts` is: who they last addressed, else the owner. */
2421
+ const replyTargetAt = (key, sender, ts) => senderAddresseeAt(key, sender, ts) ?? ownerAt(key, ts);
2296
2422
  /**
2297
2423
  * One cold verdict per message — see `followUpWinner`. Bounded, because
2298
2424
  * an entry outlives its own resolution on purpose; an entry that has not
@@ -2361,6 +2487,332 @@ export const createSlackChannelAdapter = (options) => {
2361
2487
  inflight.add(settled);
2362
2488
  void settled.finally(() => inflight.delete(settled));
2363
2489
  };
2490
+ /**
2491
+ * The kept messages (see `pendingIntakePath`), read and written one change
2492
+ * at a time so two refusals landing together cannot drop each other.
2493
+ */
2494
+ const pendingIntakePath = options.pendingIntakePath;
2495
+ let pendingChain = Promise.resolve();
2496
+ const readPending = async (file) => {
2497
+ let raw;
2498
+ try {
2499
+ raw = await readFile(file, 'utf8');
2500
+ }
2501
+ catch (error) {
2502
+ if (error.code === 'ENOENT') {
2503
+ return [];
2504
+ }
2505
+ throw error;
2506
+ }
2507
+ try {
2508
+ const parsed = JSON.parse(raw);
2509
+ if (Array.isArray(parsed)) {
2510
+ return parsed.filter(isPendingIntake);
2511
+ }
2512
+ }
2513
+ catch {
2514
+ // Named below, and replaced by the next write.
2515
+ }
2516
+ warn(`slack: ${file} is not a list of kept messages; ignoring it`);
2517
+ return [];
2518
+ };
2519
+ const changePending = (change) => {
2520
+ const file = pendingIntakePath;
2521
+ if (file === undefined) {
2522
+ return Promise.reject(new Error('no pendingIntakePath'));
2523
+ }
2524
+ const run = pendingChain.then(async () => {
2525
+ const before = await readPending(file);
2526
+ const { entries, result } = change(before);
2527
+ if (entries.length === 0) {
2528
+ await rm(file, { force: true });
2529
+ }
2530
+ else {
2531
+ // Whole or not at all: a crash mid-write must not leave half a list
2532
+ // that the next start reads as no list.
2533
+ const temporary = `${file}.${process.pid}.tmp`;
2534
+ await writeFile(temporary, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
2535
+ await rename(temporary, file);
2536
+ }
2537
+ return result;
2538
+ });
2539
+ pendingChain = run.catch(() => undefined);
2540
+ return run;
2541
+ };
2542
+ const pendingKey = (entry) => `${entry.agentId}:${entry.team}:${entry.channel}:${entry.ts}`;
2543
+ /**
2544
+ * Kept messages being replayed, by `pendingKey`. A replayed message comes
2545
+ * off the list only once its answer is confirmed (`answered`): an answer
2546
+ * or failure Slack took, a turn that chose to say nothing, or the
2547
+ * start-up recovery's own report of it reaching the thread. Every other
2548
+ * way a handler can end leaves it kept, so a path that forgets to say
2549
+ * how it went keeps the message rather than losing it. A message kept
2550
+ * again by a restart during the replay (`rekept`) stays kept too. The
2551
+ * next start hands it over again, and the gateway repeats the finished
2552
+ * turn, so the answer is posted from the session rather than run again.
2553
+ * `dispatched` and `refused` only choose the warning.
2554
+ */
2555
+ const replaying = new Map();
2556
+ /** Notes a reply outcome against the kept message it answers, if that one is being replayed. */
2557
+ const noteReplayOutcome = (connection, channel, ts, outcome) => {
2558
+ const replay = replaying.get(pendingKey({ agentId: connection.config.agentId, team: connection.teamId, channel, ts }));
2559
+ if (replay !== undefined) {
2560
+ if (outcome.refused === true) {
2561
+ replay.refused = true;
2562
+ }
2563
+ else {
2564
+ replay.answered = true;
2565
+ }
2566
+ }
2567
+ return outcome;
2568
+ };
2569
+ /** Confirms that a replayed message was answered by a report posted ahead of its own renderer. */
2570
+ const noteReplayAnsweredAhead = (connection, channel, ts) => {
2571
+ const replay = replaying.get(pendingKey({ agentId: connection.config.agentId, team: connection.teamId, channel, ts }));
2572
+ if (replay !== undefined) {
2573
+ replay.answered = true;
2574
+ }
2575
+ };
2576
+ /**
2577
+ * Live intake for a conversation (agent and channel) that has kept
2578
+ * messages waits here until each of them has taken its place on its
2579
+ * session's chain or been left for the next start, so a kept message is
2580
+ * never answered after one sent later in the same conversation. Other
2581
+ * conversations are not held. There is no timeout on the hold itself:
2582
+ * the bound is on each read (`REPLAY_READ_MAX_MS`), and a read that
2583
+ * outlasts it gives up its message for this run, which releases the hold.
2584
+ */
2585
+ const replayHolds = new Map();
2586
+ const holdKey = (agentId, channel) => `${agentId}:${channel}`;
2587
+ /**
2588
+ * Whether `connection` is the one to replay `entry` through: the same
2589
+ * agent in the workspace it was kept from. Channel and user ids belong to
2590
+ * a workspace, so an agent whose credentials moved to another one leaves
2591
+ * the entry kept (and its day-old expiry) rather than reading or answering
2592
+ * whatever has that id there.
2593
+ */
2594
+ const keptFor = (connection, entry) => connection.config.agentId === entry.agentId && connection.teamId === entry.team;
2595
+ const holdConversation = (key) => {
2596
+ if (replayHolds.has(key)) {
2597
+ return;
2598
+ }
2599
+ let release = () => { };
2600
+ const held = new Promise((resolve) => {
2601
+ release = () => {
2602
+ replayHolds.delete(key);
2603
+ resolve();
2604
+ };
2605
+ });
2606
+ replayHolds.set(key, { held, release });
2607
+ };
2608
+ const releaseConversation = (key) => replayHolds.get(key)?.release();
2609
+ /** Aborted by `stop()`, which ends a replay still reading kept messages back. */
2610
+ let replayStopping;
2611
+ const releaseAllConversations = () => {
2612
+ for (const { release } of [...replayHolds.values()]) {
2613
+ release();
2614
+ }
2615
+ };
2616
+ /** Keeps a refused message for the next start; resolves with whether it was kept. */
2617
+ const keepForNextStart = async (entry) => {
2618
+ if (pendingIntakePath === undefined) {
2619
+ return false;
2620
+ }
2621
+ try {
2622
+ // Every kept message was told it will be answered, so a full list
2623
+ // never evicts one of them: it makes room only from messages too old
2624
+ // to be answered at start anyway, and otherwise refuses this one, which
2625
+ // then gets the plain refusal and no promise.
2626
+ const { full, expired } = await changePending((entries) => {
2627
+ const key = pendingKey(entry);
2628
+ let others = entries.filter((existing) => pendingKey(existing) !== key);
2629
+ let expired = [];
2630
+ if (others.length >= PENDING_INTAKE_MAX) {
2631
+ const cutoff = now() - PENDING_INTAKE_MAX_AGE_MS;
2632
+ expired = others.filter((existing) => existing.keptAt < cutoff);
2633
+ others = others.filter((existing) => existing.keptAt >= cutoff);
2634
+ }
2635
+ if (others.length >= PENDING_INTAKE_MAX) {
2636
+ return { entries, result: { full: true, expired: [] } };
2637
+ }
2638
+ return { entries: [...others, entry], result: { full: false, expired } };
2639
+ });
2640
+ for (const dropped of expired) {
2641
+ log(`slack: ${dropped.agentId} dropped ${dropped.ts} in ${dropped.channel} kept from a restart: older than a day`);
2642
+ }
2643
+ if (full) {
2644
+ warn(`slack: could not keep ${entry.ts} in ${entry.channel} for after the restart: ${PENDING_INTAKE_MAX} messages are already kept`);
2645
+ return false;
2646
+ }
2647
+ const replay = replaying.get(pendingKey(entry));
2648
+ if (replay !== undefined) {
2649
+ replay.rekept = true;
2650
+ }
2651
+ return true;
2652
+ }
2653
+ catch (error) {
2654
+ warn(`slack: could not keep ${entry.ts} in ${entry.channel} for after the restart: ${error instanceof Error ? error.message : String(error)}`);
2655
+ return false;
2656
+ }
2657
+ };
2658
+ /** The kept message itself, as Slack has it now; `undefined` when it is gone. */
2659
+ const fetchKept = async (connection, entry) => {
2660
+ const { history, replies } = connection.web.conversations;
2661
+ const inThread = entry.threadTs !== undefined && entry.threadTs !== entry.ts;
2662
+ const range = { channel: entry.channel, oldest: entry.ts, latest: entry.ts, inclusive: true };
2663
+ const page = inThread
2664
+ ? await replies?.call(connection.web.conversations, { ...range, ts: entry.threadTs, limit: 2 })
2665
+ : await history?.call(connection.web.conversations, { ...range, limit: 1 });
2666
+ if (page === undefined) {
2667
+ throw new Error('this Slack client cannot read conversations');
2668
+ }
2669
+ return page.messages?.find((message) => message.ts === entry.ts);
2670
+ };
2671
+ /** One kept message read back with a bound; `timedOut` when Slack took longer than that. */
2672
+ const fetchKeptWithin = async (connection, entry) => {
2673
+ let timer;
2674
+ const late = new Promise((resolve) => {
2675
+ timer = setTimeout(() => resolve({ timedOut: true }), REPLAY_READ_MAX_MS);
2676
+ });
2677
+ try {
2678
+ return await Promise.race([
2679
+ fetchKept(connection, entry).then((message) => (message !== undefined ? { message } : {})),
2680
+ late,
2681
+ ]);
2682
+ }
2683
+ finally {
2684
+ clearTimeout(timer);
2685
+ }
2686
+ };
2687
+ /**
2688
+ * What the last daemon kept, handled as if it had just arrived: through
2689
+ * admission, the thread rules, and a dispatch keyed as the original
2690
+ * delivery would have been, so a message the gateway already ran is a
2691
+ * repeat there and not a second turn. That makes handing one over twice
2692
+ * safe and losing one not, so each comes off the list only once its
2693
+ * handler has reached the gateway and settled: a crash, or a failure
2694
+ * before the dispatch (the reply could not be started), leaves it for the
2695
+ * next start, and one kept again by a restart during the replay stays
2696
+ * kept. A message deleted since is dropped.
2697
+ *
2698
+ * Each conversation is replayed in ts order while its live intake is held
2699
+ * (`replayHolds`), and released once its kept messages are handed over.
2700
+ * A read that fails or outlasts `REPLAY_READ_MAX_MS` leaves that message,
2701
+ * and the ones kept after it in the conversation, for the next start:
2702
+ * handing a later one over first, or the slow one over after live
2703
+ * messages were let in, would answer them out of order.
2704
+ */
2705
+ const replayKept = async (kept, onEvent, stopping) => {
2706
+ if (pendingIntakePath === undefined || kept.length === 0) {
2707
+ return;
2708
+ }
2709
+ const cutoff = now() - PENDING_INTAKE_MAX_AGE_MS;
2710
+ const expired = kept.filter((entry) => entry.keptAt < cutoff);
2711
+ for (const entry of expired) {
2712
+ log(`slack: ${entry.agentId} dropped ${entry.ts} in ${entry.channel} kept from a restart: older than a day`);
2713
+ }
2714
+ // Not waited on before the reads: the list's writes are serialized, so
2715
+ // this one lands ahead of any the replay makes, and the reads start
2716
+ // inside start() (a stop right after it still sees each conversation's
2717
+ // first one through).
2718
+ const old = new Set(expired.map(pendingKey));
2719
+ const droppingExpired = expired.length > 0
2720
+ ? changePending((entries) => ({ entries: entries.filter((entry) => !old.has(pendingKey(entry))), result: undefined }))
2721
+ .catch((error) => warn(`slack: could not drop the day-old messages kept from a restart; the next start drops them: ${error instanceof Error ? error.message : String(error)}`))
2722
+ : Promise.resolve();
2723
+ const conversations = new Map();
2724
+ for (const entry of kept.filter((candidate) => candidate.keptAt >= cutoff)) {
2725
+ const elsewhere = connections.find((connection) => connection.config.agentId === entry.agentId && !keptFor(connection, entry));
2726
+ if (elsewhere !== undefined) {
2727
+ warn(`slack: ${entry.agentId} kept ${entry.ts} in ${entry.channel} from workspace ${entry.team}, but is now connected to ${elsewhere.teamId}; keeping it for a start in that workspace`);
2728
+ continue;
2729
+ }
2730
+ const key = holdKey(entry.agentId, entry.channel);
2731
+ conversations.set(key, [...(conversations.get(key) ?? []), entry]);
2732
+ }
2733
+ const handovers = [];
2734
+ const replayConversation = async (key, entries) => {
2735
+ try {
2736
+ entries.sort((a, b) => compareSlackTs(a.ts, b.ts));
2737
+ const agentId = entries[0].agentId;
2738
+ const connection = connections.find((candidate) => keptFor(candidate, entries[0]));
2739
+ const handle = onEvent.get(agentId);
2740
+ if (connection === undefined || handle === undefined) {
2741
+ return;
2742
+ }
2743
+ for (const [index, entry] of entries.entries()) {
2744
+ // A stop ends the replay at the next message: what it has not read
2745
+ // yet stays kept for the next start, so the stop waits on at most
2746
+ // the one bounded read in progress per conversation, not a chain
2747
+ // of them.
2748
+ if (stopping.aborted) {
2749
+ log(`slack: ${agentId} stopping; leaving ${entries.length - index} kept message(s) in ${entry.channel} for the next start`);
2750
+ return;
2751
+ }
2752
+ let read;
2753
+ try {
2754
+ read = await fetchKeptWithin(connection, entry);
2755
+ }
2756
+ catch (error) {
2757
+ warn(`slack: could not read ${entry.ts} in ${entry.channel} kept from the last restart; keeping it and ${entries.length - index - 1} after it for the next start: ${error instanceof Error ? error.message : String(error)}`);
2758
+ return;
2759
+ }
2760
+ if (read.timedOut === true) {
2761
+ warn(`slack: reading ${entry.ts} in ${entry.channel} kept from the last restart took over ${REPLAY_READ_MAX_MS / 1000}s; keeping it and ${entries.length - index - 1} after it for the next start`);
2762
+ return;
2763
+ }
2764
+ const message = read.message;
2765
+ if (message === undefined) {
2766
+ const gone = pendingKey(entry);
2767
+ await changePending((current) => ({ entries: current.filter((kept) => pendingKey(kept) !== gone), result: undefined }));
2768
+ log(`slack: ${entry.agentId} dropped ${entry.ts} in ${entry.channel} kept from a restart: no longer in Slack`);
2769
+ continue;
2770
+ }
2771
+ log(`slack: ${entry.agentId} replaying ${entry.ts} in ${entry.channel}, sent during the last restart`);
2772
+ const event = {
2773
+ ...message,
2774
+ type: 'message',
2775
+ channel: entry.channel,
2776
+ ts: entry.ts,
2777
+ ...(entry.threadTs !== undefined ? { thread_ts: entry.threadTs } : {}),
2778
+ ...(entry.channelType !== undefined ? { channel_type: entry.channelType } : {}),
2779
+ };
2780
+ const replayKey = pendingKey(entry);
2781
+ const replay = { dispatched: false, rekept: false, refused: false, answered: false };
2782
+ replaying.set(replayKey, replay);
2783
+ // Not waited on: the handler claims its place on its session's
2784
+ // chain synchronously, as a live delivery does, so the next kept
2785
+ // message (and, after the last, the held live ones) queue behind it.
2786
+ const handled = handle({ ack: async () => { }, body: { team_id: entry.team }, event });
2787
+ handovers.push(handled.catch(() => undefined).then(async () => {
2788
+ replaying.delete(replayKey);
2789
+ if (replay.rekept) {
2790
+ return;
2791
+ }
2792
+ if (!replay.dispatched) {
2793
+ warn(`slack: ${entry.agentId} could not hand ${entry.ts} in ${entry.channel} kept from a restart to the gateway; keeping it for the next start`);
2794
+ return;
2795
+ }
2796
+ if (replay.refused) {
2797
+ warn(`slack: ${entry.agentId} could not post the answer to ${entry.ts} in ${entry.channel} kept from a restart; keeping it for the next start`);
2798
+ return;
2799
+ }
2800
+ if (!replay.answered) {
2801
+ warn(`slack: ${entry.agentId} has no confirmed answer to ${entry.ts} in ${entry.channel} kept from a restart; keeping it for the next start`);
2802
+ return;
2803
+ }
2804
+ await changePending((current) => ({ entries: current.filter((kept) => pendingKey(kept) !== replayKey), result: undefined }));
2805
+ }));
2806
+ }
2807
+ }
2808
+ finally {
2809
+ releaseConversation(key);
2810
+ }
2811
+ };
2812
+ await Promise.all([...conversations].map(([key, entries]) => replayConversation(key, entries)));
2813
+ await Promise.all(handovers);
2814
+ await droppingExpired;
2815
+ };
2364
2816
  const alreadySeen = (key) => {
2365
2817
  if (seenEvents.has(key)) {
2366
2818
  return true;
@@ -2813,69 +3265,6 @@ export const createSlackChannelAdapter = (options) => {
2813
3265
  * nothing, which is every soul written before there was a choice.
2814
3266
  */
2815
3267
  const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
2816
- /**
2817
- * Whether an agent that judges is still paying attention to a thread:
2818
- * within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
2819
- * answered a message that addressed it, both read from the session
2820
- * rather than remembered here, so a restart forgets nothing. Anchored
2821
- * to an addressed answer and not to any reply: a reply the agent chose
2822
- * to give on a turn nobody asked for does not re-arm it, or a talkative
2823
- * judge would keep itself attentive for good and cost a call per message
2824
- * — the self-extension the spec declined. An agent never addressed in
2825
- * the thread is not attentive: it was mentioned and its first turn has
2826
- * not answered yet, or answered with nothing. Measured against the
2827
- * message's own timestamp, not the clock, so a delivery that ran late is
2828
- * judged as of when it was said. Bounded below as well as above: a
2829
- * message said before the answer the window starts from is outside it
2830
- * — Slack redelivers events a restarted daemon has no memory of, and a
2831
- * negative age would otherwise pass the minutes bound however old the
2832
- * message, on a turn the agent's answer already came after.
2833
- */
2834
- const attentive = (routing, messageTs, pending) => {
2835
- if (routing.lastAnsweredAt === undefined) {
2836
- return false;
2837
- }
2838
- const answeredAt = Date.parse(routing.lastAnsweredAt);
2839
- const saidAt = Number(messageTs) * 1000;
2840
- if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
2841
- return false;
2842
- }
2843
- return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
2844
- };
2845
- /**
2846
- * Messages this adapter has dispatched to a session that the session
2847
- * does not hold yet. The store counts what it holds, and a burst of
2848
- * messages typed inside one second would each read the same count and
2849
- * each be judged — the bound the window exists to give, blown by exactly
2850
- * the thread that needs it. The renderer queue already knows: a renderer
2851
- * is queued at dispatch and its turn starts (`turnStarted`) only once
2852
- * the runner has saved the message — the same moment the store's count
2853
- * takes it over — so the two never count one message twice.
2854
- */
2855
- const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
2856
- + (pendingObserves.get(sessionId) ?? 0);
2857
- /**
2858
- * Overhears placed on the gateway's chain and not yet written, per
2859
- * session: the other kind of message the store does not hold yet. An
2860
- * observe queued behind a running turn is heard only when the turn ends,
2861
- * and a burst of messages to a colleague would otherwise count toward
2862
- * nothing until then. Counted from placement to settlement.
2863
- */
2864
- const pendingObserves = new Map();
2865
- const placeObserve = (sessionId, observed) => {
2866
- pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
2867
- const release = () => {
2868
- const left = (pendingObserves.get(sessionId) ?? 1) - 1;
2869
- if (left > 0) {
2870
- pendingObserves.set(sessionId, left);
2871
- }
2872
- else {
2873
- pendingObserves.delete(sessionId);
2874
- }
2875
- };
2876
- void observed.then(release, release);
2877
- return observed;
2878
- };
2879
3268
  /**
2880
3269
  * Run `work` after everything already queued for this session's intake,
2881
3270
  * and hold the next link until it settles. The link is claimed
@@ -2997,7 +3386,7 @@ export const createSlackChannelAdapter = (options) => {
2997
3386
  // whoever installed it.
2998
3387
  const author = await displayNameFor(hearer, speaker.botUserId);
2999
3388
  const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
3000
- return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
3389
+ return { observed };
3001
3390
  });
3002
3391
  await observed;
3003
3392
  }
@@ -3061,7 +3450,7 @@ export const createSlackChannelAdapter = (options) => {
3061
3450
  }
3062
3451
  const author = await displayNameFor(connection, connection.botUserId);
3063
3452
  const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
3064
- return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
3453
+ return { observed };
3065
3454
  });
3066
3455
  // Tracked, so stop() drains it: a restart right after a report must not
3067
3456
  // close the stores while the thread's session is still to hear it.
@@ -3161,6 +3550,55 @@ export const createSlackChannelAdapter = (options) => {
3161
3550
  },
3162
3551
  };
3163
3552
  };
3553
+ /**
3554
+ * Channel or DM, as this agent's app sees a conversation, for the
3555
+ * operator's standing channel authorization (`approvals.channels`), which
3556
+ * covers channels only. Asked of Slack (`conversations.info`), never read
3557
+ * off the id: a `G…` id is a private channel or a group DM depending on
3558
+ * its age. A definite answer is kept, since a conversation never turns
3559
+ * from one into the other; a failed lookup is not, and reads as unknown,
3560
+ * so the call asks.
3561
+ */
3562
+ const conversationKinds = new Map();
3563
+ const CONVERSATION_KIND_CACHE_LIMIT = 1000;
3564
+ const conversationKindFor = async (agentId, conversation) => {
3565
+ const id = conversation.trim();
3566
+ const connection = connectionFor(agentId);
3567
+ if (!id || !connection) {
3568
+ return undefined;
3569
+ }
3570
+ const key = `${agentId}:${botIdentities.get(agentId)?.teamId ?? ''}:${id}`;
3571
+ const known = conversationKinds.get(key);
3572
+ if (known !== undefined) {
3573
+ return known;
3574
+ }
3575
+ let info;
3576
+ try {
3577
+ info = (await connection.web.conversations.info({ channel: id })).channel;
3578
+ }
3579
+ catch {
3580
+ return undefined;
3581
+ }
3582
+ if (!info?.id) {
3583
+ return undefined;
3584
+ }
3585
+ // A DM flag wins. Otherwise a channel only when Slack says so, by a
3586
+ // channel flag or by stating it is not a DM: a reply with none of
3587
+ // them is no proof of a channel.
3588
+ const kind = info.is_im === true || info.is_mpim === true
3589
+ ? 'direct'
3590
+ : info.is_channel === true || info.is_group === true || (info.is_im === false && info.is_mpim === false) ? 'channel' : undefined;
3591
+ if (kind !== undefined) {
3592
+ if (conversationKinds.size >= CONVERSATION_KIND_CACHE_LIMIT) {
3593
+ const oldest = conversationKinds.keys().next().value;
3594
+ if (oldest !== undefined) {
3595
+ conversationKinds.delete(oldest);
3596
+ }
3597
+ }
3598
+ conversationKinds.set(key, kind);
3599
+ }
3600
+ return kind;
3601
+ };
3164
3602
  /**
3165
3603
  * The read side of the same boundary `resolveOutbound` draws: a channel
3166
3604
  * the agent's own app is a member of, public or private. DMs and group
@@ -3334,8 +3772,21 @@ export const createSlackChannelAdapter = (options) => {
3334
3772
  const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
3335
3773
  ? metadata.slackChannel
3336
3774
  : undefined;
3337
- const channel = sameChannel ?? connection.config.approvalChannel;
3338
- const thread = sameChannel && typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
3775
+ // A scheduled firing has no conversation of its own. With no approval
3776
+ // channel configured it used to be denied at its first gated call, seen
3777
+ // only in the daemon log (card 218). Its schedule's destination is a
3778
+ // conversation a person approved as where it speaks, and only approvers
3779
+ // can answer there, so that is where it is asked. A configured approval
3780
+ // channel still wins: the operator chose it for exactly this. Another
3781
+ // channel's destination is not a Slack conversation id.
3782
+ const scheduled = event.scheduleDestination?.channel.trim().toLowerCase() === 'slack'
3783
+ ? event.scheduleDestination
3784
+ : undefined;
3785
+ const viaSchedule = !sameChannel && !connection.config.approvalChannel ? scheduled : undefined;
3786
+ const channel = sameChannel ?? connection.config.approvalChannel ?? viaSchedule?.to;
3787
+ const thread = sameChannel
3788
+ ? (typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined)
3789
+ : viaSchedule?.thread;
3339
3790
  // Undeliverable, not decided: nobody was asked, so filing this next to
3340
3791
  // the denials somebody actually made would make the audit record lie
3341
3792
  // about which is which.
@@ -3600,6 +4051,11 @@ export const createSlackChannelAdapter = (options) => {
3600
4051
  return landed;
3601
4052
  };
3602
4053
  const reportUnrenderedFailure = async (event, behind, files) => {
4054
+ // A turn no renderer here was showing (card 501) that a Slack stop
4055
+ // aborted fails with the stop's own reason, so who stopped it is read
4056
+ // from the failure itself rather than from a note an outcome may never
4057
+ // come to clear.
4058
+ const stoppedBy = slackStopper(event.error);
3603
4059
  const gateway = gatewayRef;
3604
4060
  if (!gateway?.sessionRouting) {
3605
4061
  return NOTHING_REPORTED;
@@ -3627,7 +4083,10 @@ export const createSlackChannelAdapter = (options) => {
3627
4083
  }
3628
4084
  const channel = metadata.slackChannel;
3629
4085
  const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
3630
- if (routing.unaddressed === true && routing.reply === undefined) {
4086
+ if (stoppedBy === undefined && routing.unaddressed === true && routing.reply === undefined) {
4087
+ // A stop is someone asking, so a stopped turn always says who
4088
+ // stopped it, judged or not.
4089
+ //
3631
4090
  // A turn nobody asked for that said nothing before it broke — the
3632
4091
  // daemon died inside it, and the next start failed it with no
3633
4092
  // renderer here to keep quiet for it. The same rule as the lazy
@@ -3648,7 +4107,7 @@ export const createSlackChannelAdapter = (options) => {
3648
4107
  return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
3649
4108
  }
3650
4109
  let landed = [];
3651
- const said = await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), async () => {
4110
+ const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy === undefined ? `Something went wrong: ${event.error}` : `Stopped by <@${stoppedBy}>.`), async () => {
3652
4111
  landed = await uploadUnrenderedFiles(connection, channel, thread, files);
3653
4112
  });
3654
4113
  return { said, landed };
@@ -4077,7 +4536,7 @@ export const createSlackChannelAdapter = (options) => {
4077
4536
  * Dropping that one would hand the thread back to its owner after enough
4078
4537
  * mentions of a third agent, while the judge that took it still answers.
4079
4538
  */
4080
- const trimHandovers = (handovers) => {
4539
+ const trimHandovers = (key, handovers) => {
4081
4540
  if (handovers.length <= THREAD_HANDOVER_DEPTH) {
4082
4541
  return handovers;
4083
4542
  }
@@ -4093,6 +4552,13 @@ export const createSlackChannelAdapter = (options) => {
4093
4552
  // The kept take sits on top of the full recent tail, one past the depth,
4094
4553
  // so `holderAt` still sees every recent claim it saw before.
4095
4554
  const tail = handovers.slice(tailFrom);
4555
+ // The cut only ever moves later: a claim inserted into the gap since
4556
+ // is dropped again, and the gap it fell in stays unknown.
4557
+ const until = tail[0].ts;
4558
+ const before = threadTrimmedUntil.get(key);
4559
+ if (before === undefined || before < until) {
4560
+ threadTrimmedUntil.set(key, until);
4561
+ }
4096
4562
  return latestTake > 0 && latestTake < tailFrom
4097
4563
  ? [first, handovers[latestTake], ...tail]
4098
4564
  : [first, ...tail];
@@ -4124,7 +4590,7 @@ export const createSlackChannelAdapter = (options) => {
4124
4590
  * Slack timestamps compare as strings: `seconds.microseconds`, both
4125
4591
  * parts fixed width, so lexicographic order is chronological order.
4126
4592
  */
4127
- const rememberAddressee = (key, agentId, ts, takes = false) => {
4593
+ const rememberAddressee = (key, agentId, ts, takes = false, cold = false) => {
4128
4594
  const handovers = threadAddressee.get(key) ?? [];
4129
4595
  let at = handovers.length;
4130
4596
  while (at > 0 && handovers[at - 1].ts > ts) {
@@ -4135,17 +4601,23 @@ export const createSlackChannelAdapter = (options) => {
4135
4601
  if (takes) {
4136
4602
  before.takes = true;
4137
4603
  }
4604
+ // Seen live as well: no longer only a reconstruction.
4605
+ if (!cold) {
4606
+ delete before.cold;
4607
+ }
4138
4608
  return;
4139
4609
  }
4140
- handovers.splice(at, 0, takes ? { agentId, ts, takes } : { agentId, ts });
4610
+ handovers.splice(at, 0, { agentId, ts, ...(takes ? { takes } : {}), ...(cold ? { cold } : {}) });
4141
4611
  // Re-inserted rather than mutated in place: a Map iterates in insertion
4142
4612
  // order, which is what makes the first key the one to evict.
4143
4613
  threadAddressee.delete(key);
4144
- threadAddressee.set(key, trimHandovers(handovers));
4614
+ threadAddressee.set(key, trimHandovers(key, handovers));
4145
4615
  if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
4146
4616
  const oldest = threadAddressee.keys().next();
4147
4617
  if (!oldest.done) {
4148
4618
  threadAddressee.delete(oldest.value);
4619
+ threadTrimmedUntil.delete(oldest.value);
4620
+ threadReadUntil.delete(oldest.value);
4149
4621
  // The owner goes with the record of who took the thread since: an
4150
4622
  // owner kept alone would stand in for a judge's takeover it no
4151
4623
  // longer knows of, where the thread read would see it.
@@ -4158,15 +4630,69 @@ export const createSlackChannelAdapter = (options) => {
4158
4630
  * handover at or before it. Undefined when this process has no handover
4159
4631
  * that old, which sends the question to the sessions.
4160
4632
  */
4633
+ /**
4634
+ * Whose a stop at `ts` is, from the live record: who was asked last as
4635
+ * of `ts` (`holderAt`). Undefined when the record cannot say, either
4636
+ * because this process has seen nobody asked here by `ts`, or because the
4637
+ * newest entry by then was rebuilt from a thread read (card 508): a
4638
+ * mention since would not be among them. The stop is then left to a
4639
+ * fresh read of the thread, never sent to the owner on a guess.
4640
+ */
4641
+ const stopHolderAt = (key, ts) => holderAt(key, ts);
4642
+ /** Whether the record of thread `key` lost who held it at `ts` to a cut. */
4643
+ const inTrimmedGap = (key, handovers, ts) => {
4644
+ const until = threadTrimmedUntil.get(key);
4645
+ const first = handovers[0];
4646
+ return until !== undefined && first !== undefined && first.ts < ts && ts < until;
4647
+ };
4648
+ /**
4649
+ * Records that a thread read of `key` covered every message before `ts`.
4650
+ * Bounded on its own, since a read that found nobody's post leaves an
4651
+ * owner and no record (Codex 4321103567). A thread whose mark is evicted
4652
+ * loses its record and owner with it, so it is read afresh instead of
4653
+ * placed from claims the read may have stopped short of.
4654
+ */
4655
+ const noteReadUntil = (key, ts) => {
4656
+ const until = threadReadUntil.get(key);
4657
+ threadReadUntil.delete(key);
4658
+ threadReadUntil.set(key, until !== undefined && compareSlackTs(ts, until) < 0 ? until : ts);
4659
+ if (threadReadUntil.size > THREAD_ADDRESSEE_CAPACITY) {
4660
+ const oldest = threadReadUntil.keys().next();
4661
+ if (!oldest.done) {
4662
+ threadReadUntil.delete(oldest.value);
4663
+ threadAddressee.delete(oldest.value);
4664
+ threadTrimmedUntil.delete(oldest.value);
4665
+ threadOrigins.delete(oldest.value);
4666
+ }
4667
+ }
4668
+ };
4669
+ /**
4670
+ * Where the record of thread `key` stops knowing every take, for a
4671
+ * message at `ts` after what a thread read covered (card 562): a claim
4672
+ * older than the returned point may have been taken from since, unseen.
4673
+ * Undefined when the record is complete as far as `ts`.
4674
+ */
4675
+ const unreadBefore = (key, ts) => {
4676
+ const until = threadReadUntil.get(key);
4677
+ return until !== undefined && liveFrom !== undefined && compareSlackTs(until, liveFrom) < 0 && compareSlackTs(ts, until) > 0
4678
+ ? liveFrom
4679
+ : undefined;
4680
+ };
4161
4681
  const holderAt = (key, ts) => {
4162
4682
  const handovers = threadAddressee.get(key);
4163
- if (!handovers) {
4683
+ if (!handovers || inTrimmedGap(key, handovers, ts)) {
4164
4684
  return undefined;
4165
4685
  }
4686
+ // The newest entry at or before `ts` answers, and only if this process
4687
+ // saw it live. A claim rebuilt from a thread read is a post; who was
4688
+ // asked since may be a mention it cannot see, so it leaves the question
4689
+ // to a fresh read. And it is newer than every live entry before it, so
4690
+ // skipping it for one of those would answer with a claim it superseded.
4691
+ const unread = unreadBefore(key, ts);
4166
4692
  for (let index = handovers.length - 1; index >= 0; index -= 1) {
4167
4693
  const handover = handovers[index];
4168
4694
  if (handover && handover.ts <= ts) {
4169
- return handover.agentId;
4695
+ return handover.cold === true || (unread !== undefined && compareSlackTs(handover.ts, unread) < 0) ? undefined : handover.agentId;
4170
4696
  }
4171
4697
  }
4172
4698
  return undefined;
@@ -4188,13 +4714,19 @@ export const createSlackChannelAdapter = (options) => {
4188
4714
  const ownerAt = (key, ts) => {
4189
4715
  const origin = threadOrigins.get(key);
4190
4716
  const handovers = threadAddressee.get(key) ?? [];
4717
+ if (inTrimmedGap(key, handovers, ts)) {
4718
+ return undefined;
4719
+ }
4720
+ // A take from before the stretch the read left out may have been
4721
+ // superseded there: the question goes to a fresh read (card 562).
4722
+ const unread = unreadBefore(key, ts);
4191
4723
  for (let index = handovers.length - 1; index >= 0; index -= 1) {
4192
4724
  const handover = handovers[index];
4193
4725
  if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
4194
- return handover.agentId;
4726
+ return unread !== undefined && compareSlackTs(handover.ts, unread) < 0 ? undefined : handover.agentId;
4195
4727
  }
4196
4728
  }
4197
- return typeof origin === 'string' ? origin : undefined;
4729
+ return typeof origin === 'string' && unread === undefined ? origin : undefined;
4198
4730
  };
4199
4731
  /**
4200
4732
  * The agent the thread `key` belongs to, whoever holds it now: the owner
@@ -4402,7 +4934,7 @@ export const createSlackChannelAdapter = (options) => {
4402
4934
  const own = message.user === undefined ? undefined : byBotUser.get(message.user);
4403
4935
  if (own !== undefined) {
4404
4936
  place(own, at);
4405
- posts.push({ agentId: own, at, asked: asked.has(own) });
4937
+ posts.push({ agentId: own, at, ts: message.ts, asked: asked.has(own) });
4406
4938
  // An agent here naming a colleague that answers its mentions
4407
4939
  // asks it, as `admitAgentMention` has it live, so the answer is
4408
4940
  // not a judge speaking unasked.
@@ -4692,13 +5224,38 @@ export const createSlackChannelAdapter = (options) => {
4692
5224
  // takes nothing.
4693
5225
  const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
4694
5226
  let taken;
5227
+ const takeovers = [];
4695
5228
  for (const post of read.posts) {
4696
5229
  const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
4697
- if (takes && (!taken || post.at > taken.at)) {
4698
- taken = post;
5230
+ if (takes) {
5231
+ takeovers.push(post);
5232
+ if (!taken || post.at > taken.at) {
5233
+ taken = post;
5234
+ }
4699
5235
  }
4700
5236
  }
5237
+ // Once the owner is known, the live record learns what the thread read
5238
+ // found, as it would have live: who owns the thread, and every post
5239
+ // that moved it, where it sits. With only the verdict, a judge's reply
5240
+ // to this message became the first claim this process saw and read as
5241
+ // the owner, so the real owner answering a mention could not take the
5242
+ // thread back (Codex 4290051968). Every take rather than the latest, so
5243
+ // a message delivered late is placed where it was typed, and the judge's
5244
+ // take at its own post, so a reply typed before its answer lands still
5245
+ // goes to the judge, as this verdict says.
5246
+ const warm = () => {
5247
+ if (ownerId === undefined) {
5248
+ return;
5249
+ }
5250
+ const key = threadKeyFor(parts);
5251
+ noteOrigin(key, ownerId);
5252
+ for (const post of takeovers) {
5253
+ rememberAddressee(key, post.agentId, post.ts, true, true);
5254
+ }
5255
+ noteReadUntil(key, ts);
5256
+ };
4701
5257
  if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
5258
+ warm();
4702
5259
  return taken.agentId;
4703
5260
  }
4704
5261
  if (!owner) {
@@ -4707,7 +5264,7 @@ export const createSlackChannelAdapter = (options) => {
4707
5264
  if (ownerId === undefined) {
4708
5265
  return undefined;
4709
5266
  }
4710
- noteOrigin(threadKeyFor(parts), ownerId);
5267
+ warm();
4711
5268
  return ownerId;
4712
5269
  };
4713
5270
  /**
@@ -4823,19 +5380,30 @@ export const createSlackChannelAdapter = (options) => {
4823
5380
  const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
4824
5381
  const running = renderers.get(sessionId) ?? [];
4825
5382
  const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
4826
- if (running.length === 0 && inIntake === 0) {
4827
- return false;
4828
- }
5383
+ const reason = `${SLACK_STOP_REASON}${userId}`;
4829
5384
  for (const renderer of running) {
4830
5385
  if (renderer.stoppedBy === undefined) {
4831
5386
  renderer.stoppedBy = userId;
4832
- renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${userId}`));
4833
- }
5387
+ renderer.controller.abort(new RunAbortedError(reason));
5388
+ }
5389
+ }
5390
+ // Also a turn no renderer of this process started (card 501): one the
5391
+ // gateway recovered after a restart, or another caller's on this
5392
+ // session. The gateway counts every turn still on the session, one
5393
+ // already stopping included, so a repeated stop while a stopped turn
5394
+ // winds down is still a stop, and once it has ended, however it ended,
5395
+ // a stop with nothing running is an ordinary message. The renderers'
5396
+ // started turns are among those counted.
5397
+ const onSession = gatewayRef?.abortTurns?.(sessionId, reason) ?? 0;
5398
+ if (running.length === 0 && inIntake === 0 && onSession === 0) {
5399
+ return false;
4834
5400
  }
4835
5401
  if (inIntake > 0) {
4836
5402
  stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
4837
5403
  }
4838
- log(`slack: ${userId} stopped ${running.length + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
5404
+ // A renderer's started turn is also one the gateway counts, so this is
5405
+ // the larger of the two, not their sum.
5406
+ log(`slack: ${userId} stopped ${Math.max(running.length, onSession) + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
4839
5407
  return true;
4840
5408
  };
4841
5409
  /**
@@ -5048,6 +5616,7 @@ export const createSlackChannelAdapter = (options) => {
5048
5616
  // Ava's transcript; only the map learns who holds the thread.
5049
5617
  if (threadKey && handoverTo) {
5050
5618
  rememberAddressee(threadKey, handoverTo, event.ts);
5619
+ rememberSenderAddressee(threadKey, sender, handoverTo, event.ts);
5051
5620
  }
5052
5621
  // A home-channel message hands its new thread to the channel's owner
5053
5622
  // the way a mention would, and is recorded by every socket for the same
@@ -5109,7 +5678,7 @@ export const createSlackChannelAdapter = (options) => {
5109
5678
  const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
5110
5679
  const held = threadKey === undefined
5111
5680
  ? undefined
5112
- : (refusedStop ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
5681
+ : refusedStop ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
5113
5682
  if (addressed || held === connection.config.agentId) {
5114
5683
  if (!alreadySeen(eventKey)) {
5115
5684
  log(refusal);
@@ -5197,12 +5766,12 @@ export const createSlackChannelAdapter = (options) => {
5197
5766
  return { ...admission, overhear: true };
5198
5767
  }
5199
5768
  // An untagged reply. How this agent listens decides what it is: an
5200
- // agent that answers only mentions hears it and nothing more, and one
5201
- // that judges hears it and, if still attentive, takes a turn nobody
5202
- // asked for on it — decided in the intake, since only the session can
5203
- // say. Neither takes part in the holder rule below: that rule answers
5204
- // "whose is an untagged reply", and for these two agents the soul has
5205
- // already answered.
5769
+ // agent that answers only mentions hears it and nothing more. One that
5770
+ // judges takes a turn nobody asked for on it — one it may stay quiet
5771
+ // on — when the reply is its own by the rule every agent shares (card
5772
+ // 541): the agent this person last addressed, else the thread's owner.
5773
+ // No window: an owner an hour past its last answer still hears an
5774
+ // instruction as its own, and exactly one agent takes each reply.
5206
5775
  const listens = listensOf(connection.config.agentId);
5207
5776
  if (listens === 'mentions' || listens === 'judge') {
5208
5777
  // Except a stop (card 337): the holder rule decides whose it is, as
@@ -5213,13 +5782,24 @@ export const createSlackChannelAdapter = (options) => {
5213
5782
  (connection.config.approvers ?? []).includes(sender) &&
5214
5783
  isStopRequest(text, connection.botUserId);
5215
5784
  const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
5216
- // A stop held by another agent is only heard: judging it would let an
5217
- // attentive agent take a turn on a stop that was never its own.
5218
- const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
5785
+ if (listens === 'judge' && !approverStop && threadKey !== undefined) {
5786
+ const target = replyTargetAt(threadKey, sender, event.ts);
5787
+ if (target === undefined) {
5788
+ // Nothing seen here says whose thread this is: the sessions and
5789
+ // the thread read decide, as for any agent.
5790
+ return { ...admission, overhear: true, judge: true, settled: false, judgeReason: 'owner, from the thread read' };
5791
+ }
5792
+ if (target !== connection.config.agentId) {
5793
+ return { ...admission, overhear: true, judgeReason: `${target}'s reply` };
5794
+ }
5795
+ const reason = senderAddresseeAt(threadKey, sender, event.ts) === target ? 'addressed it last' : 'owner';
5796
+ return { ...admission, overhear: true, judge: true, judgeReason: reason };
5797
+ }
5798
+ // An approver's stop is never judged (card 541): it stops the turn of
5799
+ // whoever holds the thread, and with nothing to stop it is only heard.
5219
5800
  return {
5220
5801
  ...admission,
5221
5802
  overhear: true,
5222
- ...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
5223
5803
  ...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
5224
5804
  ...(approverStop && stopHolder === undefined ? { settled: false } : {}),
5225
5805
  };
@@ -5234,7 +5814,7 @@ export const createSlackChannelAdapter = (options) => {
5234
5814
  isStopRequest(text, connection.botUserId);
5235
5815
  const holder = threadKey === undefined
5236
5816
  ? undefined
5237
- : (stopRequest ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
5817
+ : stopRequest ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
5238
5818
  if (holder !== undefined && threadKey !== undefined) {
5239
5819
  if (holder !== connection.config.agentId) {
5240
5820
  // Standing down for the holder, whether or not the holder can still
@@ -5338,29 +5918,13 @@ export const createSlackChannelAdapter = (options) => {
5338
5918
  // not to be this agent's leaves an empty link behind, which is all it
5339
5919
  // should cost.
5340
5920
  let overhear = admitted.overhear;
5341
- // Judged, not ruled: a turn nobody asked for, while the agent is
5342
- // still attentive, and a free overhear past that. Read off the
5343
- // chain — a session the invitation is still creating reads as not
5344
- // there, and the message is heard rather than judged, which is the
5345
- // safe side. A host that cannot say leaves the agent hearing.
5921
+ // Judged, not ruled (card 541): the intake already placed this reply
5922
+ // with this agent, so it runs a turn nobody asked for, which may stay
5923
+ // quiet. A reply only the sessions can place waits for them below.
5346
5924
  let judged = false;
5347
- if (admitted.judge && gateway.sessionRouting) {
5348
- // The pending count is taken BEFORE the store is read: a message
5349
- // that settles between the two would be counted by neither the
5350
- // other way round, and a window that runs one message short in a
5351
- // race is a bound kept; one that runs a message long is not.
5352
- const pending = pendingFor(sessionId);
5353
- let routing;
5354
- try {
5355
- routing = await gateway.sessionRouting(sessionId);
5356
- }
5357
- catch (error) {
5358
- warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
5359
- }
5360
- if (routing && attentive(routing, event.ts, pending)) {
5361
- judged = true;
5362
- overhear = false;
5363
- }
5925
+ if (admitted.judge && admitted.settled) {
5926
+ judged = true;
5927
+ overhear = false;
5364
5928
  }
5365
5929
  if (!admitted.settled) {
5366
5930
  // One verdict for this message, shared with whichever other agents
@@ -5382,6 +5946,10 @@ export const createSlackChannelAdapter = (options) => {
5382
5946
  overhear = true;
5383
5947
  }
5384
5948
  else {
5949
+ if (admitted.judge) {
5950
+ judged = true;
5951
+ overhear = false;
5952
+ }
5385
5953
  if (threadKey) {
5386
5954
  // Answered from the store rather than from memory: hold it, so
5387
5955
  // the rest of the thread is ordered by receipt like any other.
@@ -5397,6 +5965,12 @@ export const createSlackChannelAdapter = (options) => {
5397
5965
  }
5398
5966
  }
5399
5967
  }
5968
+ // One line per decision a judging agent makes on an untagged reply
5969
+ // (card 541): whether it took the turn, and why. Never the text.
5970
+ if (admitted.judgeReason !== undefined) {
5971
+ const reason = admitted.settled || judged ? admitted.judgeReason : 'another agent\'s, from the thread read';
5972
+ log(`slack: judge: ${connection.config.agentId} ${judged ? 'took' : 'heard'} ${event.ts} in ${event.channel} (${reason})`);
5973
+ }
5400
5974
  const cleaned = await humanizeMentions(connection, event.text ?? '');
5401
5975
  // Fetched inside the chain, like the lookups below: it is network
5402
5976
  // I/O, and a message whose screenshot is slow to arrive must not lose
@@ -5500,7 +6074,7 @@ export const createSlackChannelAdapter = (options) => {
5500
6074
  // an overhear queued behind a long turn would hold the next
5501
6075
  // message's place in this chain hostage to that turn.
5502
6076
  return {
5503
- observed: placeObserve(sessionId, gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata })),
6077
+ observed: gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata }),
5504
6078
  };
5505
6079
  }
5506
6080
  // A turn nobody asked for opens its placeholder on its first text,
@@ -5590,7 +6164,7 @@ export const createSlackChannelAdapter = (options) => {
5590
6164
  leaveIntake();
5591
6165
  if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
5592
6166
  renderer.stoppedBy = stopped.by;
5593
- renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${stopped.by}`));
6167
+ renderer.controller.abort(new RunAbortedError(`${SLACK_STOP_REASON}${stopped.by}`));
5594
6168
  }
5595
6169
  // In a DM each message is its own status key, and Slack stops showing
5596
6170
  // the running turn's once a newer message sits below it — so a
@@ -5600,6 +6174,10 @@ export const createSlackChannelAdapter = (options) => {
5600
6174
  renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
5601
6175
  let repeat;
5602
6176
  let absorbed = false;
6177
+ const replay = replaying.get(pendingKey({ agentId: connection.config.agentId, team: connection.teamId, channel: event.channel, ts: event.ts }));
6178
+ if (replay !== undefined) {
6179
+ replay.dispatched = true;
6180
+ }
5603
6181
  const turn = gateway.dispatch({
5604
6182
  sessionId,
5605
6183
  agentId: connection.config.agentId,
@@ -5619,10 +6197,16 @@ export const createSlackChannelAdapter = (options) => {
5619
6197
  },
5620
6198
  // Sent while a turn is running, this message is answered by the
5621
6199
  // next turn together with everything else that queued (card 423);
5622
- // the first of them renders that answer.
5623
- onAbsorbed: () => {
5624
- absorbed = true;
5625
- },
6200
+ // the first of them renders that answer. Not a replayed message:
6201
+ // it leaves the kept list only on its own confirmed answer, and an
6202
+ // answer another message's renderer posts is not one it can see.
6203
+ ...(replay === undefined
6204
+ ? {
6205
+ onAbsorbed: () => {
6206
+ absorbed = true;
6207
+ },
6208
+ }
6209
+ : {}),
5626
6210
  });
5627
6211
  return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
5628
6212
  });
@@ -5675,6 +6259,15 @@ export const createSlackChannelAdapter = (options) => {
5675
6259
  // Likewise a message absorbed into a merged turn: the turn's leading
5676
6260
  // message's renderer answers for all of them.
5677
6261
  if (repeat() === 'live' || absorbed()) {
6262
+ // A replayed message whose turn the start-up recovery owns is
6263
+ // answered by that recovery's report, and only if it reached the
6264
+ // thread; anything else leaves it kept.
6265
+ const ahead = repeat() === 'live' && renderer.outcomeReportedAhead !== undefined
6266
+ ? await renderer.outcomeReportedAhead
6267
+ : undefined;
6268
+ if (ahead?.said === true) {
6269
+ noteReplayAnsweredAhead(connection, event.channel, event.ts);
6270
+ }
5678
6271
  await renderer.withdraw();
5679
6272
  renderers.get(sessionId)?.[0]?.refreshLoading();
5680
6273
  return;
@@ -5712,6 +6305,7 @@ export const createSlackChannelAdapter = (options) => {
5712
6305
  }));
5713
6306
  }
5714
6307
  if (reportedAhead?.said === true) {
6308
+ noteReplayAnsweredAhead(connection, event.channel, event.ts);
5715
6309
  await renderer.withdraw();
5716
6310
  renderers.get(sessionId)?.[0]?.refreshLoading();
5717
6311
  return;
@@ -5744,6 +6338,7 @@ export const createSlackChannelAdapter = (options) => {
5744
6338
  // interim messages already in the thread are heard either way.
5745
6339
  let replyLanded = false;
5746
6340
  const settled = finalized.then((outcome) => {
6341
+ noteReplayOutcome(connection, event.channel, event.ts, outcome);
5747
6342
  replyLanded = outcome.published;
5748
6343
  return true;
5749
6344
  }, () => true);
@@ -5782,11 +6377,48 @@ export const createSlackChannelAdapter = (options) => {
5782
6377
  // not a failure of the agent. Matched by name, as this package does
5783
6378
  // not import the gateway. A turn nobody asked for stays silent.
5784
6379
  const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
5785
- const { spoke, spokeAt } = renderer.stoppedBy !== undefined
6380
+ // A daemon on its way down that still heard this (the gateway keeps
6381
+ // channels connected through its drain): the message is kept for the
6382
+ // next start, which handles it as if it had just arrived. Said only
6383
+ // to a message that asked: a judged reply in the agent's thread (a
6384
+ // turn nobody asked for) is kept so the next daemon can still judge
6385
+ // it, but nobody asked, so a notice under it would be noise. An
6386
+ // overheard message never gets here; it was never going to be a turn.
6387
+ // Matched by name, like the pause.
6388
+ const stoppingRefusal = failure instanceof Error && failure.name === 'GatewayStoppingError' && renderer.stoppedBy === undefined;
6389
+ let deferredText;
6390
+ if (stoppingRefusal) {
6391
+ const restarting = failure.restarting !== false;
6392
+ const kept = await keepForNextStart({
6393
+ agentId: connection.config.agentId,
6394
+ team,
6395
+ channel: event.channel,
6396
+ ts: event.ts,
6397
+ ...(event.thread_ts !== undefined ? { threadTs: event.thread_ts } : {}),
6398
+ user: userId,
6399
+ ...(event.channel_type !== undefined ? { channelType: event.channel_type } : {}),
6400
+ keptAt: now(),
6401
+ });
6402
+ if (kept) {
6403
+ log(`slack: ${connection.config.agentId} kept ${event.ts} in ${event.channel} to answer after the ${restarting ? 'restart' : 'stop'}`);
6404
+ deferredText = restarting ? RESTARTING_REPLY_TEXT : STOPPING_REPLY_TEXT;
6405
+ }
6406
+ else {
6407
+ deferredText = failure.message;
6408
+ }
6409
+ }
6410
+ // A refusal (paused, or a daemon on its way down) ran no turn, so its
6411
+ // notice is never what answers a replayed message: that one stays
6412
+ // kept, whether or not keeping it again just worked.
6413
+ const refused = pausedReply || stoppingRefusal;
6414
+ const outcome = renderer.stoppedBy !== undefined
5786
6415
  ? await renderer.stopped(renderer.stoppedBy)
5787
- : pausedReply
5788
- ? await renderer.finalize(renderer.lazy ? '' : failure.message)
6416
+ : refused
6417
+ ? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
5789
6418
  : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
6419
+ const { spoke, spokeAt } = refused && renderer.stoppedBy === undefined
6420
+ ? outcome
6421
+ : noteReplayOutcome(connection, event.channel, event.ts, outcome);
5790
6422
  if (spoke && threadKey !== undefined) {
5791
6423
  // A file it posted before breaking has still spoken, by the same
5792
6424
  // rule as a reply that landed.
@@ -5803,9 +6435,14 @@ export const createSlackChannelAdapter = (options) => {
5803
6435
  // A channel id is unique only within its workspace, so a standing
5804
6436
  // grant on one is bound to the team the agent's app authenticated in.
5805
6437
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
6438
+ conversationKindFor,
5806
6439
  requestCredential,
5807
6440
  async start(gateway) {
5808
6441
  gatewayRef = gateway;
6442
+ // Nothing counts as seen live until every socket is up (Codex
6443
+ // 4321128637): a message sent while the sockets are still coming up
6444
+ // reaches none of them, so a read stopping before it stops short.
6445
+ liveFrom = NOT_LIVE_YET;
5809
6446
  unsubscribe = gateway.bus.subscribe((event) => {
5810
6447
  // Approval traffic is not part of a reply: it gets its own message
5811
6448
  // with its own lifetime, and routing it through the running turn's
@@ -5957,21 +6594,55 @@ export const createSlackChannelAdapter = (options) => {
5957
6594
  }
5958
6595
  }
5959
6596
  settleHomeOwners();
6597
+ const listeners = new Map();
6598
+ // Read before any socket delivers, so each conversation with kept
6599
+ // messages is held from the first live event on.
6600
+ let kept = [];
6601
+ if (pendingIntakePath !== undefined) {
6602
+ try {
6603
+ kept = await readPending(pendingIntakePath);
6604
+ }
6605
+ catch (error) {
6606
+ warn(`slack: could not read the messages kept from the last restart: ${error instanceof Error ? error.message : String(error)}`);
6607
+ }
6608
+ const cutoff = now() - PENDING_INTAKE_MAX_AGE_MS;
6609
+ for (const entry of kept) {
6610
+ if (entry.keptAt >= cutoff && authenticated.some((connection) => keptFor(connection, entry))) {
6611
+ holdConversation(holdKey(entry.agentId, entry.channel));
6612
+ }
6613
+ }
6614
+ }
5960
6615
  for (const connection of authenticated) {
5961
6616
  const { config, socket } = connection;
5962
6617
  try {
5963
6618
  // Returns the work it started: the SDK ignores it, and a test's fake
5964
6619
  // socket waits on it rather than guessing how long it takes.
5965
- const onEvent = (args) => {
5966
- const handled = handleInbound(connection, args).catch((error) => {
6620
+ const handle = (inbound) => {
6621
+ const handled = inbound.catch((error) => {
5967
6622
  warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
5968
6623
  });
5969
6624
  inflight.add(handled);
5970
6625
  void handled.finally(() => inflight.delete(handled));
5971
6626
  return handled;
5972
6627
  };
6628
+ // The replay's own deliveries never wait on the hold they release.
6629
+ const onReplay = (args) => handle(handleInbound(connection, args));
6630
+ const onEvent = (args) => {
6631
+ const channel = args.event?.channel;
6632
+ const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
6633
+ if (hold === undefined) {
6634
+ return onReplay(args);
6635
+ }
6636
+ // Acked now, as any delivery is, so Slack does not redeliver a
6637
+ // message that is only waiting its turn; admitted after the hold.
6638
+ void Promise.resolve(args.ack()).catch((error) => {
6639
+ warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
6640
+ });
6641
+ return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
6642
+ };
5973
6643
  socket.on('app_mention', onEvent);
5974
6644
  socket.on('message', onEvent);
6645
+ listeners.set(config.agentId, onReplay);
5975
6646
  socket.on('interactive', (args) => {
5976
6647
  const handled = handleInteractive(connection, args).catch((error) => {
5977
6648
  warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -5989,8 +6660,19 @@ export const createSlackChannelAdapter = (options) => {
5989
6660
  warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
5990
6661
  }
5991
6662
  }
6663
+ const liveMs = now() + LIVE_FROM_MARGIN_MS;
6664
+ liveFrom = `${Math.floor(liveMs / 1000)}.${String((liveMs % 1000) * 1000).padStart(6, '0')}`;
6665
+ // Once every app is connected, so a kept message is handled with the
6666
+ // whole roster's identities known, as a live one would be. Only for the
6667
+ // apps that did connect: the rest stay kept for a start where they do.
6668
+ // A kept message is from before the restart, so its thread read stops
6669
+ // short of the start, as `liveFrom` already says (card 562).
6670
+ replayStopping = new AbortController();
6671
+ track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
5992
6672
  },
5993
6673
  async stop() {
6674
+ replayStopping?.abort();
6675
+ releaseAllConversations();
5994
6676
  // Stop intake first, so no new INBOUND event can slip into
5995
6677
  // `inflight` while the drain below runs.
5996
6678
  await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
@@ -6018,8 +6700,10 @@ export const createSlackChannelAdapter = (options) => {
6018
6700
  connections.length = 0;
6019
6701
  approvalPosts.clear();
6020
6702
  threadAddressee.clear();
6703
+ threadTrimmedUntil.clear();
6704
+ threadReadUntil.clear();
6705
+ senderAddressees.clear();
6021
6706
  coldVerdicts.clear();
6022
- pendingObserves.clear();
6023
6707
  botIdentities.clear();
6024
6708
  homeOwners.clear();
6025
6709
  rendering.clear();