tokenmaxxing 1.6.0 → 1.8.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 (58) hide show
  1. package/DESIGN.md +5 -31
  2. package/LICENSE +21 -0
  3. package/README.md +1 -2
  4. package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
  5. package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
  6. package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
  7. package/agent-plugin/hooks/cursor-relay.json +14 -0
  8. package/agent-plugin/mcp.json +10 -0
  9. package/agent-plugin/plugin.json +20 -0
  10. package/agent-plugin/skills/codex-pool/SKILL.md +23 -0
  11. package/agent-plugin/skills/codex-pool/references/codex.md +5 -0
  12. package/agent-plugin/skills/credentials-hygiene/SKILL.md +26 -0
  13. package/agent-plugin/skills/credentials-hygiene/references/credentials.md +6 -0
  14. package/agent-plugin/skills/doctor-diagnostics/SKILL.md +26 -0
  15. package/agent-plugin/skills/doctor-diagnostics/references/troubleshooting.md +5 -0
  16. package/agent-plugin/skills/pool-status/SKILL.md +27 -0
  17. package/agent-plugin/skills/pool-status/references/commands.md +8 -0
  18. package/agent-plugin/skills/relay-session/SKILL.md +118 -0
  19. package/agent-plugin/skills/relay-session/references/ipc.md +23 -0
  20. package/agent-plugin/skills/safe-contribution/SKILL.md +27 -0
  21. package/agent-plugin/skills/safe-contribution/references/ship.md +5 -0
  22. package/agent-plugin/skills/sdk-pairing/SKILL.md +33 -0
  23. package/agent-plugin/skills/sdk-pairing/references/sdk.md +6 -0
  24. package/agent-plugin/skills/switching-policy/SKILL.md +29 -0
  25. package/agent-plugin/skills/switching-policy/references/policy.md +7 -0
  26. package/package.json +3 -5
  27. package/src/cli/codexinit.ts +11 -2
  28. package/src/cli/init.ts +9 -3
  29. package/src/cli/relay.ts +323 -0
  30. package/src/entries/codexstophook.ts +10 -0
  31. package/src/entries/mcp.ts +288 -0
  32. package/src/entries/relaypermission.ts +105 -0
  33. package/src/entries/stophook.ts +11 -0
  34. package/src/lib/decide.ts +2 -4
  35. package/src/lib/install.ts +61 -7
  36. package/src/lib/lock.ts +3 -7
  37. package/src/lib/log.ts +8 -11
  38. package/src/lib/paths.ts +3 -9
  39. package/src/lib/relay/config.ts +84 -0
  40. package/src/lib/relay/decide.ts +75 -0
  41. package/src/lib/relay/gc.ts +80 -0
  42. package/src/lib/relay/install.ts +143 -0
  43. package/src/lib/relay/markers.ts +148 -0
  44. package/src/lib/relay/modes.ts +82 -0
  45. package/src/lib/relay/protocol.ts +61 -0
  46. package/src/lib/relay/registry.ts +175 -0
  47. package/src/lib/relay/tmux.ts +109 -0
  48. package/src/lib/relay/turn.ts +137 -0
  49. package/src/lib/relay/worker.ts +141 -0
  50. package/src/lib/usage.ts +6 -5
  51. package/src/main.ts +6 -6
  52. package/src/cli/serve.ts +0 -1790
  53. package/src/lib/slackbridge.ts +0 -1363
  54. package/src/lib/slackstate.ts +0 -352
  55. package/src/lib/slackstream.ts +0 -300
  56. package/src/serve-plugin/.claude-plugin/plugin.json +0 -4
  57. package/src/serve-plugin/skills/ask-the-user/SKILL.md +0 -41
  58. package/src/serve-plugin/skills/serve-session/SKILL.md +0 -50
