tokenmaxxing 0.19.1 → 1.0.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 (61) hide show
  1. package/DESIGN.md +34 -25
  2. package/README.md +6 -5
  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/codexrm.ts +49 -0
  10. package/src/cli/codexswitch.ts +20 -2
  11. package/src/cli/config.ts +25 -1
  12. package/src/cli/doctor.ts +3 -3
  13. package/src/cli/init.ts +54 -32
  14. package/src/cli/onboard.ts +62 -45
  15. package/src/cli/rename.ts +20 -0
  16. package/src/cli/render.ts +0 -16
  17. package/src/cli/rm.ts +40 -2
  18. package/src/cli/serve.ts +638 -115
  19. package/src/cli/status.ts +69 -23
  20. package/src/cli/switch.ts +54 -19
  21. package/src/entries/codexstophook.ts +123 -4
  22. package/src/entries/codexsupervisor.ts +87 -13
  23. package/src/entries/sessionstart.ts +1 -1
  24. package/src/entries/statusline.ts +56 -20
  25. package/src/entries/stophook.ts +23 -9
  26. package/src/entries/supervisor.ts +184 -20
  27. package/src/lib/atomic.ts +28 -6
  28. package/src/lib/claudebin.ts +2 -2
  29. package/src/lib/claudejson.ts +5 -5
  30. package/src/lib/claudelock.ts +112 -37
  31. package/src/lib/codexauth.ts +18 -3
  32. package/src/lib/codexbin.ts +1 -1
  33. package/src/lib/codexdecide.ts +149 -19
  34. package/src/lib/codexpick.ts +17 -6
  35. package/src/lib/codexpresence.ts +59 -21
  36. package/src/lib/codexsample.ts +17 -8
  37. package/src/lib/codexswap.ts +10 -1
  38. package/src/lib/credstore.ts +6 -2
  39. package/src/lib/decide.ts +136 -49
  40. package/src/lib/install.ts +125 -17
  41. package/src/lib/keychain.ts +41 -15
  42. package/src/lib/lock.ts +57 -35
  43. package/src/lib/log.ts +36 -7
  44. package/src/lib/oauth.ts +18 -11
  45. package/src/lib/paths.ts +17 -11
  46. package/src/lib/picker.ts +11 -3
  47. package/src/lib/proc.ts +37 -0
  48. package/src/lib/sample.ts +91 -31
  49. package/src/lib/sessions.ts +23 -1
  50. package/src/lib/settings.ts +59 -18
  51. package/src/lib/slackbridge.ts +581 -81
  52. package/src/lib/slackstate.ts +159 -12
  53. package/src/lib/slackstream.ts +123 -20
  54. package/src/lib/state.ts +131 -35
  55. package/src/lib/swap.ts +109 -47
  56. package/src/lib/types.ts +92 -38
  57. package/src/lib/usage.ts +114 -16
  58. package/src/main.ts +70 -9
  59. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  60. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  61. 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,475 @@ 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). A turn whose child reached a
