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 +1 -1
- package/src/cli/serve.ts +344 -12
- package/src/lib/slackbridge.ts +43 -20
- package/src/lib/slackstate.ts +43 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "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
|
@@ -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".
|
|
62
|
-
*
|
|
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
|
-
|
|
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,
|
|
540
|
-
*
|
|
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
|
-
|
|
780
|
-
|
|
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)
|
package/src/lib/slackbridge.ts
CHANGED
|
@@ -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
|
|
284
|
-
*
|
|
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
|
|
289
|
-
* in the daemon process, but
|
|
290
|
-
* subprocess is still mid-turn and segments are still
|
|
291
|
-
*
|
|
292
|
-
* turn ends (serve.ts). alwaysLoad keeps the
|
|
293
|
-
* instead of deferred behind tool search:
|
|
294
|
-
* moment the user says the work is done. */
|
|
295
|
-
function
|
|
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
|
|
754
|
-
// happened in this session, and a limit right after
|
|
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
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
|
|
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 }],
|
package/src/lib/slackstate.ts
CHANGED
|
@@ -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.
|
|
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
|
|