tokenmaxxing 1.1.1 → 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/DESIGN.md CHANGED
@@ -103,7 +103,8 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
103
103
  - **Usage-limit auto-resume** (2026-07-21, superseding the drop-beats-false-promise rule for the known-wake case): a turn that ends at a usage limit with a KNOWN recovery time defers instead of dropping. relayThread reports `deferUntil`, the thread's durable activeTurn marker keeps a `resumeAt`, and a per-thread daemon timer (cancelled on drain, re-armed from the record at startup: future wakes schedule, past wakes recover immediately) fires the same interrupted-turn recovery path - serialized chain, fresh-reload supersede check, pool-recovery notice, `resumeDecision` capping total resumes. The defer triggers: known recovery past the parking deadline or recovery budget, retries exhausted while a post-burn pool probe reports a wake, and an unclassifiable child failure (no limit phrase in the error) converted by a pool probe reporting exhaustion - the pool state is the evidence the error text did not carry. Only an unknown recovery time still posts the honest re-send drop.
104
104
  - **Slack-native output hygiene**: relayed turns run a small standalone `systemPrompt` telling the model replies render as Slack markdown, never HTML (a live turn once answered with a literal `<br>`; the SDK's default system prompt is minimal since 0.1.0, so the string replaces nothing).
105
105
  - **Slash commands** (2026-07-18): a mention-stripped thread message that starts with `/` runs as a claude slash command - the SDK delivers a string prompt as one stream-json user message and the CLI routes a leading-slash prompt through its command table, on fresh and resumed sessions alike (live-verified with free `/usage` and `/context` reads). Local command output (`num_turns` 0) arrives as a non-streamed assistant message plus `result.result`, which relayThread's no-text fallback posts; `slackstream` also maps the documented `system/local_command_output` wire subtype in case a claude update flips the emitter. `/goal` (built-in since 2.1.139) works headless and its active goal is restored on resume, so it survives the per-message resume pattern. Slack gotcha: the composer eats ANY message whose first character is `/` client-side (channels and thread replies; registered app commands cannot even dispatch from threads), so the supported forms are `@bot /usage` (mention-first) and ` /usage` (leading space, Slack's own documented workaround) - both normalize to a position-0 command via `stripLeadingMention`.
