tokenmaxxing 1.1.2 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/serve.ts CHANGED
@@ -58,8 +58,11 @@ const SERVE_USAGE = "usage: tokenmaxxing serve [setup | link <channel-id> <repo>
58
58
  * works without them, verified live). agent_view is an OBJECT whose required
59
59
  * field is agent_description (max 300 chars; docs.slack.dev app-manifest
60
60
  * reference, re-verified 2026-07-20) - a bare `agent_view: true` is rejected
61
- * with "Must provide an object". Changing scopes on an existing app requires
62
- * reinstalling it to the workspace. */
61
+ * with "Must provide an object". reactions:write powers the status reactions
62
+ * the daemon sets on triggering messages; reactions:read + the reaction_added
63
+ * event feed user reactions back in (matching @chat-adapter/slack's own
64
+ * README manifest). Changing scopes on an existing app requires reinstalling
65
+ * it to the workspace. */
63
66
  const APP_MANIFEST = `display_information:
64
67
  name: tokenmaxxing
65
68
  description: bridges Slack threads to Claude Code sessions
@@ -81,6 +84,8 @@ oauth_config:
81
84
  - chat:write
82
85
  - files:write
83
86
  - im:history
87
+ - reactions:read
88
+ - reactions:write
84
89
  - users:read
85
90
 
86
91
  settings:
@@ -92,6 +97,7 @@ settings:
92
97
  - message.channels
93
98
  - message.groups
94
99
  - message.im
100
+ - reaction_added
95
101
  socket_mode_enabled: true
96
102
  org_deploy_enabled: false
97
103
  token_rotation_enabled: false`;
@@ -248,14 +254,35 @@ const ServeThreadSchema = z.custom<{
248
254
  }>();
249
255
  type ServeThread = z.infer<typeof ServeThreadSchema>;
250
256
 
251
- /** The slice of a Chat SDK message the author guard + folding read. */
257
+ /** The slice of a Chat SDK message the author guard + folding read. id is the
258
+ * Slack message ts (verified in @chat-adapter/slack 4.34.0: Message.id =
259
+ * event.ts, exactly what reactions.add takes as timestamp), so it is the
260
+ * handle status reactions attach to. */
252
261
  const ServeMessageSchema = z.custom<{
262
+ id: string;
253
263
  text: string;
254
264
  author: { userId: string; isMe: boolean; isBot?: boolean | "unknown" };
255
265
  raw?: unknown;
256
266
  }>();
257
267
  type ServeMessage = z.infer<typeof ServeMessageSchema>;
258
268
 
269
+ /** Status reactions on the triggering message: hourglass while the turn runs,
270
+ * then exactly one terminal state. Color-of-the-moment for the whole thread
271
+ * list: which asks are being worked, which wait on the user, which are done. */
272
+ const STATUS_EMOJI = {
273
+ processing: "hourglass_flowing_sand",
274
+ done: "white_check_mark",
275
+ failed: "x",
276
+ attention: "question",
277
+ } as const;
278
+
279
+ /** One nudge per ask, this long after the asking turn settled: enough for a
280
+ * present user to answer on their own, short enough that a blocked thread
281
+ * does not sit forgotten. */
282
+ export const ATTENTION_NUDGE_MS = 600_000;
283
+ /** How often the daemon sweeps for overdue attention (runDaemon interval). */
284
+ export const NUDGE_SWEEP_MS = 60_000;
285
+
259
286
  /** Reap a previous generation's detached claude child that survived an
260
287
  * uncatchable daemon death (SIGKILL, crash: the "exit" event never fires
261
288
  * on those, so the hook that kills the group never ran) - resuming beside
@@ -307,6 +334,18 @@ export function buildServeRuntime(seam: {
307
334
  drainSignal?: AbortSignal;
308
335
  }) => Promise<TurnOutcome>;
309
336
  cleanup: (input: { threadId: string }) => CleanupOutcome;
337
+ /** add/remove a status reaction on a message (production: the Slack
338
+ * adapter's reactions.add/remove). Callers never let a rejection escape:
339
+ * a missing scope or an already_reacted must not fail a turn. */
340
+ react: (input: { threadId: string; messageId: string; emoji: string; op: "add" | "remove" }) => Promise<void>;
341
+ /** post one standalone text message into a thread by id (production:
342
+ * bot.thread(threadId).post) - the nudge path, which runs outside any
343
+ * turn and needs no streaming. */
344
+ postToThread: (input: { threadId: string; text: string }) => Promise<void>;
345
+ /** does this user belong to the home workspace? Reaction events carry no
346
+ * team-origin fields, so the note path verifies reactors through this
347
+ * (production: users.info, cached). null = unverifiable = fail closed. */
348
+ isHomeUser: (input: { userId: string }) => Promise<boolean | null>;
310
349
  /** builds a streamable proactive thread handle for marker recovery (startup
311
350
  * resumes and deferred-turn wakes both need one). runDaemon passes a lazy
312
351
  * closure over its bot-backed streamableThread; tests pass a fake. */
