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 +2 -1
- package/package.json +1 -1
- package/src/cli/serve.ts +363 -15
- package/src/lib/slackbridge.ts +162 -27
- package/src/lib/slackstate.ts +43 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +6 -1
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.
|
|
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".
|
|
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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
529
|
-
*
|
|
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
|
-
|
|
769
|
-
|
|
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)
|
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. */
|
|
@@ -263,19 +268,37 @@ function pushableStream(): {
|
|
|
263
268
|
};
|
|
264
269
|
}
|
|
265
270
|
|
|
266
|
-
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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
|
|
272
|
-
* in the daemon process, but
|
|
273
|
-
* subprocess is still mid-turn and segments are still
|
|
274
|
-
*
|
|
275
|
-
* turn ends (serve.ts). alwaysLoad keeps the
|
|
276
|
-
* instead of deferred behind tool search:
|
|
277
|
-
* moment the user says the work is done. */
|
|
278
|
-
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 }) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
645
|
-
// happened in this session, and a limit right after
|
|
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
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
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 }))
|
|
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
|
|
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
|
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
|
|