tokenmaxxing 0.19.0 → 0.21.0

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