package/src/cli/serve.ts DELETED
@@ -1,1790 +0,0 @@
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 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).
11
- //
12
- // serve setup print the app manifest + prompt for the two tokens
13
- // serve link <ch> <repo> [--dangerous] [--model <m>]
14
- // serve unlink <ch> remove a link
15
- // serve links list links
16
- // serve run the daemon
17
-
18
- import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
19
- import { join } from "node:path";
20
- import { delay, omit, uniq } from "es-toolkit";
21
- import { z } from "zod";
22
- import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
23
- import { createSlackAdapter } from "@chat-adapter/slack";
24
- import { MemoryStateAdapter } from "@chat-adapter/state-memory";
25
- import {
26
- bareChannelId,
27
- isChannelId,
28
- isOutsideAuthor,
29
- linkForChannel,
30
- listSlackThreads,
31
- loadSlackConfig,
32
- loadSlackThread,
33
- removeLink,
34
- resumeDecision,
35
- saveSlackConfig,
36
- saveSlackThread,
37
- stripLeadingMention,
38
- upsertLink,
39
- SlackLinkSchema,
40
- SlackThreadSchema,
41
- type ActiveTurn,
42
- type SlackConfig,
43
- type SlackLink,
44
- type SlackThread,
45
- } from "../lib/slackstate.ts";
46
- import { cleanupThread, fetchWorkspaceTeamId, killGroup, relayThread, type CleanupOutcome, type TurnOutcome } from "../lib/slackbridge.ts";
47
- import { ensureBestAccount, type SwapDecision } from "../sdk.ts";
48
- import { pidStartTime } from "../lib/proc.ts";
49
- import { acquireLock } from "../lib/lock.ts";
50
- import { paths } from "../lib/paths.ts";
51
- import { log, setLogEcho } from "../lib/log.ts";
52
- import { c, count } from "./render.ts";
53
-
54
- const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo> [--yolo | --dangerous] [--model <m>] | unlink <channel-id> | links]";
55
-
56
- /** The manifest the user pastes at api.slack.com/apps > From an app manifest.
57
- * Scopes/events verified against docs.slack.dev 2026-07-18: a channel-thread
58
- * relay plus Slack's Agent messaging experience (agent_view + assistant:write
59
- * power the DM assistant surface and typing status; channel-thread streaming
60
- * works without them, verified live). agent_view is an OBJECT whose required
61
- * field is agent_description (max 300 chars; docs.slack.dev app-manifest
62
- * reference, re-verified 2026-07-20) - a bare `agent_view: true` is rejected
63
- * with "Must provide an object". reactions:write powers the status reactions
64
- * the daemon sets on triggering messages; reactions:read + the reaction_added
65
- * event feed user reactions back in (matching @chat-adapter/slack's own
66
- * README manifest). Changing scopes on an existing app requires reinstalling
67
- * it to the workspace. */
68
- const APP_MANIFEST = `display_information:
69
- name: tokenmaxxing
70
- description: bridges Slack threads to Claude Code sessions
71
-
72
- features:
73
- agent_view:
74
- agent_description: bridges Slack threads to Claude Code sessions
75
- bot_user:
76
- display_name: tokenmaxxing
77
- always_online: true
78
-
79
- oauth_config:
80
- scopes:
81
- bot:
82
- - app_mentions:read
83
- - assistant:write
84
- - channels:history
85
- - groups:history
86
- - chat:write
87
- - files:write
88
- - im:history
89
- - reactions:read
90
- - reactions:write
91
- - users:read
92
-
93
- settings:
94
- event_subscriptions:
95
- bot_events:
96
- - app_context_changed
97
- - app_home_opened
98
- - app_mention
99
- - message.channels
100
- - message.groups
101
- - message.im
102
- - reaction_added
103
- socket_mode_enabled: true
104
- org_deploy_enabled: false
105
- token_rotation_enabled: false`;
106
-
107
- function printSetupInstructions(): void {
108
- console.log(c.bold("Slack app setup (one time)"));
109
- console.log(`1. Open ${c.cyan("https://api.slack.com/apps")} > Create New App > From an app manifest, pick your workspace, and paste:`);
110
- console.log();
111
- console.log(APP_MANIFEST);
112
- console.log();
113
- console.log("2. OAuth & Permissions > Install to Workspace, copy the Bot User OAuth Token (xoxb-...).");
114
- console.log("3. Basic Information > App-Level Tokens > Generate (add the connections:write scope), copy the token (xapp-...).");
115
- console.log(`4. Run ${c.cyan("tokenmaxxing serve setup")} and paste both tokens, then ${c.cyan("tokenmaxxing serve link <channel-id> <repo>")} and invite the bot to that channel.`);
116
- 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.")}`);
117
- }
118
-
119
- async function cmdServeSetup(): Promise<number> {
120
- printSetupInstructions();
121
- console.log();
122
- const botToken = prompt("bot token (xoxb-...):")?.trim();
123
- const appToken = prompt("app token (xapp-...):")?.trim();
124
- if (!botToken || !appToken) {
125
- console.error(c.red("both tokens are required - nothing saved"));
126
- return 1;
127
- }
128
- const existing = loadSlackConfig();
129
- let cfg: SlackConfig;
130
- try {
131
- cfg = { botToken, appToken, links: existing?.links ?? [] };
132
- saveSlackConfig(cfg);
133
- } catch {
134
- console.error(c.red("tokens rejected: the bot token must start with xoxb- and the app token with xapp-"));
135
- return 1;
136
- }
137
- // the external-author guard needs the home workspace id; capture it from the
138
- // token itself so the reference can never drift from the workspace the bot
139
- // actually lives in (re-captured on every setup: new tokens may belong to a
140
- // different workspace).
141
- try {
142
- cfg = { ...cfg, workspaceTeamId: await fetchWorkspaceTeamId({ botToken }) };
143
- saveSlackConfig(cfg);
144
- } catch (e) {
145
- const detail = e instanceof Error ? e.message : String(e);
146
- console.error(c.red(`tokens saved, but ${detail} - check the bot token; the daemon re-tries the capture at start`));
147
- return 1;
148
- }
149
- console.log(`${c.green("✓")} saved to slack.json (0600) for workspace ${cfg.workspaceTeamId} with ${count({ n: cfg.links.length, noun: "link" })}`);
150
- return 0;
151
- }
152
-
153
- function cmdServeLink(argv: string[]): number {
154
- // yolo mode = the SDK's bypassPermissions; --dangerous is the same switch.
155
- const dangerous = argv.includes("--yolo") || argv.includes("--dangerous");
156
- const modelIdx = argv.indexOf("--model");
157
- const model = modelIdx >= 0 ? argv[modelIdx + 1] : undefined;
158
- const rest = argv.filter((a, i) => !a.startsWith("--") && (modelIdx < 0 || i !== modelIdx + 1));
159
- const [channel, repo] = rest;
160
- if (!channel || !repo) {
161
- console.error(SERVE_USAGE);
162
- return 2;
163
- }
164
- if (!isChannelId(channel)) {
165
- console.error(c.red(`"${channel}" is not a Slack channel id (C.../G...). In Slack: right-click the channel > View channel details - the id is at the bottom.`));
166
- return 1;
167
- }
168
- if (!existsSync(repo)) {
169
- console.error(c.red(`repo path does not exist: ${repo}`));
170
- return 1;
171
- }
172
- const repoReal = realpathSync(repo);
173
- if (!existsSync(`${repoReal}/.git`)) {
174
- console.error(c.red(`${repoReal} is not a git repository`));
175
- return 1;
176
- }
177
- const cfg = loadSlackConfig();
178
- if (!cfg) {
179
- console.error(c.red("no slack.json yet - run `tokenmaxxing serve setup` first"));
180
- return 1;
181
- }
182
- const link = SlackLinkSchema.parse({
183
- channel,
184
- repo: repoReal,
185
- permissionMode: dangerous ? "bypassPermissions" : "acceptEdits",
186
- ...(model ? { model } : {}),
187
- });
188
- saveSlackConfig(upsertLink(cfg, link));
189
- const flags = [link.permissionMode, ...(model ? [model] : [])].join(", ");
190
- console.log(`${c.green("✓")} linked ${c.bold(channel)} → ${repoReal} (${flags})`);
191
- return 0;
192
- }
193
-
194
- function cmdServeUnlink(channel: string | undefined): number {
195
- if (!channel) {
196
- console.error(SERVE_USAGE);
197
- return 2;
198
- }
199
- const cfg = loadSlackConfig();
200
- const next = cfg ? removeLink(cfg, channel) : null;
201
- if (!next) {
202
- console.error(c.red(`no link for channel ${channel}`));
203
- return 1;
204
- }
205
- saveSlackConfig(next);
206
- console.log(`${c.green("✓")} unlinked ${channel}`);
207
- return 0;
208
- }
209
-
210
- function cmdServeLinks(): number {
211
- const cfg = loadSlackConfig();
212
- if (!cfg || cfg.links.length === 0) {
213
- console.log(c.dim("no channel links - run `tokenmaxxing serve link <channel-id> <repo>`"));
214
- return 0;
215
- }
216
- for (const l of cfg.links) {
217
- const flags = [l.permissionMode, ...(l.model ? [l.model] : [])].join(", ");
218
- console.log(`${c.bold(l.channel)} → ${l.repo} ${c.dim(`(${flags})`)}`);
219
- }
220
- return 0;
221
- }
222
-
223
- /** Event-name endings that pick the terminal paint: red for failures, yellow
224
- * for degraded-but-continuing conditions, cyan otherwise. Structural endsWith
225
- * checks so new events inherit sensible colors from their naming. */
226
- const RED_EVENT_ENDINGS = ["error", "failed", "invalid_grant"];
227
- const YELLOW_EVENT_ENDINGS = ["_dropped", "_drift", "_unparsed", "_gave_up", "_abort", "forced_exit", "contested", "draining"];
228
-
229
- function eventPaint(event: string): (s: string) => string {
230
- if (RED_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.red;
231
- if (YELLOW_EVENT_ENDINGS.some((ending) => event.endsWith(ending))) return c.yellow;
232
- return c.cyan;
233
- }
234
-
235
- /** One terminal line per log() event while the daemon runs: the file log stays
236
- * canonical; this makes `xx serve` observable without tailing tokenmaxxing.log.
237
- * Field values can carry newlines (e.g. usage.probe_failed's stderr excerpt),
238
- * so they are escaped to keep the one-line-per-event contract. Exported for
239
- * tests. */
240
- export function formatLogLine(input: { event: string; parts: string }): string {
241
- const time = new Date().toLocaleTimeString("en-GB");
242
- const parts = input.parts.replaceAll("\r", "\\r").replaceAll("\n", "\\n");
243
- return `${c.dim(time)} ${eventPaint(input.event)(input.event)}${parts ? ` ${parts}` : ""}`;
244
- }
245
-
246
- /** The slice of a Chat SDK thread the runtime touches. z.custom because it
247
- * carries functions: the zod-native way to name the structural shape once for
248
- * the daemon and test fakes alike. */
249
- const ServeThreadSchema = z.custom<{
250
- id: string;
251
- channelId: string;
252
- post: (m: string | AsyncIterable<string | StreamChunk> | StreamingPlan) => Promise<unknown>;
253
- subscribe: () => Promise<void>;
254
- unsubscribe: () => Promise<void>;
255
- startTyping: () => Promise<void>;
256
- }>();
257
- type ServeThread = z.infer<typeof ServeThreadSchema>;
258
-
259
- /** The slice of a Chat SDK message the author guard + folding read. id is the
260
- * Slack message ts (verified in @chat-adapter/slack 4.34.0: Message.id =
261
- * event.ts, exactly what reactions.add takes as timestamp), so it is the
262
- * handle status reactions attach to. */
263
- const ServeMessageSchema = z.custom<{
264
- id: string;
265
- text: string;
266
- author: { userId: string; isMe: boolean; isBot?: boolean | "unknown" };
267
- raw?: unknown;
268
- }>();
269
- type ServeMessage = z.infer<typeof ServeMessageSchema>;
270
-
271
- /** Status reactions on the triggering message: hourglass while the turn runs,
272
- * then exactly one terminal state. Color-of-the-moment for the whole thread
273
- * list: which asks are being worked, which wait on the user, which are done. */
274
- const STATUS_EMOJI = {
275
- processing: "hourglass_flowing_sand",
276
- done: "white_check_mark",
277
- failed: "x",
278
- attention: "question",
279
- } as const;
280
-
281
- /** One nudge per ask, this long after the asking turn settled: enough for a
282
- * present user to answer on their own, short enough that a blocked thread
283
- * does not sit forgotten. */
284
- export const ATTENTION_NUDGE_MS = 600_000;
285
- /** How often the daemon sweeps for overdue attention (runDaemon interval). */
286
- export const NUDGE_SWEEP_MS = 60_000;
287
-
288
- /** Reap a previous generation's detached claude child that survived an
289
- * uncatchable daemon death (SIGKILL, crash: the "exit" event never fires
290
- * on those, so the hook that kills the group never ran) - resuming beside
291
- * a live orphan would put two claude processes on one cwd and session
292
- * (adversarial-review catch). Signals fire ONLY on a verified pid+lstart
293
- * identity match (cubic review catch: this machine runs the user's real
294
- * claude sessions; a recycled pid must never get the kill). SIGTERM the
295
- * group, escalate to SIGKILL if it lingers past the grace. */
296
- async function reapOrphan(turn: ActiveTurn): Promise<void> {
297
- if (turn.pid === undefined || turn.pidStartedAt === undefined) return;
298
- if (pidStartTime(turn.pid) !== turn.pidStartedAt) return; // gone, or a recycled pid
299
- log("serve.orphan_reaped", { pid: turn.pid });
300
- killGroup(turn.pid);
301
- for (let i = 0; i < 10; i++) {
302
- await delay(500);
303
- if (pidStartTime(turn.pid) !== turn.pidStartedAt) return;
304
- }
305
- killGroup(turn.pid, "SIGKILL");
306
- }
307
-
308
- /**
309
- * The daemon's message-handling runtime, extracted from runDaemon as an
310
- * injectable seam so tests can drive the REAL handler wiring (author guard,
311
- * skipped-message folding, per-thread serialization, activeTurn markers,
312
- * drain drops, finish close-out) against fake threads and a fake relay.
313
- * runDaemon passes the production deps; behavior is identical. The startup
314
- * interrupted-turn recovery stays inline in runDaemon (it needs the live bot
315
- * and adapter for streamable thread handles) and reuses the chain/turn pieces
316
- * exposed here.
317
- */
318
- export function buildServeRuntime(seam: {
319
- cfg: SlackConfig;
320
- workspaceTeamId: string;
321
- /** read per message: the adapter only learns its bot user id on connect. */
322
- botUserId: () => string | null;
323
- relay: (input: {
324
- cwd: string;
325
- sessionId: string | null;
326
- prompt: string;
327
- requesterIds: string[];
328
- link: SlackLink;
329
- post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>;
330
- /** fires when init assigns a session id the caller has not persisted yet
331
- * (see relayThread). */
332
- onSessionId?: (sessionId: string) => void;
333
- /** fires with the detached claude child's pid at each spawn (see
334
- * relayThread). */
335
- onSpawn?: (pid: number) => void;
336
- drainSignal?: AbortSignal;
337
- /** steering seam (see relayThread): a live attempt's steer function, or
338
- * null when the attempt ends. steer runs its onAccept callback
339
- * synchronously inside acceptance, before the child sees the text. */
340
- onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
341
- }) => Promise<TurnOutcome>;
342
- cleanup: (input: { threadId: string }) => CleanupOutcome;
343
- /** add/remove a status reaction on a message (production: the Slack
344
- * adapter's reactions.add/remove). Callers never let a rejection escape:
345
- * a missing scope or an already_reacted must not fail a turn. */
346
- react: (input: { threadId: string; messageId: string; emoji: string; op: "add" | "remove" }) => Promise<void>;
347
- /** post one standalone text message into a thread by id (production:
348
- * bot.thread(threadId).post) - the nudge path, which runs outside any
349
- * turn and needs no streaming. */
350
- postToThread: (input: { threadId: string; text: string }) => Promise<void>;
351
- /** does this user belong to the home workspace? Reaction events carry no
352
- * team-origin fields, so the note path verifies reactors through this
353
- * (production: users.info, cached). null = unverifiable = fail closed. */
354
- isHomeUser: (input: { userId: string }) => Promise<boolean | null>;
355
- /** builds a streamable proactive thread handle for marker recovery (startup
356
- * resumes and deferred-turn wakes both need one). runDaemon passes a lazy
357
- * closure over its bot-backed streamableThread; tests pass a fake. */
358
- streamable: (threadId: string) => Promise<{ thread: ServeThread; requesterIds: string[] }>;
359
- /** the pool decision a deferred wake pre-probes with before posting the
360
- * recovery notice (production: ensureBestAccount; tests: a stub). */
361
- decide: () => Promise<SwapDecision>;
362
- }) {
363
- const { cfg, workspaceTeamId } = seam;
364
- /** short re-arm after a deferred wake fails transiently (Slack hiccup at
365
- * the resume): the durable marker keeps deferring, so the retry loop ends
366
- * the moment any marker-clearing path runs. */
367
- const RESUME_RETRY_MS = 300_000;
368
- // in-flight turns, tracked so a shutdown signal can drain them instead of
369
- // killing a half-streamed answer (live incident 2026-07-18: a deploy
370
- // restart cut a turn mid-sentence and the answer never reached Slack).
371
- const activeTurns = new Set<Promise<void>>();
372
- // aborts depleted-pool park/retry sleeps on drain, so a countdown never
373
- // holds the restart hostage.
374
- const drainAbort = new AbortController();
375
- let draining = false;
376
- // channels already diagnosed as unlinked this run (see handleTurn).
377
- const unlinkedLogged = new Set<string>();
378
- // corrupt thread records already logged as skipped this run (see nudgeSweep).
379
- const sweepSkipLogged = new Set<string>();
380
- // Steering registry: one acceptor per thread with a LIVE query attempt
381
- // (registered by runTurn via the relay's onSteer hook, removed when the
382
- // attempt ends). onMessage tries the acceptor before the inbox - steering
383
- // is the default for a mid-turn reply (owner decision 2026-07-27); a
384
- // refusal (attempt ending, mention-only text, out-of-order arrival) falls
385
- // back to the inbox path. A thread only ever appears here after handleTurn
386
- // ran under this daemon's cfg, so the channel is known linked.
387
- const liveSteers = new Map<string, (m: { relayed: { text: string; authorId: string; id: string }[] }) => Promise<boolean>>();
388
- // TURN-PRODUCING serialized work per thread (running plus waiting). Only
389
- // turn producers count (adversarial-review catch: reaction notes and nudge
390
- // bookkeeping ride the same serialized chain, and counting them silently
391
- // disabled steering for the rest of any turn a user reacted to). A steer
392
- // is only ordered when nothing waits behind the live turn.
393
- const turnDepth = new Map<string, number>();
394
- // Un-steered messages waiting for the next turn, folded and drained as ONE
395
- // turn (sorted by Slack ts, which restores order for any upstream arrival
396
- // race). This is the daemon-owned replacement for the chat queue's
397
- // skipped-message folding. Each entry carries its delivery's mention flag,
398
- // so the drained turn derives mention-ness from the RETAINED messages - a
399
- // mention dropped by the overflow cap must not leave a phantom flag behind
400
- // (cubic review catch on PR #50).
401
- const pendingInbox = new Map<string, { text: string; authorId: string; id: string; isMention: boolean }[]>();
402
- // Per-thread arrival ordering: the steer attempt and the inbox push for
403
- // one message run to completion before the next message's do, so two
404
- // near-simultaneous replies can never interleave at the acceptor's await
405
- // points and land on the child's stdin out of order.
406
- const arrivalChains = new Map<string, Promise<void>>();
407
-
408
- /** Best-effort status reaction: reaction state is decoration, so every
409
- * failure (missing reactions:write until the app is reinstalled,
410
- * already_reacted, no_reaction on remove) is log-only and can never fail
411
- * the turn it annotates. */
412
- const setStatus = async (input: { threadId: string; messageId: string; emoji: string; op: "add" | "remove" }) => {
413
- try {
414
- await seam.react(input);
415
- } catch (e) {
416
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
417
- log("serve.reaction_error", { thread: input.threadId, emoji: input.emoji, op: input.op, err: detail });
418
- }
419
- };
420
-
421
- /** One relayed turn with the durable activeTurn marker around it: written
422
- * before the spawn, cleared when the turn returns, so a marker surviving
423
- * into the next daemon start identifies a turn a restart killed mid-run.
424
- * The session id persists the moment init assigns it - a first-turn kill
425
- * must stay resumable. Returns the steered message ids alongside the
426
- * outcome: they joined the turn mid-run, so the caller's settle must
427
- * close their reaction lifecycle too. */
428
- const runTurn = async (input: {
429
- thread: { id: string; post: (m: StreamingPlan) => Promise<unknown> };
430
- record: SlackThread;
431
- prompt: string;
432
- requesterIds: string[];
433
- sessionId: string | null;
434
- marker: ActiveTurn;
435
- link: SlackLink;
436
- }): Promise<{ outcome: TurnOutcome; steeredMessageIds: string[] }> => {
437
- let record: SlackThread = { ...input.record, activeTurn: input.marker };
438
- saveSlackThread(record);
439
- // the whole turn (parks and retries included) reads as "being processed";
440
- // a resumed marker's steered messages re-arm their hourglass too
441
- // (setStatus swallows already_reacted).
442
- for (const id of [input.marker.messageId, ...(input.marker.steeredMessageIds ?? [])]) {
443
- if (id) await setStatus({ threadId: input.thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "add" });
444
- }
445
- let outcome: TurnOutcome | null = null;
446
- let steeredMessageIds = input.marker.steeredMessageIds ?? [];
447
- try {
448
- outcome = await seam.relay({
449
- cwd: record.cwd,
450
- sessionId: input.sessionId,
451
- prompt: input.prompt,
452
- requesterIds: input.requesterIds,
453
- link: input.link,
454
- // every posted segment groups its task cards into one collapsible
455
- // Slack plan block (task_display_mode "plan"; user ask 2026-07-20:
456
- // "squash them into one dropdown") instead of a card-per-task
457
- // timeline. Text-only segments (notices, plain replies) carry no
458
- // tasks, so the wrap is a no-op for them.
459
- post: (m) => input.thread.post(new StreamingPlan(m, { groupTasks: "plan" })),
460
- onSpawn: (pid) => {
461
- // the lstart token makes the pid a verifiable identity for the
462
- // orphan reaper; a child dead before ps sees it persists without
463
- // one, and an identity-less pid is never signaled. Built on the
464
- // CURRENT marker (a steer may have grown it), with the previous
465
- // spawn's identity dropped: a retry child must never inherit the
466
- // dead child's lstart, or the reaper would skip (or mis-verify)
467
- // the live group.
468
- const startedAt = pidStartTime(pid);
469
- const marker = omit(record.activeTurn ?? input.marker, ["pid", "pidStartedAt"]);
470
- record = { ...record, activeTurn: { ...marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
471
- saveSlackThread(record);
472
- },
473
- onSessionId: (sessionId) => {
474
- record = { ...record, sessionId };
475
- saveSlackThread(record);
476
- },
477
- drainSignal: drainAbort.signal,
478
- // While an attempt is steerable, a relayable mid-turn message folds
479
- // into the RUNNING turn (owner decision 2026-07-27: steering is the
480
- // default; the queued next turn is only the fallback). The acceptor
481
- // runs its marker mutation synchronously after a successful steer:
482
- // the steered text becomes part of the durable prompt (replays and
483
- // retries must include it) before any await can interleave with the
484
- // turn's own marker writes.
485
- onSteer: (steerText) => {
486
- if (steerText === null) {
487
- liveSteers.delete(input.thread.id);
488
- return;
489
- }
490
- liveSteers.set(input.thread.id, async (m) => {
491
- const stripped = m.relayed
492
- .map((r) => ({ text: stripLeadingMention({ text: r.text, botUserId: seam.botUserId() }), authorId: r.authorId, id: r.id }))
493
- .filter((r) => r.text !== "");
494
- if (stripped.length === 0) return false;
495
- // out-of-order insurance: Slack ids are timestamps, so a message
496
- // OLDER than anything this turn already carries arrived late
497
- // through an upstream race - refuse it into the inbox, whose
498
- // drain sorts by ts, instead of folding it after its successor.
499
- const seen = record.activeTurn ?? input.marker;
500
- const newestSeen = Math.max(Number(seen.messageId ?? 0) || 0, ...(seen.steeredMessageIds ?? []).map((id) => Number(id) || 0));
501
- if (stripped.some((r) => (Number(r.id) || 0) < newestSeen)) return false;
502
- // per-turn steer budget (cursor security review, round 2 on
503
- // PR #50): accepted steers grow the durable prompt and the
504
- // child's queue outside the inbox's 100-entry cap, so a flood
505
- // could balloon one turn without bound. Far past any human
506
- // steering cadence, a reply takes the capped inbox path instead.
507
- if ((seen.steeredMessageIds?.length ?? 0) + stripped.length > 25) return false;
508
- // hourglass BEFORE the steer so a settle racing this acceptor
509
- // can never leave an unremovable reaction; a refusal takes it
510
- // back off.
511
- for (const r of stripped) {
512
- await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "add" });
513
- }
514
- // authors the turn does not know yet get named inline: the
515
- // UserPromptSubmit context does not re-fire for folded mid-turn
516
- // messages, so attribution rides the message itself.
517
- const text = stripped
518
- .map((r) => (input.requesterIds.includes(r.authorId) ? r.text : `Message from <@${r.authorId}>:\n${r.text}`))
519
- .join("\n\n");
520
- // The durable commit runs INSIDE steer's acceptance, in the same
521
- // JS tick as its liveness check (adversarial-review catch, round
522
- // 3: this acceptor runs on the arrival chain and can resume from
523
- // its hourglass awaits AFTER the turn ended - a write-ahead save
524
- // here resurrected the finished turn's marker on disk, and its
525
- // refusal rollback clobbered post-turn state with a stale
526
- // snapshot). Acceptance proves the turn is live, so the closure
527
- // record is disk-faithful; the marker still grows durably BEFORE
528
- // the text reaches the child's stdin (a crash in between replays
529
- // a steer the child may never have seen - duplication over
530
- // loss); and a refusal commits nothing, so a stale invocation is
531
- // a harmless fall-through to the inbox.
532
- let asked: SlackThread["attention"];
533
- const commit = () => {
534
- const mergedRequesters = uniq([...input.requesterIds, ...stripped.map((r) => r.authorId)]);
535
- const marker = record.activeTurn ?? input.marker;
536
- const grownIds = [...(marker.steeredMessageIds ?? []), ...stripped.map((r) => r.id)];
537
- let next = { ...record, activeTurn: { ...marker, prompt: `${marker.prompt}\n\n${text}`, requesterIds: mergedRequesters, steeredMessageIds: grownIds } };
538
- // the user responded: a pending ask is answered by the steer
539
- // just like by a queued turn (adversarial-review catch:
540
- // leaving it would strand the question mark and fire a
541
- // spurious nudge about an ask this very message answered).
542
- const pendingAsk = next.attention;
543
- if (pendingAsk) next = omit(next, ["attention"]);
544
- // all-or-nothing (cubic review catch, round 4): the durable
545
- // save runs before ANY outer mutation, so a failed write
546
- // leaves the turn's view untouched and the refusal fallback
547
- // below starts from clean state - without this ordering the
548
- // settle would stamp the never-delivered message with the
549
- // turn's outcome emoji.
550
- saveSlackThread(next);
551
- steeredMessageIds = grownIds;
552
- record = next;
553
- asked = pendingAsk;
554
- for (const r of stripped) {
555
- if (!input.requesterIds.includes(r.authorId)) input.requesterIds.push(r.authorId);
556
- }
557
- };
558
- // a throwing commit (the durable save failing) escapes steer()
559
- // BEFORE the text reaches the child or the relay records it
560
- // (cubic review catch, round 4): treat it as a refusal, so the
561
- // message keeps its inbox fallback instead of vanishing into
562
- // the generic task_crashed log with its hourglass stranded. If
563
- // the disk stays broken, the inbox turn's own marker write
564
- // surfaces it loudly through the crash-notice path.
565
- let accepted = false;
566
- try {
567
- accepted = steerText(text, commit);
568
- } catch (e) {
569
- log("serve.steer_commit_failed", { thread: input.thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
570
- }
571
- if (!accepted) {
572
- for (const r of stripped) {
573
- await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "remove" });
574
- }
575
- return false;
576
- }
577
- log("serve.steered", { thread: input.thread.id, texts: stripped.length });
578
- if (asked?.messageId) {
579
- await setStatus({ threadId: input.thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
580
- }
581
- return true;
582
- });
583
- },
584
- });
585
- record = { ...record, sessionId: outcome.sessionId };
586
- return { outcome, steeredMessageIds };
587
- } finally {
588
- // a failure DURING a drain is presumed to be the shutdown signal killing
589
- // the claude child (terminal Ctrl-C and group signals hit the whole
590
- // process group, so the child dies and relayThread returns failed while
591
- // the daemon is still draining - codex review catch): keep the marker so
592
- // the next generation auto-resumes, exactly the killed-turn state it
593
- // exists to detect. Outside a drain, or on success, clear it.
594
- // An announced drop is TERMINAL: relayThread told the user to resend, so
595
- // retaining the marker would replay work the drop notice disclaimed
596
- // (duplicate turns, quota, side effects). A turn whose child reached a
597
- // SUCCESSFUL result is also terminal even when failed (that failure is
598
- // Slack delivery, not a killed child - resuming would re-run completed
599
- // work; adversarial-review catch). null outcome = relay threw =
600
- // still presumed killed.
601
- const presumedKilled = draining && (outcome === null || (outcome.failed && !outcome.announcedDrop && !outcome.resultReceived));
602
- // A usage-limit DEFERRAL keeps the marker with resumeAt: the turn
603
- // returned on purpose so the queue slot frees up, and the scheduler
604
- // resumes it from this durable record once the pool recovers.
605
- const deferUntil = outcome?.deferUntil ?? null;
606
- // An unannounced drop OUTSIDE a drain still clears the marker on
607
- // purpose (retention would re-execute the turn at the next restart; see
608
- // notifyDelivered's doc) - but the loss must be operator-visible.
609
- if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop && deferUntil === null) {
610
- log("serve.drop_unannounced", { thread: input.thread.id });
611
- }
612
- // belt-and-braces: the relay clears its steer hook per attempt, but the
613
- // registry entry must never outlive the turn that owns it.
614
- liveSteers.delete(input.thread.id);
615
- if (deferUntil !== null && record.activeTurn) {
616
- record = { ...record, activeTurn: { ...record.activeTurn, resumeAt: deferUntil } };
617
- saveSlackThread(record);
618
- scheduleDeferred(record.threadId, deferUntil);
619
- } else {
620
- saveSlackThread(presumedKilled ? record : omit(record, ["activeTurn"]));
621
- }
622
- }
623
- };
624
-
625
- const handleTurn = async (input: {
626
- thread: ServeThread;
627
- /** every relayed message this turn (the inbox batch, ts-sorted), text
628
- * paired with its author id: a decision may be owed to an earlier
629
- * folded sender, and a sender whose whole message was the bot mention
630
- * contributes no prompt text, so text and author filter together
631
- * (review catches 2026-07-18). id is the Slack message ts; the LAST
632
- * entry (the triggering relayable message) carries the status
633
- * reactions. */
634
- relayed: { text: string; authorId: string; id: string }[];
635
- isMention: boolean;
636
- }) => {
637
- const { thread, isMention } = input;
638
- const link = linkForChannel(cfg, bareChannelId(thread.channelId));
639
- if (!link) {
640
- // checked BEFORE the draining branch: unlinked channels are
641
- // contractually log-only silent, and a drain-window drop notice posted
642
- // into one would tell a user to resend a message that will never be
643
- // served (closing-review catch). Logged once per channel per daemon run:
644
- // with several daemons sharing one Slack app this fires on every
645
- // load-balanced envelope for a sibling's channel (live incident
646
- // 2026-07-20), and the diagnosis needs one line, not a stream.
647
- if (!unlinkedLogged.has(thread.channelId)) {
648
- unlinkedLogged.add(thread.channelId);
649
- log("serve.unlinked_channel", {
650
- channel: thread.channelId,
651
- note: "not linked on this host; if another tokenmaxxing serve shares this Slack app, Slack delivers each socket event to only ONE of them and thread replies get lost - give every daemon its own Slack app",
652
- });
653
- }
654
- return; // not a linked channel - stay silent in Slack
655
- }
656
- if (draining) {
657
- // the socket stays connected until the drain finishes; anything landing
658
- // in that window is dropped loudly rather than spawning an unwaitable
659
- // turn - and the THREAD is told, not just the log (a silent drop reads
660
- // as the bot thinking; slaude's recorded drop-notice rule). Tracked so
661
- // the drain wait flushes it before exit; errors swallowed so the notice
662
- // can never fail the drain.
663
- log("serve.drain_dropped", { thread: thread.id });
664
- void tracked(
665
- (async () => {
666
- try {
667
- await thread.post(
668
- (async function* () {
669
- yield "tokenmaxxing is restarting - this message was dropped; please re-send it in a moment.";
670
- })(),
671
- );
672
- } catch (e) {
673
- // caught (never rethrown - the notice must not fail the drain)
674
- // but logged: an unposted notice means the user saw nothing.
675
- log("serve.drain_notice_failed", { thread: thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
676
- }
677
- })(),
678
- );
679
- return;
680
- }
681
- log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
682
- // relayed carries every message the inbox batched for this turn, folded
683
- // into one prompt here. A message that is empty once its bot mention is
684
- // stripped contributes neither prompt text nor a requester id (cursor
685
- // review catch 2026-07-18).
686
- const stripped = input.relayed
687
- .map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
688
- .filter((m) => m.text !== "");
689
- let prompt = stripped.map((m) => m.text).join("\n\n");
690
- const requesterIds = uniq(stripped.map((m) => m.authorId));
691
- // the triggering message (the last relayable one), even when its own text
692
- // was just the bot mention: the status reactions belong on the message
693
- // the user watched the bot pick up.
694
- const messageId = input.relayed.at(-1)?.id;
695
- if (!prompt) return;
696
- // this whole handler runs inside the per-thread `serialized` chain (call
697
- // sites below), which startup resumes share too - so this load already
698
- // sees any session id a resume persisted, and no second claude process
699
- // can ever share this thread's cwd.
700
- let record = loadSlackThread(thread.id);
701
- if (!record) {
702
- if (!isMention) return; // only a mention opens a session
703
- record = { threadId: thread.id, repo: link.repo, cwd: link.repo, sessionId: null, createdAt: new Date().toISOString() };
704
- saveSlackThread(record);
705
- log("serve.thread_opened", { thread: thread.id, cwd: link.repo });
706
- }
707
- // Inside the serialized chain a surviving marker is either a PREVIOUS
708
- // generation's killed turn (an inbound message can win the chain ahead of
709
- // startup recovery, e.g. Slack redelivering the killed turn's unacked
710
- // mention) or a LIMIT-DEFERRED turn holding its resumeAt promise. Reap a
711
- // possible orphan either way, so two claude processes never share the
712
- // thread's cwd and session (closing-review catch: the recovery-path reap
713
- // alone loses this race).
714
- if (record.activeTurn) await reapOrphan(record.activeTurn);
715
- // An inbound message takes over a deferred thread (its wake timer dies
716
- // with the takeover; runTurn's fresh marker replaces the deferred one),
717
- // and the held prompt ALWAYS folds in front of the new text: silently
718
- // discarding it was the adversarial-review MAJOR catch on PR #44 (it
719
- // would re-lose exactly the 2026-07-20 two-message shape the deferral
720
- // exists to save), and no spawn-progress signal on the marker can prove
721
- // the held prompt ever reached the session (a child can spawn and die
722
- // before init - vercel review catch). A completed turn never defers, so
723
- // folding can never re-run finished work; at worst a mid-turn deferral's
724
- // prompt re-appears alongside the session transcript that already holds
725
- // its partial work, and the newer message steers.
726
- const deferred = record.activeTurn?.resumeAt !== undefined ? record.activeTurn : null;
727
- // a taken-over deferral's messages still wear their processing hourglass
728
- // (a deferral settles nothing); this turn serves their held prompt, so
729
- // it adopts their ids and settles them with its own outcome - without
730
- // the adoption the old trigger's hourglass would read "processing"
731
- // forever once the fold replaced its marker.
732
- const adoptedIds = deferred ? [...(deferred.messageId ? [deferred.messageId] : []), ...(deferred.steeredMessageIds ?? [])] : [];
733
- if (deferred) {
734
- const timer = deferredTimers.get(thread.id);
735
- if (timer !== undefined) clearTimeout(timer);
736
- deferredTimers.delete(thread.id);
737
- prompt = `${deferred.prompt}\n\n${prompt}`;
738
- log("serve.deferred_folded", { thread: thread.id });
739
- }
740
- // the user responded: the thread is no longer waiting on them. Clear the
741
- // attention state and its question-mark reaction before the new turn
742
- // runs, so a due nudge can never fire about an ask that just got its
743
- // answer.
744
- if (record.attention) {
745
- const asked = record.attention;
746
- record = omit(record, ["attention"]);
747
- saveSlackThread(record);
748
- if (asked.messageId) {
749
- await setStatus({ threadId: thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
750
- }
751
- }
752
- // reactions observed since the last turn ride into this prompt as
753
- // context, then clear: the model sees them without any metered
754
- // reaction-triggered turn.
755
- if (record.pendingReactions && record.pendingReactions.length > 0) {
756
- const notes = record.pendingReactions
757
- .map((r) => `<@${r.userId}> reacted :${r.emoji}: in this thread.`)
758
- .join("\n");
759
- prompt = `${prompt}\n\nSlack reactions since your last turn:\n${notes}`;
760
- record = omit(record, ["pendingReactions"]);
761
- saveSlackThread(record);
762
- }
763
- // subscriptions live in the memory state, so a daemon restart forgets
764
- // them; every mention re-subscribes to keep follow-up replies flowing.
765
- if (isMention) await thread.subscribe();
766
- // "is working..." assistant status; a no-op until the Slack app has the
767
- // agent feature + assistant:write (the adapter warns instead of throwing).
768
- await thread.startTyping();
769
- const startedAt = Date.now();
770
- const { outcome, steeredMessageIds } = await runTurn({
771
- thread,
772
- record,
773
- prompt,
774
- requesterIds,
775
- sessionId: record.sessionId,
776
- marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds, ...(adoptedIds.length > 0 ? { steeredMessageIds: adoptedIds } : {}) },
777
- link,
778
- });
779
- await settleTurn({ thread, outcome, startedAt, messageId, steeredMessageIds, requesterIds });
780
- };
781
-
782
- /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
783
- * log line, the status-reaction settle, the attention marking when the
784
- * model asked the user, and the finish_thread garbage collection. Never
785
- * throws into the caller - the daemon must keep serving. */
786
- const settleTurn = async (input: {
787
- thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
788
- outcome: TurnOutcome;
789
- startedAt: number;
790
- /** the triggering message carrying the status reactions; absent = skip. */
791
- messageId?: string;
792
- /** messages steered into the turn mid-run: they carry the same reaction
793
- * lifecycle as the trigger and settle with the same emoji. */
794
- steeredMessageIds?: string[];
795
- /** the turn's asked users, persisted when the model flagged attention. */
796
- requesterIds?: string[];
797
- }) => {
798
- const { thread, outcome, startedAt } = input;
799
- log(outcome.deferUntil !== null ? "serve.turn_deferred" : outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
800
- thread: thread.id,
801
- seconds: Math.round((Date.now() - startedAt) / 1000),
802
- ...(outcome.deferUntil === null ? {} : { resumeAt: outcome.deferUntil }),
803
- });
804
- // settle the status reaction: failed beats attention beats done (a failed
805
- // ask never reads as a clean question mark), then drop the hourglass.
806
- // Three review-caught exceptions: a drain-presumed-killed turn (same
807
- // predicate as runTurn's marker retention) keeps its hourglass - it will
808
- // auto-resume next start, and a terminal x nothing ever removes would
809
- // read a later successful resume as failed; a usage-limit DEFERRAL is
810
- // the other auto-resume case and keeps its hourglass for the identical
811
- // reason (cursor + vercel review catch on PR #43: the durable marker
812
- // promises a resume, so the triggering message must not read as failed
813
- // for the whole deferral); and a finished thread settles as done even
814
- // when the model also flagged attention, because the record deletion
815
- // below makes the question mark unremovable forever.
816
- const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
817
- const deferredForResume = outcome.deferUntil !== null;
818
- if (!killedByDrain && !deferredForResume) {
819
- const emoji = outcome.failed ? STATUS_EMOJI.failed : outcome.attention && !outcome.finish ? STATUS_EMOJI.attention : STATUS_EMOJI.done;
820
- // steerLost: a steered follow-up's own drained turn failed after the
821
- // primary turn succeeded, so the steered messages settle as failed -
822
- // matching the in-thread re-send notice; a lost instruction must never
823
- // read green (see TurnOutcomeSchema.steerLost for the per-turn
824
- // attribution tradeoff).
825
- const steeredEmoji = outcome.steerLost ? STATUS_EMOJI.failed : emoji;
826
- for (const id of [input.messageId, ...(input.steeredMessageIds ?? [])]) {
827
- if (!id) continue;
828
- await setStatus({ threadId: thread.id, messageId: id, emoji: id === input.messageId ? emoji : steeredEmoji, op: "add" });
829
- await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
830
- }
831
- }
832
- // the model asked the user for a decision: mark the thread waiting so the
833
- // nudge sweep and the reaction-answer path can see it. Persisted even on
834
- // a failed turn (the ask may have streamed before the failure; a spurious
835
- // nudge beats a silently forgotten ask). Skipped on finish: the record is
836
- // about to be deleted.
837
- if (outcome.attention && !outcome.finish) {
838
- const fresh = loadSlackThread(thread.id);
839
- if (fresh) {
840
- saveSlackThread({
841
- ...fresh,
842
- attention: {
843
- requesterIds: input.requesterIds ?? [],
844
- askedAt: new Date().toISOString(),
845
- ...(input.messageId ? { messageId: input.messageId } : {}),
846
- },
847
- });
848
- log("serve.attention_marked", { thread: thread.id });
849
- }
850
- }
851
- // the user declared the work finished: close the thread now that the
852
- // turn (and its claude subprocess) is over. Never throw into the caller -
853
- // the daemon must keep serving other threads.
854
- if (!outcome.finish) return;
855
- if (outcome.deferUntil !== null) {
856
- // finish is sticky across retries, so it can ride a deferred outcome -
857
- // and cleanup would delete the very record the deferral just promised
858
- // to resume (adversarial-review catch on PR #44). The deferral wins:
859
- // the resumed turn finishes the remaining work, and the user closes
860
- // the thread again once it actually lands.
861
- log("serve.finish_deferred", { thread: thread.id });
862
- return;
863
- }
864
- let result: CleanupOutcome;
865
- try {
866
- result = seam.cleanup({ threadId: thread.id });
867
- } catch (e) {
868
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
869
- log("serve.cleanup_error", { thread: thread.id, err: detail });
870
- try {
871
- await thread.post(`tokenmaxxing: cleanup failed: ${detail}`);
872
- } catch (postErr) {
873
- // the diagnostic is best-effort, but its failure is never silent.
874
- log("serve.finish_notify_error", { thread: thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
875
- }
876
- return;
877
- }
878
- // the Slack calls are guarded too: this whole path must never throw into
879
- // the caller (review catch, PR #18) - the record is already gone, so a
880
- // failed confirmation only gets logged.
881
- try {
882
- // a refusal keeps the subscription so the thread stays live for a retry.
883
- if (result.removed) await thread.unsubscribe();
884
- await thread.post(result.message);
885
- } catch (e) {
886
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
887
- log("serve.finish_notify_error", { thread: thread.id, err: detail });
888
- }
889
- log("serve.thread_finished", { thread: thread.id, removed: result.removed });
890
- };
891
-
892
- const relayable = (m: ServeMessage) => {
893
- if (m.author.isMe || m.author.isBot === true) return false;
894
- // outsiders must not drive sessions (owner rule 2026-07-16, ported from
895
- // slaude): Slack Connect externals and cross-workspace guests are
896
- // rejected fail-closed - silent in Slack, loud in the log.
897
- if (isOutsideAuthor({ raw: m.raw, workspaceTeamId })) {
898
- log("serve.outside_author", {});
899
- return false;
900
- }
901
- return true;
902
- };
903
-
904
- /** The ownership funnel for every task the daemon spawns: registration in
905
- * activeTurns so a shutdown drains it, and the daemon's terminal error
906
- * boundary. Bun kills the WHOLE process on any unhandled rejection
907
- * (default-mode exit verified 2026-07-27), so a `void tracked(...)`
908
- * fire-and-forget whose task threw would otherwise take every concurrent
909
- * session's turn down with it - one thread's bad state file must never
910
- * end another thread's half-streamed answer. Site-specific handling (the
911
- * in-thread crash notice in onMessage) stays at the site that has the
912
- * context; whatever escapes lands here, logged, and the daemon keeps
913
- * serving. */
914
- const tracked = async (turn: Promise<void>) => {
915
- activeTurns.add(turn);
916
- try {
917
- await turn;
918
- } catch (e) {
919
- log("serve.task_crashed", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
920
- } finally {
921
- activeTurns.delete(turn);
922
- }
923
- };
924
-
925
- // Per-thread serialization owned HERE: with concurrent dispatch (see
926
- // runDaemon's Chat config) chat provides no per-thread locking at all, so
927
- // this chain is the ONLY thing keeping two claude turns off one thread's
928
- // cwd and session. It predates the concurrent switch for the same reason
929
- // in weaker form: chat's old queue lock expired 30s into any claude turn
930
- // and let a second handler start anyway (review catch, PR #18).
931
- const threadTurns = new Map<string, Promise<void>>();
932
- const serialized = (threadId: string, run: () => Promise<void>) => {
933
- const prev = threadTurns.get(threadId) ?? Promise.resolve();
934
- const next = (async () => {
935
- try {
936
- await prev;
937
- } catch { /* the previous turn's rejection was already surfaced to its own handler */ }
938
- await run();
939
- })();
940
- threadTurns.set(threadId, next);
941
- // GC observer: swallow next's rejection HERE only (the handler awaiting
942
- // `next` still sees it), else the observer chain is an unhandled rejection
943
- // (cubic review catch, PR #18).
944
- void (async () => {
945
- try {
946
- await next;
947
- } catch { /* surfaced to the awaiting handler */ }
948
- if (threadTurns.get(threadId) === next) threadTurns.delete(threadId);
949
- })();
950
- return next;
951
- };
952
- /** serialized + the turnDepth count, for callers that produce a claude
953
- * TURN (inbox drains, reaction answers, recoveries). Bookkeeping riders
954
- * on the chain (reaction notes, nudges) use bare `serialized` so they
955
- * never gate steering. */
956
- const serializedTurn = (threadId: string, run: () => Promise<void>) => {
957
- turnDepth.set(threadId, (turnDepth.get(threadId) ?? 0) + 1);
958
- const next = serialized(threadId, run);
959
- void next.catch(() => {}).finally(() => {
960
- const depth = (turnDepth.get(threadId) ?? 1) - 1;
961
- if (depth <= 0) turnDepth.delete(threadId);
962
- else turnDepth.set(threadId, depth);
963
- });
964
- return next;
965
- };
966
-
967
- // both Chat SDK callbacks funnel here. Filter EVERY message, trigger
968
- // included: an outsider (or our own post) arriving last must not discard
969
- // relayable home-workspace messages delivered alongside it (review catch,
970
- // PR #18). context.skipped is empty under concurrent dispatch but stays
971
- // merged defensively - a chat release reintroducing batching must not
972
- // silently drop messages.
973
- const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
974
- const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
975
- if (relayed.length === 0) return; // outsider mentions never open a session
976
- // arrivals for one thread DECIDE strictly one at a time, in delivery
977
- // order: the steer-or-inbox decision for this message completes before
978
- // the next message's begins, so two near-simultaneous replies can never
979
- // interleave at the acceptor's await points. The chain holds only the
980
- // DECISION - a scheduled turn's completion is awaited outside it, or a
981
- // long turn would hold every later arrival hostage and steering could
982
- // never engage.
983
- const prev = arrivalChains.get(input.thread.id) ?? Promise.resolve();
984
- const job = (async () => {
985
- try {
986
- await prev;
987
- } catch { /* the previous arrival's failure was surfaced to its own caller */ }
988
- return dispatchArrival({ thread: input.thread, relayed, isMention: input.isMention });
989
- })();
990
- const link = job.then(
991
- () => {},
992
- () => {},
993
- );
994
- arrivalChains.set(input.thread.id, link);
995
- void link.then(() => {
996
- if (arrivalChains.get(input.thread.id) === link) arrivalChains.delete(input.thread.id);
997
- });
998
- // tracked from the DECISION on (codex review catch on PR #50): the drain
999
- // waits on activeTurns, and an arrival still deciding at shutdown lived
1000
- // nowhere else - the daemon could exit before the message reached the
1001
- // inbox, a marker, or the drop notice.
1002
- await tracked((async () => {
1003
- const scheduled = await job;
1004
- if (scheduled) await scheduled.turn;
1005
- })());
1006
- };
1007
-
1008
- /** One message's steer-or-inbox decision. Steering first (owner decision
1009
- * 2026-07-27): a reply landing while the thread's turn is running folds
1010
- * into that turn instead of waiting behind it. Guarded to an empty inbox
1011
- * and no waiting turn, or the fold would reorder this message ahead of
1012
- * one already waiting; a drain keeps its loud-drop contract. Every
1013
- * refusal falls through to the inbox, whose drain runs ALL waiting
1014
- * messages as one folded turn - the daemon-owned replacement for the
1015
- * chat queue's skipped-message folding, sorted by Slack ts so an
1016
- * upstream arrival race cannot reorder the prompt. Returns the tracked
1017
- * drain-turn promise when this arrival scheduled one, so the caller can
1018
- * await the turn without holding the arrival chain. */
1019
- const dispatchArrival = async (input: { thread: ServeThread; relayed: { text: string; authorId: string; id: string }[]; isMention: boolean }): Promise<{ turn: Promise<void> } | null> => {
1020
- const threadId = input.thread.id;
1021
- if (!draining && (pendingInbox.get(threadId)?.length ?? 0) === 0 && (turnDepth.get(threadId) ?? 0) <= 1) {
1022
- const accept = liveSteers.get(threadId);
1023
- if (accept && (await accept({ relayed: input.relayed }))) return null;
1024
- }
1025
- const inbox = pendingInbox.get(threadId) ?? [];
1026
- const hadPending = inbox.length > 0;
1027
- inbox.push(...input.relayed.map((r) => ({ ...r, isMention: input.isMention })));
1028
- // hard cap, replacing the retired chat queue's maxQueueSize bound
1029
- // (cursor security review on PR #50): without it a flood during one
1030
- // long turn grows memory and the folded prompt without limit. Newest
1031
- // dropped, LOUDLY - the old queue's silent drop-oldest ate the earliest
1032
- // instructions, the worse failure.
1033
- if (inbox.length > 100) {
1034
- log("serve.inbox_dropped", { thread: threadId, dropped: inbox.length - 100 });
1035
- inbox.length = 100;
1036
- }
1037
- pendingInbox.set(threadId, inbox);
1038
- // one drain per non-empty inbox: later arrivals fold into the batch the
1039
- // already-scheduled drain snapshots when it finally runs.
1040
- if (hadPending) return null;
1041
- const turn = tracked(serializedTurn(threadId, async () => {
1042
- const batch = (pendingInbox.get(threadId) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
1043
- pendingInbox.delete(threadId);
1044
- if (batch.length === 0) return;
1045
- try {
1046
- await handleTurn({ thread: input.thread, relayed: batch, isMention: batch.some((m) => m.isMention) });
1047
- } catch (e) {
1048
- // an escaped handleTurn throw (a state-file parse, a Slack API
1049
- // rejection outside relayThread's never-throws boundary) previously
1050
- // died in the chat SDK's catch-and-log: the user's message vanished
1051
- // with no reply and no log line of ours (2026-07-27 report). Tell
1052
- // the thread and keep the daemon serving.
1053
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
1054
- log("serve.turn_crashed", { thread: input.thread.id, err: detail });
1055
- // a surviving activeTurn marker means the turn is PRESERVED (a drain
1056
- // kill kept it for the next generation's auto-resume): a "re-send it"
1057
- // notice would invite a duplicate run and a failed x would misread a
1058
- // guaranteed retry (cubic review catch, round 3) - log only, the
1059
- // resume machinery owns the messaging. The read is best-effort: an
1060
- // unreadable record (possibly the crash itself) takes the visible
1061
- // crash path.
1062
- let preserved = false;
1063
- try {
1064
- preserved = loadSlackThread(input.thread.id)?.activeTurn !== undefined;
1065
- } catch { /* unreadable record: treat as not preserved */ }
1066
- if (preserved) return;
1067
- try {
1068
- await input.thread.post(
1069
- (async function* () {
1070
- yield `tokenmaxxing: this message's handling crashed: ${detail}. If no reply landed above, re-send it.`;
1071
- })(),
1072
- );
1073
- } catch (postErr) {
1074
- log("serve.turn_crash_notice_failed", { thread: input.thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
1075
- }
1076
- // a crash after runTurn added the hourglass would otherwise read as
1077
- // "processing" forever (codex review catch); setStatus never throws.
1078
- const messageId = batch.at(-1)?.id;
1079
- if (messageId) {
1080
- await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.failed, op: "add" });
1081
- await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
1082
- }
1083
- }
1084
- }));
1085
- return { turn };
1086
- };
1087
-
1088
- /** A user reaction in a tracked thread. While the thread waits on an asked
1089
- * user, THAT user's reaction TO THE ASK is their answer: it relays as a
1090
- * normal turn for the model to interpret (thumbs up approves, thumbs down
1091
- * declines - the model decides). "To the ask" is enforced structurally
1092
- * (review catch: a mid-turn encouragement reaction queued behind the
1093
- * asking turn must not auto-approve the question it never saw): the
1094
- * reaction must have occurred AFTER askedAt (occurredAt = the Slack
1095
- * event_ts) and sit on a message at or after the ask's triggering message
1096
- * (Slack ids are timestamps, so >= compares post order). Anything that
1097
- * fails a gate degrades to the unmetered note path, which folds into the
1098
- * next turn's prompt. The answer path never pre-clears the attention
1099
- * state (review catch): handleTurn's own consume step clears it at the
1100
- * point the turn is committed, so a failed thread fetch, an unlinked
1101
- * channel, or a drain landing mid-await leaves the ask intact for the
1102
- * nudge and the next daemon generation.
1103
- * Reactor identity: the answer path only trusts ids that already passed
1104
- * the author guard as requesters (reaction events carry no team-origin
1105
- * fields, so isOutsideAuthor cannot run here); the note path fail-closed
1106
- * verifies the reactor against the home workspace via seam.isHomeUser
1107
- * and drops non-home or unverifiable reactors loudly (review catch: the
1108
- * outsiders-never-reach-claude invariant covers context lines too), and
1109
- * only structurally valid emoji names are ever folded. Removals and
1110
- * untracked threads are ignored, as are other bots; our own reactions
1111
- * never arrive (chat core drops isMe reaction events before routing). */
1112
- const onReaction = async (
1113
- input: { threadId: string; messageId: string; emoji: string; userId: string; isBot?: boolean | "unknown"; added: boolean; occurredAt: number | null },
1114
- streamable: (threadId: string) => Promise<{ thread: ServeThread }>,
1115
- ) => {
1116
- if (!input.added || input.isBot === true) return;
1117
- if (!loadSlackThread(input.threadId)) return; // untracked thread
1118
- // unlinked channels are contractually silent AND inert (vercel review
1119
- // catch on PR #43): a reaction stored to pendingReactions here would
1120
- // reach claude after a re-link, the one leak every other unlinked path
1121
- // already closes.
1122
- if (!linkForChannel(cfg, bareChannelId(input.threadId.split(":").slice(0, 2).join(":")))) {
1123
- log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
1124
- return;
1125
- }
1126
- // bare `serialized` on purpose, so a reaction can never gate steering
1127
- // (adversarial-review catch: counting these bookkeeping riders in
1128
- // turnDepth silently disabled steering for the rest of any turn a user
1129
- // reacted to). Accepted tradeoff (documented, WONTFIX): in the rare
1130
- // shape where an ANSWER turn is queued here behind a live turn, a
1131
- // fresh reply may steer the live turn ahead of the queued answer - both
1132
- // still reach the model, and an answer coexisting with a live turn only
1133
- // occurs in multi-user threads.
1134
- await tracked(serialized(input.threadId, async () => {
1135
- const fresh = loadSlackThread(input.threadId);
1136
- if (!fresh) return; // finished while queued
1137
- const asked = fresh.attention;
1138
- const afterAsk = asked !== undefined && input.occurredAt !== null && input.occurredAt >= Date.parse(asked.askedAt);
1139
- const onAskMessage = asked !== undefined
1140
- && (asked.messageId === undefined || (Number.isFinite(Number(input.messageId)) && Number(input.messageId) >= Number(asked.messageId)));
1141
- // draining takes the durable note path even for an asked user: the
1142
- // answer turn could not run anyway, and a "please re-send" drop notice
1143
- // makes no sense for a reaction - the note survives the restart and
1144
- // folds into the next turn.
1145
- if (!draining && asked && asked.requesterIds.includes(input.userId) && afterAsk && onAskMessage) {
1146
- log("serve.reaction_answer", { thread: input.threadId, emoji: input.emoji });
1147
- try {
1148
- const { thread } = await streamable(input.threadId);
1149
- await handleTurn({
1150
- thread,
1151
- relayed: [{
1152
- text: `<@${input.userId}> answered your pending question with the Slack reaction :${input.emoji}:. Interpret the reaction as their reply and continue.`,
1153
- authorId: input.userId,
1154
- id: input.messageId,
1155
- }],
1156
- isMention: false,
1157
- });
1158
- } catch (e) {
1159
- // a crashed answer turn must not read as an accepted answer (codex
1160
- // review catch): tell the thread and settle the reacted-to
1161
- // message's status so it never reads as processing forever.
1162
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
1163
- log("serve.reaction_crashed", { thread: input.threadId, err: detail });
1164
- try {
1165
- await seam.postToThread({ threadId: input.threadId, text: `tokenmaxxing: handling your reaction answer crashed: ${detail}. Reply in the thread to answer instead.` });
1166
- } catch (postErr) {
1167
- log("serve.reaction_crash_notice_failed", { thread: input.threadId, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
1168
- }
1169
- await setStatus({ threadId: input.threadId, messageId: input.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
1170
- await setStatus({ threadId: input.threadId, messageId: input.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
1171
- // handleTurn consumed the attention state (and its question mark)
1172
- // before the turn ran; a crashed answer must not eat the ask (cubic
1173
- // review catch, round 2). Restore both so the nudge sweep and the
1174
- // reaction-answer gates keep working; the restore itself is
1175
- // best-effort (the crash may BE an unreadable record).
1176
- try {
1177
- const cur = loadSlackThread(input.threadId);
1178
- if (cur && !cur.attention) {
1179
- saveSlackThread({ ...cur, attention: asked });
1180
- if (asked.messageId) {
1181
- await setStatus({ threadId: input.threadId, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "add" });
1182
- }
1183
- }
1184
- } catch (restoreErr) {
1185
- log("serve.attention_restore_failed", { thread: input.threadId, err: (restoreErr instanceof Error ? restoreErr.message : String(restoreErr)).slice(0, 300) });
1186
- }
1187
- }
1188
- return;
1189
- }
1190
- const home = await seam.isHomeUser({ userId: input.userId });
1191
- if (home !== true) {
1192
- log("serve.reaction_dropped", { thread: input.threadId, reason: home === false ? "outside-author" : "unverifiable-author" });
1193
- return;
1194
- }
1195
- const validEmoji = input.emoji.length > 0 && input.emoji.length <= 100
1196
- && [...input.emoji].every((ch) => (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "-" || ch === "+" || ch === "'");
1197
- if (!validEmoji) {
1198
- log("serve.reaction_dropped", { thread: input.threadId, reason: "invalid-emoji-name" });
1199
- return;
1200
- }
1201
- const notes = [...(fresh.pendingReactions ?? []), { userId: input.userId, emoji: input.emoji, at: new Date().toISOString() }].slice(-10);
1202
- saveSlackThread({ ...fresh, pendingReactions: notes });
1203
- log("serve.reaction_noted", { thread: input.threadId, emoji: input.emoji });
1204
- }));
1205
- };
1206
-
1207
- /** One pass over every thread record: threads whose attention went
1208
- * unanswered past ATTENTION_NUDGE_MS get one mention-tagging reminder.
1209
- * Greedy and convergent: nudgedAt persists, so re-runs (and daemon
1210
- * restarts) never repeat a nudge; a failed post retries next sweep,
1211
- * logged each time. The per-thread work runs inside the serialized chain
1212
- * so a sweep can never resurrect an attention state a concurrent turn
1213
- * just cleared. */
1214
- const nudgeSweep = async (input?: { now?: number }) => {
1215
- if (draining) return;
1216
- const now = input?.now ?? Date.now();
1217
- // per-record parsing, not listSlackThreads: state files that fail to
1218
- // parse THROW by contract, but here one corrupt record aborting the
1219
- // whole sweep would silence every OTHER thread's overdue nudge on every
1220
- // tick (codex review catch) - and before the daemon's rejection backstop
1221
- // existed, this bare-interval throw was a whole-daemon crash killing
1222
- // every in-flight turn (2026-07-27 report shape). The skip is logged
1223
- // once per file per daemon run; a 60s tick would repeat it forever.
1224
- let files: string[] = [];
1225
- try {
1226
- files = existsSync(paths.slackThreadsDir) ? readdirSync(paths.slackThreadsDir) : [];
1227
- } catch (e) {
1228
- log("serve.nudge_sweep_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
1229
- return;
1230
- }
1231
- for (const f of files) {
1232
- if (!f.endsWith(".json")) continue;
1233
- let record: SlackThread;
1234
- try {
1235
- record = SlackThreadSchema.parse(JSON.parse(readFileSync(join(paths.slackThreadsDir, f), "utf8")));
1236
- } catch (e) {
1237
- if (!sweepSkipLogged.has(f)) {
1238
- sweepSkipLogged.add(f);
1239
- log("serve.nudge_record_skipped", { file: f, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
1240
- }
1241
- continue;
1242
- }
1243
- const asked = record.attention;
1244
- if (!asked || asked.nudgedAt !== undefined || now - Date.parse(asked.askedAt) < ATTENTION_NUDGE_MS) continue;
1245
- // unlinked channels are contractually silent in Slack (review catch:
1246
- // `serve unlink` leaves thread records behind, and every other outbound
1247
- // path honors the contract). Skipped without a log line on purpose - a
1248
- // 60s sweep would otherwise repeat the same warning forever.
1249
- if (!linkForChannel(cfg, bareChannelId(record.threadId.split(":").slice(0, 2).join(":")))) continue;
1250
- void tracked(serialized(record.threadId, async () => {
1251
- if (draining) return;
1252
- const fresh = loadSlackThread(record.threadId);
1253
- const due = fresh?.attention;
1254
- if (!fresh || !due || due.nudgedAt !== undefined || now - Date.parse(due.askedAt) < ATTENTION_NUDGE_MS) return;
1255
- const tags = due.requesterIds.map((id) => `<@${id}>`).join(" ");
1256
- try {
1257
- await seam.postToThread({
1258
- threadId: record.threadId,
1259
- text: `${tags || "the requester"} still waiting on your input above.`,
1260
- });
1261
- saveSlackThread({ ...fresh, attention: { ...due, nudgedAt: new Date().toISOString() } });
1262
- log("serve.nudge_sent", { thread: record.threadId });
1263
- } catch (e) {
1264
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
1265
- log("serve.nudge_error", { thread: record.threadId, err: detail });
1266
- }
1267
- }));
1268
- }
1269
- };
1270
-
1271
- /** Deferred-turn wakes: one process-local timer per thread, re-armed by a
1272
- * later deferral. The durable marker (activeTurn.resumeAt) is the source
1273
- * of truth - timers die with the process and startup re-arms or recovers
1274
- * from the record. Node clamps setTimeout delays above 2^31-1ms to 1ms,
1275
- * so the delay is capped instead: an early fire re-defers off the
1276
- * still-depleted pool, bounded by resumeCount. */
1277
- const deferredTimers = new Map<string, ReturnType<typeof setTimeout>>();
1278
- const scheduleDeferred = (threadId: string, resumeAt: number) => {
1279
- const prev = deferredTimers.get(threadId);
1280
- if (prev !== undefined) clearTimeout(prev);
1281
- log("serve.resume_scheduled", { thread: threadId, resumeAt });
1282
- const timer = setTimeout(() => {
1283
- deferredTimers.delete(threadId);
1284
- if (draining) return;
1285
- try {
1286
- const record = loadSlackThread(threadId);
1287
- if (!record?.activeTurn) return; // superseded: a turn already cleared it
1288
- void tracked(recoverInterrupted(record));
1289
- } catch (e) {
1290
- log("serve.resume_error", { thread: threadId, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
1291
- }
1292
- }, Math.min(Math.max(0, resumeAt - Date.now()), 2_147_483_647));
1293
- deferredTimers.set(threadId, timer);
1294
- };
1295
-
1296
- /** Recover one thread whose activeTurn marker survived: a restart killed
1297
- * that turn mid-run, or a usage-limit deferral parked it (resumeAt) and
1298
- * the wake arrived. Notify the thread, then resume the session (or replay
1299
- * the original prompt when the turn never reached init); past the retry
1300
- * cap, give up loudly. EVERY branch runs inside the shared per-thread
1301
- * `serialized` chain and recomputes the decision from a fresh reload
1302
- * there: an inbound turn (or Slack redelivering the killed turn's unacked
1303
- * mention) can win the chain first, handle the thread, and clear the
1304
- * marker - acting on the startup snapshot would then re-run superseded
1305
- * work and write stale record fields over the session id that turn
1306
- * persisted (adversarial-review catch). Lives in the seam with
1307
- * `streamable` INJECTED (the daemon passes its bot-backed handle builder,
1308
- * tests a fake) so that superseded-recovery race is pinnable
1309
- * (closing-review catch: the invariant had no test while inline). */
1310
- const recoverInterrupted = async (record: SlackThread) => {
1311
- try {
1312
- const { thread, requesterIds } = await seam.streamable(record.threadId);
1313
- const link = linkForChannel(cfg, bareChannelId(thread.channelId));
1314
- // serializedTurn: a recovery runs a real claude turn, so it must gate
1315
- // steering-order like any other queued turn.
1316
- await serializedTurn(record.threadId, async () => {
1317
- // a drain signal can land between the scan and this turn; leave the
1318
- // marker at its previous count so the next start retries.
1319
- if (draining) return;
1320
- const fresh = loadSlackThread(record.threadId);
1321
- const turn = fresh?.activeTurn;
1322
- const decision = fresh ? resumeDecision(fresh) : null;
1323
- if (!fresh || !turn || !decision) return; // superseded: an earlier turn already cleared the marker
1324
- if (turn.resumeAt !== undefined && turn.resumeAt > Date.now()) {
1325
- // a wake that queued behind an in-flight turn can find a RE-DEFERRED
1326
- // marker whose new wake is hours out; resuming it now would post a
1327
- // false "pool has recovered" notice and burn a resume attempt
1328
- // (adversarial-review catch on PR #44). Re-arm and step aside, the
1329
- // same guard the startup scan applies.
1330
- scheduleDeferred(record.threadId, turn.resumeAt);
1331
- return;
1332
- }
1333
- await reapOrphan(turn);
1334
- if (!link) {
1335
- // unlinked since the turn started: nothing can run here; drop the
1336
- // marker and stay silent, like every unlinked-channel path.
1337
- saveSlackThread(omit(fresh, ["activeTurn"]));
1338
- log("serve.resume_unlinked", { thread: record.threadId });
1339
- return;
1340
- }
1341
- if (decision.kind === "give-up") {
1342
- log("serve.resume_gave_up", { thread: record.threadId });
1343
- // post BEFORE clearing, mirroring the resume branch's ordering: a
1344
- // kill or post failure here leaves the marker for the next restart
1345
- // to retry the notice (at-least-once; worst case a duplicate
1346
- // give-up notice), instead of the thread going permanently dark
1347
- // with the user never told the daemon gave up.
1348
- await thread.post(decision.notice);
1349
- saveSlackThread(omit(fresh, ["activeTurn"]));
1350
- // the abandoned turn's messages must not keep reading as processing.
1351
- for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
1352
- if (!id) continue;
1353
- await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
1354
- await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
1355
- }
1356
- return;
1357
- }
1358
- if (turn.resumeAt !== undefined) {
1359
- // the wake arrived, but the reset clock was extrapolated: confirm
1360
- // the pool ACTUALLY recovered before posting the recovery notice
1361
- // and spending one of the capped resume attempts - a still-depleted
1362
- // pool with a KNOWN wake re-defers silently (no quota was spent, so
1363
- // no attempt burns; cursor review catch on PR #44). This runs AFTER
1364
- // the give-up branch on purpose: a turn at the resume cap gives up
1365
- // honestly at its due wake instead of re-deferring forever (cubic
1366
- // review catch). A still-depleted pool with an UNKNOWN wake drops
1367
- // honestly (vercel + cubic review catch: falling through would post
1368
- // a false "pool has recovered" notice and burn an attempt on a
1369
- // spawn-boundary drop) - the drop-beats-false-promise principle
1370
- // stands for the unknown-wake case, and a short silent re-arm loop
1371
- // against a never-recovering pool would keep a zombie promise
1372
- // alive instead. A probe failure falls through to the normal
1373
- // resume, whose own decision path announces honestly.
1374
- try {
1375
- const verdict = await seam.decide();
1376
- const depleted = verdict.reason === "all-depleted" || verdict.reason === "depleted-wait";
1377
- if (depleted) {
1378
- const wake = verdict.waitUntil ?? null;
1379
- if (wake != null) {
1380
- const resumeAt = wake + 5_000;
1381
- saveSlackThread({ ...fresh, activeTurn: { ...turn, resumeAt } });
1382
- scheduleDeferred(record.threadId, resumeAt);
1383
- log("serve.resume_still_depleted", { thread: record.threadId, resumeAt });
1384
- return;
1385
- }
1386
- log("serve.resume_dropped_unknown", { thread: record.threadId });
1387
- await thread.post("the pool is still at its usage limit and its recovery time is now unknown - this held message is dropped; re-send it once the pool recovers.");
1388
- // terminal exit on a linked channel: settle the trigger's status
1389
- // or its hourglass reads "processing" forever (cubic review
1390
- // catch on PR #43). The unlinked abandon above stays reactionless
1391
- // on purpose: unlinked channels are contractually untouchable.
1392
- for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
1393
- if (!id) continue;
1394
- await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
1395
- await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
1396
- }
1397
- saveSlackThread(omit(fresh, ["activeTurn"]));
1398
- return;
1399
- }
1400
- } catch (e) {
1401
- log("serve.resume_probe_error", { thread: record.threadId, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
1402
- }
1403
- }
1404
- log("serve.resume_interrupted", { thread: record.threadId, attempt: decision.marker.resumeCount });
1405
- // the notice posts BEFORE runTurn persists the incremented marker,
1406
- // on purpose: the cap bounds quota-SPENDING attempts (the spawn),
1407
- // and a failed notice spends nothing - the marker survives at its
1408
- // old count for the next restart to retry, at most once per
1409
- // operator-triggered restart, each logged as serve.resume_error.
1410
- // A permanently unpostable channel (bot kicked, archived) therefore
1411
- // retries on every restart; unlinking it clears the marker.
1412
- await thread.post(decision.notice);
1413
- const startedAt = Date.now();
1414
- // the KILLED turn's actual askers outrank the streamable handle's
1415
- // newest-author derivation: a recovered need_attention turn must
1416
- // nudge and answer-gate the users who were actually asked (vercel
1417
- // review catch on PR #43); older markers without the field fall back.
1418
- const resumedRequesterIds = decision.marker.requesterIds ?? requesterIds;
1419
- const { outcome, steeredMessageIds } = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
1420
- await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, steeredMessageIds, requesterIds: resumedRequesterIds });
1421
- });
1422
- } catch (e) {
1423
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
1424
- log("serve.resume_error", { thread: record.threadId, err: detail });
1425
- // a DEFERRED turn's wake must survive a transient failure here (a
1426
- // Slack hiccup at an unattended 4am wake would otherwise strand the
1427
- // held turn until the next restart - adversarial-review catch on
1428
- // PR #44): re-arm a short retry while the marker still defers. The
1429
- // marker-clearing paths (resume, supersession, unlink, give-up) all
1430
- // end the loop; killed-turn (no resumeAt) startup semantics keep
1431
- // their once-per-restart retry.
1432
- try {
1433
- const marker = loadSlackThread(record.threadId)?.activeTurn;
1434
- if (marker?.resumeAt !== undefined && !draining) scheduleDeferred(record.threadId, Date.now() + RESUME_RETRY_MS);
1435
- } catch {
1436
- // the record itself is unreadable; the startup scan is the backstop.
1437
- }
1438
- }
1439
- };
1440
-
1441
- return {
1442
- /** in-flight turn promises; shutdown drains them. */
1443
- activeTurns,
1444
- isDraining: () => draining,
1445
- /** stop taking new turns and wake parked/retrying ones. Pending deferred
1446
- * wakes are cancelled: the markers are durable and the next generation
1447
- * re-arms them at startup. */
1448
- beginDrain: () => {
1449
- draining = true;
1450
- drainAbort.abort();
1451
- for (const timer of deferredTimers.values()) clearTimeout(timer);
1452
- deferredTimers.clear();
1453
- },
1454
- relayable,
1455
- onMessage,
1456
- onReaction,
1457
- nudgeSweep,
1458
- /** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
1459
- * resumed turn shares the exact chain and marker machinery of an inbound
1460
- * one. */
1461
- serialized,
1462
- tracked,
1463
- runTurn,
1464
- settleTurn,
1465
- recoverInterrupted,
1466
- scheduleDeferred,
1467
- };
1468
- }
1469
-
1470
- /** users.info slice the home-workspace reactor check reads. */
1471
- const UsersInfoSchema = z.looseObject({
1472
- ok: z.boolean(),
1473
- user: z.looseObject({ team_id: z.string().optional() }).optional(),
1474
- });
1475
-
1476
- /** The raw Slack reaction event slice the daemon reads: event_ts is when the
1477
- * reaction happened, the discriminator that keeps a pre-ask reaction from
1478
- * answering a question the user never saw. */
1479
- const ReactionRawSchema = z.looseObject({ event_ts: z.string().optional() });
1480
-
1481
- async function runDaemon(): Promise<number> {
1482
- const homeUserCache = new Map<string, boolean>();
1483
- let cfg = loadSlackConfig();
1484
- if (!cfg) {
1485
- printSetupInstructions();
1486
- return 1;
1487
- }
1488
- if (cfg.links.length === 0) {
1489
- console.error(c.red("no channel links - run `tokenmaxxing serve link <channel-id> <repo>` first"));
1490
- return 1;
1491
- }
1492
- // the external-author guard compares every message's origin against the home
1493
- // workspace. The reference is re-captured from the live token at EVERY start
1494
- // (a stale persisted id would fail-closed reject the owner's own messages);
1495
- // a failed capture fails the daemon fast - the guard never runs
1496
- // reference-less, and the daemon is useless without Slack reachable anyway.
1497
- const workspaceTeamId = await fetchWorkspaceTeamId({ botToken: cfg.botToken });
1498
- if (cfg.workspaceTeamId !== workspaceTeamId) {
1499
- cfg = { ...cfg, workspaceTeamId };
1500
- saveSlackConfig(cfg);
1501
- log("serve.team_captured", { team: workspaceTeamId });
1502
- }
1503
-
1504
- // every log() event from here on (serve.* plus the in-process swap/decision
1505
- // events fired by ensureBestAccount/stopHookCheck) also prints to the
1506
- // terminal, so a foreground `xx serve` shows what it is doing live.
1507
- setLogEcho({ printer: (entry) => console.log(formatLogLine(entry)) });
1508
-
1509
- // single-instance guard, held for the process lifetime (the fd releases on
1510
- // exit): without it a replacement daemon can start inside the previous
1511
- // generation's drain window, read a still-RUNNING turn's activeTurn marker,
1512
- // and resume it - two claude processes in one cwd (adversarial-review
1513
- // catch). Blocking here makes a rolling restart wait out the drain instead.
1514
- console.log(c.dim("acquiring the serve singleton lock (waits for a draining daemon to exit)"));
1515
- await acquireLock(paths.serveLockFile);
1516
-
1517
- const slack = createSlackAdapter({
1518
- mode: "socket",
1519
- botToken: cfg.botToken,
1520
- appToken: cfg.appToken,
1521
- // Native append-streaming (chat.startStream) with task cards is the
1522
- // correct mode (user-confirmed live 2026-07-18): it works in channel
1523
- // threads even when auth.test reports no assistant:write, so never gate
1524
- // it on a scope probe. The adapter falls back to post-and-edit by itself
1525
- // when a workspace truly rejects streaming. agentView matches the
1526
- // manifest's Agent messaging experience for the DM surface.
1527
- agentView: true,
1528
- // the web-api default retry policy (tenRetriesInAboutThirtyMinutes) can
1529
- // stall a streamed turn ~30min on one rate-limited edit; this is
1530
- // @slack/web-api's fiveRetriesInFiveMinutes literal (dep not declared,
1531
- // so the values are inlined).
1532
- webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
1533
- // the adapter's default logger is info-level and prints a line per
1534
- // redelivered socket envelope ("Processing socket mode retry") - steady
1535
- // noise during restart catch-up. warn matches the Chat logger below;
1536
- // real degradations (streaming fallback etc.) are warn-level and survive.
1537
- logger: new ConsoleLogger("warn", "chat-sdk").child("slack"),
1538
- });
1539
- // held directly (not only via Chat) so startup can re-subscribe recorded
1540
- // threads: subscriptions live in this in-memory state and die with the
1541
- // process, and only a fresh mention would otherwise revive a thread.
1542
- const state = new MemoryStateAdapter();
1543
- const bot = new Chat({
1544
- userName: "tokenmaxxing",
1545
- adapters: { slack },
1546
- state,
1547
- // CONCURRENT dispatch (steering redesign, superseding the queue strategy
1548
- // and its 1h-TTL/size-100 tuning): every message reaches onMessage the
1549
- // moment Slack delivers it, in arrival order. The queue strategy's
1550
- // 30s dispatch-lock lease made mid-turn messages invisible AND could
1551
- // reorder them - a reply enqueued behind a live lease was only released
1552
- // when a LATER message took the expired lock, dispatching newest-first
1553
- // (adversarial-review catch; chat 4.34.0 handleQueueOrDebounce). The
1554
- // daemon owns everything the queue used to provide: per-thread turn
1555
- // ordering (the serialized chain), mid-turn folding (the steering path),
1556
- // and batching of waiting messages (the per-thread inbox in
1557
- // buildServeRuntime, drained sorted as ONE folded turn). Chat's
1558
- // message-id dedupe runs before the concurrency branch, so the
1559
- // app_mention + message.channels double-delivery stays deduped.
1560
- concurrency: { strategy: "concurrent" },
1561
- // without this a cards-only segment in post-and-edit fallback would
1562
- // strand a bare "..." placeholder message.
1563
- fallbackStreamingPlaceholderText: null,
1564
- logger: "warn",
1565
- });
1566
-
1567
- // the message-handling runtime (author guard, folding, per-thread
1568
- // serialization, activeTurn markers, drain drops, finish close-out) lives
1569
- // in buildServeRuntime so tests can drive the real wiring; production deps
1570
- // go in here.
1571
- const runtime = buildServeRuntime({
1572
- cfg,
1573
- workspaceTeamId,
1574
- botUserId: () => slack.botUserId ?? null,
1575
- relay: relayThread,
1576
- cleanup: cleanupThread,
1577
- // reactions.add/remove; the runtime wraps every call in its own log-only
1578
- // guard, so adapter failures (missing reactions:write until the app is
1579
- // reinstalled with the current manifest) stay invisible to turns.
1580
- react: async (input) => {
1581
- if (input.op === "add") await slack.addReaction(input.threadId, input.messageId, input.emoji);
1582
- else await slack.removeReaction(input.threadId, input.messageId, input.emoji);
1583
- },
1584
- // one standalone line (the nudge); a lazy handle posts fine card-less.
1585
- postToThread: async (input) => {
1586
- await bot.thread(input.threadId).post(input.text);
1587
- },
1588
- // reaction events carry no team-origin fields, so the note path verifies
1589
- // reactors via users.info (scope users:read, already in the manifest).
1590
- // Definitive answers cache for the daemon's lifetime; errors return null
1591
- // (fail closed at the caller) without caching so transient failures heal.
1592
- isHomeUser: async (input) => {
1593
- const cached = homeUserCache.get(input.userId);
1594
- if (cached !== undefined) return cached;
1595
- try {
1596
- const resp = UsersInfoSchema.parse(await slack.webClient.users.info({ user: input.userId }));
1597
- const teamId = resp.user?.team_id;
1598
- if (!resp.ok || teamId === undefined) return null;
1599
- const home = teamId === workspaceTeamId;
1600
- homeUserCache.set(input.userId, home);
1601
- return home;
1602
- } catch {
1603
- return null;
1604
- }
1605
- },
1606
- // lazy on purpose: streamableThread is declared just below and only ever
1607
- // invoked long after startup (recovery runs and deferred wakes).
1608
- streamable: (threadId) => streamableThread(threadId),
1609
- decide: ensureBestAccount,
1610
- });
1611
-
1612
- /** A proactive thread handle that can still stream natively. bot.thread()
1613
- * carries no currentMessage, and without one handleStream has no
1614
- * recipientUserId/recipientTeamId, so the Slack adapter's stream() gate
1615
- * falls back to card-less post-and-edit that also strands a blank message
1616
- * per card-only segment (verified in chat 4.34.0 + @chat-adapter/slack).
1617
- * Reusing the newest human message in the thread as currentMessage
1618
- * restores the exact context an inbound turn would have, and its author is
1619
- * the natural requester to tag on a resumed turn. The explicit ThreadImpl
1620
- * constructor (published typed API) is deliberate over the prose-documented
1621
- * ThreadImpl.fromJSON/reviver restore path: fromJSON takes the lazy config
1622
- * branch, which silently reverts fallbackStreamingPlaceholderText to "..."
1623
- * (re-stranding the placeholder this daemon suppresses) and needs
1624
- * registerSingleton for state. */
1625
- const streamableThread = async (threadId: string) => {
1626
- const handle = bot.thread(threadId);
1627
- for await (const message of handle.messages) {
1628
- if (runtime.relayable(message)) {
1629
- const thread = new ThreadImpl({
1630
- adapter: slack,
1631
- stateAdapter: state,
1632
- channelId: handle.channelId,
1633
- id: threadId,
1634
- isDM: false,
1635
- currentMessage: message,
1636
- fallbackStreamingPlaceholderText: null,
1637
- });
1638
- return { thread, requesterIds: [message.author.userId] };
1639
- }
1640
- }
1641
- // no human message on record: card-less is all there is
1642
- return { thread: handle, requesterIds: [] };
1643
- };
1644
-
1645
- bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
1646
- bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
1647
- // user reactions: an asked user's reaction answers the pending question,
1648
- // anything else folds into the next turn as context (runtime.onReaction).
1649
- // chat core already drops the bot's own reactions before routing.
1650
- bot.onReaction(async (event) => {
1651
- const raw = ReactionRawSchema.safeParse(event.raw);
1652
- const eventTs = raw.success && raw.data.event_ts !== undefined ? Number(raw.data.event_ts) * 1000 : Number.NaN;
1653
- await runtime.onReaction(
1654
- {
1655
- threadId: event.threadId,
1656
- messageId: event.messageId,
1657
- emoji: event.emoji.name,
1658
- userId: event.user.userId,
1659
- isBot: event.user.isBot,
1660
- added: event.added,
1661
- occurredAt: Number.isFinite(eventTs) ? eventTs : null,
1662
- },
1663
- streamableThread,
1664
- );
1665
- });
1666
-
1667
- // drain instead of dying mid-answer: stop taking new turns, let in-flight
1668
- // ones finish (bounded - a hung claude turn must not block a restart
1669
- // forever), then disconnect. A second signal forces an immediate exit.
1670
- // Registered BEFORE initialize() (post-0.19.1 review catch): the socket
1671
- // goes live inside initialize, so a turn could start while runDaemon was
1672
- // still suspended there and a signal in that window hit default
1673
- // disposition - an instant kill with no drain.
1674
- const DRAIN_MS = 300_000;
1675
- const shutdown = async (signal: string) => {
1676
- if (runtime.isDraining()) {
1677
- log("serve.forced_exit", { signal });
1678
- process.exit(1);
1679
- }
1680
- runtime.beginDrain(); // parked/retrying turns wake, post their drop notice, and finish
1681
- log("serve.draining", { signal, turns: runtime.activeTurns.size });
1682
- console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: runtime.activeTurns.size, noun: "in-flight turn" })} (again to force)`);
1683
- // re-snapshot until stable inside the deadline: drain-window drop notices
1684
- // join activeTurns after the first snapshot and must still flush.
1685
- const deadline = Date.now() + DRAIN_MS;
1686
- while (runtime.activeTurns.size > 0 && Date.now() < deadline) {
1687
- await Promise.race([Promise.allSettled([...runtime.activeTurns]), delay(deadline - Date.now())]);
1688
- }
1689
- try {
1690
- await bot.shutdown();
1691
- } catch (e) {
1692
- // exit must be reached even when the socket teardown rejects; the
1693
- // second-signal force path must not be the only escape.
1694
- log("serve.shutdown_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
1695
- }
1696
- log("serve.stopped", { dropped: runtime.activeTurns.size });
1697
- process.exit(0);
1698
- };
1699
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
1700
- process.on("SIGINT", () => void shutdown("SIGINT"));
1701
- // closing the foreground terminal sends SIGHUP, whose default disposition
1702
- // kills the daemon WITHOUT the "exit" event - so the process-exit hook that
1703
- // kill-groups the DETACHED claude child never runs, the child survives as
1704
- // an orphan still mutating the cwd, and the freed serve-lock lets the next
1705
- // generation resume the same session beside it (adversarial-review catch,
1706
- // exit-skip verified empirically on Bun; a mid-turn orphan does NOT die on
1707
- // its dead stdout pipe - also verified - which is why the reaper exists).
1708
- // Draining instead keeps the child owned; its Slack streaming needs no
1709
- // tty, so the turn can even finish. A SIGKILL/crash orphan is covered by
1710
- // reapOrphan via the marker's pid identity; the only unmarked window is
1711
- // spawn-to-persist, both inside the spawn hook BEFORE the SDK writes the
1712
- // prompt, and a prompt-less orphan exits on its dead stdin's EOF (verified
1713
- // against the real claude binary in the SDK's exact stdio shape: dead in
1714
- // 2s, zero API calls).
1715
- process.on("SIGHUP", () => void shutdown("SIGHUP"));
1716
-
1717
- // LAST-RESORT backstop, not the error strategy: `tracked` is the daemon's
1718
- // own boundary, so this should stay idle - it exists for rejections minted
1719
- // outside our funnels (the chat SDK's socket client, adapter internals),
1720
- // where Bun's default is to kill the whole process (verified 2026-07-27)
1721
- // and with it every concurrent session's in-flight turn, leaving
1722
- // half-streamed Slack messages and no daemon to resume the markers.
1723
- // Sync throws keep the default crash: an uncaughtException means state is
1724
- // undefined and the durable markers make a restart the honest recovery.
1725
- // message-only, like every other logged error here: a rejection minted by
1726
- // an auth-carrying HTTP client must not persist its request into the log
1727
- // (cursor review catch).
1728
- process.on("unhandledRejection", (reason) => {
1729
- log("serve.unhandled_rejection", { err: (reason instanceof Error ? reason.message : String(reason)).slice(0, 300) });
1730
- });
1731
-
1732
- // initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
1733
- // wired straight into event routing; the daemon only has to stay alive.
1734
- // Never call startSocketModeListener here: that is the serverless leased
1735
- // variant (it demands options.waitUntil and returns instantly without it),
1736
- // and awaiting it in a loop starved the event loop so hard the WebSocket
1737
- // never delivered a single event (live incident 2026-07-18).
1738
- await bot.initialize();
1739
-
1740
- // subscriptions live in the memory state and died with the previous daemon;
1741
- // the durable slack-threads/ records say which threads are ours, so restore
1742
- // routing for them (message routing checks stateAdapter.isSubscribed,
1743
- // verified in chat 4.34.0). Without this a restart leaves every open thread
1744
- // deaf to non-mention follow-ups.
1745
- const records = listSlackThreads();
1746
- for (const record of records) await state.subscribe(record.threadId);
1747
- log("serve.resubscribed", { threads: records.length });
1748
-
1749
- // overdue-attention sweep: one mention-tagging reminder per unanswered ask.
1750
- // Never cleared: the sweep no-ops while draining, and shutdown exits the
1751
- // process outright.
1752
- setInterval(() => void runtime.nudgeSweep(), NUDGE_SWEEP_MS);
1753
-
1754
- // threads whose activeTurn marker survived the previous daemon were either
1755
- // killed mid-turn by a restart (live incident 2026-07-18: a redeploy
1756
- // silently killed a ship turn 8 minutes in and the thread just went dark)
1757
- // or deferred at a usage limit (resumeAt; 2026-07-20 incident: dropped
1758
- // messages sat dead for hours after the pool recovered). A future resumeAt
1759
- // re-arms its timer; everything else recovers now, tracked so a drain
1760
- // waits for it; the actionable decision is recomputed under the per-thread
1761
- // lock inside.
1762
- for (const record of records) {
1763
- const marker = record.activeTurn;
1764
- if (!marker) continue;
1765
- if (marker.resumeAt !== undefined && marker.resumeAt > Date.now()) {
1766
- runtime.scheduleDeferred(record.threadId, marker.resumeAt);
1767
- continue;
1768
- }
1769
- void runtime.tracked(runtime.recoverInterrupted(record));
1770
- }
1771
-
1772
- 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)`);
1773
- log("serve.started", { links: cfg.links.length });
1774
- await new Promise<never>(() => {});
1775
- return 0;
1776
- }
1777
-
1778
- export async function cmdServe(argv: string[]): Promise<number> {
1779
- const [sub, ...rest] = argv;
1780
- switch (sub) {
1781
- case undefined: return runDaemon();
1782
- case "setup": return cmdServeSetup();
1783
- case "link": return cmdServeLink(rest);
1784
- case "unlink": return cmdServeUnlink(rest[0]);
1785
- case "links": return cmdServeLinks();
1786
- default:
1787
- console.error(SERVE_USAGE);
1788
- return 2;
1789
- }
1790
- }