367
+ // SUCCESSFUL result is also terminal even when failed (that failure is
368
+ // Slack delivery, not a killed child - resuming would re-run completed
369
+ // work; adversarial-review catch). null outcome = relay threw =
370
+ // still presumed killed.
371
+ const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop && !outcome.resultReceived));
372
+ // An unannounced drop OUTSIDE a drain still clears the marker on
373
+ // purpose (retention would re-execute the turn at the next restart; see
374
+ // notifyDelivered's doc) - but the loss must be operator-visible.
375
+ if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop) {
376
+ log("serve.drop_unannounced", { thread: input.thread.id });
377
+ }
378
+ saveSlackThread(presumedKilled ? record : omit(record, ["activeTurn"]));
379
+ }
380
+ };
381
+
382
+ const handleTurn = async (input: {
383
+ thread: ServeThread;
384
+ /** every relayed message this turn (queue-skipped + triggering), text
385
+ * paired with its author id: a decision may be owed to an earlier
386
+ * folded sender, and a sender whose whole message was the bot mention
387
+ * contributes no prompt text, so text and author filter together
388
+ * (review catches 2026-07-18). */
389
+ relayed: { text: string; authorId: string }[];
390
+ isMention: boolean;
391
+ }) => {
392
+ const { thread, isMention } = input;
393
+ const link = linkForChannel(cfg, bareChannelId(thread.channelId));
394
+ if (!link) {
395
+ // checked BEFORE the draining branch: unlinked channels are
396
+ // contractually log-only silent, and a drain-window drop notice posted
397
+ // into one would tell a user to resend a message that will never be
398
+ // served (closing-review catch).
399
+ log("serve.unlinked_channel", { channel: thread.channelId });
400
+ return; // not a linked channel - stay silent in Slack
401
+ }
402
+ if (draining) {
403
+ // the socket stays connected until the drain finishes; anything landing
404
+ // in that window is dropped loudly rather than spawning an unwaitable
405
+ // turn - and the THREAD is told, not just the log (a silent drop reads
406
+ // as the bot thinking; slaude's recorded drop-notice rule). Tracked so
407
+ // the drain wait flushes it before exit; errors swallowed so the notice
408
+ // can never fail the drain.
409
+ log("serve.drain_dropped", { thread: thread.id });
410
+ void tracked(
411
+ (async () => {
412
+ try {
413
+ await thread.post(
414
+ (async function* () {
415
+ yield "tokenmaxxing is restarting - this message was dropped; please re-send it in a moment.";
416
+ })(),
417
+ );
418
+ } catch (e) {
419
+ // caught (never rethrown - the notice must not fail the drain)
420
+ // but logged: an unposted notice means the user saw nothing.
421
+ log("serve.drain_notice_failed", { thread: thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
422
+ }
423
+ })(),
424
+ );
425
+ return;
426
+ }
427
+ log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
428
+ // relayed carries queue-skipped messages plus the triggering one: the
429
+ // queue strategy hands a turn only the LATEST message and the rest via
430
+ // context.skipped, so they are folded into one prompt here. A message
431
+ // that is empty once its bot mention is stripped contributes neither
432
+ // prompt text nor a requester id (cursor review catch 2026-07-18).
433
+ const stripped = input.relayed
434
+ .map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
435
+ .filter((m) => m.text !== "");
436
+ const prompt = stripped.map((m) => m.text).join("\n\n");
437
+ const requesterIds = uniq(stripped.map((m) => m.authorId));
438
+ if (!prompt) return;
439
+ // this whole handler runs inside the per-thread `serialized` chain (call
440
+ // sites below), which startup resumes share too - so this load already
441
+ // sees any session id a resume persisted, and no second claude process
442
+ // can ever share this thread's cwd.
443
+ let record = loadSlackThread(thread.id);
444
+ if (!record) {
445
+ if (!isMention) return; // only a mention opens a session
446
+ record = { threadId: thread.id, repo: link.repo, cwd: link.repo, sessionId: null, createdAt: new Date().toISOString() };
447
+ saveSlackThread(record);
448
+ log("serve.thread_opened", { thread: thread.id, cwd: link.repo });
449
+ }
450
+ // Inside the serialized chain a marker can only be a PREVIOUS
451
+ // generation's killed turn (this generation's turns clear theirs before
452
+ // releasing the chain, and the serve-lock keeps generations exclusive):
453
+ // an inbound message can win the chain ahead of startup recovery (e.g.
454
+ // Slack redelivering the killed turn's unacked mention), and runTurn's
455
+ // fresh marker would silently discard the orphan's pid identity - reap it
456
+ // here so two claude processes never share the thread's cwd and session
457
+ // (closing-review catch, the recovery-path reap alone loses this race).
458
+ if (record.activeTurn) await reapOrphan(record.activeTurn);
459
+ // subscriptions live in the memory state, so a daemon restart forgets
460
+ // them; every mention re-subscribes to keep follow-up replies flowing.
461
+ if (isMention) await thread.subscribe();
462
+ // "is working..." assistant status; a no-op until the Slack app has the
463
+ // agent feature + assistant:write (the adapter warns instead of throwing).
464
+ await thread.startTyping();
465
+ const startedAt = Date.now();
466
+ const outcome = await runTurn({
467
+ thread,
468
+ record,
469
+ prompt,
470
+ requesterIds,
471
+ sessionId: record.sessionId,
472
+ marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0 },
473
+ link,
474
+ });
475
+ await settleTurn({ thread, outcome, startedAt });
476
+ };
477
+
478
+ /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
479
+ * log line, and the finish_thread garbage collection when the model called
480
+ * it. Never throws into the caller - the daemon must keep serving. */
481
+ const settleTurn = async (input: {
482
+ thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
483
+ outcome: TurnOutcome;
484
+ startedAt: number;
485
+ }) => {
486
+ const { thread, outcome, startedAt } = input;
487
+ log(outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
488
+ thread: thread.id,
489
+ seconds: Math.round((Date.now() - startedAt) / 1000),
490
+ });
491
+ // the user declared the work finished: close the thread now that the
492
+ // turn (and its claude subprocess) is over. Never throw into the caller -
493
+ // the daemon must keep serving other threads.
494
+ if (!outcome.finish) return;
495
+ let result: CleanupOutcome;
496
+ try {
497
+ result = seam.cleanup({ threadId: thread.id });
498
+ } catch (e) {
499
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
500
+ log("serve.cleanup_error", { thread: thread.id, err: detail });
501
+ try {
502
+ await thread.post(`tokenmaxxing: cleanup failed: ${detail}`);
503
+ } catch (postErr) {
504
+ // the diagnostic is best-effort, but its failure is never silent.
505
+ log("serve.finish_notify_error", { thread: thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
506
+ }
507
+ return;
508
+ }
509
+ // the Slack calls are guarded too: this whole path must never throw into
510
+ // the caller (review catch, PR #18) - the record is already gone, so a
511
+ // failed confirmation only gets logged.
512
+ try {
513
+ // a refusal keeps the subscription so the thread stays live for a retry.
514
+ if (result.removed) await thread.unsubscribe();
515
+ await thread.post(result.message);
516
+ } catch (e) {
517
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
518
+ log("serve.finish_notify_error", { thread: thread.id, err: detail });
519
+ }
520
+ log("serve.thread_finished", { thread: thread.id, removed: result.removed });
521
+ };
522
+
523
+ const relayable = (m: ServeMessage) => {
524
+ if (m.author.isMe || m.author.isBot === true) return false;
525
+ // outsiders must not drive sessions (owner rule 2026-07-16, ported from
526
+ // slaude): Slack Connect externals and cross-workspace guests are
527
+ // rejected fail-closed - silent in Slack, loud in the log.
528
+ if (isOutsideAuthor({ raw: m.raw, workspaceTeamId })) {
529
+ log("serve.outside_author", {});
530
+ return false;
531
+ }
532
+ return true;
533
+ };
534
+
535
+ const tracked = async (turn: Promise<void>) => {
536
+ activeTurns.add(turn);
537
+ try {
538
+ await turn;
539
+ } finally {
540
+ activeTurns.delete(turn);
541
+ }
542
+ };
543
+
544
+ // Per-thread serialization owned HERE, not only by the SDK's queue lock:
545
+ // chat 4.34.0 acquires the dispatch lock with a 30s TTL and extends it only
546
+ // BETWEEN dispatches, so any handler outliving 30s (every claude turn, any
547
+ // depleted-pool park) lets a later message take the expired lock and start
548
+ // a second concurrent handler in the same thread and cwd (review catch,
549
+ // PR #18). Escaped messages run as sequential turns in arrival order.
550
+ const threadTurns = new Map<string, Promise<void>>();
551
+ const serialized = (threadId: string, run: () => Promise<void>) => {
552
+ const prev = threadTurns.get(threadId) ?? Promise.resolve();
553
+ const next = (async () => {
554
+ try {
555
+ await prev;
556
+ } catch { /* the previous turn's rejection was already surfaced to its own handler */ }
557
+ await run();
558
+ })();
559
+ threadTurns.set(threadId, next);
560
+ // GC observer: swallow next's rejection HERE only (the handler awaiting
561
+ // `next` still sees it), else the observer chain is an unhandled rejection
562
+ // (cubic review catch, PR #18).
563
+ void (async () => {
564
+ try {
565
+ await next;
566
+ } catch { /* surfaced to the awaiting handler */ }
567
+ if (threadTurns.get(threadId) === next) threadTurns.delete(threadId);
568
+ })();
569
+ return next;
570
+ };
571
+
572
+ // both Chat SDK callbacks funnel here. Filter EVERY message, trigger
573
+ // included: an outsider (or our own post) arriving last must not discard
574
+ // relayable home-workspace messages queued behind the turn - the queue hands
575
+ // the handler only the latest message and the rest ride context.skipped
576
+ // (review catch, PR #18).
577
+ const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
578
+ const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId }));
579
+ if (relayed.length === 0) return; // outsider mentions never open a session
580
+ await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
581
+ };
582
+
583
+ /** Recover one thread whose activeTurn marker survived the previous daemon:
584
+ * a restart killed that turn mid-run. Notify the thread, then resume the
585
+ * session (or replay the original prompt when the kill landed before init
586
+ * assigned a session id); past the retry cap, give up loudly. EVERY branch
587
+ * runs inside the shared per-thread `serialized` chain and recomputes the
588
+ * decision from a fresh reload there: an inbound turn (or Slack
589
+ * redelivering the killed turn's unacked mention) can win the chain first,
590
+ * handle the thread, and clear the marker - acting on the startup snapshot
591
+ * would then re-run superseded work and write stale record fields over the
592
+ * session id that turn persisted (adversarial-review catch). Lives in the
593
+ * seam with `streamable` INJECTED (the daemon passes its bot-backed handle
594
+ * builder, tests a fake) so that superseded-recovery race is pinnable
595
+ * (closing-review catch: the invariant had no test while inline). */
596
+ const recoverInterrupted = async (
597
+ record: SlackThread,
598
+ streamable: (threadId: string) => Promise<{ thread: ServeThread; requesterIds: string[] }>,
599
+ ) => {
600
+ try {
601
+ const { thread, requesterIds } = await streamable(record.threadId);
602
+ const link = linkForChannel(cfg, bareChannelId(thread.channelId));
603
+ await serialized(record.threadId, async () => {
604
+ // a drain signal can land between the scan and this turn; leave the
605
+ // marker at its previous count so the next start retries.
606
+ if (draining) return;
607
+ const fresh = loadSlackThread(record.threadId);
608
+ const turn = fresh?.activeTurn;
609
+ const decision = fresh ? resumeDecision(fresh) : null;
610
+ if (!fresh || !turn || !decision) return; // superseded: an earlier turn already cleared the marker
611
+ await reapOrphan(turn);
612
+ if (!link) {
613
+ // unlinked since the turn started: nothing can run here; drop the
614
+ // marker and stay silent, like every unlinked-channel path.
615
+ saveSlackThread(omit(fresh, ["activeTurn"]));
616
+ log("serve.resume_unlinked", { thread: record.threadId });
617
+ return;
618
+ }
619
+ if (decision.kind === "give-up") {
620
+ log("serve.resume_gave_up", { thread: record.threadId });
621
+ // post BEFORE clearing, mirroring the resume branch's ordering: a
622
+ // kill or post failure here leaves the marker for the next restart
623
+ // to retry the notice (at-least-once; worst case a duplicate
624
+ // give-up notice), instead of the thread going permanently dark
625
+ // with the user never told the daemon gave up.
626
+ await thread.post(decision.notice);
627
+ saveSlackThread(omit(fresh, ["activeTurn"]));
628
+ return;
629
+ }
630
+ log("serve.resume_interrupted", { thread: record.threadId, attempt: decision.marker.resumeCount });
631
+ // the notice posts BEFORE runTurn persists the incremented marker,
632
+ // on purpose: the cap bounds quota-SPENDING attempts (the spawn),
633
+ // and a failed notice spends nothing - the marker survives at its
634
+ // old count for the next restart to retry, at most once per
635
+ // operator-triggered restart, each logged as serve.resume_error.
636
+ // A permanently unpostable channel (bot kicked, archived) therefore
637
+ // retries on every restart; unlinking it clears the marker.
638
+ await thread.post(decision.notice);
639
+ const startedAt = Date.now();
640
+ const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
641
+ await settleTurn({ thread, outcome, startedAt });
642
+ });
643
+ } catch (e) {
644
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
645
+ log("serve.resume_error", { thread: record.threadId, err: detail });
646
+ }
647
+ };
648
+
649
+ return {
650
+ /** in-flight turn promises; shutdown drains them. */
651
+ activeTurns,
652
+ isDraining: () => draining,
653
+ /** stop taking new turns and wake parked/retrying ones. */
654
+ beginDrain: () => {
655
+ draining = true;
656
+ drainAbort.abort();
657
+ },
658
+ relayable,
659
+ onMessage,
660
+ /** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
661
+ * resumed turn shares the exact chain and marker machinery of an inbound
662
+ * one. */
663
+ serialized,
664
+ tracked,
665
+ runTurn,
666
+ settleTurn,
667
+ recoverInterrupted,
668
+ };
669
+ }
670
+
188
671
  async function runDaemon(): Promise<number> {
189
- const cfg = loadSlackConfig();
672
+ let cfg = loadSlackConfig();
190
673
  if (!cfg) {
191
674
  printSetupInstructions();
192
675
  return 1;
@@ -195,6 +678,30 @@ async function runDaemon(): Promise<number> {
195
678
  console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
196
679
  return 1;
197
680
  }
681
+ // the external-author guard compares every message's origin against the home
682
+ // workspace. The reference is re-captured from the live token at EVERY start
683
+ // (a stale persisted id would fail-closed reject the owner's own messages);
684
+ // a failed capture fails the daemon fast - the guard never runs
685
+ // reference-less, and the daemon is useless without Slack reachable anyway.
686
+ const workspaceTeamId = await fetchWorkspaceTeamId({ botToken: cfg.botToken });
687
+ if (cfg.workspaceTeamId !== workspaceTeamId) {
688
+ cfg = { ...cfg, workspaceTeamId };
689
+ saveSlackConfig(cfg);
690
+ log("serve.team_captured", { team: workspaceTeamId });
691
+ }
692
+
693
+ // every log() event from here on (serve.* plus the in-process swap/decision
694
+ // events fired by ensureBestAccount/stopHookCheck) also prints to the
695
+ // terminal, so a foreground `xx serve` shows what it is doing live.
696
+ setLogEcho({ printer: (entry) => console.log(formatLogLine(entry)) });
697
+
698
+ // single-instance guard, held for the process lifetime (the fd releases on
699
+ // exit): without it a replacement daemon can start inside the previous
700
+ // generation's drain window, read a still-RUNNING turn's activeTurn marker,
701
+ // and resume it - two claude processes in one cwd (adversarial-review
702
+ // catch). Blocking here makes a rolling restart wait out the drain instead.
703
+ console.log(c.dim("acquiring the serve singleton lock (waits for a draining daemon to exit)"));
704
+ await acquireLock(paths.serveLockFile);
198
705
 
199
706
  const slack = createSlackAdapter({
200
707
  mode: "socket",
@@ -222,95 +729,121 @@ async function runDaemon(): Promise<number> {
222
729
  adapters: { slack },
223
730
  state,
224
731
  // 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 },
732
+ // instead of racing a second claude spawn on the same cwd. Queue-entry TTL
733
+ // expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
734
+ // must outlast the longest legitimate hold: a depleted-pool park
735
+ // (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
736
+ // silently-vanished, hence a full hour. maxQueueSize matters for the same
737
+ // reason: the SDK default is 10 with a LOG-LESS drop-oldest trim
738
+ // (@chat-adapter/state-memory enqueue splices the front, and the SDK's
739
+ // message-dropped log only fires for drop-newest), so a >10-message burst
740
+ // behind one long turn silently ate the oldest instructions
741
+ // (adversarial-review catch). 100 outlasts any legitimate burst; the TTL
742
+ // stays the real bound.
743
+ concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000, maxQueueSize: 100 },
229
744
  // without this a cards-only segment in post-and-edit fallback would
230
745
  // strand a bare "..." placeholder message.
231
746
  fallbackStreamingPlaceholderText: null,
232
747
  logger: "warn",
233
748
  });
234
749
 
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;
750
+ // the message-handling runtime (author guard, folding, per-thread
751
+ // serialization, activeTurn markers, drain drops, finish close-out) lives
752
+ // in buildServeRuntime so tests can drive the real wiring; production deps
753
+ // go in here.
754
+ const runtime = buildServeRuntime({
755
+ cfg,
756
+ workspaceTeamId,
757
+ botUserId: () => slack.botUserId ?? null,
758
+ relay: relayThread,
759
+ cleanup: cleanupThread,
760
+ });
240
761
 
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 });
762
+ /** A proactive thread handle that can still stream natively. bot.thread()
763
+ * carries no currentMessage, and without one handleStream has no
764
+ * recipientUserId/recipientTeamId, so the Slack adapter's stream() gate
765
+ * falls back to card-less post-and-edit that also strands a blank message
766
+ * per card-only segment (verified in chat 4.34.0 + @chat-adapter/slack).
767
+ * Reusing the newest human message in the thread as currentMessage
768
+ * restores the exact context an inbound turn would have, and its author is
769
+ * the natural requester to tag on a resumed turn. The explicit ThreadImpl
770
+ * constructor (published typed API) is deliberate over the prose-documented
771
+ * ThreadImpl.fromJSON/reviver restore path: fromJSON takes the lazy config
772
+ * branch, which silently reverts fallbackStreamingPlaceholderText to "..."
773
+ * (re-stranding the placeholder this daemon suppresses) and needs
774
+ * registerSingleton for state. */
775
+ const streamableThread = async (threadId: string) => {
776
+ const handle = bot.thread(threadId);
777
+ for await (const message of handle.messages) {
778
+ if (runtime.relayable(message)) {
779
+ const thread = new ThreadImpl({
780
+ adapter: slack,
781
+ stateAdapter: state,
782
+ channelId: handle.channelId,
783
+ id: threadId,
784
+ isDM: false,
785
+ currentMessage: message,
786
+ fallbackStreamingPlaceholderText: null,
787
+ });
788
+ return { thread, requesterIds: [message.author.userId] };
789
+ }
291
790
  }
791
+ // no human message on record: card-less is all there is
792
+ return { thread: handle, requesterIds: [] };
292
793
  };
