tokenmaxxing 0.19.1 → 0.21.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.
Files changed (59) hide show
  1. package/DESIGN.md +33 -24
  2. package/README.md +4 -4
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexswitch.ts +15 -1
  10. package/src/cli/config.ts +10 -1
  11. package/src/cli/doctor.ts +3 -3
  12. package/src/cli/init.ts +54 -32
  13. package/src/cli/onboard.ts +62 -45
  14. package/src/cli/render.ts +0 -16
  15. package/src/cli/rm.ts +40 -2
  16. package/src/cli/serve.ts +629 -115
  17. package/src/cli/status.ts +69 -23
  18. package/src/cli/switch.ts +54 -19
  19. package/src/entries/codexstophook.ts +123 -4
  20. package/src/entries/codexsupervisor.ts +87 -13
  21. package/src/entries/sessionstart.ts +1 -1
  22. package/src/entries/statusline.ts +56 -20
  23. package/src/entries/stophook.ts +23 -9
  24. package/src/entries/supervisor.ts +134 -18
  25. package/src/lib/atomic.ts +28 -6
  26. package/src/lib/claudebin.ts +2 -2
  27. package/src/lib/claudejson.ts +5 -5
  28. package/src/lib/claudelock.ts +112 -37
  29. package/src/lib/codexauth.ts +10 -2
  30. package/src/lib/codexbin.ts +1 -1
  31. package/src/lib/codexdecide.ts +149 -19
  32. package/src/lib/codexpick.ts +17 -6
  33. package/src/lib/codexpresence.ts +59 -21
  34. package/src/lib/codexsample.ts +17 -8
  35. package/src/lib/codexswap.ts +10 -1
  36. package/src/lib/credstore.ts +6 -2
  37. package/src/lib/decide.ts +114 -42
  38. package/src/lib/install.ts +125 -17
  39. package/src/lib/keychain.ts +41 -15
  40. package/src/lib/lock.ts +57 -35
  41. package/src/lib/log.ts +36 -7
  42. package/src/lib/oauth.ts +18 -11
  43. package/src/lib/paths.ts +17 -11
  44. package/src/lib/picker.ts +11 -3
  45. package/src/lib/proc.ts +37 -0
  46. package/src/lib/sample.ts +91 -31
  47. package/src/lib/sessions.ts +23 -1
  48. package/src/lib/settings.ts +59 -18
  49. package/src/lib/slackbridge.ts +574 -81
  50. package/src/lib/slackstate.ts +159 -12
  51. package/src/lib/slackstream.ts +123 -20
  52. package/src/lib/state.ts +131 -35
  53. package/src/lib/swap.ts +109 -47
  54. package/src/lib/types.ts +79 -37
  55. package/src/lib/usage.ts +114 -16
  56. package/src/main.ts +61 -7
  57. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  58. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  59. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/cli/serve.ts CHANGED
@@ -1,43 +1,54 @@
1
1
  // `tokenmaxxing serve` - the Slack bridge daemon. Socket Mode (no public URL):
2
- // a mention in a linked channel opens a claude session for that thread (in its
3
- // own git worktree by default), and every further thread message becomes one
4
- // claude turn whose streamed output posts back into the thread. Stack chosen by
5
- // the user 2026-07-18: Vercel Chat SDK (`chat` + `@chat-adapter/slack`) for
6
- // Slack, the Claude Agent SDK driven through src/sdk.ts for claude (EVE was
7
- // researched and dropped: it owns its own model loop instead of driving
8
- // Claude Code).
2
+ // a mention in a linked channel opens a claude session for that thread IN the
3
+ // linked repo checkout (normal mode, user decision 2026-07-18 superseding the
4
+ // same-day worktree-per-thread default: a thread's agent cuts its own worktree
5
+ // only when a task needs isolation, guidance in `.memory`), and every further
6
+ // thread message becomes one claude turn whose streamed output posts back into
7
+ // the thread. Stack chosen by the user 2026-07-18: Vercel Chat SDK (`chat` +
8
+ // `@chat-adapter/slack`) for Slack, the Claude Agent SDK driven through
9
+ // src/sdk.ts for claude (EVE was researched and dropped: it owns its own model
10
+ // loop instead of driving Claude Code).
9
11
  //
10
12
  // serve setup print the app manifest + prompt for the two tokens
11
- // serve link <ch> <repo> [--no-worktree] [--dangerous] [--model <m>]
13
+ // serve link <ch> <repo> [--dangerous] [--model <m>]
12
14
  // serve unlink <ch> remove a link
13
15
  // serve links list links
14
16
  // serve run the daemon
15
17
 
16
18
  import { existsSync, realpathSync } from "node:fs";
17
- import { delay } from "es-toolkit";
18
- import { Chat, type StreamChunk } from "chat";
19
+ import { delay, omit, uniq } from "es-toolkit";
20
+ import { z } from "zod";
21
+ import { Chat, ThreadImpl, type StreamChunk } from "chat";
19
22
  import { createSlackAdapter } from "@chat-adapter/slack";
20
23
  import { createMemoryState } from "@chat-adapter/state-memory";
21
24
  import {
22
25
  bareChannelId,
23
26
  isChannelId,
27
+ isOutsideAuthor,
24
28
  linkForChannel,
25
29
  listSlackThreads,
26
30
  loadSlackConfig,
27
31
  loadSlackThread,
28
32
  removeLink,
33
+ resumeDecision,
29
34
  saveSlackConfig,
30
35
  saveSlackThread,
31
36
  stripLeadingMention,
32
37
  upsertLink,
33
38
  SlackLinkSchema,
39
+ type ActiveTurn,
34
40
  type SlackConfig,
41
+ type SlackLink,
42
+ type SlackThread,
35
43
  } from "../lib/slackstate.ts";