106
- - **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output) that Slack groups into ONE collapsible plan block per turn (the serve edge wraps every posted segment in a `StreamingPlan` with `groupTasks: "plan"`, Slack's `task_display_mode: "plan"`; user ask 2026-07-20 "squash them into one dropdown", superseding the 2026-07-18 separate-messages shape), and reply text as native markdown with rendered code fences - so one turn streams as a single Slack message: the reply plus one dropdown of its activity. A later main text block (typically post-tool prose) opens on a `\n\n` paragraph break (`textStreamed`), restoring the separation the per-tool message split used to provide; whitespace-only deltas do not arm it. Notices still post as their own messages, and a rejected append (Slack's message cap, a finalized idle stream) carries its undelivered chunks into a follow-on message via the dead-segment salvage.
106
+ - **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output) that Slack groups into ONE collapsible plan block per turn (the serve edge wraps every posted segment in a `StreamingPlan` with `groupTasks: "plan"`, Slack's `task_display_mode: "plan"`; user ask 2026-07-20 "squash them into one dropdown", superseding the 2026-07-18 separate-messages shape), and reply text as native markdown with rendered code fences - so one turn streams as a single Slack message: the reply plus one dropdown of its activity. Reply text also splits at `SEGMENT_TEXT_MAX` (10,000 chars): Slack rejects an over-long message with `msg_too_long` (the streamed message accumulates server-side toward the documented 12,000-char `markdown_text` envelope) and nothing in the chat stack bounds or splits text, so one over-cap append used to reject the whole post and lose the reply (live incident 2026-07-20); a break forced inside a code fence closes and reopens the fence, fence parity is tracked over the segment's accumulated text (SDK deltas do not respect markdown token boundaries), and a cut never slices through a backtick run. A later main text block (typically post-tool prose) opens on a `\n\n` paragraph break (`textStreamed`), restoring the separation the per-tool message split used to provide; whitespace-only deltas do not arm it. Notices still post as their own messages, and a rejected append (Slack's message cap, a finalized idle stream) carries its undelivered chunks into a follow-on message via the dead-segment salvage.
107
+ - **One Slack app per daemon** (live incident 2026-07-20): Slack Socket Mode delivers each event envelope to exactly one of an app's open connections, so two serve daemons sharing one Slack app steal each other's events - mentions usually survive (Slack emits `app_mention` plus `message.channels` for one mention and the Chat SDK dedupes them per-process), but a non-mention thread follow-up rides a single envelope and silently dies when the wrong daemon receives it. No code can route Slack's load-balancing (the hello frame's `num_connections` is discarded by `@slack/socket-mode` before any event fires), so the constraint is documented (docs limitations page) and diagnosed: `serve.unlinked_channel` logs once per channel per run with the shared-app explanation. Every host needs its own Slack app with its own tokens.
107
108
  - **Todo checklist card**: TodoWrite is bookkeeping, not a real tool run, so it never opens a generic card. Instead each stream gets one stable-id "Todos" card (id `todos`, subagents `todos-<parent_tool_use_id>`) that updates in place on every TodoWrite: `✅ content` for completed, `🔄 activeForm` for the in-progress item (live narration), `⬜ content` for pending (the Chat SDK Plan object's own iconography); card status goes complete only when every item is completed. The "Todos have been modified successfully" tool_result is suppressed, but a FAILED TodoWrite flips the card to error (the optimistic checklist must not claim a state that never took effect). Card ids do not carry across messages, so a TodoWrite after a dead-segment recovery starts a fresh card in the follow-on message: accepted, the latest state is always in the newest message. claude >= 2.1.142 defaults to the structured Task tools and never emits TodoWrite, so relayThread sets `CLAUDE_CODE_ENABLE_TASKS: "0"` in the spawn env (the documented opt-out; without it the checklist card is inert).
108
109
  - **Terminal echo** (2026-07-18): the daemon registers a `log()` echo (`setLogEcho` in log.ts, off by default so hooks and the statusline keep their stdout protocols clean), so every event while it runs - serve.* plus the in-process swap/decision events from `ensureBestAccount()`/`stopHookCheck` - also prints one colored line in the foreground terminal (`formatLogLine`: dim HH:MM:SS, event painted red/yellow/cyan by structural endsWith severity, redacted fields). `serve.turn_done`/`serve.turn_failed` (with seconds) close every relayed turn, so a foreground `xx serve` is observable without tailing `tokenmaxxing.log`.
109
110
  - **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens a session (worktree-per-thread at the time), replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, stream mapping, fail-fast paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.1.1",
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
@@ -18,7 +18,7 @@
18
18
  import { existsSync, realpathSync } from "node:fs";
19
19
  import { delay, omit, uniq } from "es-toolkit";
20
20
  import { z } from "zod";
21
- import { Chat, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
21
+ import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
22
22
  import { createSlackAdapter } from "@chat-adapter/slack";
23
23
  import { createMemoryState } from "@chat-adapter/state-memory";
24
24
  import {
@@ -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. */
@@ -328,6 +367,21 @@ export function buildServeRuntime(seam: {
328
367
  // holds the restart hostage.
329
368
  const drainAbort = new AbortController();
330
369
  let draining = false;
370
+ // channels already diagnosed as unlinked this run (see handleTurn).
371
+ const unlinkedLogged = new Set<string>();
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
+ };
331
385
 
332
386
  /** One relayed turn with the durable activeTurn marker around it: written
333
387
  * before the spawn, cleared when the turn returns, so a marker surviving
@@ -345,6 +399,10 @@ export function buildServeRuntime(seam: {
345
399
  }): Promise<TurnOutcome> => {
346
400
  let record: SlackThread = { ...input.record, activeTurn: input.marker };
347
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
+ }
348
406
  let outcome: TurnOutcome | null = null;
349
407
  try {
350
408
  outcome = await seam.relay({
@@ -416,8 +474,10 @@ export function buildServeRuntime(seam: {
416
474
  * paired with its author id: a decision may be owed to an earlier
417
475
  * folded sender, and a sender whose whole message was the bot mention
418
476
  * contributes no prompt text, so text and author filter together
419
- * (review catches 2026-07-18). */
420
- 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 }[];
421
481
  isMention: boolean;
422
482
  }) => {
423
483
  const { thread, isMention } = input;
@@ -426,8 +486,17 @@ export function buildServeRuntime(seam: {
426
486
  // checked BEFORE the draining branch: unlinked channels are
427
487
  // contractually log-only silent, and a drain-window drop notice posted
428
488
  // into one would tell a user to resend a message that will never be
429
- // served (closing-review catch).
430
- log("serve.unlinked_channel", { channel: thread.channelId });
489
+ // served (closing-review catch). Logged once per channel per daemon run:
490
+ // with several daemons sharing one Slack app this fires on every
491
+ // load-balanced envelope for a sibling's channel (live incident
492
+ // 2026-07-20), and the diagnosis needs one line, not a stream.
493
+ if (!unlinkedLogged.has(thread.channelId)) {
494
+ unlinkedLogged.add(thread.channelId);
495
+ log("serve.unlinked_channel", {
496
+ channel: thread.channelId,
497
+ note: "not linked on this host; if another tokenmaxxing serve shares this Slack app, Slack delivers each socket event to only ONE of them and thread replies get lost - give every daemon its own Slack app",
498
+ });
499
+ }
431
500
  return; // not a linked channel - stay silent in Slack
432
501
  }
433
502
  if (draining) {
@@ -466,6 +535,10 @@ export function buildServeRuntime(seam: {
466
535
  .filter((m) => m.text !== "");
467
536
  let prompt = stripped.map((m) => m.text).join("\n\n");
468
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;
469
542
  if (!prompt) return;
470
543
  // this whole handler runs inside the per-thread `serialized` chain (call
471
544
  // sites below), which startup resumes share too - so this load already
@@ -505,6 +578,29 @@ export function buildServeRuntime(seam: {
505
578
  prompt = `${deferred.prompt}\n\n${prompt}`;
506
579
  log("serve.deferred_folded", { thread: thread.id });
507
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
+ }
508
604
  // subscriptions live in the memory state, so a daemon restart forgets
509
605
  // them; every mention re-subscribes to keep follow-up replies flowing.
510
606
  if (isMention) await thread.subscribe();
@@ -518,19 +614,24 @@ export function buildServeRuntime(seam: {
518
614
  prompt,
519
615
  requesterIds,
520
616
  sessionId: record.sessionId,
521
- marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0 },
617
+ marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds },
522
618
  link,
523
619
  });
524
- await settleTurn({ thread, outcome, startedAt });
620
+ await settleTurn({ thread, outcome, startedAt, messageId, requesterIds });
525
621
  };
526
622
 
527
623
  /** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
528
- * log line, and the finish_thread garbage collection when the model called
529
- * 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. */
530
627
  const settleTurn = async (input: {
531
628
  thread: { id: string; post: (m: string | AsyncIterable<string | StreamChunk>) => Promise<unknown>; unsubscribe: () => Promise<void> };
532
629
  outcome: TurnOutcome;
533
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[];
534
635
  }) => {
535
636
  const { thread, outcome, startedAt } = input;
536
637
  log(outcome.deferUntil !== null ? "serve.turn_deferred" : outcome.failed ? "serve.turn_failed" : "serve.turn_done", {
@@ -538,6 +639,44 @@ export function buildServeRuntime(seam: {
538
639
  seconds: Math.round((Date.now() - startedAt) / 1000),
539
640
  ...(outcome.deferUntil === null ? {} : { resumeAt: outcome.deferUntil }),
540
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
+ }
541
680
  // the user declared the work finished: close the thread now that the
542
681
  // turn (and its claude subprocess) is over. Never throw into the caller -
543
682
  // the daemon must keep serving other threads.
@@ -634,11 +773,130 @@ export function buildServeRuntime(seam: {
634
773
  // the handler only the latest message and the rest ride context.skipped
635
774
  // (review catch, PR #18).
636
775
  const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
637
- 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 }));
638
777
  if (relayed.length === 0) return; // outsider mentions never open a session
639
778
  await tracked(serialized(input.thread.id, () => handleTurn({ thread: input.thread, relayed, isMention: input.isMention })));
640
779
  };
641
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
+
642
900
  /** Deferred-turn wakes: one process-local timer per thread, re-armed by a
643
901
  * later deferral. The durable marker (activeTurn.resumeAt) is the source
644
902
  * of truth - timers die with the process and startup re-arms or recovers
@@ -716,6 +974,11 @@ export function buildServeRuntime(seam: {
716
974
  // with the user never told the daemon gave up.
717
975
  await thread.post(decision.notice);
718
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
+ }
719
982
  return;
720
983
  }
721
984
  if (turn.resumeAt !== undefined) {
@@ -748,6 +1011,14 @@ export function buildServeRuntime(seam: {
748
1011
  }
749
1012
  log("serve.resume_dropped_unknown", { thread: record.threadId });
750
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
+ }
751
1022
  saveSlackThread(omit(fresh, ["activeTurn"]));
752
1023
  return;
753
1024
  }
@@ -765,8 +1036,13 @@ export function buildServeRuntime(seam: {
765
1036
  // retries on every restart; unlinking it clears the marker.
766
1037
  await thread.post(decision.notice);
767
1038
  const startedAt = Date.now();
768
- const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
769
- 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 });
770
1046
  });
771
1047
  } catch (e) {
772
1048
  const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
@@ -802,6 +1078,8 @@ export function buildServeRuntime(seam: {
802
1078
  },
803
1079
  relayable,
804
1080
  onMessage,
1081
+ onReaction,
1082
+ nudgeSweep,
805
1083
  /** the pieces runDaemon's startup interrupted-turn recovery reuses, so a
806
1084
  * resumed turn shares the exact chain and marker machinery of an inbound
807
1085
  * one. */
@@ -814,7 +1092,19 @@ export function buildServeRuntime(seam: {
814
1092
  };
815
1093
  }
816
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
+
817
1106
  async function runDaemon(): Promise<number> {
1107
+ const homeUserCache = new Map<string, boolean>();
818
1108
  let cfg = loadSlackConfig();
819
1109
  if (!cfg) {
820
1110
  printSetupInstructions();
@@ -865,6 +1155,11 @@ async function runDaemon(): Promise<number> {
865
1155
  // @slack/web-api's fiveRetriesInFiveMinutes literal (dep not declared,
866
1156
  // so the values are inlined).
867
1157
  webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
1158
+ // the adapter's default logger is info-level and prints a line per
1159
+ // redelivered socket envelope ("Processing socket mode retry") - steady
1160
+ // noise during restart catch-up. warn matches the Chat logger below;
1161
+ // real degradations (streaming fallback etc.) are warn-level and survive.
1162
+ logger: new ConsoleLogger("warn", "chat-sdk").child("slack"),
868
1163
  });
869
1164
  // held directly (not only via Chat) so startup can re-subscribe recorded
870
1165
  // threads: subscriptions live in this in-memory state and die with the
@@ -903,6 +1198,35 @@ async function runDaemon(): Promise<number> {
903
1198
  botUserId: () => slack.botUserId ?? null,
904
1199
  relay: relayThread,
905
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
+ },
906
1230
  // lazy on purpose: streamableThread is declared just below and only ever
907
1231
  // invoked long after startup (recovery runs and deferred wakes).
908
1232
  streamable: (threadId) => streamableThread(threadId),
@@ -944,6 +1268,25 @@ async function runDaemon(): Promise<number> {
944
1268
 
945
1269
  bot.onNewMention(async (thread, message, context) => runtime.onMessage({ thread, message, skipped: context?.skipped ?? [], isMention: true }));
946
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
+ });
947
1290
 
948
1291
  // drain instead of dying mid-answer: stop taking new turns, let in-flight
949
1292
  // ones finish (bounded - a hung claude turn must not block a restart
@@ -1012,6 +1355,11 @@ async function runDaemon(): Promise<number> {
1012
1355
  for (const record of records) await state.subscribe(record.threadId);
1013
1356
  log("serve.resubscribed", { threads: records.length });
1014
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
+
1015
1363
  // threads whose activeTurn marker survived the previous daemon were either
1016
1364
  // killed mid-turn by a restart (live incident 2026-07-18: a redeploy
1017
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. */
@@ -263,19 +268,37 @@ function pushableStream(): {
263
268
  };
264
269
  }
265
270
 
266
- /** Full permission name of the finish_thread tool (mcp__<server>__<tool>):
267
- * it must be in allowedTools, because no one can answer a permission prompt
268
- * through Slack. */
271
+ /** Slack rejects an over-long message with msg_too_long, and NOTHING in chat
272
+ * 4.34.0 or the Slack adapter bounds, truncates, or splits reply text
273
+ * (verified in-source 2026-07-20): a natively streamed message accumulates
274
+ * server-side toward the 12,000-char markdown_text envelope (docs.slack.dev
275
+ * documents that limit on chat.postMessage/update and all three streaming
276
+ * methods), the post-and-edit fallback re-sends the FULL accumulated text as
277
+ * markdown_text on every edit, and once anything rendered natively a failed
278
+ * append REJECTS the whole thread.post - the reply dies (live incident
279
+ * 2026-07-20, three failed turns). relayThread therefore splits reply text
280
+ * across Slack messages BEFORE the cap; the 2,000-char margin absorbs the
281
+ * renderer's markdown normalization and mention-linkification expansion.
282
+ * Tradeoff (accepted): the adapter-internal plain-text fallback edits via
283
+ * chat.update `text` (hard 4,000-char cap), but it only engages when the
284
+ * workspace refused native streaming outright - splitting every normal reply
285
+ * 3x tighter to cover that never-hit path is worse than the residual risk. */
286
+ export const SEGMENT_TEXT_MAX = 10_000;
287
+
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. */
269
291
  const FINISH_THREAD_TOOL = "mcp__tokenmaxxing__finish_thread";
292
+ const NEED_ATTENTION_TOOL = "mcp__tokenmaxxing__need_attention";
270
293
 
271
- /** The per-turn in-process MCP server exposing finish_thread. The handler runs
272
- * in the daemon process, but it must NOT delete anything inline: the claude
273
- * subprocess is still mid-turn and segments are still streaming to Slack, so
274
- * it only records the request and the daemon closes the thread after the
275
- * turn ends (serve.ts). alwaysLoad keeps the tool visible in the prompt
276
- * instead of deferred behind tool search: it has to be in view at the exact
277
- * moment the user says the work is done. */
278
- 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 }) {
279
302
  return createSdkMcpServer({
280
303
  name: "tokenmaxxing",
281
304
  alwaysLoad: true,
@@ -285,10 +308,19 @@ function finishToolServer(onFinish: () => void) {
285
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.",
286
309
  {},
287
310
  async () => {
288
- onFinish();
311
+ input.onFinish();
289
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" }] };
290
313
  },
291
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
+ ),
292
324
  ],
293
325
  });
294
326
  }
@@ -429,9 +461,14 @@ export async function relayThread(input: {
429
461
  * out a depleted-pool countdown. */
430
462
  drainSignal?: AbortSignal;
431
463
  }): Promise<TurnOutcome> {
432
- 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 };
433
465
  let segment: ReturnType<typeof pushableStream> | null = null;
434
- let segmentMeta: { text: boolean } | null = null;
466
+ // acc mirrors the segment's pushed text (bounded by SEGMENT_TEXT_MAX plus a
467
+ // small overshoot): fence parity must be computed over the ACCUMULATED text,
468
+ // never per chunk - SDK deltas do not respect markdown token boundaries, so
469
+ // a ``` split across two deltas would be invisible to per-chunk counting
470
+ // (pullfrog review catch, PR #42).
471
+ let segmentMeta: { text: boolean; acc: string; reopenFence: boolean } | null = null;
435
472
  let lastPost: Promise<unknown> = Promise.resolve();
436
473
  // Reply TEXT that died with a rejected segment and was neither salvaged into
437
474
  // a follow-on message nor re-delivered by a later text-bearing segment: the
@@ -459,7 +496,14 @@ export async function relayThread(input: {
459
496
  const openSegment = () => {
460
497
  const seg = pushableStream();
461
498
  segment = seg;
462
- const meta = { text: false };
499
+ // reopenFence: the delivered prefix of a REJECTED predecessor left a code
500
+ // fence open, so the first TEXT entering this salvage segment must be
501
+ // preceded by a reopen or it renders outside the code block. Pending
502
+ // rather than pushed eagerly (pullfrog catches, PR #42): a card-only
503
+ // salvage would otherwise either skip the reopen (later text joining the
504
+ // segment renders unfenced) or dangle an empty open fence at message end
505
+ // when no text ever follows. Materialized by BOTH text entry points.
506
+ const meta = { text: false, acc: "", reopenFence: false };
463
507
  segmentMeta = meta;
464
508
  lastPost = input.post(seg.iterable).then(
465
509
  () => {
@@ -541,6 +585,22 @@ export async function relayThread(input: {
541
585
  rest = rest.slice(nl + 1);
542
586
  }
543
587
  }
588
+ // A fence OPENED in the delivered prefix leaves later code fenceless
589
+ // in the fresh salvage message (pullfrog catches, PR #42): arm the
590
+ // segment's pending reopen, materialized right before the FIRST text
591
+ // that enters it - whether a salvaged remainder piece here or a later
592
+ // streamed push joining the segment (a card-only salvage must not
593
+ // skip the reopen, and a text-less segment must not dangle one).
594
+ // Fold this dying segment's OWN still-armed reopen into the parity:
595
+ // an armed-but-never-materialized reopenFence means the segment
596
+ // logically BEGAN inside an open fence (a card-only salvage that
597
+ // inherited one and died before any text materialized it), so that
598
+ // open state must propagate to the next salvage or its later text
599
+ // renders unfenced (vercel + cubic chained-salvage catch, PR #42).
600
+ // Once materialized, reopenFence is false and the reopen chunk is in
601
+ // fullRaw, so the XOR is a no-op; a normal segment's flag is false.
602
+ const deliveredFenceOpen =
603
+ ((fullRaw.slice(0, deliveredLen).split("```").length - 1) % 2 === 1) !== meta.reopenFence;
544
604
  if (lost.length > 0 && salvagesLeft > 0) {
545
605
  salvagesLeft -= 1;
546
606
  log("serve.post_salvage", { chunks: lost.length, left: salvagesLeft });
@@ -549,6 +609,7 @@ export async function relayThread(input: {
549
609
  // of any push still awaiting lastPost; the salvage segment's own
550
610
  // settle then decides whether its text counts as delivered.
551
611
  const next = openSegment();
612
+ next.meta.reopenFence = deliveredFenceOpen;
552
613
  for (const c of lost) next.pushInto(c);
553
614
  } else if (textRemainder !== "") {
554
615
  textLost = true;
@@ -561,11 +622,23 @@ export async function relayThread(input: {
561
622
  // at its original push, and postedText is attempt-scoped - a salvage
562
623
  // landing after a retry reset would otherwise re-arm it and suppress
563
624
  // the retry's `!postedText && result` fallback, silently dropping a
564
- // result-only answer (adversarial-review catch on PR #45).
565
- if (!(chunk instanceof Object)) meta.text = true;
625
+ // result-only answer (adversarial-review catch on PR #45). acc still
626
+ // accumulates: it mirrors the segment's FULL text on every entry path,
627
+ // so pushText's room accounting and fence parity see salvaged text too
628
+ // (a salvage segment that continued via pushText could otherwise grow
629
+ // past the msg_too_long cap).
630
+ if (!(chunk instanceof Object)) {
631
+ if (meta.reopenFence) {
632
+ meta.reopenFence = false;
633
+ meta.acc += "```\n";
634
+ seg.push("```\n");
635
+ }
636
+ meta.text = true;
637
+ meta.acc += chunk;
638
+ }
566
639
  seg.push(chunk);
567
640
  };
568
- return { seg, pushInto };
641
+ return { seg, meta, pushInto };
569
642
  };
570
643
  const push = async (chunk: SegmentChunk) => {
571
644
  if (segment === null) {
@@ -576,15 +649,66 @@ export async function relayThread(input: {
576
649
  }
577
650
  const target = segment ?? openSegment().seg;
578
651
  if (!(chunk instanceof Object)) {
652
+ // materialize a salvage segment's pending fence reopen (see
653
+ // openSegment) before the first text from this entry point too.
654
+ if (segmentMeta!.reopenFence) {
655
+ segmentMeta!.reopenFence = false;
656
+ segmentMeta!.acc += "```\n";
657
+ target.push("```\n");
658
+ }
579
659
  postedText = true;
580
660
  segmentMeta!.text = true;
661
+ segmentMeta!.acc += chunk;
581
662
  }
582
663
  target.push(chunk);
583
664
  };
665
+ // parity by occurrence count over the segment's accumulated text: an odd
666
+ // number of ``` markers means the segment currently sits inside a fence.
667
+ const fenceOpen = () => segmentMeta !== null && (segmentMeta.acc.split("```").length - 1) % 2 === 1;
584
668
  const breakSegment = () => {
585
669
  segment?.end();
586
670
  segment = null;
587
671
  };
672
+ /** Reply text routed through here splits across Slack messages before the
673
+ * msg_too_long cap (see SEGMENT_TEXT_MAX): a break prefers the last newline
674
+ * inside the remaining room, and a break forced inside a code fence closes
675
+ * it and reopens it in the next message so both halves render as code. */
676
+ const pushText = async (text: string) => {
677
+ for (let rest = text; rest !== "";) {
678
+ const room = SEGMENT_TEXT_MAX - (segment === null ? 0 : segmentMeta!.acc.length);
679
+ // a fence-close suffix can nudge a segment a few chars past the cap;
680
+ // a full segment just breaks and the loop re-measures a fresh one.
681
+ if (room <= 0) {
682
+ breakSegment();
683
+ continue;
684
+ }
685
+ if (rest.length <= room) {
686
+ await push(rest);
687
+ return;
688
+ }
689
+ // prefer a newline cut only when it lands in the back half of the room:
690
+ // an early newline followed by one giant unbroken run would otherwise
691
+ // make no progress and (with the fence-reopen prefix) loop forever.
692
+ const nl = rest.lastIndexOf("\n", room - 1);
693
+ let cut = nl >= Math.floor(room / 2) ? nl + 1 : room;
694
+ // never slice through a backtick run: a cut inside ``` would strand a
695
+ // partial delimiter on each side and break both halves' rendering
696
+ // (cubic review catch, PR #42). Walk the cut left past the run; a run
697
+ // reaching position 0 keeps the original cut (progress beats rendering
698
+ // for pathological all-backtick input).
699
+ if (rest[cut - 1] === "`" && rest[cut] === "`") {
700
+ let backedUp = cut;
701
+ while (backedUp > 0 && rest[backedUp - 1] === "`") backedUp -= 1;
702
+ if (backedUp > 0) cut = backedUp;
703
+ }
704
+ const head = rest.slice(0, cut);
705
+ await push(head);
706
+ const reopen = fenceOpen();
707
+ if (reopen) await push("\n```");
708
+ breakSegment();
709
+ rest = (reopen ? "```\n" : "") + rest.slice(head.length);
710
+ }
711
+ };
588
712
  // settle the whole post chain: a rejection handler may replace lastPost with
589
713
  // a salvage segment's post, which still needs ending and settling.
590
714
  const settlePosts = async () => {
@@ -641,9 +765,9 @@ export async function relayThread(input: {
641
765
  outcome.failed = false;
642
766
  outcome.rateLimited = false;
643
767
  outcome.resultReceived = false;
644
- // outcome.finish stays sticky across retries: the tool call already
645
- // happened in this session, and a limit right after it must not unfinish
646
- // 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.
647
771
  // the identity this spawn meters: a limit observation is attributed to it,
648
772
  // never to whatever account a concurrent thread swaps live mid-turn. Read
649
773
  // inside the try: a malformed claude.json must fail the TURN, not the
@@ -688,10 +812,18 @@ export async function relayThread(input: {
688
812
  }
689
813
  return child;
690
814
  },
691
- // the user saying "we're done" closes the thread: the model flags it
692
- // via this in-process tool, the daemon drops the record post-turn.
693
- mcpServers: { tokenmaxxing: finishToolServer(() => { outcome.finish = true; }) },
694
- 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],
695
827
  // serve skills (ask-the-user, serve-session); discovered skills are
696
828
  // enabled by default, so no `skills` option is needed.
697
829
  plugins: [{ type: "local", path: SERVE_PLUGIN_DIR }],
@@ -741,10 +873,13 @@ export async function relayThread(input: {
741
873
  outcome.resultReceived = true;
742
874
  }
743
875
  }
744
- for (const part of agentEventChunks({ state: mapState, message })) await push(part);
876
+ for (const part of agentEventChunks({ state: mapState, message })) {
877
+ if (part instanceof Object) await push(part);
878
+ else await pushText(part);
879
+ }
745
880
  }
746
881
  // a turn that produced no streamed text (tool-only turns) still reports.
747
- if (!postedText && result) await push(result);
882
+ if (!postedText && result) await pushText(result);
748
883
  if (!postedText && !result && outcome.failed && !outcome.rateLimited) {
749
884
  // held back until the depleted-pool probe rules: a "trying again may
750
885
  // help" line right before a deferral notice invites a manual re-send
@@ -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