293
794
 
294
- const relayable = (m: { author: { isMe: boolean; isBot?: boolean | "unknown" } }) => !m.author.isMe && m.author.isBot !== true;
795
+ bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
796
+ bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
295
797
 
296
- const tracked = async (turn: Promise<void>) => {
297
- activeTurns.add(turn);
798
+ // drain instead of dying mid-answer: stop taking new turns, let in-flight
799
+ // ones finish (bounded - a hung claude turn must not block a restart
800
+ // forever), then disconnect. A second signal forces an immediate exit.
801
+ // Registered BEFORE initialize() (post-0.19.1 review catch): the socket
802
+ // goes live inside initialize, so a turn could start while runDaemon was
803
+ // still suspended there and a signal in that window hit default
804
+ // disposition - an instant kill with no drain.
805
+ const DRAIN_MS = 300_000;
806
+ const shutdown = async (signal: string) => {
807
+ if (runtime.isDraining()) {
808
+ log("serve.forced_exit", { signal });
809
+ process.exit(1);
810
+ }
811
+ runtime.beginDrain(); // parked/retrying turns wake, post their drop notice, and finish
812
+ log("serve.draining", { signal, turns: runtime.activeTurns.size });
813
+ console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: runtime.activeTurns.size, noun: "in-flight turn" })} (again to force)`);
814
+ // re-snapshot until stable inside the deadline: drain-window drop notices
815
+ // join activeTurns after the first snapshot and must still flush.
816
+ const deadline = Date.now() + DRAIN_MS;
817
+ while (runtime.activeTurns.size > 0 && Date.now() < deadline) {
818
+ await Promise.race([Promise.allSettled([...runtime.activeTurns]), delay(deadline - Date.now())]);
819
+ }
298
820
  try {
299
- await turn;
300
- } finally {
301
- activeTurns.delete(turn);
821
+ await bot.shutdown();
822
+ } catch (e) {
823
+ // exit must be reached even when the socket teardown rejects; the
824
+ // second-signal force path must not be the only escape.
825
+ log("serve.shutdown_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
302
826
  }
827
+ log("serve.stopped", { dropped: runtime.activeTurns.size });
828
+ process.exit(0);
303
829
  };
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
- });
830
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
831
+ process.on("SIGINT", () => void shutdown("SIGINT"));
832
+ // closing the foreground terminal sends SIGHUP, whose default disposition
833
+ // kills the daemon WITHOUT the "exit" event - so the process-exit hook that
834
+ // kill-groups the DETACHED claude child never runs, the child survives as
835
+ // an orphan still mutating the cwd, and the freed serve-lock lets the next
836
+ // generation resume the same session beside it (adversarial-review catch,
837
+ // exit-skip verified empirically on Bun; a mid-turn orphan does NOT die on
838
+ // its dead stdout pipe - also verified - which is why the reaper exists).
839
+ // Draining instead keeps the child owned; its Slack streaming needs no
840
+ // tty, so the turn can even finish. A SIGKILL/crash orphan is covered by
841
+ // reapOrphan via the marker's pid identity; the only unmarked window is
842
+ // spawn-to-persist, both inside the spawn hook BEFORE the SDK writes the
843
+ // prompt, and a prompt-less orphan exits on its dead stdin's EOF (verified
844
+ // against the real claude binary in the SDK's exact stdio shape: dead in
845
+ // 2s, zero API calls).
846
+ process.on("SIGHUP", () => void shutdown("SIGHUP"));
314
847
 
315
848
  // initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
316
849
  // wired straight into event routing; the daemon only has to stay alive.
@@ -329,25 +862,15 @@ async function runDaemon(): Promise<number> {
329
862
  for (const record of records) await state.subscribe(record.threadId);
330
863
  log("serve.resubscribed", { threads: records.length });
331
864
 
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"));
865
+ // threads whose activeTurn marker survived the previous daemon were killed
866
+ // mid-turn by a restart (live incident 2026-07-18: a redeploy silently
867
+ // killed a ship turn 8 minutes in and the thread just went dark). Each gets
868
+ // a notice and an auto-resumed turn, tracked so a drain waits for them too;
869
+ // the actionable decision is recomputed under the per-thread lock inside.
870
+ for (const record of records) {
871
+ if (!record.activeTurn) continue;
872
+ void runtime.tracked(runtime.recoverInterrupted(record, streamableThread));
873
+ }
351
874
 
352
875
  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
876
  log("serve.started", { links: cfg.links.length });