tokenmaxxing 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/serve.ts CHANGED
@@ -15,12 +15,13 @@
15
15
  // serve links list links
16
16
  // serve run the daemon
17
17
 
18
- import { existsSync, realpathSync } from "node:fs";
18
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
19
+ import { join } from "node:path";
19
20
  import { delay, omit, uniq } from "es-toolkit";
20
21
  import { z } from "zod";
21
22
  import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
22
23
  import { createSlackAdapter } from "@chat-adapter/slack";
23
- import { createMemoryState } from "@chat-adapter/state-memory";
24
+ import { MemoryStateAdapter } from "@chat-adapter/state-memory";
24
25
  import {
25
26
  bareChannelId,
26
27
  isChannelId,
@@ -36,6 +37,7 @@ import {
36
37
  stripLeadingMention,
37
38
  upsertLink,
38
39
  SlackLinkSchema,
40
+ SlackThreadSchema,
39
41
  type ActiveTurn,
40
42
  type SlackConfig,
41
43
  type SlackLink,
@@ -332,6 +334,10 @@ export function buildServeRuntime(seam: {
332
334
  * relayThread). */
333
335
  onSpawn?: (pid: number) => void;
334
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;
335
341
  }) => Promise<TurnOutcome>;
336
342
  cleanup: (input: { threadId: string }) => CleanupOutcome;
337
343
  /** add/remove a status reaction on a message (production: the Slack
@@ -369,6 +375,35 @@ export function buildServeRuntime(seam: {
369
375
  let draining = false;
370
376
  // channels already diagnosed as unlinked this run (see handleTurn).
371
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>>();
372
407
 
373
408
  /** Best-effort status reaction: reaction state is decoration, so every
374
409
  * failure (missing reactions:write until the app is reinstalled,
@@ -387,7 +422,9 @@ export function buildServeRuntime(seam: {
387
422
  * before the spawn, cleared when the turn returns, so a marker surviving
388
423
  * into the next daemon start identifies a turn a restart killed mid-run.
389
424
  * The session id persists the moment init assigns it - a first-turn kill
390
- * must stay resumable. */
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. */
391
428
  const runTurn = async (input: {
392
429
  thread: { id: string; post: (m: StreamingPlan) => Promise<unknown> };
393
430
  record: SlackThread;
@@ -396,14 +433,17 @@ export function buildServeRuntime(seam: {
396
433
  sessionId: string | null;
397
434
  marker: ActiveTurn;
398
435
  link: SlackLink;
399
- }): Promise<TurnOutcome> => {
436
+ }): Promise<{ outcome: TurnOutcome; steeredMessageIds: string[] }> => {
400
437
  let record: SlackThread = { ...input.record, activeTurn: input.marker };
401
438
  saveSlackThread(record);
402
- // the whole turn (parks and retries included) reads as "being processed".
403
- if (input.marker.messageId) {
404
- await setStatus({ threadId: input.thread.id, messageId: input.marker.messageId, emoji: STATUS_EMOJI.processing, op: "add" });
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" });
405
444
  }
406
445
  let outcome: TurnOutcome | null = null;
446
+ let steeredMessageIds = input.marker.steeredMessageIds ?? [];
407
447
  try {
408
448
  outcome = await seam.relay({
409
449
  cwd: record.cwd,
@@ -420,9 +460,14 @@ export function buildServeRuntime(seam: {
420
460
  onSpawn: (pid) => {
421
461
  // the lstart token makes the pid a verifiable identity for the
422
462
  // orphan reaper; a child dead before ps sees it persists without
423
- // one, and an identity-less pid is never signaled.
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.
424
468
  const startedAt = pidStartTime(pid);
425
- record = { ...record, activeTurn: { ...input.marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
469
+ const marker = omit(record.activeTurn ?? input.marker, ["pid", "pidStartedAt"]);
470
+ record = { ...record, activeTurn: { ...marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
426
471
  saveSlackThread(record);
427
472
  },
428
473
  onSessionId: (sessionId) => {
@@ -430,9 +475,115 @@ export function buildServeRuntime(seam: {
430
475
  saveSlackThread(record);
431
476
  },
432
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
+ },
433
584
  });
434
585
  record = { ...record, sessionId: outcome.sessionId };
435
- return outcome;
586
+ return { outcome, steeredMessageIds };
436
587
  } finally {
437
588
  // a failure DURING a drain is presumed to be the shutdown signal killing
438
589
  // the claude child (terminal Ctrl-C and group signals hit the whole
@@ -458,6 +609,9 @@ export function buildServeRuntime(seam: {
458
609
  if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop && deferUntil === null) {
459
610
  log("serve.drop_unannounced", { thread: input.thread.id });
460
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);
461
615
  if (deferUntil !== null && record.activeTurn) {
462
616
  record = { ...record, activeTurn: { ...record.activeTurn, resumeAt: deferUntil } };
463
617
  saveSlackThread(record);
@@ -470,7 +624,7 @@ export function buildServeRuntime(seam: {
470
624
 
471
625
  const handleTurn = async (input: {
472
626
  thread: ServeThread;
473
- /** every relayed message this turn (queue-skipped + triggering), text
627
+ /** every relayed message this turn (the inbox batch, ts-sorted), text
474
628
  * paired with its author id: a decision may be owed to an earlier
475
629
  * folded sender, and a sender whose whole message was the bot mention
476
630
  * contributes no prompt text, so text and author filter together
@@ -525,11 +679,10 @@ export function buildServeRuntime(seam: {
525
679
  return;
526
680
  }
527
681
  log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
528
- // relayed carries queue-skipped messages plus the triggering one: the
529
- // queue strategy hands a turn only the LATEST message and the rest via
530
- // context.skipped, so they are folded into one prompt here. A message
531
- // that is empty once its bot mention is stripped contributes neither
532
- // prompt text nor a requester id (cursor review catch 2026-07-18).
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).
533
686
  const stripped = input.relayed
534
687
  .map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
535
688
  .filter((m) => m.text !== "");
@@ -571,6 +724,12 @@ export function buildServeRuntime(seam: {
571
724
  // prompt re-appears alongside the session transcript that already holds
572
725
  // its partial work, and the newer message steers.
573
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 ?? [])] : [];
574
733
  if (deferred) {
575
734
  const timer = deferredTimers.get(thread.id);
576
735
  if (timer !== undefined) clearTimeout(timer);
@@ -608,16 +767,16 @@ export function buildServeRuntime(seam: {
608
767
  // agent feature + assistant:write (the adapter warns instead of throwing).
609
768
  await thread.startTyping();
610
769
  const startedAt = Date.now();
611
- const outcome = await runTurn({
770
+ const { outcome, steeredMessageIds } = await runTurn({
612
771
  thread,
613
772
  record,
614
773
  prompt,
615
774
  requesterIds,
616
775
  sessionId: record.sessionId,
617
- marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds },
776
+ marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds, ...(adoptedIds.length > 0 ? { steeredMessageIds: adoptedIds } : {}) },
618
777
  link,
619
778
  });
620
- await settleTurn({ thread, outcome, startedAt, messageId, requesterIds });
779
+ await settleTurn({ thread, outcome, startedAt, messageId, steeredMessageIds, requesterIds });
621
780
  };
622
781
 
623
782
  /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
@@ -630,6 +789,9 @@ export function buildServeRuntime(seam: {
630
789
  startedAt: number;
631
790
  /** the triggering message carrying the status reactions; absent = skip. */
632
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[];
633
795
  /** the turn's asked users, persisted when the model flagged attention. */
634
796
  requesterIds?: string[];
635
797
  }) => {
@@ -653,10 +815,19 @@ export function buildServeRuntime(seam: {
653
815
  // below makes the question mark unremovable forever.
654
816
  const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
655
817
  const deferredForResume = outcome.deferUntil !== null;
656
- if (input.messageId && !killedByDrain && !deferredForResume) {
818
+ if (!killedByDrain && !deferredForResume) {
657
819
  const emoji = outcome.failed ? STATUS_EMOJI.failed : outcome.attention && !outcome.finish ? STATUS_EMOJI.attention : STATUS_EMOJI.done;
658
- await setStatus({ threadId: thread.id, messageId: input.messageId, emoji, op: "add" });
659
- await setStatus({ threadId: thread.id, messageId: input.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
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
+ }
660
831
  }
661
832
  // the model asked the user for a decision: mark the thread waiting so the
662
833
  // nudge sweep and the reaction-answer path can see it. Persisted even on
@@ -730,21 +901,33 @@ export function buildServeRuntime(seam: {
730
901
  return true;
731
902
  };
732
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. */
733
914
  const tracked = async (turn: Promise<void>) => {
734
915
  activeTurns.add(turn);
735
916
  try {
736
917
  await turn;
918
+ } catch (e) {
919
+ log("serve.task_crashed", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
737
920
  } finally {
738
921
  activeTurns.delete(turn);
739
922
  }
740
923
  };
741
924
 
742
- // Per-thread serialization owned HERE, not only by the SDK's queue lock:
743
- // chat 4.34.0 acquires the dispatch lock with a 30s TTL and extends it only
744
- // BETWEEN dispatches, so any handler outliving 30s (every claude turn, any
745
- // depleted-pool park) lets a later message take the expired lock and start
746
- // a second concurrent handler in the same thread and cwd (review catch,
747
- // PR #18). Escaped messages run as sequential turns in arrival order.
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).
748
931
  const threadTurns = new Map<string, Promise<void>>();
749
932
  const serialized = (threadId: string, run: () => Promise<void>) => {
750
933
  const prev = threadTurns.get(threadId) ?? Promise.resolve();
@@ -766,16 +949,140 @@ export function buildServeRuntime(seam: {
766
949
  })();
767
950
  return next;
768
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
+ };
769
966
 
770
967
  // both Chat SDK callbacks funnel here. Filter EVERY message, trigger
771
968
  // included: an outsider (or our own post) arriving last must not discard
772
- // relayable home-workspace messages queued behind the turn - the queue hands
773
- // the handler only the latest message and the rest ride context.skipped
774
- // (review catch, PR #18).
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.
775
973
  const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
776
974
  const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
777
975
  if (relayed.length === 0) return; // outsider mentions never open a session
778
- await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
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 };
779
1086
  };
780
1087
 
781
1088
  /** A user reaction in a tracked thread. While the thread waits on an asked
@@ -816,6 +1123,14 @@ export function buildServeRuntime(seam: {
816
1123
  log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
817
1124
  return;
818
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.
819
1134
  await tracked(serialized(input.threadId, async () => {
820
1135
  const fresh = loadSlackThread(input.threadId);
821
1136
  if (!fresh) return; // finished while queued
@@ -829,16 +1144,47 @@ export function buildServeRuntime(seam: {
829
1144
  // folds into the next turn.
830
1145
  if (!draining && asked && asked.requesterIds.includes(input.userId) && afterAsk && onAskMessage) {
831
1146
  log("serve.reaction_answer", { thread: input.threadId, emoji: input.emoji });
832
- const { thread } = await streamable(input.threadId);
833
- await handleTurn({
834
- thread,
835
- relayed: [{
836
- text: `<@${input.userId}> answered your pending question with the Slack reaction :${input.emoji}:. Interpret the reaction as their reply and continue.`,
837
- authorId: input.userId,
838
- id: input.messageId,
839
- }],
840
- isMention: false,
841
- });
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
+ }
842
1188
  return;
843
1189
  }
844
1190
  const home = await seam.isHomeUser({ userId: input.userId });
@@ -868,7 +1214,32 @@ export function buildServeRuntime(seam: {
868
1214
  const nudgeSweep = async (input?: { now?: number }) => {
869
1215
  if (draining) return;
870
1216
  const now = input?.now ?? Date.now();
871
- for (const record of listSlackThreads()) {
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
+ }
872
1243
  const asked = record.attention;
873
1244
  if (!asked || asked.nudgedAt !== undefined || now - Date.parse(asked.askedAt) < ATTENTION_NUDGE_MS) continue;
874
1245
  // unlinked channels are contractually silent in Slack (review catch:
@@ -940,7 +1311,9 @@ export function buildServeRuntime(seam: {
940
1311
  try {
941
1312
  const { thread, requesterIds } = await seam.streamable(record.threadId);
942
1313
  const link = linkForChannel(cfg, bareChannelId(thread.channelId));
943
- await serialized(record.threadId, async () => {
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 () => {
944
1317
  // a drain signal can land between the scan and this turn; leave the
945
1318
  // marker at its previous count so the next start retries.
946
1319
  if (draining) return;
@@ -974,10 +1347,11 @@ export function buildServeRuntime(seam: {
974
1347
  // with the user never told the daemon gave up.
975
1348
  await thread.post(decision.notice);
976
1349
  saveSlackThread(omit(fresh, ["activeTurn"]));
977
- // the abandoned turn's message must not keep reading as processing.
978
- if (turn.messageId) {
979
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
980
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
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" });
981
1355
  }
982
1356
  return;
983
1357
  }
@@ -1015,9 +1389,10 @@ export function buildServeRuntime(seam: {
1015
1389
  // or its hourglass reads "processing" forever (cubic review
1016
1390
  // catch on PR #43). The unlinked abandon above stays reactionless
1017
1391
  // on purpose: unlinked channels are contractually untouchable.
1018
- if (turn.messageId) {
1019
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
1020
- await setStatus({ threadId: thread.id, messageId: turn.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
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" });
1021
1396
  }
1022
1397
  saveSlackThread(omit(fresh, ["activeTurn"]));
1023
1398
  return;
@@ -1041,8 +1416,8 @@ export function buildServeRuntime(seam: {
1041
1416
  // nudge and answer-gate the users who were actually asked (vercel
1042
1417
  // review catch on PR #43); older markers without the field fall back.
1043
1418
  const resumedRequesterIds = decision.marker.requesterIds ?? requesterIds;
1044
- const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
1045
- await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, requesterIds: resumedRequesterIds });
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 });
1046
1421
  });
1047
1422
  } catch (e) {
1048
1423
  const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
@@ -1164,24 +1539,25 @@ async function runDaemon(): Promise<number> {
1164
1539
  // held directly (not only via Chat) so startup can re-subscribe recorded
1165
1540
  // threads: subscriptions live in this in-memory state and die with the
1166
1541
  // process, and only a fresh mention would otherwise revive a thread.
1167
- const state = createMemoryState();
1542
+ const state = new MemoryStateAdapter();
1168
1543
  const bot = new Chat({
1169
1544
  userName: "tokenmaxxing",
1170
1545
  adapters: { slack },
1171
1546
  state,
1172
- // per-thread lock with queueing: a message landing mid-turn waits its turn
1173
- // instead of racing a second claude spawn on the same cwd. Queue-entry TTL
1174
- // expiry is SILENT (chat 4.34.0 has no app callback for it), so the TTL
1175
- // must outlast the longest legitimate hold: a depleted-pool park
1176
- // (PARK_MAX_MS 14min) plus a long claude turn. Expired-and-folded beats
1177
- // silently-vanished, hence a full hour. maxQueueSize matters for the same
1178
- // reason: the SDK default is 10 with a LOG-LESS drop-oldest trim
1179
- // (@chat-adapter/state-memory enqueue splices the front, and the SDK's
1180
- // message-dropped log only fires for drop-newest), so a >10-message burst
1181
- // behind one long turn silently ate the oldest instructions
1182
- // (adversarial-review catch). 100 outlasts any legitimate burst; the TTL
1183
- // stays the real bound.
1184
- concurrency: { strategy: "queue", queueEntryTtlMs: 3_600_000, maxQueueSize: 100 },
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" },
1185
1561
  // without this a cards-only segment in post-and-edit fallback would
1186
1562
  // strand a bare "..." placeholder message.
1187
1563
  fallbackStreamingPlaceholderText: null,
@@ -1338,6 +1714,21 @@ async function runDaemon(): Promise<number> {
1338
1714
  // 2s, zero API calls).
1339
1715
  process.on("SIGHUP", () => void shutdown("SIGHUP"));
1340
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
+
1341
1732
  // initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
1342
1733
  // wired straight into event routing; the daemon only has to stay alive.
1343
1734
  // Never call startSocketModeListener here: that is the serverless leased