36
- import { ensureThreadCwd, relayThread } from "../lib/slackbridge.ts";
37
- import { log } from "../lib/log.ts";
44
+ import { cleanupThread, fetchWorkspaceTeamId, killGroup, relayThread, type CleanupOutcome, type TurnOutcome } from "../lib/slackbridge.ts";
45
+ import { pidStartTime } from "../lib/proc.ts";
46
+ import { acquireLock } from "../lib/lock.ts";
47
+ import { paths } from "../lib/paths.ts";
48
+ import { log, setLogEcho } from "../lib/log.ts";
38
49
  import { c, count } from "./render.ts";
39
50
 
40
- const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--no-worktree] [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
51
+ const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
41
52
 
42
53
  /** The manifest the user pastes at api.slack.com/apps > From an app manifest.
43
54
  * Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
@@ -64,6 +75,7 @@ oauth_config:
64
75
  - groups:history
65
76
  - chat:write
66
77
  - files:write
78
+ - im:history
67
79
  - users:read
68
80
 
69
81
  settings:
@@ -91,7 +103,7 @@ function printSetupInstructions(): void {
91
103
  console.log(`${c.dim("Existing app? Paste the manifest over App Manifest in its settings, then reinstall to the workspace (scope changes need it). Tokens stay valid unless you rotate them.")}`);
92
104
  }
93
105
 
94
- function cmdServeSetup(): number {
106
+ async function cmdServeSetup(): Promise<number> {
95
107
  printSetupInstructions();
96
108
  console.log();
97
109
  const botToken = prompt("bot token (xoxb-...):")?.trim();
@@ -109,12 +121,23 @@ function cmdServeSetup(): number {
109
121
  console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
110
122
  return 1;
111
123
  }
112
- console.log(`${c.green("✓")} saved to slack.json (0600) with ${count({ n: cfg.links.length, noun: "link" })}`);
124
+ // the external-author guard needs the home workspace id; capture it from the
125
+ // token itself so the reference can never drift from the workspace the bot
126
+ // actually lives in (re-captured on every setup: new tokens may belong to a
127
+ // different workspace).
128
+ try {
129
+ cfg = { ...cfg, workspaceTeamId: await fetchWorkspaceTeamId({ botToken }) };
130
+ saveSlackConfig(cfg);
131
+ } catch (e) {
132
+ const detail = e instanceof Error ? e.message : String(e);
133
+ console.error(c.red(`tokens saved, but ${detail} - check the bot token; the daemon re-tries the capture at start`));
134
+ return 1;
135
+ }
136
+ console.log(`${c.green("✓")} saved to slack.json (0600) for workspace ${cfg.workspaceTeamId} with ${count({ n: cfg.links.length, noun: "link" })}`);
113
137
  return 0;
114
138
  }
115
139
 
116
140
  function cmdServeLink(argv: string[]): number {
117
- const worktree = !argv.includes("--no-worktree");
118
141
  // yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
119
142
  const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
120
143
  const modelIdx = argv.indexOf("--model");
@@ -135,7 +158,7 @@ function cmdServeLink(argv: string[]): number {
135
158
  }
136
159
  const repoReal = realpathSync(repo);
137
160
  if (!existsSync(`${repoReal}/.git`)) {
138
- console.error(c.red(`${repoReal} is not a git repository (worktree mode needs one)`));
161
+ console.error(c.red(`${repoReal} is not a git repository`));
139
162
  return 1;
140
163
  }
141
164
  const cfg = loadSlackConfig();
@@ -146,12 +169,11 @@ function cmdServeLink(argv: string[]): number {
146
169
  const link = SlackLinkSchema.parse({
147
170
  channel,
148
171
  repo: repoReal,
149
- worktree,
150
172
  permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
151
173
  ...(model ? { model } : {}),
152
174
  });
153
175
  saveSlackConfig(upsertLink(cfg, link));
154
- const flags = [worktree ? "worktree" : "in-place", link.permissionMode, ...(model ? [model] : [])].join(", ");
176
+ const flags = [link.permissionMode, ...(model ? [model] : [])].join(", ");
155
177
  console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
156
178
  return 0;
157
179
  }
@@ -179,14 +201,472 @@ function cmdServeLinks(): number {
179
201
  return 0;
180
202
  }
181
203
  for (const l of cfg.links) {
182
- const flags = [l.worktree ? "worktree" : "in-place", l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
204
+ const flags = [l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
183
205
  console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
184
206
  }
185
207
  return 0;
186
208
  }
187
209
 
210
+ /** Event-name endings that pick the terminal paint: red for failures, yellow
211
+ * for degraded-but-continuing conditions, cyan otherwise. Structural endsWith
212
+ * checks so new events inherit sensible colors from their naming. */
213
+ const RED_EVENT_ENDINGS = ["error", "failed", "invalid_grant"];
214
+ const YELLOW_EVENT_ENDINGS = ["_dropped", "_drift", "_unparsed", "_gave_up", "_abort", "forced_exit", "contested", "draining"];
215
+
216
+ function eventPaint(event: string): (s: string) => string {
217
+ if (RED_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.red;
218
+ if (YELLOW_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.yellow;
219
+ return c.cyan;
220
+ }
221
+
222
+ /** One terminal line per log() event while the daemon runs: the file log stays
223
+ * canonical; this makes `xx serve` observable without tailing tokenmaxxing.log.
224
+ * Field values can carry newlines (e.g. usage.probe_failed's stderr excerpt),
225
+ * so they are escaped to keep the one-line-per-event contract. Exported for
226
+ * tests. */
227
+ export function formatLogLine(input: { event: string; parts: string }): string {
228
+ const time = new Date().toLocaleTimeString("en-GB");
229
+ const parts = input.parts.replaceAll("\r", "\\r").replaceAll("\n", "\\n");
230
+ return `${c.dim(time)} ${eventPaint(input.event)(input.event)}${parts ? ` ${parts}` : ""}`;
231
+ }
232
+
233
+ /** The slice of a Chat SDK thread the runtime touches. z.custom because it
234
+ * carries functions: the zod-native way to name the structural shape once for
235
+ * the daemon and test fakes alike. */
236
+ const ServeThreadSchema = z.custom<{
237
+ id: string;
238
+ channelId: string;
239
+ post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>;
240
+ subscribe: () => Promise<void>;
241
+ unsubscribe: () => Promise<void>;
242
+ startTyping: () => Promise<void>;
243
+ }>();
244
+ type ServeThread = z.infer<typeof ServeThreadSchema>;
245
+
246
+ /** The slice of a Chat SDK message the author guard + folding read. */
247
+ const ServeMessageSchema = z.custom<{
248
+ text: string;
249
+ author: { userId: string; isMe: boolean; isBot?: boolean | "unknown" };
250
+ raw?: unknown;
251
+ }>();
252
+ type ServeMessage = z.infer<typeof ServeMessageSchema>;
253
+
254
+ /** Reap a previous generation's detached claude child that survived an
255
+ * uncatchable daemon death (SIGKILL, crash: the "exit" event never fires
256
+ * on those, so the hook that kills the group never ran) - resuming beside
257
+ * a live orphan would put two claude processes on one cwd and session
258
+ * (adversarial-review catch). Signals fire ONLY on a verified pid+lstart
259
+ * identity match (cubic review catch: this machine runs the user's real
260
+ * claude sessions; a recycled pid must never get the kill). SIGTERM the
261
+ * group, escalate to SIGKILL if it lingers past the grace. */
262
+ async function reapOrphan(turn: ActiveTurn): Promise<void> {
263
+ if (turn.pid === undefined || turn.pidStartedAt === undefined) return;
264
+ if (pidStartTime(turn.pid) !== turn.pidStartedAt) return; // gone, or a recycled pid
265
+ log("serve.orphan_reaped", { pid: turn.pid });
266
+ killGroup(turn.pid);
267
+ for (let i = 0; i < 10; i++) {
268
+ await delay(500);
269
+ if (pidStartTime(turn.pid) !== turn.pidStartedAt) return;
270
+ }
271
+ killGroup(turn.pid, "SIGKILL");
272
+ }
273
+
274
+ /**
275
+ * The daemon's message-handling runtime, extracted from runDaemon as an
276
+ * injectable seam so tests can drive the REAL handler wiring (author guard,
277
+ * skipped-message folding, per-thread serialization, activeTurn markers,
278
+ * drain drops, finish close-out) against fake threads and a fake relay.
279
+ * runDaemon passes the production deps; behavior is identical. The startup
280
+ * interrupted-turn recovery stays inline in runDaemon (it needs the live bot
281
+ * and adapter for streamable thread handles) and reuses the chain/turn pieces
282
+ * exposed here.
283
+ */
284
+ export function buildServeRuntime(seam: {
285
+ cfg: SlackConfig;
286
+ workspaceTeamId: string;
287
+ /** read per message: the adapter only learns its bot user id on connect. */
288
+ botUserId: () => string | null;
289
+ relay: (input: {
290
+ cwd: string;
291
+ sessionId: string | null;
292
+ prompt: string;
293
+ requesterIds: string[];
294
+ link: SlackLink;
295
+ post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>;
296
+ /** fires when init assigns a session id the caller has not persisted yet
297
+ * (see relayThread). */
298
+ onSessionId?: (sessionId: string) => void;
299
+ /** fires with the detached claude child's pid at each spawn (see
300
+ * relayThread). */
301
+ onSpawn?: (pid: number) => void;
302
+ drainSignal?: AbortSignal;
303
+ }) => Promise<TurnOutcome>;
304
+ cleanup: (input: { threadId: string }) => CleanupOutcome;
305
+ }) {
306
+ const { cfg, workspaceTeamId } = seam;
307
+ // in-flight turns, tracked so a shutdown signal can drain them instead of
308
+ // killing a half-streamed answer (live incident 2026-07-18: a deploy
309
+ // restart cut a turn mid-sentence and the answer never reached Slack).
310
+ const activeTurns = new Set<Promise<void>>();
311
+ // aborts depleted-pool park/retry sleeps on drain, so a countdown never
312
+ // holds the restart hostage.
313
+ const drainAbort = new AbortController();
314
+ let draining = false;
315
+
316
+ /** One relayed turn with the durable activeTurn marker around it: written
317
+ * before the spawn, cleared when the turn returns, so a marker surviving
318
+ * into the next daemon start identifies a turn a restart killed mid-run.
319
+ * The session id persists the moment init assigns it - a first-turn kill
320
+ * must stay resumable. */
321
+ const runTurn = async (input: {
322
+ thread: { id: string; post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown> };
323
+ record: SlackThread;
324
+ prompt: string;
325
+ requesterIds: string[];
326
+ sessionId: string | null;
327
+ marker: ActiveTurn;
328
+ link: SlackLink;
329
+ }): Promise<TurnOutcome> => {
330
+ let record: SlackThread = { ...input.record, activeTurn: input.marker };
331
+ saveSlackThread(record);
332
+ let outcome: TurnOutcome | null = null;
333
+ try {
334
+ outcome = await seam.relay({
335
+ cwd: record.cwd,
336
+ sessionId: input.sessionId,
337
+ prompt: input.prompt,
338
+ requesterIds: input.requesterIds,
339
+ link: input.link,
340
+ post: (m) => input.thread.post(m),
341
+ onSpawn: (pid) => {
342
+ // the lstart token makes the pid a verifiable identity for the
343
+ // orphan reaper; a child dead before ps sees it persists without
344
+ // one, and an identity-less pid is never signaled.
345
+ const startedAt = pidStartTime(pid);
346
+ record = { ...record, activeTurn: { ...input.marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
347
+ saveSlackThread(record);
348
+ },
349
+ onSessionId: (sessionId) => {
350
+ record = { ...record, sessionId };
351
+ saveSlackThread(record);
352
+ },
353
+ drainSignal: drainAbort.signal,
354
+ });
355
+ record = { ...record, sessionId: outcome.sessionId };
356
+ return outcome;
357
+ } finally {
358
+ // a failure DURING a drain is presumed to be the shutdown signal killing
359
+ // the claude child (terminal Ctrl-C and group signals hit the whole
360
+ // process group, so the child dies and relayThread returns failed while
361
+ // the daemon is still draining - codex review catch): keep the marker so
362
+ // the next generation auto-resumes, exactly the killed-turn state it
363
+ // exists to detect. Outside a drain, or on success, clear it.
364
+ // An announced drop is TERMINAL: relayThread told the user to resend, so
365
+ // retaining the marker would replay work the drop notice disclaimed
366
+ // (duplicate turns, quota, side effects). null outcome = relay threw =
367
+ // still presumed killed.
368
+ const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop));
369
+ // An unannounced drop OUTSIDE a drain still clears the marker on
370
+ // purpose (retention would re-execute the turn at the next restart; see
371
+ // notifyDelivered's doc) - but the loss must be operator-visible.
372
+ if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop) {
373
+ log("serve.drop_unannounced", { thread: input.thread.id });
374
+ }
375
+ saveSlackThread(presumedKilled ? record : omit(record, ["activeTurn"]));
376
+ }
377
+ };
378
+
379
+ const handleTurn = async (input: {
380
+ thread: ServeThread;
381
+ /** every relayed message this turn (queue-skipped + triggering), text
382
+ * paired with its author id: a decision may be owed to an earlier
383
+ * folded sender, and a sender whose whole message was the bot mention
384
+ * contributes no prompt text, so text and author filter together
385
+ * (review catches 2026-07-18). */
386
+ relayed: { text: string; authorId: string }[];
387
+ isMention: boolean;
388
+ }) => {
389
+ const { thread, isMention } = input;
390
+ const link = linkForChannel(cfg, bareChannelId(thread.channelId));
391
+ if (!link) {
392
+ // checked BEFORE the draining branch: unlinked channels are
393
+ // contractually log-only silent, and a drain-window drop notice posted
394
+ // into one would tell a user to resend a message that will never be
395
+ // served (closing-review catch).
396
+ log("serve.unlinked_channel", { channel: thread.channelId });
397
+ return; // not a linked channel - stay silent in Slack
398
+ }
399
+ if (draining) {
400
+ // the socket stays connected until the drain finishes; anything landing
401
+ // in that window is dropped loudly rather than spawning an unwaitable
402
+ // turn - and the THREAD is told, not just the log (a silent drop reads
403
+ // as the bot thinking; slaude's recorded drop-notice rule). Tracked so
404
+ // the drain wait flushes it before exit; errors swallowed so the notice
405
+ // can never fail the drain.
406
+ log("serve.drain_dropped", { thread: thread.id });
407
+ void tracked(
408
+ (async () => {
409
+ try {
410
+ await thread.post(
411
+ (async function* () {
412
+ yield "tokenmaxxing is restarting - this message was dropped; please re-send it in a moment.";
413
+ })(),
414
+ );
415
+ } catch (e) {
416
+ // caught (never rethrown - the notice must not fail the drain)
417
+ // but logged: an unposted notice means the user saw nothing.
418
+ log("serve.drain_notice_failed", { thread: thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
419
+ }
420
+ })(),
421
+ );
422
+ return;
423
+ }
424
+ log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
425
+ // relayed carries queue-skipped messages plus the triggering one: the
426
+ // queue strategy hands a turn only the LATEST message and the rest via
427
+ // context.skipped, so they are folded into one prompt here. A message
428
+ // that is empty once its bot mention is stripped contributes neither
429
+ // prompt text nor a requester id (cursor review catch 2026-07-18).
430
+ const stripped = input.relayed
431
+ .map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
432
+ .filter((m) => m.text !== "");
433
+ const prompt = stripped.map((m) => m.text).join("\n\n");
434
+ const requesterIds = uniq(stripped.map((m) => m.authorId));
435
+ if (!prompt) return;
436
+ // this whole handler runs inside the per-thread `serialized` chain (call
437
+ // sites below), which startup resumes share too - so this load already
438
+ // sees any session id a resume persisted, and no second claude process
439
+ // can ever share this thread's cwd.
440
+ let record = loadSlackThread(thread.id);
441
+ if (!record) {
442
+ if (!isMention) return; // only a mention opens a session
443
+ record = { threadId: thread.id, repo: link.repo, cwd: link.repo, sessionId: null, createdAt: new Date().toISOString() };
444
+ saveSlackThread(record);
445
+ log("serve.thread_opened", { thread: thread.id, cwd: link.repo });
446
+ }
447
+ // Inside the serialized chain a marker can only be a PREVIOUS
448
+ // generation's killed turn (this generation's turns clear theirs before
449
+ // releasing the chain, and the serve-lock keeps generations exclusive):
450
+ // an inbound message can win the chain ahead of startup recovery (e.g.
451
+ // Slack redelivering the killed turn's unacked mention), and runTurn's
452
+ // fresh marker would silently discard the orphan's pid identity - reap it
453
+ // here so two claude processes never share the thread's cwd and session
454
+ // (closing-review catch, the recovery-path reap alone loses this race).
455
+ if (record.activeTurn) await reapOrphan(record.activeTurn);
456
+ // subscriptions live in the memory state, so a daemon restart forgets
457
+ // them; every mention re-subscribes to keep follow-up replies flowing.
458
+ if (isMention) await thread.subscribe();
459
+ // "is working..." assistant status; a no-op until the Slack app has the
460
+ // agent feature + assistant:write (the adapter warns instead of throwing).
461
+ await thread.startTyping();
462
+ const startedAt = Date.now();
463
+ const outcome = await runTurn({
464
+ thread,
465
+ record,
466
+ prompt,
467
+ requesterIds,
468
+ sessionId: record.sessionId,
469
+ marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0 },
470
+ link,
471
+ });
472
+ await settleTurn({ thread, outcome, startedAt });
473
+ };
474
+
475
+ /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
476
+ * log line, and the finish_thread garbage collection when the model called
477
+ * it. Never throws into the caller - the daemon must keep serving. */
478
+ const settleTurn = async (input: {
479
+ thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
480
+ outcome: TurnOutcome;
481
+ startedAt: number;
482
+ }) => {
483
+ const { thread, outcome, startedAt } = input;
484
+ log(outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
485
+ thread: thread.id,
486
+ seconds: Math.round((Date.now() - startedAt) / 1000),
487
+ });
488
+ // the user declared the work finished: close the thread now that the
489
+ // turn (and its claude subprocess) is over. Never throw into the caller -
490
+ // the daemon must keep serving other threads.
491
+ if (!outcome.finish) return;
492
+ let result: CleanupOutcome;
493
+ try {
494
+ result = seam.cleanup({ threadId: thread.id });
495
+ } catch (e) {
496
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
497
+ log("serve.cleanup_error", { thread: thread.id, err: detail });
498
+ try {
499
+ await thread.post(`tokenmaxxing: cleanup failed: ${detail}`);
500
+ } catch (postErr) {
501
+ // the diagnostic is best-effort, but its failure is never silent.
502
+ log("serve.finish_notify_error", { thread: thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
503
+ }
504
+ return;
505
+ }
506
+ // the Slack calls are guarded too: this whole path must never throw into
507
+ // the caller (review catch, PR #18) - the record is already gone, so a
508
+ // failed confirmation only gets logged.
509
+ try {
510
+ // a refusal keeps the subscription so the thread stays live for a retry.
511
+ if (result.removed) await thread.unsubscribe();
512
+ await thread.post(result.message);
513
+ } catch (e) {
514
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
515
+ log("serve.finish_notify_error", { thread: thread.id, err: detail });
516
+ }
517
+ log("serve.thread_finished", { thread: thread.id, removed: result.removed });
518
+ };
519
+
520
+ const relayable = (m: ServeMessage) => {
521
+ if (m.author.isMe || m.author.isBot === true) return false;
522
+ // outsiders must not drive sessions (owner rule 2026-07-16, ported from
523
+ // slaude): Slack Connect externals and cross-workspace guests are
524
+ // rejected fail-closed - silent in Slack, loud in the log.
525
+ if (isOutsideAuthor({ raw: m.raw, workspaceTeamId })) {
526
+ log("serve.outside_author", {});
527
+ return false;
528
+ }
529
+ return true;
530
+ };
531
+
532
+ const tracked = async (turn: Promise<void>) => {
533
+ activeTurns.add(turn);
534
+ try {
535
+ await turn;
536
+ } finally {
537
+ activeTurns.delete(turn);
538
+ }
539
+ };
540
+
541
+ // Per-thread serialization owned HERE, not only by the SDK's queue lock:
542
+ // chat 4.34.0 acquires the dispatch lock with a 30s TTL and extends it only
543
+ // BETWEEN dispatches, so any handler outliving 30s (every claude turn, any
544
+ // depleted-pool park) lets a later message take the expired lock and start
545
+ // a second concurrent handler in the same thread and cwd (review catch,
546
+ // PR #18). Escaped messages run as sequential turns in arrival order.
547
+ const threadTurns = new Map<string, Promise<void>>();
548
+ const serialized = (threadId: string, run: () => Promise<void>) => {
549
+ const prev = threadTurns.get(threadId) ?? Promise.resolve();
550
+ const next = (async () => {
551
+ try {
552
+ await prev;
553
+ } catch { /* the previous turn's rejection was already surfaced to its own handler */ }
554
+ await run();
555
+ })();
556
+ threadTurns.set(threadId, next);
557
+ // GC observer: swallow next's rejection HERE only (the handler awaiting
558
+ // `next` still sees it), else the observer chain is an unhandled rejection
559
+ // (cubic review catch, PR #18).
560
+ void (async () => {
561
+ try {
562
+ await next;
563
+ } catch { /* surfaced to the awaiting handler */ }
564
+ if (threadTurns.get(threadId) === next) threadTurns.delete(threadId);
565
+ })();
566
+ return next;
567
+ };
568
+
569
+ // both Chat SDK callbacks funnel here. Filter EVERY message, trigger
570
+ // included: an outsider (or our own post) arriving last must not discard
571
+ // relayable home-workspace messages queued behind the turn - the queue hands
572
+ // the handler only the latest message and the rest ride context.skipped
573
+ // (review catch, PR #18).
574
+ const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
575
+ const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId }));
576
+ if (relayed.length === 0) return; // outsider mentions never open a session
577
+ await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
578
+ };
579
+
580
+ /** Recover one thread whose activeTurn marker survived the previous daemon:
581
+ * a restart killed that turn mid-run. Notify the thread, then resume the
582
+ * session (or replay the original prompt when the kill landed before init
583
+ * assigned a session id); past the retry cap, give up loudly. EVERY branch
584
+ * runs inside the shared per-thread `serialized` chain and recomputes the
585
+ * decision from a fresh reload there: an inbound turn (or Slack
586
+ * redelivering the killed turn's unacked mention) can win the chain first,
587
+ * handle the thread, and clear the marker - acting on the startup snapshot
588
+ * would then re-run superseded work and write stale record fields over the
589
+ * session id that turn persisted (adversarial-review catch). Lives in the
590
+ * seam with `streamable` INJECTED (the daemon passes its bot-backed handle
591
+ * builder, tests a fake) so that superseded-recovery race is pinnable
592
+ * (closing-review catch: the invariant had no test while inline). */
593
+ const recoverInterrupted = async (
594
+ record: SlackThread,
595
+ streamable: (threadId: string) => Promise<{ thread: ServeThread; requesterIds: string[] }>,
596
+ ) => {
597
+ try {
598
+ const { thread, requesterIds } = await streamable(record.threadId);
599
+ const link = linkForChannel(cfg, bareChannelId(thread.channelId));
600
+ await serialized(record.threadId, async () => {
601
+ // a drain signal can land between the scan and this turn; leave the
602
+ // marker at its previous count so the next start retries.
603
+ if (draining) return;
604
+ const fresh = loadSlackThread(record.threadId);
605
+ const turn = fresh?.activeTurn;
606
+ const decision = fresh ? resumeDecision(fresh) : null;
607
+ if (!fresh || !turn || !decision) return; // superseded: an earlier turn already cleared the marker
608
+ await reapOrphan(turn);
609
+ if (!link) {
610
+ // unlinked since the turn started: nothing can run here; drop the
611
+ // marker and stay silent, like every unlinked-channel path.
612
+ saveSlackThread(omit(fresh, ["activeTurn"]));
613
+ log("serve.resume_unlinked", { thread: record.threadId });
614
+ return;
615
+ }
616
+ if (decision.kind === "give-up") {
617
+ log("serve.resume_gave_up", { thread: record.threadId });
618
+ // post BEFORE clearing, mirroring the resume branch's ordering: a
619
+ // kill or post failure here leaves the marker for the next restart
620
+ // to retry the notice (at-least-once; worst case a duplicate
621
+ // give-up notice), instead of the thread going permanently dark
622
+ // with the user never told the daemon gave up.
623
+ await thread.post(decision.notice);
624
+ saveSlackThread(omit(fresh, ["activeTurn"]));
625
+ return;
626
+ }
627
+ log("serve.resume_interrupted", { thread: record.threadId, attempt: decision.marker.resumeCount });
628
+ // the notice posts BEFORE runTurn persists the incremented marker,
629
+ // on purpose: the cap bounds quota-SPENDING attempts (the spawn),
630
+ // and a failed notice spends nothing - the marker survives at its
631
+ // old count for the next restart to retry, at most once per
632
+ // operator-triggered restart, each logged as serve.resume_error.
633
+ // A permanently unpostable channel (bot kicked, archived) therefore
634
+ // retries on every restart; unlinking it clears the marker.
635
+ await thread.post(decision.notice);
636
+ const startedAt = Date.now();
637
+ const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
638
+ await settleTurn({ thread, outcome, startedAt });
639
+ });
640
+ } catch (e) {
641
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
642
+ log("serve.resume_error", { thread: record.threadId, err: detail });
643
+ }
644
+ };
645
+
646
+ return {
647
+ /** in-flight turn promises; shutdown drains them. */
648
+ activeTurns,
649
+ isDraining: () => draining,
650
+ /** stop taking new turns and wake parked/retrying ones. */
651
+ beginDrain: () => {
652
+ draining = true;
653
+ drainAbort.abort();
654
+ },
655
+ relayable,
656
+ onMessage,
657
+ /** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
658
+ * resumed turn shares the exact chain and marker machinery of an inbound
659
+ * one. */
660
+ serialized,
661
+ tracked,
662
+ runTurn,
663
+ settleTurn,
664
+ recoverInterrupted,
665
+ };
666
+ }
667
+
188
668
  async function runDaemon(): Promise<number> {
189
- const cfg = loadSlackConfig();
669
+ let cfg = loadSlackConfig();
190
670
  if (!cfg) {
191
671
  printSetupInstructions();
192
672
  return 1;
@@ -195,6 +675,30 @@ async function runDaemon(): Promise<number> {
195
675
  console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
196
676
  return 1;
197
677
  }
678
+ // the external-author guard compares every message's origin against the home
679
+ // workspace. The reference is re-captured from the live token at EVERY start
680
+ // (a stale persisted id would fail-closed reject the owner's own messages);
681
+ // a failed capture fails the daemon fast - the guard never runs
682
+ // reference-less, and the daemon is useless without Slack reachable anyway.
683
+ const workspaceTeamId = await fetchWorkspaceTeamId({ botToken: cfg.botToken });
684
+ if (cfg.workspaceTeamId !== workspaceTeamId) {
685
+ cfg = { ...cfg, workspaceTeamId };
686
+ saveSlackConfig(cfg);
687
+ log("serve.team_captured", { team: workspaceTeamId });
688
+ }
689
+
690
+ // every log() event from here on (serve.* plus the in-process swap/decision
691
+ // events fired by ensureBestAccount/stopHookCheck) also prints to the
692
+ // terminal, so a foreground `xx serve` shows what it is doing live.
693
+ setLogEcho({ printer: (entry) => console.log(formatLogLine(entry)) });
694
+
695
+ // single-instance guard, held for the process lifetime (the fd releases on
696
+ // exit): without it a replacement daemon can start inside the previous
697
+ // generation's drain window, read a still-RUNNING turn's activeTurn marker,
698
+ // and resume it - two claude processes in one cwd (adversarial-review
699
+ // catch). Blocking here makes a rolling restart wait out the drain instead.
700
+ console.log(c.dim("acquiring the serve singleton lock (waits for a draining daemon to exit)"));
701
+ await acquireLock(paths.serveLockFile);
198
702
 
199
703
  const slack = createSlackAdapter({
200
704
  mode: "socket",
@@ -222,95 +726,115 @@ async function runDaemon(): Promise<number> {
222
726
  adapters: { slack },
223
727
  state,
224
728
  // per-thread lock with queueing: a message landing mid-turn waits its turn
225
- // instead of racing a second claude spawn on the same cwd. The default
226
- // 90s queue-entry TTL silently discards anything queued behind a turn
227
- // longer than that (claude turns routinely are), hence the override.
228
- concurrency: { strategy: "queue", queueEntryTtlMs: 900_000 },
729
+ // instead of racing a second claude spawn on the same cwd. Queue-entry TTL
730
+ // expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
731
+ // must outlast the longest legitimate hold: a depleted-pool park
732
+ // (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
733
+ // silently-vanished, hence a full hour.
734
+ concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000 },
229
735
  // without this a cards-only segment in post-and-edit fallback would
230
736
  // strand a bare "..." placeholder message.
231
737
  fallbackStreamingPlaceholderText: null,
232
738
  logger: "warn",
233
739
  });
234
740
 
235
- // in-flight turns, tracked so a shutdown signal can drain them instead of
236
- // killing a half-streamed answer (live incident 2026-07-18: a deploy
237
- // restart cut a turn mid-sentence and the answer never reached Slack).
238
- const activeTurns = new Set<Promise<void>>();
239
- let draining = false;
741
+ // the message-handling runtime (author guard, folding, per-thread
742
+ // serialization, activeTurn markers, drain drops, finish close-out) lives
743
+ // in buildServeRuntime so tests can drive the real wiring; production deps
744
+ // go in here.
745
+ const runtime = buildServeRuntime({
746
+ cfg,
747
+ workspaceTeamId,
748
+ botUserId: () => slack.botUserId ?? null,
749
+ relay: relayThread,
750
+ cleanup: cleanupThread,
751
+ });
240
752
 
241
- const handleTurn = async (input: {
242
- thread: { id: string; channelId: string; post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>; subscribe: () => Promise<void>; startTyping: () => Promise<void> };
243
- texts: string[];
244
- isMention: boolean;
245
- }) => {
246
- const { thread, texts, isMention } = input;
247
- if (draining) {
248
- // the socket stays connected until the drain finishes; anything landing
249
- // in that window is dropped loudly rather than spawning an unwaitable
250
- // turn. The user re-sends after the restart.
251
- log("serve.drain_dropped", { thread: thread.id });
252
- return;
253
- }
254
- const link = linkForChannel(cfg, bareChannelId(thread.channelId));
255
- if (!link) {
256
- log("serve.unlinked_channel", { channel: thread.channelId });
257
- return; // not a linked channel - stay silent in Slack
258
- }
259
- log("serve.message", { thread: thread.id, isMention, texts: texts.length });
260
- // texts carries queue-skipped messages plus the triggering one: the queue
261
- // strategy hands a turn only the LATEST message and the rest via
262
- // context.skipped, so they are folded into one prompt here.
263
- const prompt = texts
264
- .map((t) => stripLeadingMention(t))
265
- .filter((t) => t !== "")
266
- .join("\n\n");
267
- if (!prompt) return;
268
- let record = loadSlackThread(thread.id);
269
- if (!record) {
270
- if (!isMention) return; // only a mention opens a session
271
- const cwd = ensureThreadCwd({ link, threadId: thread.id });
272
- record = { threadId: thread.id, repo: link.repo, cwd, sessionId: null, createdAt: new Date().toISOString() };
273
- saveSlackThread(record);
274
- log("serve.thread_opened", { thread: thread.id, cwd });
275
- }
276
- // subscriptions live in the memory state, so a daemon restart forgets
277
- // them; every mention re-subscribes to keep follow-up replies flowing.
278
- if (isMention) await thread.subscribe();
279
- // "is working..." assistant status; a no-op until the Slack app has the
280
- // agent feature + assistant:write (the adapter warns instead of throwing).
281
- await thread.startTyping();
282
- const outcome = await relayThread({
283
- cwd: record.cwd,
284
- sessionId: record.sessionId,
285
- prompt,
286
- link,
287
- post: (m) => thread.post(m),
288
- });
289
- if (outcome.sessionId !== record.sessionId) {
290
- saveSlackThread({ ...record, sessionId: outcome.sessionId });
753
+ /** A proactive thread handle that can still stream natively. bot.thread()
754
+ * carries no currentMessage, and without one handleStream has no
755
+ * recipientUserId/recipientTeamId, so the Slack adapter's stream() gate
756
+ * falls back to card-less post-and-edit that also strands a blank message
757
+ * per card-only segment (verified in chat 4.34.0 + @chat-adapter/slack).
758
+ * Reusing the newest human message in the thread as currentMessage
759
+ * restores the exact context an inbound turn would have, and its author is
760
+ * the natural requester to tag on a resumed turn. The explicit ThreadImpl
761
+ * constructor (published typed API) is deliberate over the prose-documented
762
+ * ThreadImpl.fromJSON/reviver restore path: fromJSON takes the lazy config
763
+ * branch, which silently reverts fallbackStreamingPlaceholderText to "..."
764
+ * (re-stranding the placeholder this daemon suppresses) and needs
765
+ * registerSingleton for state. */
766
+ const streamableThread = async (threadId: string) => {
767
+ const handle = bot.thread(threadId);
768
+ for await (const message of handle.messages) {
769
+ if (runtime.relayable(message)) {
770
+ const thread = new ThreadImpl({
771
+ adapter: slack,
772
+ stateAdapter: state,
773
+ channelId: handle.channelId,
774
+ id: threadId,
775
+ isDM: false,
776
+ currentMessage: message,
777
+ fallbackStreamingPlaceholderText: null,
778
+ });
779
+ return { thread, requesterIds: [message.author.userId] };
780
+ }
291
781
  }
782
+ // no human message on record: card-less is all there is
783
+ return { thread: handle, requesterIds: [] };
292
784
  };
293
785
 
294
- const relayable = (m: { author: { isMe: boolean; isBot?: boolean | "unknown" } }) => !m.author.isMe && m.author.isBot !== true;
786
+ bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
787
+ bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
295
788
 
296
- const tracked = async (turn: Promise<void>) => {
297
- activeTurns.add(turn);
789
+ // drain instead of dying mid-answer: stop taking new turns, let in-flight
790
+ // ones finish (bounded - a hung claude turn must not block a restart
791
+ // forever), then disconnect. A second signal forces an immediate exit.
792
+ // Registered BEFORE initialize() (post-0.19.1 review catch): the socket
793
+ // goes live inside initialize, so a turn could start while runDaemon was
794
+ // still suspended there and a signal in that window hit default
795
+ // disposition - an instant kill with no drain.
796
+ const DRAIN_MS = 300_000;
797
+ const shutdown = async (signal: string) => {
798
+ if (runtime.isDraining()) {
799
+ log("serve.forced_exit", { signal });
800
+ process.exit(1);
801
+ }
802
+ runtime.beginDrain(); // parked/retrying turns wake, post their drop notice, and finish
803
+ log("serve.draining", { signal, turns: runtime.activeTurns.size });
804
+ console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: runtime.activeTurns.size, noun: "in-flight turn" })} (again to force)`);
805
+ // re-snapshot until stable inside the deadline: drain-window drop notices
806
+ // join activeTurns after the first snapshot and must still flush.
807
+ const deadline = Date.now() + DRAIN_MS;
808
+ while (runtime.activeTurns.size > 0 && Date.now() < deadline) {
809
+ await Promise.race([Promise.allSettled([...runtime.activeTurns]), delay(deadline - Date.now())]);
810
+ }
298
811
  try {
299
- await turn;
300
- } finally {
301
- activeTurns.delete(turn);
812
+ await bot.shutdown();
813
+ } catch (e) {
814
+ // exit must be reached even when the socket teardown rejects; the
815
+ // second-signal force path must not be the only escape.
816
+ log("serve.shutdown_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
302
817
  }
818
+ log("serve.stopped", { dropped: runtime.activeTurns.size });
819
+ process.exit(0);
303
820
  };
304
-
305
- bot.onNewMention(async (thread, message, context) => {
306
- const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
307
- await tracked(handleTurn({ thread, texts, isMention: true }));
308
- });
309
- bot.onSubscribedMessage(async (thread, message, context) => {
310
- if (!relayable(message)) return; // never relay our own posts
311
- const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
312
- await tracked(handleTurn({ thread, texts, isMention: false }));
313
- });
821
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
822
+ process.on("SIGINT", () => void shutdown("SIGINT"));
823
+ // closing the foreground terminal sends SIGHUP, whose default disposition
824
+ // kills the daemon WITHOUT the "exit" event - so the process-exit hook that
825
+ // kill-groups the DETACHED claude child never runs, the child survives as
826
+ // an orphan still mutating the cwd, and the freed serve-lock lets the next
827
+ // generation resume the same session beside it (adversarial-review catch,
828
+ // exit-skip verified empirically on Bun; a mid-turn orphan does NOT die on
829
+ // its dead stdout pipe - also verified - which is why the reaper exists).
830
+ // Draining instead keeps the child owned; its Slack streaming needs no
831
+ // tty, so the turn can even finish. A SIGKILL/crash orphan is covered by
832
+ // reapOrphan via the marker's pid identity; the only unmarked window is
833
+ // spawn-to-persist, both inside the spawn hook BEFORE the SDK writes the
834
+ // prompt, and a prompt-less orphan exits on its dead stdin's EOF (verified
835
+ // against the real claude binary in the SDK's exact stdio shape: dead in
836
+ // 2s, zero API calls).
837
+ process.on("SIGHUP", () => void shutdown("SIGHUP"));
314
838
 
315
839
  // initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
316
840
  // wired straight into event routing; the daemon only has to stay alive.
@@ -329,25 +853,15 @@ async function runDaemon(): Promise<number> {
329
853
  for (const record of records) await state.subscribe(record.threadId);
330
854
  log("serve.resubscribed", { threads: records.length });
331
855
 
332
- // drain instead of dying mid-answer: stop taking new turns, let in-flight
333
- // ones finish (bounded - a hung claude turn must not block a restart
334
- // forever), then disconnect. A second signal forces an immediate exit.
335
- const DRAIN_MS = 300_000;
336
- const shutdown = async (signal: string) => {
337
- if (draining) {
338
- log("serve.forced_exit", { signal });
339
- process.exit(1);
340
- }
341
- draining = true;
342
- log("serve.draining", { signal, turns: activeTurns.size });
343
- console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: activeTurns.size, noun: "in-flight turn" })} (again to force)`);
344
- await Promise.race([Promise.allSettled([...activeTurns]), delay(DRAIN_MS)]);
345
- await bot.shutdown();
346
- log("serve.stopped", { dropped: activeTurns.size });
347
- process.exit(0);
348
- };
349
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
350
- process.on("SIGINT", () => void shutdown("SIGINT"));
856
+ // threads whose activeTurn marker survived the previous daemon were killed
857
+ // mid-turn by a restart (live incident 2026-07-18: a redeploy silently
858
+ // killed a ship turn 8 minutes in and the thread just went dark). Each gets
859
+ // a notice and an auto-resumed turn, tracked so a drain waits for them too;
860
+ // the actionable decision is recomputed under the per-thread lock inside.
861
+ for (const record of records) {
862
+ if (!record.activeTurn) continue;
863
+ void runtime.tracked(runtime.recoverInterrupted(record, streamableThread));
864
+ }
351
865
 
352
866
  console.log(`${c.green("●")} serving ${count({ n: cfg.links.length, noun: "linked channel" })} over Slack Socket Mode - mention the bot in a linked channel to open a session (Ctrl-C to stop)`);
353
867
  log("serve.started", { links: cfg.links.length });