@@ -331,6 +370,19 @@ export function buildServeRuntime(seam: {
331
370
  // channels already diagnosed as unlinked this run (see handleTurn).
332
371
  const unlinkedLogged = new Set<string>();
333
372
 
373
+ /** Best-effort status reaction: reaction state is decoration, so every
374
+ * failure (missing reactions:write until the app is reinstalled,
375
+ * already_reacted, no_reaction on remove) is log-only and can never fail
376
+ * the turn it annotates. */
377
+ const setStatus = async (input: { threadId: string; messageId: string; emoji: string; op: "add" | "remove" }) => {
378
+ try {
379
+ await seam.react(input);
380
+ } catch (e) {
381
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
382
+ log("serve.reaction_error", { thread: input.threadId, emoji: input.emoji, op: input.op, err: detail });
383
+ }
384
+ };
385
+
334
386
  /** One relayed turn with the durable activeTurn marker around it: written
335
387
  * before the spawn, cleared when the turn returns, so a marker surviving
336
388
  * into the next daemon start identifies a turn a restart killed mid-run.
@@ -347,6 +399,10 @@ export function buildServeRuntime(seam: {
347
399
  }): Promise<TurnOutcome> => {
348
400
  let record: SlackThread = { ...input.record, activeTurn: input.marker };
349
401
  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" });
405
+ }
350
406
  let outcome: TurnOutcome | null = null;
351
407
  try {
352
408
  outcome = await seam.relay({
@@ -418,8 +474,10 @@ export function buildServeRuntime(seam: {
418
474
  * paired with its author id: a decision may be owed to an earlier
419
475
  * folded sender, and a sender whose whole message was the bot mention
420
476
  * contributes no prompt text, so text and author filter together
421
- * (review catches 2026-07-18). */
422
- relayed: { text: string; authorId: string }[];
477
+ * (review catches 2026-07-18). id is the Slack message ts; the LAST
478
+ * entry (the triggering relayable message) carries the status
479
+ * reactions. */
480
+ relayed: { text: string; authorId: string; id: string }[];
423
481
  isMention: boolean;
424
482
  }) => {
425
483
  const { thread, isMention } = input;
@@ -477,6 +535,10 @@ export function buildServeRuntime(seam: {
477
535
  .filter((m) => m.text !== "");
478
536
  let prompt = stripped.map((m) => m.text).join("\n\n");
479
537
  const requesterIds = uniq(stripped.map((m) => m.authorId));
538
+ // the triggering message (the last relayable one), even when its own text
539
+ // was just the bot mention: the status reactions belong on the message
540
+ // the user watched the bot pick up.
541
+ const messageId = input.relayed.at(-1)?.id;
480
542
  if (!prompt) return;
481
543
  // this whole handler runs inside the per-thread `serialized` chain (call
482
544
  // sites below), which startup resumes share too - so this load already
@@ -516,6 +578,29 @@ export function buildServeRuntime(seam: {
516
578
  prompt = `${deferred.prompt}\n\n${prompt}`;
517
579
  log("serve.deferred_folded", { thread: thread.id });
518
580
  }
581
+ // the user responded: the thread is no longer waiting on them. Clear the
582
+ // attention state and its question-mark reaction before the new turn
583
+ // runs, so a due nudge can never fire about an ask that just got its
584
+ // answer.
585
+ if (record.attention) {
586
+ const asked = record.attention;
587
+ record = omit(record, ["attention"]);
588
+ saveSlackThread(record);
589
+ if (asked.messageId) {
590
+ await setStatus({ threadId: thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
591
+ }
592
+ }
593
+ // reactions observed since the last turn ride into this prompt as
594
+ // context, then clear: the model sees them without any metered
595
+ // reaction-triggered turn.
596
+ if (record.pendingReactions && record.pendingReactions.length > 0) {
597
+ const notes = record.pendingReactions
598
+ .map((r) => `<@${r.userId}> reacted :${r.emoji}: in this thread.`)
599
+ .join("\n");
600
+ prompt = `${prompt}\n\nSlack reactions since your last turn:\n${notes}`;
601
+ record = omit(record, ["pendingReactions"]);
602
+ saveSlackThread(record);
603
+ }
519
604
  // subscriptions live in the memory state, so a daemon restart forgets
520
605
  // them; every mention re-subscribes to keep follow-up replies flowing.
521
606
  if (isMention) await thread.subscribe();
@@ -529,19 +614,24 @@ export function buildServeRuntime(seam: {
529
614
  prompt,
530
615
  requesterIds,
531
616
  sessionId: record.sessionId,
532
- marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0 },
617
+ marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds },
533
618
  link,
534
619
  });
535
- await settleTurn({ thread, outcome, startedAt });
620
+ await settleTurn({ thread, outcome, startedAt, messageId, requesterIds });
536
621
  };
537
622
 
538
623
  /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
539
- * log line, and the finish_thread garbage collection when the model called
540
- * it. Never throws into the caller - the daemon must keep serving. */
624
+ * log line, the status-reaction settle, the attention marking when the
625
+ * model asked the user, and the finish_thread garbage collection. Never
626
+ * throws into the caller - the daemon must keep serving. */
541
627
  const settleTurn = async (input: {
542
628
  thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
543
629
  outcome: TurnOutcome;
544
630
  startedAt: number;
631
+ /** the triggering message carrying the status reactions; absent = skip. */
632
+ messageId?: string;
633
+ /** the turn's asked users, persisted when the model flagged attention. */
634
+ requesterIds?: string[];
545
635
  }) => {
546
636
  const { thread, outcome, startedAt } = input;
547
637
  log(outcome.deferUntil !== null ? "serve.turn_deferred" : outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
@@ -549,6 +639,44 @@ export function buildServeRuntime(seam: {
549
639
  seconds: Math.round((Date.now() - startedAt) / 1000),
550
640
  ...(outcome.deferUntil === null ? {} : { resumeAt: outcome.deferUntil }),
551
641
  });
642
+ // settle the status reaction: failed beats attention beats done (a failed
643
+ // ask never reads as a clean question mark), then drop the hourglass.
644
+ // Three review-caught exceptions: a drain-presumed-killed turn (same
645
+ // predicate as runTurn's marker retention) keeps its hourglass - it will
646
+ // auto-resume next start, and a terminal x nothing ever removes would
647
+ // read a later successful resume as failed; a usage-limit DEFERRAL is
648
+ // the other auto-resume case and keeps its hourglass for the identical
649
+ // reason (cursor + vercel review catch on PR #43: the durable marker
650
+ // promises a resume, so the triggering message must not read as failed
651
+ // for the whole deferral); and a finished thread settles as done even
652
+ // when the model also flagged attention, because the record deletion
653
+ // below makes the question mark unremovable forever.
654
+ const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
655
+ const deferredForResume = outcome.deferUntil !== null;
656
+ if (input.messageId && !killedByDrain && !deferredForResume) {
657
+ 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" });
660
+ }
661
+ // the model asked the user for a decision: mark the thread waiting so the
662
+ // nudge sweep and the reaction-answer path can see it. Persisted even on
663
+ // a failed turn (the ask may have streamed before the failure; a spurious
664
+ // nudge beats a silently forgotten ask). Skipped on finish: the record is
665
+ // about to be deleted.
666
+ if (outcome.attention && !outcome.finish) {
667
+ const fresh = loadSlackThread(thread.id);
668
+ if (fresh) {
669
+ saveSlackThread({
670
+ ...fresh,
671
+ attention: {
672
+ requesterIds: input.requesterIds ?? [],
673
+ askedAt: new Date().toISOString(),
674
+ ...(input.messageId ? { messageId: input.messageId } : {}),
675
+ },
676
+ });
677
+ log("serve.attention_marked", { thread: thread.id });
678
+ }
679
+ }
552
680
  // the user declared the work finished: close the thread now that the
553
681
  // turn (and its claude subprocess) is over. Never throw into the caller -
554
682
  // the daemon must keep serving other threads.
@@ -645,11 +773,130 @@ export function buildServeRuntime(seam: {
645
773
  // the handler only the latest message and the rest ride context.skipped
646
774
  // (review catch, PR #18).
647
775
  const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
648
- const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId }));
776
+ const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
649
777
  if (relayed.length === 0) return; // outsider mentions never open a session
650
778
  await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
651
779
  };
652
780
 
781
+ /** A user reaction in a tracked thread. While the thread waits on an asked
782
+ * user, THAT user's reaction TO THE ASK is their answer: it relays as a
783
+ * normal turn for the model to interpret (thumbs up approves, thumbs down
784
+ * declines - the model decides). "To the ask" is enforced structurally
785
+ * (review catch: a mid-turn encouragement reaction queued behind the
786
+ * asking turn must not auto-approve the question it never saw): the
787
+ * reaction must have occurred AFTER askedAt (occurredAt = the Slack
788
+ * event_ts) and sit on a message at or after the ask's triggering message
789
+ * (Slack ids are timestamps, so >= compares post order). Anything that
790
+ * fails a gate degrades to the unmetered note path, which folds into the
791
+ * next turn's prompt. The answer path never pre-clears the attention
792
+ * state (review catch): handleTurn's own consume step clears it at the
793
+ * point the turn is committed, so a failed thread fetch, an unlinked
794
+ * channel, or a drain landing mid-await leaves the ask intact for the
795
+ * nudge and the next daemon generation.
796
+ * Reactor identity: the answer path only trusts ids that already passed
797
+ * the author guard as requesters (reaction events carry no team-origin
798
+ * fields, so isOutsideAuthor cannot run here); the note path fail-closed
799
+ * verifies the reactor against the home workspace via seam.isHomeUser
800
+ * and drops non-home or unverifiable reactors loudly (review catch: the
801
+ * outsiders-never-reach-claude invariant covers context lines too), and
802
+ * only structurally valid emoji names are ever folded. Removals and
803
+ * untracked threads are ignored, as are other bots; our own reactions
804
+ * never arrive (chat core drops isMe reaction events before routing). */
805
+ const onReaction = async (
806
+ input: { threadId: string; messageId: string; emoji: string; userId: string; isBot?: boolean | "unknown"; added: boolean; occurredAt: number | null },
807
+ streamable: (threadId: string) => Promise<{ thread: ServeThread }>,
808
+ ) => {
809
+ if (!input.added || input.isBot === true) return;
810
+ if (!loadSlackThread(input.threadId)) return; // untracked thread
811
+ // unlinked channels are contractually silent AND inert (vercel review
812
+ // catch on PR #43): a reaction stored to pendingReactions here would
813
+ // reach claude after a re-link, the one leak every other unlinked path
814
+ // already closes.
815
+ if (!linkForChannel(cfg, bareChannelId(input.threadId.split(":").slice(0, 2).join(":")))) {
816
+ log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
817
+ return;
818
+ }
819
+ await tracked(serialized(input.threadId, async () => {
820
+ const fresh = loadSlackThread(input.threadId);
821
+ if (!fresh) return; // finished while queued
822
+ const asked = fresh.attention;
823
+ const afterAsk = asked !== undefined && input.occurredAt !== null && input.occurredAt >= Date.parse(asked.askedAt);
824
+ const onAskMessage = asked !== undefined
825
+ && (asked.messageId === undefined || (Number.isFinite(Number(input.messageId)) && Number(input.messageId) >= Number(asked.messageId)));
826
+ // draining takes the durable note path even for an asked user: the
827
+ // answer turn could not run anyway, and a "please re-send" drop notice
828
+ // makes no sense for a reaction - the note survives the restart and
829
+ // folds into the next turn.
830
+ if (!draining && asked && asked.requesterIds.includes(input.userId) && afterAsk && onAskMessage) {
831
+ 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
+ });
842
+ return;
843
+ }
844
+ const home = await seam.isHomeUser({ userId: input.userId });
845
+ if (home !== true) {
846
+ log("serve.reaction_dropped", { thread: input.threadId, reason: home === false ? "outside-author" : "unverifiable-author" });
847
+ return;
848
+ }
849
+ const validEmoji = input.emoji.length > 0 && input.emoji.length <= 100
850
+ && [...input.emoji].every((ch) => (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "-" || ch === "+" || ch === "'");
851
+ if (!validEmoji) {
852
+ log("serve.reaction_dropped", { thread: input.threadId, reason: "invalid-emoji-name" });
853
+ return;
854
+ }
855
+ const notes = [...(fresh.pendingReactions ?? []), { userId: input.userId, emoji: input.emoji, at: new Date().toISOString() }].slice(-10);
856
+ saveSlackThread({ ...fresh, pendingReactions: notes });
857
+ log("serve.reaction_noted", { thread: input.threadId, emoji: input.emoji });
858
+ }));
859
+ };
860
+
861
+ /** One pass over every thread record: threads whose attention went
862
+ * unanswered past ATTENTION_NUDGE_MS get one mention-tagging reminder.
863
+ * Greedy and convergent: nudgedAt persists, so re-runs (and daemon
864
+ * restarts) never repeat a nudge; a failed post retries next sweep,
865
+ * logged each time. The per-thread work runs inside the serialized chain
866
+ * so a sweep can never resurrect an attention state a concurrent turn
867
+ * just cleared. */
868
+ const nudgeSweep = async (input?: { now?: number }) => {
869
+ if (draining) return;
870
+ const now = input?.now ?? Date.now();
871
+ for (const record of listSlackThreads()) {
872
+ const asked = record.attention;
873
+ if (!asked || asked.nudgedAt !== undefined || now - Date.parse(asked.askedAt) < ATTENTION_NUDGE_MS) continue;
874
+ // unlinked channels are contractually silent in Slack (review catch:
875
+ // `serve unlink` leaves thread records behind, and every other outbound
876
+ // path honors the contract). Skipped without a log line on purpose - a
877
+ // 60s sweep would otherwise repeat the same warning forever.
878
+ if (!linkForChannel(cfg, bareChannelId(record.threadId.split(":").slice(0, 2).join(":")))) continue;
879
+ void tracked(serialized(record.threadId, async () => {
880
+ if (draining) return;
881
+ const fresh = loadSlackThread(record.threadId);
882
+ const due = fresh?.attention;
883
+ if (!fresh || !due || due.nudgedAt !== undefined || now - Date.parse(due.askedAt) < ATTENTION_NUDGE_MS) return;
884
+ const tags = due.requesterIds.map((id) => `<@${id}>`).join(" ");
885
+ try {
886
+ await seam.postToThread({
887
+ threadId: record.threadId,
888
+ text: `${tags || "the requester"} still waiting on your input above.`,
889
+ });
890
+ saveSlackThread({ ...fresh, attention: { ...due, nudgedAt: new Date().toISOString() } });
891
+ log("serve.nudge_sent", { thread: record.threadId });
892
+ } catch (e) {
893
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
894
+ log("serve.nudge_error", { thread: record.threadId, err: detail });
895
+ }
896
+ }));
897
+ }
898
+ };
899
+
653
900
  /** Deferred-turn wakes: one process-local timer per thread, re-armed by a
654
901
  * later deferral. The durable marker (activeTurn.resumeAt) is the source
655
902
  * of truth - timers die with the process and startup re-arms or recovers
@@ -727,6 +974,11 @@ export function buildServeRuntime(seam: {
727
974
  // with the user never told the daemon gave up.
728
975
  await thread.post(decision.notice);
729
976
  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" });
981
+ }
730
982
  return;
731
983
  }
732
984
  if (turn.resumeAt !== undefined) {
@@ -759,6 +1011,14 @@ export function buildServeRuntime(seam: {
759
1011
  }
760
1012
  log("serve.resume_dropped_unknown", { thread: record.threadId });
761
1013
  await thread.post("the pool is still at its usage limit and its recovery time is now unknown - this held message is dropped; re-send it once the pool recovers.");
1014
+ // terminal exit on a linked channel: settle the trigger's status
1015
+ // or its hourglass reads "processing" forever (cubic review
1016
+ // catch on PR #43). The unlinked abandon above stays reactionless
1017
+ // 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" });
1021
+ }
762
1022
  saveSlackThread(omit(fresh, ["activeTurn"]));
763
1023
  return;
764
1024
  }
@@ -776,8 +1036,13 @@ export function buildServeRuntime(seam: {
776
1036
  // retries on every restart; unlinking it clears the marker.
777
1037
  await thread.post(decision.notice);
778
1038
  const startedAt = Date.now();
779
- const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
780
- await settleTurn({ thread, outcome, startedAt });
1039
+ // the KILLED turn's actual askers outrank the streamable handle's
1040
+ // newest-author derivation: a recovered need_attention turn must
1041
+ // nudge and answer-gate the users who were actually asked (vercel
1042
+ // review catch on PR #43); older markers without the field fall back.
1043
+ 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 });
781
1046
  });
782
1047
  } catch (e) {
783
1048
  const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
@@ -813,6 +1078,8 @@ export function buildServeRuntime(seam: {
813
1078
  },
814
1079
  relayable,
815
1080
  onMessage,
1081
+ onReaction,
1082
+ nudgeSweep,
816
1083
  /** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
817
1084
  * resumed turn shares the exact chain and marker machinery of an inbound
818
1085
  * one. */
@@ -825,7 +1092,19 @@ export function buildServeRuntime(seam: {
825
1092
  };
826
1093
  }
827
1094
 
1095
+ /** users.info slice the home-workspace reactor check reads. */
1096
+ const UsersInfoSchema = z.looseObject({
1097
+ ok: z.boolean(),
1098
+ user: z.looseObject({ team_id: z.string().optional() }).optional(),
1099
+ });
1100
+
1101
+ /** The raw Slack reaction event slice the daemon reads: event_ts is when the
1102
+ * reaction happened, the discriminator that keeps a pre-ask reaction from
1103
+ * answering a question the user never saw. */
1104
+ const ReactionRawSchema = z.looseObject({ event_ts: z.string().optional() });
1105
+
828
1106
  async function runDaemon(): Promise<number> {
1107
+ const homeUserCache = new Map<string, boolean>();
829
1108
  let cfg = loadSlackConfig();
830
1109
  if (!cfg) {
831
1110
  printSetupInstructions();
@@ -919,6 +1198,35 @@ async function runDaemon(): Promise<number> {
919
1198
  botUserId: () => slack.botUserId ?? null,
920
1199
  relay: relayThread,
921
1200
  cleanup: cleanupThread,
1201
+ // reactions.add/remove; the runtime wraps every call in its own log-only
1202
+ // guard, so adapter failures (missing reactions:write until the app is
1203
+ // reinstalled with the current manifest) stay invisible to turns.
1204
+ react: async (input) => {
1205
+ if (input.op === "add") await slack.addReaction(input.threadId, input.messageId, input.emoji);
1206
+ else await slack.removeReaction(input.threadId, input.messageId, input.emoji);
1207
+ },
1208
+ // one standalone line (the nudge); a lazy handle posts fine card-less.
1209
+ postToThread: async (input) => {
1210
+ await bot.thread(input.threadId).post(input.text);
1211
+ },
1212
+ // reaction events carry no team-origin fields, so the note path verifies
1213
+ // reactors via users.info (scope users:read, already in the manifest).
1214
+ // Definitive answers cache for the daemon's lifetime; errors return null
1215
+ // (fail closed at the caller) without caching so transient failures heal.
1216
+ isHomeUser: async (input) => {
1217
+ const cached = homeUserCache.get(input.userId);
1218
+ if (cached !== undefined) return cached;
1219
+ try {
1220
+ const resp = UsersInfoSchema.parse(await slack.webClient.users.info({ user: input.userId }));
1221
+ const teamId = resp.user?.team_id;
1222
+ if (!resp.ok || teamId === undefined) return null;
1223
+ const home = teamId === workspaceTeamId;
1224
+ homeUserCache.set(input.userId, home);
1225
+ return home;
1226
+ } catch {
1227
+ return null;
1228
+ }
1229
+ },
922
1230
  // lazy on purpose: streamableThread is declared just below and only ever
923
1231
  // invoked long after startup (recovery runs and deferred wakes).
924
1232
  streamable: (threadId) => streamableThread(threadId),
@@ -960,6 +1268,25 @@ async function runDaemon(): Promise<number> {
960
1268
 
961
1269
  bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
962
1270
  bot.onSubscribedMessage(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: false }));
1271
+ // user reactions: an asked user's reaction answers the pending question,
1272
+ // anything else folds into the next turn as context (runtime.onReaction).
1273
+ // chat core already drops the bot's own reactions before routing.
1274
+ bot.onReaction(async (event) => {
1275
+ const raw = ReactionRawSchema.safeParse(event.raw);
1276
+ const eventTs = raw.success && raw.data.event_ts !== undefined ? Number(raw.data.event_ts) * 1000 : Number.NaN;
1277
+ await runtime.onReaction(
1278
+ {
1279
+ threadId: event.threadId,
1280
+ messageId: event.messageId,
1281
+ emoji: event.emoji.name,
1282
+ userId: event.user.userId,
1283
+ isBot: event.user.isBot,
1284
+ added: event.added,
1285
+ occurredAt: Number.isFinite(eventTs) ? eventTs : null,
1286
+ },
1287
+ streamableThread,
1288
+ );
1289
+ });
963
1290
 
964
1291
  // drain instead of dying mid-answer: stop taking new turns, let in-flight
965
1292
  // ones finish (bounded - a hung claude turn must not block a restart
@@ -1028,6 +1355,11 @@ async function runDaemon(): Promise<number> {
1028
1355
  for (const record of records) await state.subscribe(record.threadId);
1029
1356
  log("serve.resubscribed", { threads: records.length });
1030
1357
 
1358
+ // overdue-attention sweep: one mention-tagging reminder per unanswered ask.
1359
+ // Never cleared: the sweep no-ops while draining, and shutdown exits the
1360
+ // process outright.
1361
+ setInterval(() => void runtime.nudgeSweep(), NUDGE_SWEEP_MS);
1362
+
1031
1363
  // threads whose activeTurn marker survived the previous daemon were either
1032
1364
  // killed mid-turn by a restart (live incident 2026-07-18: a redeploy
1033
1365
  // silently killed a ship turn 8 minutes in and the thread just went dark)
@@ -37,6 +37,11 @@ export const TurnOutcomeSchema = z.object({
37
37
  rateLimited: z.boolean(),
38
38
  /** the model called finish_thread this turn: garbage-collect after the turn. */
39
39
  finish: z.boolean(),
40
+ /** the model called need_attention this turn (it asked the user for a
41
+ * decision): the daemon marks the thread waiting - question-mark status
42
+ * reaction, a one-time nudge if the user stays quiet, and an asked user's
43
+ * reaction relays as their answer. */
44
+ attention: z.boolean(),
40
45
  /** relayThread posted a TERMINAL drop notice for this message ("dropped;
41
46
  * re-send it"): a drain must NOT presume a killed child and retain the
42
47
  * resume marker, or startup replays work the user was told to resend. */
@@ -280,19 +285,20 @@ function pushableStream(): {
280
285
  * 3x tighter to cover that never-hit path is worse than the residual risk. */
281
286
  export const SEGMENT_TEXT_MAX = 10_000;
282
287
 
283
- /** Full permission name of the finish_thread tool (mcp__<server>__<tool>):
284
- * it must be in allowedTools, because no one can answer a permission prompt
285
- * through Slack. */
288
+ /** Full permission names of the in-process tools (mcp__<server>__<tool>):
289
+ * they must be in allowedTools, because no one can answer a permission
290
+ * prompt through Slack. */
286
291
  const FINISH_THREAD_TOOL = "mcp__tokenmaxxing__finish_thread";
292
+ const NEED_ATTENTION_TOOL = "mcp__tokenmaxxing__need_attention";
287
293
 
288
- /** The per-turn in-process MCP server exposing finish_thread. The handler runs
289
- * in the daemon process, but it must NOT delete anything inline: the claude
290
- * subprocess is still mid-turn and segments are still streaming to Slack, so
291
- * it only records the request and the daemon closes the thread after the
292
- * turn ends (serve.ts). alwaysLoad keeps the tool visible in the prompt
293
- * instead of deferred behind tool search: it has to be in view at the exact
294
- * moment the user says the work is done. */
295
- function finishToolServer(onFinish: () => void) {
294
+ /** The per-turn in-process MCP server exposing finish_thread and
295
+ * need_attention. The handlers run in the daemon process, but they must NOT
296
+ * act inline: the claude subprocess is still mid-turn and segments are still
297
+ * streaming to Slack, so each only records the request and the daemon acts
298
+ * after the turn ends (serve.ts). alwaysLoad keeps the tools visible in the
299
+ * prompt instead of deferred behind tool search: they have to be in view at
300
+ * the exact moment the user says the work is done or the model hits a fork. */
301
+ function serveToolServer(input: { onFinish: () => void; onAttention: () => void }) {
296
302
  return createSdkMcpServer({
297
303
  name: "tokenmaxxing",
298
304
  alwaysLoad: true,
@@ -302,10 +308,19 @@ function finishToolServer(onFinish: () => void) {
302
308
  "Close out this Slack thread when the user clearly states the work is finished (shipped, done, clean this up) and wants the thread closed. After this turn ends the daemon drops the thread's session record, unsubscribes, and posts a confirmation; the repo checkout and everything in it are untouched. Do not call this for a merely answered question - only for an explicit wrap-up.",
303
309
  {},
304
310
  async () => {
305
- onFinish();
311
+ input.onFinish();
306
312
  return { content: [{ type: "text", text: "close-out scheduled - it runs right after this turn ends and posts its own confirmation; just acknowledge the wrap-up now" }] };
307
313
  },
308
314
  ),
315
+ tool(
316
+ "need_attention",
317
+ "Flag this Slack thread as waiting on the requesting user. Call it in the same turn in which you ask them for a decision, approval, or missing information (the ask-the-user skill), then ask in your reply text with their mention token and end the turn. After the turn ends the daemon marks the thread attention-needed (a question-mark reaction on the triggering message) and tags the user once more if they stay quiet; a reaction from them, such as a thumbs up, relays back to you as their answer. Do not call this for rhetorical questions or ordinary replies.",
318
+ {},
319
+ async () => {
320
+ input.onAttention();
321
+ return { content: [{ type: "text", text: "attention flagged - after this turn ends the daemon marks the thread as waiting on the user and nudges them if they stay quiet; ask your question in the reply text with their mention token, then end the turn" }] };
322
+ },
323
+ ),
309
324
  ],
310
325
  });
311
326
  }
@@ -446,7 +461,7 @@ export async function relayThread(input: {
446
461
  * out a depleted-pool countdown. */
447
462
  drainSignal?: AbortSignal;
448
463
  }): Promise<TurnOutcome> {
449
- const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, announcedDrop: false, resultReceived: false, deferUntil: null };
464
+ const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null };
450
465
  let segment: ReturnType<typeof pushableStream> | null = null;
451
466
  // acc mirrors the segment's pushed text (bounded by SEGMENT_TEXT_MAX plus a
452
467
  // small overshoot): fence parity must be computed over the ACCUMULATED text,
@@ -750,9 +765,9 @@ export async function relayThread(input: {
750
765
  outcome.failed = false;
751
766
  outcome.rateLimited = false;
752
767
  outcome.resultReceived = false;
753
- // outcome.finish stays sticky across retries: the tool call already
754
- // happened in this session, and a limit right after it must not unfinish
755
- // the thread.
768
+ // outcome.finish and outcome.attention stay sticky across retries: the
769
+ // tool calls already happened in this session, and a limit right after
770
+ // one must not unfinish the thread or drop the pending ask.
756
771
  // the identity this spawn meters: a limit observation is attributed to it,
757
772
  // never to whatever account a concurrent thread swaps live mid-turn. Read
758
773
  // inside the try: a malformed claude.json must fail the TURN, not the
@@ -797,10 +812,18 @@ export async function relayThread(input: {
797
812
  }
798
813
  return child;
799
814
  },
800
- // the user saying "we're done" closes the thread: the model flags it
801
- // via this in-process tool, the daemon drops the record post-turn.
802
- mcpServers: { tokenmaxxing: finishToolServer(() => { outcome.finish = true; }) },
803
- allowedTools: [FINISH_THREAD_TOOL],
815
+ // the user saying "we're done" closes the thread, and the model
816
+ // asking the user for a decision marks it waiting: both flagged via
817
+ // in-process tools, both acted on by the daemon post-turn. Like
818
+ // finish, attention stays sticky across retries: the tool call
819
+ // already happened in this session.
820
+ mcpServers: {
821
+ tokenmaxxing: serveToolServer({
822
+ onFinish: () => { outcome.finish = true; },
823
+ onAttention: () => { outcome.attention = true; },
824
+ }),
825
+ },
826
+ allowedTools: [FINISH_THREAD_TOOL, NEED_ATTENTION_TOOL],
804
827
  // serve skills (ask-the-user, serve-session); discovered skills are
805
828
  // enabled by default, so no `skills` option is needed.
806
829
  plugins: [{ type: "local", path: SERVE_PLUGIN_DIR }],
@@ -120,9 +120,48 @@ const ActiveTurnSchema = z.object({
120
120
  * runs the user's real claude sessions). Absent = identity unverifiable =
121
121
  * never signal. */
122
122
  pidStartedAt: z.string().optional(),
123
+ /** Slack id (message ts) of the turn's triggering message: it carries the
124
+ * status reactions (hourglass while running, check/x/question at settle),
125
+ * so a recovered turn can finish the lifecycle it started. Absent for
126
+ * turns whose trigger had no relayable message id (e.g. a resumed record
127
+ * from before this field existed) - status reactions just skip then. */
128
+ messageId: z.string().optional(),
129
+ /** the triggering turn's requester ids: a recovered turn that flags
130
+ * attention must persist the KILLED turn's actual askers, not whoever
131
+ * authored the thread's newest message at recovery time (vercel review
132
+ * catch on PR #43 - the wrong user would get nudged and answer-gated).
133
+ * Absent on older records: recovery falls back to the streamable
134
+ * handle's newest-author derivation. */
135
+ requesterIds: z.array(z.string()).optional(),
123
136
  });
124
137
  export type ActiveTurn = z.infer<typeof ActiveTurnSchema>;
125
138
 
139
+ /** The thread is waiting on the user: set after a turn in which the model
140
+ * called need_attention (the ask-the-user flow), cleared when any relayable
141
+ * message or an asked user's reaction arrives. One nudge per ask: nudgedAt
142
+ * marks it spent, so the sweep can never mention-spam. */
143
+ const ThreadAttentionSchema = z.object({
144
+ /** the asked users: only their reactions count as an answer. */
145
+ requesterIds: z.array(z.string()),
146
+ askedAt: z.string(),
147
+ nudgedAt: z.string().optional(),
148
+ /** the asking turn's triggering message: carries the question-mark status
149
+ * reaction, removed once the user responds. */
150
+ messageId: z.string().optional(),
151
+ });
152
+ export type ThreadAttention = z.infer<typeof ThreadAttentionSchema>;
153
+
154
+ /** A user reaction observed while no answer was owed: folded into the next
155
+ * turn's prompt as context (the model sees it and can respond), then
156
+ * cleared. Bounded to the newest few so an emoji burst cannot grow the
157
+ * record without bound. */
158
+ const PendingReactionSchema = z.object({
159
+ userId: z.string(),
160
+ emoji: z.string(),
161
+ at: z.string(),
162
+ });
163
+ export type PendingReaction = z.infer<typeof PendingReactionSchema>;
164
+
126
165
  export const SlackThreadSchema = z.object({
127
166
  /** chat-sdk thread id, e.g. "slack:C0123:1721300000.123456". */
128
167
  threadId: z.string(),
@@ -136,6 +175,10 @@ export const SlackThreadSchema = z.object({
136
175
  createdAt: z.string(),
137
176
  /** present only while a turn is running (or was killed mid-run). */
138
177
  activeTurn: ActiveTurnSchema.optional(),
178
+ /** present only while the thread waits on the user's answer. */
179
+ attention: ThreadAttentionSchema.optional(),
180
+ /** reactions observed since the last turn, folded into the next prompt. */
181
+ pendingReactions: z.array(PendingReactionSchema).optional(),
139
182
  });
140
183
  export type SlackThread = z.infer<typeof SlackThreadSchema>;
141
184
 
@@ -17,7 +17,12 @@ reaches anyone. A plain reply does not notify the user; a mention does.
17
17
  notifies them.
18
18
  2. State the fork in one short paragraph: what you were doing, the options,
19
19
  which one you recommend and why. One question at a time.
20
- 3. End the turn after asking. Do not pick a real fork's option unilaterally,
20
+ 3. Call the in-process `need_attention` tool (mcp__tokenmaxxing__need_attention)
21
+ in this same turn. The daemon then marks the thread as waiting on the user
22
+ (a question-mark reaction on their message), tags them once more if they
23
+ stay quiet, and relays a reaction from them back to you as their answer -
24
+ so phrase yes/no forks so a thumbs up picks your recommended option.
25
+ 4. End the turn after asking. Do not pick a real fork's option unilaterally,
21
26
  do not busy-wait, and do not keep working past the fork: the user's thread
22
27
  reply arrives as your next turn and continues this same session.
23
28