tokenmaxxing 1.4.0 → 1.6.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 +4 -2
- package/README.md +27 -0
- package/package.json +4 -3
- package/src/cli/serve.ts +321 -57
- package/src/lib/install.ts +62 -2
- package/src/lib/slackbridge.ts +201 -24
- package/src/lib/slackstate.ts +9 -0
package/DESIGN.md
CHANGED
|
@@ -94,11 +94,13 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
|
|
|
94
94
|
- **Config**: `slack.json` (0600 - it holds the xoxb-/xapp- tokens) with per-channel links `{channel, repo, permissionMode, model?}`. `serve setup` prints the app manifest (bot scopes: app_mentions:read, assistant:write, channels:history, groups:history, chat:write, files:write, im:history, users:read; agent_view enabled; events incl. app_mention, message.channels/groups/im, app_home_opened, app_context_changed; socket mode) and prompts for the tokens; `serve link <channel-id> <repo>` manages links (channel IDs only - names drift, ids don't).
|
|
95
95
|
- **Thread = session**: a bot mention in a linked channel subscribes the thread and opens the session in the linked repo checkout (normal mode, user decision 2026-07-18 superseding the same-day worktree-per-thread default; a later same-day decision: the agent cuts its own worktree FIRST for any mutating task - read-only turns stay parallel in the shared checkout - taught by the serve-session skill, since the recorded cwd can never move), and records `{threadId, cwd, sessionId}` under `slack-threads/`. Resume is cwd-keyed in claude, so the cwd stays byte-stable for the thread's life; records from the worktree era pin their old `slack-worktrees/<threadKey>` cwd and keep working (those worktrees are never auto-deleted - they hold the thread's work).
|
|
96
96
|
- **Thread close-out (finish_thread)**: when the user says the work is finished, the model calls the in-process MCP tool `finish_thread` (a per-turn `createSdkMcpServer` in relayThread, `alwaysLoad: true`, granted via `allowedTools` since nobody can answer a permission prompt over Slack). The handler runs in the daemon but only flags the turn outcome; after the turn ends (claude subprocess gone, segments posted) serve.ts runs `cleanupThread`: delete the `slack-threads/` record, `thread.unsubscribe()`, and post one confirmation line. Threads run in the shared repo checkout, so there is nothing on disk to collect and the checkout is never touched; a fresh mention after close-out starts a new session.
|
|
97
|
-
- **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (
|
|
97
|
+
- **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (the SDK subprocess reads credentials at spawn, so per-turn spawns are what let `ensureBestAccount()` land each turn on the freshest account, and the daemon can restart without losing threads). `stopHookCheck` rides along as the SDK Stop hook. Streamed `text_delta`s feed `thread.post(AsyncIterable)` (the adapter debounces edits); tool-only turns post the final result text.
|
|
98
|
+
- **Steering (2026-07-27, owner decision: steering is the default for a mid-turn reply)**: the per-turn query takes STREAMING input (`prompt` as an AsyncIterable of stream-json user messages) instead of a one-shot string, so a relayable reply landing while the turn runs is written onto the live child's stdin. The CLI folds it into the running turn at the next tool boundary (the same queued-message injection interactive Claude Code uses; a message that misses the last fold window runs as its own turn in the same child before exit, so nothing drops - both verified against claude 2.1.220). serve-side: `relayThread` exposes a per-attempt `steer()` via `onSteer`, refused once the attempt's result arrives (stdin ends then, and the SDK silently drops writes to an ended stdin); `buildServeRuntime` keeps one acceptor per live thread and tries it per arriving message - refusals (idle thread, parked pool, a non-empty inbox or waiting turn for ordering, an out-of-order late arrival, drain) fall back to the inbox path. Steered text appends to the durable `activeTurn.prompt` (kill-replays and limit-resumes carry it) and folds into retry prompts; steered message ids ride the marker (`steeredMessageIds`) wearing the same hourglass-to-terminal reaction lifecycle; an inbound takeover of a deferred turn adopts the held turn's unsettled ids so no hourglass is orphaned, and a steer answers a resumed turn's pending ask (attention cleared) like a queued turn would. SUCCESS IS STICKY per attempt: a post-fold-window steer's own drained turn can emit a second, errored result after the primary answer already succeeded, and marking the turn failed then would re-run completed work through the retry machinery - the steer is announced lost in-thread instead, `outcome.steerLost` settles the steered messages' reactions as failed (a lost instruction must never read green; attribution is per-turn, a documented tradeoff on TurnOutcomeSchema), a limit classification stays sticky across a child's errored results (a drained turn's generic death must not declassify the primary turn's recoverable limit), and each turn's result-only answer is flushed AT its result so a trailing turn's notice or result can neither suppress nor clobber a tool-only answer (all adversarial-review catches). Two CLI gotchas are load-bearing: steered messages carry NO uuid (the CLI dedupes by uuid and silently swallows a reuse) and NO priority (default "next" folds politely; "now" is a hard interrupt).
|
|
99
|
+
- **Arrival dispatch (2026-07-27, replacing the chat queue strategy)**: Chat runs `concurrency: {strategy: "concurrent"}` - every message reaches `onMessage` the moment Slack delivers it. The queue strategy's 30s dispatch-lock lease made mid-turn messages invisible until the turn ended and could REORDER them (a reply enqueued behind a live lease was released only when a later message took the expired lock, newest dispatched first - adversarial-review catch on the steering redesign, present at any TTL). The daemon owns everything the queue provided: per-thread turn ordering via the serialized chain (`serializedTurn` counts only TURN-producing work, so reaction notes and nudges can never gate steering - a second review catch); mid-turn folding via steering; and batching via a per-thread INBOX - un-steered messages accumulate and drain as ONE folded turn, sorted by Slack ts (ids are timestamps), so an upstream arrival race cannot reorder the prompt and a burst behind a long turn costs one metered spawn, not N. Per-thread arrival decisions are themselves chained (steer-or-inbox for one message completes before the next begins), and chat's message-id dedupe runs before its concurrency branch, so the app_mention + message.channels double-delivery stays deduped.
|
|
98
100
|
- **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--yolo` (alias `--dangerous`) opts a link into `bypassPermissions`, and relayThread pairs it with the SDK's mandatory `allowDangerouslySkipPermissions: true` opt-in. `AskUserQuestion` is disallowed (unanswerable over Slack; the model asks in prose instead). Turn failures post a trimmed message-only diagnostic (never a raw error body). Outsiders must not drive sessions (harvested from Slaude at its 2026-07-18 shutdown): `isOutsideAuthor` fail-closed rejects any message whose team-origin fields disagree with the home workspace's `workspaceTeamId` (captured via auth.test at setup and re-captured at every daemon start, so the reference can never go stale against a rotated token), so Slack Connect externals and cross-workspace guests are silently ignored and can never open a session.
|
|
99
101
|
- **Serve skills** (`src/serve-plugin/`, ships in the package via `files: ["src"]`): a Claude Code plugin loaded per turn (`plugins: [{type: "local", path}]`; discovered skills are enabled by default, so no `skills` option). `tokenmaxxing:ask-the-user` teaches the decision protocol - when input is needed, tag the requester with the raw Slack mention token `<@U...>`, ask compactly, END the turn (the thread reply is the next turn); `tokenmaxxing:serve-session` documents how the session runs (shared repo checkout, resume across turns, worktree-by-default for mutating tasks, handoff). The one dynamic fact skills cannot carry - who asked - rides in per turn via a `UserPromptSubmit` hook whose `additionalContext` ("Slack relay context: ...", built by `serveTurnContext`) names the triggering message author's mention token. The mention survives the pipeline because streamed `markdown_text` deltas pass verbatim and the post-and-edit fallback's `finalize` only linkifies bare `@U...`, never escaping an already-formed `<@U...>`. Skills are independent of the system prompt choice (probe-verified 2026-07-18 under the custom-string `SLACK_SYSTEM_PROMPT`: the init message lists the plugin + both skills + the Skill tool; the skill listing arrives as a conversation system-reminder, and CLAUDE.md loads via settingSources).
|
|
100
102
|
- **Socket lifecycle**: `bot.initialize()` starts the persistent auto-reconnecting SocketModeClient; the daemon then just stays alive. The leased `startSocketModeListener` API must never be looped: it returns instantly without `waitUntil` and the loop starves the event loop (live incident 2026-07-18 - connected but silent).
|
|
101
|
-
- **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread;
|
|
103
|
+
- **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread; per-thread turn serialization is owned by the daemon itself (a promise chain per thread id), and message batching by the daemon's inbox (see the arrival-dispatch bullet; the chat queue strategy and its skipped/TTL machinery were retired with the 2026-07-27 steering redesign, though `context.skipped` is still merged defensively when present); unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
|
|
102
104
|
- **Restart resilience** (0.19.0, from the 2026-07-18 dead-thread incident: a deploy restart cut a turn mid-answer and left the thread deaf to follow-ups): startup re-subscribes every `slack-threads/` record straight on the state adapter (`state.subscribe(threadId)`; message routing checks `stateAdapter.isSubscribed`, verified in chat 4.34.0), so open threads survive restarts without needing a fresh mention. SIGTERM/SIGINT drains instead of dying: new turns are dropped loudly (`serve.drain_dropped` plus a tracked in-thread notice - a log-only drop reads as the bot thinking), tracked in-flight turns get up to 300s to finish (a re-snapshotting wait, so late-added notices still flush), then `Chat.shutdown()`; a second signal forces exit; the drain also aborts any depleted-pool park so a countdown never delays a restart. The claude child spawns detached in its own process group (`detachedClaudeSpawn` via the SDK's `spawnClaudeCodeProcess` hook), because a terminal Ctrl-C signals the whole foreground group and a non-detached child died with the daemon before the drain could save the turn; the signal handlers register before `bot.initialize()` so no turn can start while the process still has default signal disposition, and a rejecting `Chat.shutdown()` is caught so the drain always reaches its exit. When a `thread.post` rejects mid-turn (e.g. Slack finalizes an idle stream after an undocumented window: `message_not_in_streaming_state`), relayThread salvages the dead segment in TEXT space: proven-delivered text is a mirror StreamingMarkdownRenderer's committable prefix over the confirmed chunks (each pull proves the previous append landed; the adapter's renderer holds back the trailing unterminated line until its post-iteration forced flush, so chunk-granular salvage would miss a final reply with no trailing newline - the live 2026-07-21 incident), and the remainder re-posts as a fresh message split at line boundaries, plus unconfirmed cards, bounded by a futility budget that refills on delivery progress; the textLost diagnostic remains only for exhausted salvage, and ambiguous failures duplicate a tail line rather than lose it.
|
|
103
105
|
- **Interrupted-turn recovery** (from the second 2026-07-18 restart incident: a redeploy killed a ship turn 8 minutes in with zero notice - drain cannot save a long turn, and a group-wide kill can take the claude child even detached): the thread record persists the claude session id the moment the init message assigns it, and every turn is wrapped in a durable `activeTurn` marker (original prompt, start time, resume count) written before the spawn and cleared when the turn returns - except that a turn failing DURING a drain keeps its marker, since that failure is presumed to be the shutdown signal killing the child. On startup, a surviving marker means a restart killed that turn: the daemon posts a notice into the thread (the chat-sdk's documented proactive handle, rebuilt with the thread's newest human message as streaming recipient context so the resumed turn keeps its native task cards, that message's author becoming the turn's requester) and auto-resumes the work - resuming the recorded session with a continuation prompt, or replaying the original prompt fresh when the kill landed before the session opened; resumed turns settle like inbound ones (outcome log + finish_thread GC). Retries cap at 3 (each spends real quota) with a loud give-up notice posted before its marker clears; a per-thread turn lock keeps a startup resume from ever racing an inbound message turn in the same cwd, every recovery branch recomputes its decision from a fresh record reload under that lock (so a resume superseded by a faster inbound turn no-ops instead of double-running), and a blocking singleton flock makes a new daemon generation wait for the previous one - drain included - to fully exit before touching any thread record. Uncatchable deaths cannot leak a working child either: SIGHUP drains like SIGTERM (its default disposition skips the exit hook that kills the detached group), and the marker records the child's group pid plus its C-locale ps start-time token at spawn, so recovery kills only an exactly-identified SIGKILL-orphaned claude (never a recycled pid) before resuming its turn.
|
|
104
106
|
- **Depleted-pool recovery** (harvested from Slaude, reshaped around the pool): relayThread consumes the spawn-boundary switch decision instead of discarding it - a depleted pool with a known recovery inside the message's one 14min parking deadline (shared across chained parks, so the thread's queue slot is never held longer in total) posts a park notice and retries at the reset. A mid-turn limit - detected ONLY on errored results, since `is_error` can ride subtype `"success"` (`Claude AI usage limit reached|<epoch>`) and a successful answer discussing limits must never be re-run - is persisted into usage.json first (`recordObservedLimit`: the serve process has no statusLine tee, and the snapshot TTL would otherwise feed the retry the stale pre-limit state), then retried silently after a short beat, so a pool swap makes the hiccup invisible. Bounded recoveries; every drop the relay performs is announced in-thread.
|
package/README.md
CHANGED
|
@@ -23,6 +23,33 @@ bun add -g tokenmaxxing
|
|
|
23
23
|
tokenmaxxing init
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Or with Nix (same source-run-by-Bun package; `init` still owns credentials, the `claude` shim, and settings merges). Install onto PATH first, then init — `nix run ... -- init` alone leaves supervisor shims without a stable `tokenmaxxing` on PATH after the ephemeral run exits:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
nix profile install github:anaclumos/tokenmaxxing
|
|
30
|
+
tokenmaxxing init
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
nix-darwin:
|
|
34
|
+
|
|
35
|
+
```nix
|
|
36
|
+
# flake inputs: tokenmaxxing.url = "github:anaclumos/tokenmaxxing";
|
|
37
|
+
modules = [
|
|
38
|
+
inputs.tokenmaxxing.darwinModules.withOverlay
|
|
39
|
+
{ programs.tokenmaxxing.enable = true; }
|
|
40
|
+
];
|
|
41
|
+
# then: tokenmaxxing init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Home Manager:
|
|
45
|
+
|
|
46
|
+
```nix
|
|
47
|
+
imports = [ inputs.tokenmaxxing.homeManagerModules.default ];
|
|
48
|
+
programs.tokenmaxxing.enable = true;
|
|
49
|
+
programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.default;
|
|
50
|
+
# then: tokenmaxxing init
|
|
51
|
+
```
|
|
52
|
+
|
|
26
53
|
`init` imports the account you're already on, installs the `claude` supervisor + four `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
|
|
27
54
|
|
|
28
55
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -33,10 +33,11 @@
|
|
|
33
33
|
"dev": "bun run src/main.ts",
|
|
34
34
|
"test": "bun test",
|
|
35
35
|
"typecheck": "tsc --noEmit",
|
|
36
|
-
"prepublishOnly": "bun run typecheck && bun run test"
|
|
36
|
+
"prepublishOnly": "bun run typecheck && bun run test",
|
|
37
|
+
"nix:bun": "bunx bun2nix@2.1.2 -o bun.nix"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@types/bun": "
|
|
40
|
+
"@types/bun": "1.3.14",
|
|
40
41
|
"typescript": "^5.6.0"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
package/src/cli/serve.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { delay, omit, uniq } from "es-toolkit";
|
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
|
|
23
23
|
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
24
|
-
import {
|
|
24
|
+
import { MemoryStateAdapter } from "@chat-adapter/state-memory";
|
|
25
25
|
import {
|
|
26
26
|
bareChannelId,
|
|
27
27
|
isChannelId,
|
|
@@ -334,6 +334,10 @@ export function buildServeRuntime(seam: {
|
|
|
334
334
|
* relayThread). */
|
|
335
335
|
onSpawn?: (pid: number) => void;
|
|
336
336
|
drainSignal?: AbortSignal;
|
|
337
|
+
/** steering seam (see relayThread): a live attempt's steer function, or
|
|
338
|
+
* null when the attempt ends. steer runs its onAccept callback
|
|
339
|
+
* synchronously inside acceptance, before the child sees the text. */
|
|
340
|
+
onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
|
|
337
341
|
}) => Promise<TurnOutcome>;
|
|
338
342
|
cleanup: (input: { threadId: string }) => CleanupOutcome;
|
|
339
343
|
/** add/remove a status reaction on a message (production: the Slack
|
|
@@ -373,6 +377,33 @@ export function buildServeRuntime(seam: {
|
|
|
373
377
|
const unlinkedLogged = new Set<string>();
|
|
374
378
|
// corrupt thread records already logged as skipped this run (see nudgeSweep).
|
|
375
379
|
const sweepSkipLogged = new Set<string>();
|
|
380
|
+
// Steering registry: one acceptor per thread with a LIVE query attempt
|
|
381
|
+
// (registered by runTurn via the relay's onSteer hook, removed when the
|
|
382
|
+
// attempt ends). onMessage tries the acceptor before the inbox - steering
|
|
383
|
+
// is the default for a mid-turn reply (owner decision 2026-07-27); a
|
|
384
|
+
// refusal (attempt ending, mention-only text, out-of-order arrival) falls
|
|
385
|
+
// back to the inbox path. A thread only ever appears here after handleTurn
|
|
386
|
+
// ran under this daemon's cfg, so the channel is known linked.
|
|
387
|
+
const liveSteers = new Map<string, (m: { relayed: { text: string; authorId: string; id: string }[] }) => Promise<boolean>>();
|
|
388
|
+
// TURN-PRODUCING serialized work per thread (running plus waiting). Only
|
|
389
|
+
// turn producers count (adversarial-review catch: reaction notes and nudge
|
|
390
|
+
// bookkeeping ride the same serialized chain, and counting them silently
|
|
391
|
+
// disabled steering for the rest of any turn a user reacted to). A steer
|
|
392
|
+
// is only ordered when nothing waits behind the live turn.
|
|
393
|
+
const turnDepth = new Map<string, number>();
|
|
394
|
+
// Un-steered messages waiting for the next turn, folded and drained as ONE
|
|
395
|
+
// turn (sorted by Slack ts, which restores order for any upstream arrival
|
|
396
|
+
// race). This is the daemon-owned replacement for the chat queue's
|
|
397
|
+
// skipped-message folding. Each entry carries its delivery's mention flag,
|
|
398
|
+
// so the drained turn derives mention-ness from the RETAINED messages - a
|
|
399
|
+
// mention dropped by the overflow cap must not leave a phantom flag behind
|
|
400
|
+
// (cubic review catch on PR #50).
|
|
401
|
+
const pendingInbox = new Map<string, { text: string; authorId: string; id: string; isMention: boolean }[]>();
|
|
402
|
+
// Per-thread arrival ordering: the steer attempt and the inbox push for
|
|
403
|
+
// one message run to completion before the next message's do, so two
|
|
404
|
+
// near-simultaneous replies can never interleave at the acceptor's await
|
|
405
|
+
// points and land on the child's stdin out of order.
|
|
406
|
+
const arrivalChains = new Map<string, Promise<void>>();
|
|
376
407
|
|
|
377
408
|
/** Best-effort status reaction: reaction state is decoration, so every
|
|
378
409
|
* failure (missing reactions:write until the app is reinstalled,
|
|
@@ -391,7 +422,9 @@ export function buildServeRuntime(seam: {
|
|
|
391
422
|
* before the spawn, cleared when the turn returns, so a marker surviving
|
|
392
423
|
* into the next daemon start identifies a turn a restart killed mid-run.
|
|
393
424
|
* The session id persists the moment init assigns it - a first-turn kill
|
|
394
|
-
* must stay resumable.
|
|
425
|
+
* must stay resumable. Returns the steered message ids alongside the
|
|
426
|
+
* outcome: they joined the turn mid-run, so the caller's settle must
|
|
427
|
+
* close their reaction lifecycle too. */
|
|
395
428
|
const runTurn = async (input: {
|
|
396
429
|
thread: { id: string; post: (m: StreamingPlan) => Promise<unknown> };
|
|
397
430
|
record: SlackThread;
|
|
@@ -400,14 +433,17 @@ export function buildServeRuntime(seam: {
|
|
|
400
433
|
sessionId: string | null;
|
|
401
434
|
marker: ActiveTurn;
|
|
402
435
|
link: SlackLink;
|
|
403
|
-
}): Promise<TurnOutcome> => {
|
|
436
|
+
}): Promise<{ outcome: TurnOutcome; steeredMessageIds: string[] }> => {
|
|
404
437
|
let record: SlackThread = { ...input.record, activeTurn: input.marker };
|
|
405
438
|
saveSlackThread(record);
|
|
406
|
-
// the whole turn (parks and retries included) reads as "being processed"
|
|
407
|
-
|
|
408
|
-
|
|
439
|
+
// the whole turn (parks and retries included) reads as "being processed";
|
|
440
|
+
// a resumed marker's steered messages re-arm their hourglass too
|
|
441
|
+
// (setStatus swallows already_reacted).
|
|
442
|
+
for (const id of [input.marker.messageId, ...(input.marker.steeredMessageIds ?? [])]) {
|
|
443
|
+
if (id) await setStatus({ threadId: input.thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "add" });
|
|
409
444
|
}
|
|
410
445
|
let outcome: TurnOutcome | null = null;
|
|
446
|
+
let steeredMessageIds = input.marker.steeredMessageIds ?? [];
|
|
411
447
|
try {
|
|
412
448
|
outcome = await seam.relay({
|
|
413
449
|
cwd: record.cwd,
|
|
@@ -424,9 +460,14 @@ export function buildServeRuntime(seam: {
|
|
|
424
460
|
onSpawn: (pid) => {
|
|
425
461
|
// the lstart token makes the pid a verifiable identity for the
|
|
426
462
|
// orphan reaper; a child dead before ps sees it persists without
|
|
427
|
-
// one, and an identity-less pid is never signaled.
|
|
463
|
+
// one, and an identity-less pid is never signaled. Built on the
|
|
464
|
+
// CURRENT marker (a steer may have grown it), with the previous
|
|
465
|
+
// spawn's identity dropped: a retry child must never inherit the
|
|
466
|
+
// dead child's lstart, or the reaper would skip (or mis-verify)
|
|
467
|
+
// the live group.
|
|
428
468
|
const startedAt = pidStartTime(pid);
|
|
429
|
-
|
|
469
|
+
const marker = omit(record.activeTurn ?? input.marker, ["pid", "pidStartedAt"]);
|
|
470
|
+
record = { ...record, activeTurn: { ...marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
|
|
430
471
|
saveSlackThread(record);
|
|
431
472
|
},
|
|
432
473
|
onSessionId: (sessionId) => {
|
|
@@ -434,9 +475,115 @@ export function buildServeRuntime(seam: {
|
|
|
434
475
|
saveSlackThread(record);
|
|
435
476
|
},
|
|
436
477
|
drainSignal: drainAbort.signal,
|
|
478
|
+
// While an attempt is steerable, a relayable mid-turn message folds
|
|
479
|
+
// into the RUNNING turn (owner decision 2026-07-27: steering is the
|
|
480
|
+
// default; the queued next turn is only the fallback). The acceptor
|
|
481
|
+
// runs its marker mutation synchronously after a successful steer:
|
|
482
|
+
// the steered text becomes part of the durable prompt (replays and
|
|
483
|
+
// retries must include it) before any await can interleave with the
|
|
484
|
+
// turn's own marker writes.
|
|
485
|
+
onSteer: (steerText) => {
|
|
486
|
+
if (steerText === null) {
|
|
487
|
+
liveSteers.delete(input.thread.id);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
liveSteers.set(input.thread.id, async (m) => {
|
|
491
|
+
const stripped = m.relayed
|
|
492
|
+
.map((r) => ({ text: stripLeadingMention({ text: r.text, botUserId: seam.botUserId() }), authorId: r.authorId, id: r.id }))
|
|
493
|
+
.filter((r) => r.text !== "");
|
|
494
|
+
if (stripped.length === 0) return false;
|
|
495
|
+
// out-of-order insurance: Slack ids are timestamps, so a message
|
|
496
|
+
// OLDER than anything this turn already carries arrived late
|
|
497
|
+
// through an upstream race - refuse it into the inbox, whose
|
|
498
|
+
// drain sorts by ts, instead of folding it after its successor.
|
|
499
|
+
const seen = record.activeTurn ?? input.marker;
|
|
500
|
+
const newestSeen = Math.max(Number(seen.messageId ?? 0) || 0, ...(seen.steeredMessageIds ?? []).map((id) => Number(id) || 0));
|
|
501
|
+
if (stripped.some((r) => (Number(r.id) || 0) < newestSeen)) return false;
|
|
502
|
+
// per-turn steer budget (cursor security review, round 2 on
|
|
503
|
+
// PR #50): accepted steers grow the durable prompt and the
|
|
504
|
+
// child's queue outside the inbox's 100-entry cap, so a flood
|
|
505
|
+
// could balloon one turn without bound. Far past any human
|
|
506
|
+
// steering cadence, a reply takes the capped inbox path instead.
|
|
507
|
+
if ((seen.steeredMessageIds?.length ?? 0) + stripped.length > 25) return false;
|
|
508
|
+
// hourglass BEFORE the steer so a settle racing this acceptor
|
|
509
|
+
// can never leave an unremovable reaction; a refusal takes it
|
|
510
|
+
// back off.
|
|
511
|
+
for (const r of stripped) {
|
|
512
|
+
await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "add" });
|
|
513
|
+
}
|
|
514
|
+
// authors the turn does not know yet get named inline: the
|
|
515
|
+
// UserPromptSubmit context does not re-fire for folded mid-turn
|
|
516
|
+
// messages, so attribution rides the message itself.
|
|
517
|
+
const text = stripped
|
|
518
|
+
.map((r) => (input.requesterIds.includes(r.authorId) ? r.text : `Message from <@${r.authorId}>:\n${r.text}`))
|
|
519
|
+
.join("\n\n");
|
|
520
|
+
// The durable commit runs INSIDE steer's acceptance, in the same
|
|
521
|
+
// JS tick as its liveness check (adversarial-review catch, round
|
|
522
|
+
// 3: this acceptor runs on the arrival chain and can resume from
|
|
523
|
+
// its hourglass awaits AFTER the turn ended - a write-ahead save
|
|
524
|
+
// here resurrected the finished turn's marker on disk, and its
|
|
525
|
+
// refusal rollback clobbered post-turn state with a stale
|
|
526
|
+
// snapshot). Acceptance proves the turn is live, so the closure
|
|
527
|
+
// record is disk-faithful; the marker still grows durably BEFORE
|
|
528
|
+
// the text reaches the child's stdin (a crash in between replays
|
|
529
|
+
// a steer the child may never have seen - duplication over
|
|
530
|
+
// loss); and a refusal commits nothing, so a stale invocation is
|
|
531
|
+
// a harmless fall-through to the inbox.
|
|
532
|
+
let asked: SlackThread["attention"];
|
|
533
|
+
const commit = () => {
|
|
534
|
+
const mergedRequesters = uniq([...input.requesterIds, ...stripped.map((r) => r.authorId)]);
|
|
535
|
+
const marker = record.activeTurn ?? input.marker;
|
|
536
|
+
const grownIds = [...(marker.steeredMessageIds ?? []), ...stripped.map((r) => r.id)];
|
|
537
|
+
let next = { ...record, activeTurn: { ...marker, prompt: `${marker.prompt}\n\n${text}`, requesterIds: mergedRequesters, steeredMessageIds: grownIds } };
|
|
538
|
+
// the user responded: a pending ask is answered by the steer
|
|
539
|
+
// just like by a queued turn (adversarial-review catch:
|
|
540
|
+
// leaving it would strand the question mark and fire a
|
|
541
|
+
// spurious nudge about an ask this very message answered).
|
|
542
|
+
const pendingAsk = next.attention;
|
|
543
|
+
if (pendingAsk) next = omit(next, ["attention"]);
|
|
544
|
+
// all-or-nothing (cubic review catch, round 4): the durable
|
|
545
|
+
// save runs before ANY outer mutation, so a failed write
|
|
546
|
+
// leaves the turn's view untouched and the refusal fallback
|
|
547
|
+
// below starts from clean state - without this ordering the
|
|
548
|
+
// settle would stamp the never-delivered message with the
|
|
549
|
+
// turn's outcome emoji.
|
|
550
|
+
saveSlackThread(next);
|
|
551
|
+
steeredMessageIds = grownIds;
|
|
552
|
+
record = next;
|
|
553
|
+
asked = pendingAsk;
|
|
554
|
+
for (const r of stripped) {
|
|
555
|
+
if (!input.requesterIds.includes(r.authorId)) input.requesterIds.push(r.authorId);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
// a throwing commit (the durable save failing) escapes steer()
|
|
559
|
+
// BEFORE the text reaches the child or the relay records it
|
|
560
|
+
// (cubic review catch, round 4): treat it as a refusal, so the
|
|
561
|
+
// message keeps its inbox fallback instead of vanishing into
|
|
562
|
+
// the generic task_crashed log with its hourglass stranded. If
|
|
563
|
+
// the disk stays broken, the inbox turn's own marker write
|
|
564
|
+
// surfaces it loudly through the crash-notice path.
|
|
565
|
+
let accepted = false;
|
|
566
|
+
try {
|
|
567
|
+
accepted = steerText(text, commit);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
log("serve.steer_commit_failed", { thread: input.thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
570
|
+
}
|
|
571
|
+
if (!accepted) {
|
|
572
|
+
for (const r of stripped) {
|
|
573
|
+
await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
log("serve.steered", { thread: input.thread.id, texts: stripped.length });
|
|
578
|
+
if (asked?.messageId) {
|
|
579
|
+
await setStatus({ threadId: input.thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
});
|
|
583
|
+
},
|
|
437
584
|
});
|
|
438
585
|
record = { ...record, sessionId: outcome.sessionId };
|
|
439
|
-
return outcome;
|
|
586
|
+
return { outcome, steeredMessageIds };
|
|
440
587
|
} finally {
|
|
441
588
|
// a failure DURING a drain is presumed to be the shutdown signal killing
|
|
442
589
|
// the claude child (terminal Ctrl-C and group signals hit the whole
|
|
@@ -462,6 +609,9 @@ export function buildServeRuntime(seam: {
|
|
|
462
609
|
if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop && deferUntil === null) {
|
|
463
610
|
log("serve.drop_unannounced", { thread: input.thread.id });
|
|
464
611
|
}
|
|
612
|
+
// belt-and-braces: the relay clears its steer hook per attempt, but the
|
|
613
|
+
// registry entry must never outlive the turn that owns it.
|
|
614
|
+
liveSteers.delete(input.thread.id);
|
|
465
615
|
if (deferUntil !== null && record.activeTurn) {
|
|
466
616
|
record = { ...record, activeTurn: { ...record.activeTurn, resumeAt: deferUntil } };
|
|
467
617
|
saveSlackThread(record);
|
|
@@ -474,7 +624,7 @@ export function buildServeRuntime(seam: {
|
|
|
474
624
|
|
|
475
625
|
const handleTurn = async (input: {
|
|
476
626
|
thread: ServeThread;
|
|
477
|
-
/** every relayed message this turn (
|
|
627
|
+
/** every relayed message this turn (the inbox batch, ts-sorted), text
|
|
478
628
|
* paired with its author id: a decision may be owed to an earlier
|
|
479
629
|
* folded sender, and a sender whose whole message was the bot mention
|
|
480
630
|
* contributes no prompt text, so text and author filter together
|
|
@@ -529,11 +679,10 @@ export function buildServeRuntime(seam: {
|
|
|
529
679
|
return;
|
|
530
680
|
}
|
|
531
681
|
log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
|
|
532
|
-
// relayed carries
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
// prompt text nor a requester id (cursor review catch 2026-07-18).
|
|
682
|
+
// relayed carries every message the inbox batched for this turn, folded
|
|
683
|
+
// into one prompt here. A message that is empty once its bot mention is
|
|
684
|
+
// stripped contributes neither prompt text nor a requester id (cursor
|
|
685
|
+
// review catch 2026-07-18).
|
|
537
686
|
const stripped = input.relayed
|
|
538
687
|
.map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
|
|
539
688
|
.filter((m) => m.text !== "");
|
|
@@ -575,6 +724,12 @@ export function buildServeRuntime(seam: {
|
|
|
575
724
|
// prompt re-appears alongside the session transcript that already holds
|
|
576
725
|
// its partial work, and the newer message steers.
|
|
577
726
|
const deferred = record.activeTurn?.resumeAt !== undefined ? record.activeTurn : null;
|
|
727
|
+
// a taken-over deferral's messages still wear their processing hourglass
|
|
728
|
+
// (a deferral settles nothing); this turn serves their held prompt, so
|
|
729
|
+
// it adopts their ids and settles them with its own outcome - without
|
|
730
|
+
// the adoption the old trigger's hourglass would read "processing"
|
|
731
|
+
// forever once the fold replaced its marker.
|
|
732
|
+
const adoptedIds = deferred ? [...(deferred.messageId ? [deferred.messageId] : []), ...(deferred.steeredMessageIds ?? [])] : [];
|
|
578
733
|
if (deferred) {
|
|
579
734
|
const timer = deferredTimers.get(thread.id);
|
|
580
735
|
if (timer !== undefined) clearTimeout(timer);
|
|
@@ -612,16 +767,16 @@ export function buildServeRuntime(seam: {
|
|
|
612
767
|
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
613
768
|
await thread.startTyping();
|
|
614
769
|
const startedAt = Date.now();
|
|
615
|
-
const outcome = await runTurn({
|
|
770
|
+
const { outcome, steeredMessageIds } = await runTurn({
|
|
616
771
|
thread,
|
|
617
772
|
record,
|
|
618
773
|
prompt,
|
|
619
774
|
requesterIds,
|
|
620
775
|
sessionId: record.sessionId,
|
|
621
|
-
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds },
|
|
776
|
+
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds, ...(adoptedIds.length > 0 ? { steeredMessageIds: adoptedIds } : {}) },
|
|
622
777
|
link,
|
|
623
778
|
});
|
|
624
|
-
await settleTurn({ thread, outcome, startedAt, messageId, requesterIds });
|
|
779
|
+
await settleTurn({ thread, outcome, startedAt, messageId, steeredMessageIds, requesterIds });
|
|
625
780
|
};
|
|
626
781
|
|
|
627
782
|
/** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
|
|
@@ -634,6 +789,9 @@ export function buildServeRuntime(seam: {
|
|
|
634
789
|
startedAt: number;
|
|
635
790
|
/** the triggering message carrying the status reactions; absent = skip. */
|
|
636
791
|
messageId?: string;
|
|
792
|
+
/** messages steered into the turn mid-run: they carry the same reaction
|
|
793
|
+
* lifecycle as the trigger and settle with the same emoji. */
|
|
794
|
+
steeredMessageIds?: string[];
|
|
637
795
|
/** the turn's asked users, persisted when the model flagged attention. */
|
|
638
796
|
requesterIds?: string[];
|
|
639
797
|
}) => {
|
|
@@ -657,10 +815,19 @@ export function buildServeRuntime(seam: {
|
|
|
657
815
|
// below makes the question mark unremovable forever.
|
|
658
816
|
const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
|
|
659
817
|
const deferredForResume = outcome.deferUntil !== null;
|
|
660
|
-
if (
|
|
818
|
+
if (!killedByDrain && !deferredForResume) {
|
|
661
819
|
const emoji = outcome.failed ? STATUS_EMOJI.failed : outcome.attention && !outcome.finish ? STATUS_EMOJI.attention : STATUS_EMOJI.done;
|
|
662
|
-
|
|
663
|
-
|
|
820
|
+
// steerLost: a steered follow-up's own drained turn failed after the
|
|
821
|
+
// primary turn succeeded, so the steered messages settle as failed -
|
|
822
|
+
// matching the in-thread re-send notice; a lost instruction must never
|
|
823
|
+
// read green (see TurnOutcomeSchema.steerLost for the per-turn
|
|
824
|
+
// attribution tradeoff).
|
|
825
|
+
const steeredEmoji = outcome.steerLost ? STATUS_EMOJI.failed : emoji;
|
|
826
|
+
for (const id of [input.messageId, ...(input.steeredMessageIds ?? [])]) {
|
|
827
|
+
if (!id) continue;
|
|
828
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: id === input.messageId ? emoji : steeredEmoji, op: "add" });
|
|
829
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
830
|
+
}
|
|
664
831
|
}
|
|
665
832
|
// the model asked the user for a decision: mark the thread waiting so the
|
|
666
833
|
// nudge sweep and the reaction-answer path can see it. Persisted even on
|
|
@@ -755,12 +922,12 @@ export function buildServeRuntime(seam: {
|
|
|
755
922
|
}
|
|
756
923
|
};
|
|
757
924
|
|
|
758
|
-
// Per-thread serialization owned HERE
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
925
|
+
// Per-thread serialization owned HERE: with concurrent dispatch (see
|
|
926
|
+
// runDaemon's Chat config) chat provides no per-thread locking at all, so
|
|
927
|
+
// this chain is the ONLY thing keeping two claude turns off one thread's
|
|
928
|
+
// cwd and session. It predates the concurrent switch for the same reason
|
|
929
|
+
// in weaker form: chat's old queue lock expired 30s into any claude turn
|
|
930
|
+
// and let a second handler start anyway (review catch, PR #18).
|
|
764
931
|
const threadTurns = new Map<string, Promise<void>>();
|
|
765
932
|
const serialized = (threadId: string, run: () => Promise<void>) => {
|
|
766
933
|
const prev = threadTurns.get(threadId) ?? Promise.resolve();
|
|
@@ -782,18 +949,101 @@ export function buildServeRuntime(seam: {
|
|
|
782
949
|
})();
|
|
783
950
|
return next;
|
|
784
951
|
};
|
|
952
|
+
/** serialized + the turnDepth count, for callers that produce a claude
|
|
953
|
+
* TURN (inbox drains, reaction answers, recoveries). Bookkeeping riders
|
|
954
|
+
* on the chain (reaction notes, nudges) use bare `serialized` so they
|
|
955
|
+
* never gate steering. */
|
|
956
|
+
const serializedTurn = (threadId: string, run: () => Promise<void>) => {
|
|
957
|
+
turnDepth.set(threadId, (turnDepth.get(threadId) ?? 0) + 1);
|
|
958
|
+
const next = serialized(threadId, run);
|
|
959
|
+
void next.catch(() => {}).finally(() => {
|
|
960
|
+
const depth = (turnDepth.get(threadId) ?? 1) - 1;
|
|
961
|
+
if (depth <= 0) turnDepth.delete(threadId);
|
|
962
|
+
else turnDepth.set(threadId, depth);
|
|
963
|
+
});
|
|
964
|
+
return next;
|
|
965
|
+
};
|
|
785
966
|
|
|
786
967
|
// both Chat SDK callbacks funnel here. Filter EVERY message, trigger
|
|
787
968
|
// included: an outsider (or our own post) arriving last must not discard
|
|
788
|
-
// relayable home-workspace messages
|
|
789
|
-
//
|
|
790
|
-
//
|
|
969
|
+
// relayable home-workspace messages delivered alongside it (review catch,
|
|
970
|
+
// PR #18). context.skipped is empty under concurrent dispatch but stays
|
|
971
|
+
// merged defensively - a chat release reintroducing batching must not
|
|
972
|
+
// silently drop messages.
|
|
791
973
|
const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
|
|
792
974
|
const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
|
|
793
975
|
if (relayed.length === 0) return; // outsider mentions never open a session
|
|
794
|
-
|
|
976
|
+
// arrivals for one thread DECIDE strictly one at a time, in delivery
|
|
977
|
+
// order: the steer-or-inbox decision for this message completes before
|
|
978
|
+
// the next message's begins, so two near-simultaneous replies can never
|
|
979
|
+
// interleave at the acceptor's await points. The chain holds only the
|
|
980
|
+
// DECISION - a scheduled turn's completion is awaited outside it, or a
|
|
981
|
+
// long turn would hold every later arrival hostage and steering could
|
|
982
|
+
// never engage.
|
|
983
|
+
const prev = arrivalChains.get(input.thread.id) ?? Promise.resolve();
|
|
984
|
+
const job = (async () => {
|
|
985
|
+
try {
|
|
986
|
+
await prev;
|
|
987
|
+
} catch { /* the previous arrival's failure was surfaced to its own caller */ }
|
|
988
|
+
return dispatchArrival({ thread: input.thread, relayed, isMention: input.isMention });
|
|
989
|
+
})();
|
|
990
|
+
const link = job.then(
|
|
991
|
+
() => {},
|
|
992
|
+
() => {},
|
|
993
|
+
);
|
|
994
|
+
arrivalChains.set(input.thread.id, link);
|
|
995
|
+
void link.then(() => {
|
|
996
|
+
if (arrivalChains.get(input.thread.id) === link) arrivalChains.delete(input.thread.id);
|
|
997
|
+
});
|
|
998
|
+
// tracked from the DECISION on (codex review catch on PR #50): the drain
|
|
999
|
+
// waits on activeTurns, and an arrival still deciding at shutdown lived
|
|
1000
|
+
// nowhere else - the daemon could exit before the message reached the
|
|
1001
|
+
// inbox, a marker, or the drop notice.
|
|
1002
|
+
await tracked((async () => {
|
|
1003
|
+
const scheduled = await job;
|
|
1004
|
+
if (scheduled) await scheduled.turn;
|
|
1005
|
+
})());
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/** One message's steer-or-inbox decision. Steering first (owner decision
|
|
1009
|
+
* 2026-07-27): a reply landing while the thread's turn is running folds
|
|
1010
|
+
* into that turn instead of waiting behind it. Guarded to an empty inbox
|
|
1011
|
+
* and no waiting turn, or the fold would reorder this message ahead of
|
|
1012
|
+
* one already waiting; a drain keeps its loud-drop contract. Every
|
|
1013
|
+
* refusal falls through to the inbox, whose drain runs ALL waiting
|
|
1014
|
+
* messages as one folded turn - the daemon-owned replacement for the
|
|
1015
|
+
* chat queue's skipped-message folding, sorted by Slack ts so an
|
|
1016
|
+
* upstream arrival race cannot reorder the prompt. Returns the tracked
|
|
1017
|
+
* drain-turn promise when this arrival scheduled one, so the caller can
|
|
1018
|
+
* await the turn without holding the arrival chain. */
|
|
1019
|
+
const dispatchArrival = async (input: { thread: ServeThread; relayed: { text: string; authorId: string; id: string }[]; isMention: boolean }): Promise<{ turn: Promise<void> } | null> => {
|
|
1020
|
+
const threadId = input.thread.id;
|
|
1021
|
+
if (!draining && (pendingInbox.get(threadId)?.length ?? 0) === 0 && (turnDepth.get(threadId) ?? 0) <= 1) {
|
|
1022
|
+
const accept = liveSteers.get(threadId);
|
|
1023
|
+
if (accept && (await accept({ relayed: input.relayed }))) return null;
|
|
1024
|
+
}
|
|
1025
|
+
const inbox = pendingInbox.get(threadId) ?? [];
|
|
1026
|
+
const hadPending = inbox.length > 0;
|
|
1027
|
+
inbox.push(...input.relayed.map((r) => ({ ...r, isMention: input.isMention })));
|
|
1028
|
+
// hard cap, replacing the retired chat queue's maxQueueSize bound
|
|
1029
|
+
// (cursor security review on PR #50): without it a flood during one
|
|
1030
|
+
// long turn grows memory and the folded prompt without limit. Newest
|
|
1031
|
+
// dropped, LOUDLY - the old queue's silent drop-oldest ate the earliest
|
|
1032
|
+
// instructions, the worse failure.
|
|
1033
|
+
if (inbox.length > 100) {
|
|
1034
|
+
log("serve.inbox_dropped", { thread: threadId, dropped: inbox.length - 100 });
|
|
1035
|
+
inbox.length = 100;
|
|
1036
|
+
}
|
|
1037
|
+
pendingInbox.set(threadId, inbox);
|
|
1038
|
+
// one drain per non-empty inbox: later arrivals fold into the batch the
|
|
1039
|
+
// already-scheduled drain snapshots when it finally runs.
|
|
1040
|
+
if (hadPending) return null;
|
|
1041
|
+
const turn = tracked(serializedTurn(threadId, async () => {
|
|
1042
|
+
const batch = (pendingInbox.get(threadId) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
|
|
1043
|
+
pendingInbox.delete(threadId);
|
|
1044
|
+
if (batch.length === 0) return;
|
|
795
1045
|
try {
|
|
796
|
-
await handleTurn({ thread: input.thread, relayed, isMention:
|
|
1046
|
+
await handleTurn({ thread: input.thread, relayed: batch, isMention: batch.some((m) => m.isMention) });
|
|
797
1047
|
} catch (e) {
|
|
798
1048
|
// an escaped handleTurn throw (a state-file parse, a Slack API
|
|
799
1049
|
// rejection outside relayThread's never-throws boundary) previously
|
|
@@ -825,13 +1075,14 @@ export function buildServeRuntime(seam: {
|
|
|
825
1075
|
}
|
|
826
1076
|
// a crash after runTurn added the hourglass would otherwise read as
|
|
827
1077
|
// "processing" forever (codex review catch); setStatus never throws.
|
|
828
|
-
const messageId =
|
|
1078
|
+
const messageId = batch.at(-1)?.id;
|
|
829
1079
|
if (messageId) {
|
|
830
1080
|
await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
831
1081
|
await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
832
1082
|
}
|
|
833
1083
|
}
|
|
834
1084
|
}));
|
|
1085
|
+
return { turn };
|
|
835
1086
|
};
|
|
836
1087
|
|
|
837
1088
|
/** A user reaction in a tracked thread. While the thread waits on an asked
|
|
@@ -872,6 +1123,14 @@ export function buildServeRuntime(seam: {
|
|
|
872
1123
|
log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
|
|
873
1124
|
return;
|
|
874
1125
|
}
|
|
1126
|
+
// bare `serialized` on purpose, so a reaction can never gate steering
|
|
1127
|
+
// (adversarial-review catch: counting these bookkeeping riders in
|
|
1128
|
+
// turnDepth silently disabled steering for the rest of any turn a user
|
|
1129
|
+
// reacted to). Accepted tradeoff (documented, WONTFIX): in the rare
|
|
1130
|
+
// shape where an ANSWER turn is queued here behind a live turn, a
|
|
1131
|
+
// fresh reply may steer the live turn ahead of the queued answer - both
|
|
1132
|
+
// still reach the model, and an answer coexisting with a live turn only
|
|
1133
|
+
// occurs in multi-user threads.
|
|
875
1134
|
await tracked(serialized(input.threadId, async () => {
|
|
876
1135
|
const fresh = loadSlackThread(input.threadId);
|
|
877
1136
|
if (!fresh) return; // finished while queued
|
|
@@ -1052,7 +1311,9 @@ export function buildServeRuntime(seam: {
|
|
|
1052
1311
|
try {
|
|
1053
1312
|
const { thread, requesterIds } = await seam.streamable(record.threadId);
|
|
1054
1313
|
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
1055
|
-
|
|
1314
|
+
// serializedTurn: a recovery runs a real claude turn, so it must gate
|
|
1315
|
+
// steering-order like any other queued turn.
|
|
1316
|
+
await serializedTurn(record.threadId, async () => {
|
|
1056
1317
|
// a drain signal can land between the scan and this turn; leave the
|
|
1057
1318
|
// marker at its previous count so the next start retries.
|
|
1058
1319
|
if (draining) return;
|
|
@@ -1086,10 +1347,11 @@ export function buildServeRuntime(seam: {
|
|
|
1086
1347
|
// with the user never told the daemon gave up.
|
|
1087
1348
|
await thread.post(decision.notice);
|
|
1088
1349
|
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
1089
|
-
// the abandoned turn's
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
await setStatus({ threadId: thread.id, messageId:
|
|
1350
|
+
// the abandoned turn's messages must not keep reading as processing.
|
|
1351
|
+
for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
|
|
1352
|
+
if (!id) continue;
|
|
1353
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1354
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1093
1355
|
}
|
|
1094
1356
|
return;
|
|
1095
1357
|
}
|
|
@@ -1127,9 +1389,10 @@ export function buildServeRuntime(seam: {
|
|
|
1127
1389
|
// or its hourglass reads "processing" forever (cubic review
|
|
1128
1390
|
// catch on PR #43). The unlinked abandon above stays reactionless
|
|
1129
1391
|
// on purpose: unlinked channels are contractually untouchable.
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
await setStatus({ threadId: thread.id, messageId:
|
|
1392
|
+
for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
|
|
1393
|
+
if (!id) continue;
|
|
1394
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1395
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1133
1396
|
}
|
|
1134
1397
|
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
1135
1398
|
return;
|
|
@@ -1153,8 +1416,8 @@ export function buildServeRuntime(seam: {
|
|
|
1153
1416
|
// nudge and answer-gate the users who were actually asked (vercel
|
|
1154
1417
|
// review catch on PR #43); older markers without the field fall back.
|
|
1155
1418
|
const resumedRequesterIds = decision.marker.requesterIds ?? requesterIds;
|
|
1156
|
-
const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
1157
|
-
await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, requesterIds: resumedRequesterIds });
|
|
1419
|
+
const { outcome, steeredMessageIds } = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
1420
|
+
await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, steeredMessageIds, requesterIds: resumedRequesterIds });
|
|
1158
1421
|
});
|
|
1159
1422
|
} catch (e) {
|
|
1160
1423
|
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
@@ -1276,24 +1539,25 @@ async function runDaemon(): Promise<number> {
|
|
|
1276
1539
|
// held directly (not only via Chat) so startup can re-subscribe recorded
|
|
1277
1540
|
// threads: subscriptions live in this in-memory state and die with the
|
|
1278
1541
|
// process, and only a fresh mention would otherwise revive a thread.
|
|
1279
|
-
const state =
|
|
1542
|
+
const state = new MemoryStateAdapter();
|
|
1280
1543
|
const bot = new Chat({
|
|
1281
1544
|
userName: "tokenmaxxing",
|
|
1282
1545
|
adapters: { slack },
|
|
1283
1546
|
state,
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
|
|
1547
|
+
// CONCURRENT dispatch (steering redesign, superseding the queue strategy
|
|
1548
|
+
// and its 1h-TTL/size-100 tuning): every message reaches onMessage the
|
|
1549
|
+
// moment Slack delivers it, in arrival order. The queue strategy's
|
|
1550
|
+
// 30s dispatch-lock lease made mid-turn messages invisible AND could
|
|
1551
|
+
// reorder them - a reply enqueued behind a live lease was only released
|
|
1552
|
+
// when a LATER message took the expired lock, dispatching newest-first
|
|
1553
|
+
// (adversarial-review catch; chat 4.34.0 handleQueueOrDebounce). The
|
|
1554
|
+
// daemon owns everything the queue used to provide: per-thread turn
|
|
1555
|
+
// ordering (the serialized chain), mid-turn folding (the steering path),
|
|
1556
|
+
// and batching of waiting messages (the per-thread inbox in
|
|
1557
|
+
// buildServeRuntime, drained sorted as ONE folded turn). Chat's
|
|
1558
|
+
// message-id dedupe runs before the concurrency branch, so the
|
|
1559
|
+
// app_mention + message.channels double-delivery stays deduped.
|
|
1560
|
+
concurrency: { strategy: "concurrent" },
|
|
1297
1561
|
// without this a cards-only segment in post-and-edit fallback would
|
|
1298
1562
|
// strand a bare "..." placeholder message.
|
|
1299
1563
|
fallbackStreamingPlaceholderText: null,
|
package/src/lib/install.ts
CHANGED
|
@@ -33,14 +33,65 @@ export function isBinDirAhead(): boolean {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Optional "1"/"true"/"yes" flag; unset/empty → undefined (feature off).
|
|
37
|
+
const EnvFlagSchema = z.enum(["1", "true", "yes"]).optional().catch(undefined);
|
|
38
|
+
|
|
39
|
+
/** True when this process is the Nix-packaged CLI (flake startScript sets
|
|
40
|
+
* TOKENMAXXING_NIX=1; store-path Bun.main is the fallback for wraps that
|
|
41
|
+
* forget the env). Env overrides parse at the read site. */
|
|
42
|
+
export function isNixPackaged(): boolean {
|
|
43
|
+
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_NIX) != null) return true;
|
|
44
|
+
try {
|
|
45
|
+
return realpathSync(Bun.main).startsWith("/nix/store/");
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when a Nix module owns the periodic check timer; init must not write
|
|
52
|
+
* a second imperative unit. */
|
|
53
|
+
export function skipImperativeTimer(): boolean {
|
|
54
|
+
return EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_TIMER) != null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Nix supervisor shim: prefer a PATH-stable `tokenmaxxing` (profile /
|
|
58
|
+
* current-system, excluding this binDir) so upgrades/GC of an old store
|
|
59
|
+
* generation stay reachable; fall back to bun+entry for the rare
|
|
60
|
+
* `nix run ... -- init` case where nothing is on PATH yet (works until that
|
|
61
|
+
* generation is GC'd — docs steer users to `nix profile install` first). */
|
|
62
|
+
function nixSupervisorShim(bun: string, entry: string): string {
|
|
63
|
+
return `#!/bin/sh
|
|
64
|
+
dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
|
65
|
+
old_ifs=$IFS
|
|
66
|
+
IFS=:
|
|
67
|
+
new_path=
|
|
68
|
+
for p in $PATH; do
|
|
69
|
+
[ "$p" = "$dir" ] && continue
|
|
70
|
+
if [ -n "$new_path" ]; then new_path="$new_path:$p"; else new_path="$p"; fi
|
|
71
|
+
done
|
|
72
|
+
IFS=$old_ifs
|
|
73
|
+
PATH=$new_path
|
|
74
|
+
export PATH
|
|
75
|
+
if command -v tokenmaxxing >/dev/null 2>&1; then
|
|
76
|
+
exec tokenmaxxing "$@"
|
|
77
|
+
fi
|
|
78
|
+
exec ${JSON.stringify(bun)} run ${JSON.stringify(entry)} "$@"
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
export function installSupervisor(): InstallOutcome {
|
|
37
83
|
mkdirSync(paths.binDir, { recursive: true });
|
|
38
84
|
const target = installedBin(); // binDir/tokenmaxxing
|
|
39
85
|
// Resolve the entry through the global-bin symlink (bun add -g links
|
|
40
86
|
// ~/.bun/bin/tokenmaxxing → the package's src/main.ts) so the shim points
|
|
41
|
-
// into the installed package tree, where its imports resolve.
|
|
87
|
+
// into the installed package tree, where its imports resolve. Nix shims
|
|
88
|
+
// prefer PATH first (see nixSupervisorShim).
|
|
42
89
|
const entry = realpathSync(Bun.main);
|
|
43
|
-
|
|
90
|
+
if (isNixPackaged()) {
|
|
91
|
+
writeFileAtomic(target, nixSupervisorShim(process.execPath, entry), 0o755);
|
|
92
|
+
} else {
|
|
93
|
+
writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
|
|
94
|
+
}
|
|
44
95
|
|
|
45
96
|
// the on-PATH `claude` wrapper
|
|
46
97
|
writeFileAtomic(paths.supervisorLink, `#!/bin/sh\nexec ${JSON.stringify(target)} __supervise "$@"\n`, 0o755);
|
|
@@ -174,6 +225,10 @@ function run(cmd: string[]): boolean {
|
|
|
174
225
|
* place but activation failed (e.g. systemd user session absent over ssh) -
|
|
175
226
|
* the caller prints the manual activation step. */
|
|
176
227
|
function installCheckTimer(): boolean {
|
|
228
|
+
// Nix module owns the timer (TOKENMAXXING_SKIP_TIMER): do not write a second
|
|
229
|
+
// unit that would double-fire or clobber the declarative one.
|
|
230
|
+
if (skipImperativeTimer()) return true;
|
|
231
|
+
|
|
177
232
|
if (process.platform === "darwin") {
|
|
178
233
|
const plist = launchdPlist();
|
|
179
234
|
writeFileAtomic(
|
|
@@ -243,6 +298,9 @@ export function timerActivationHint(): string {
|
|
|
243
298
|
|
|
244
299
|
/** True when the timer unit exists AND the service manager reports it loaded. */
|
|
245
300
|
export function checkTimerHealthy(): boolean {
|
|
301
|
+
// Declarative Nix timer: init wrote nothing; doctor must not demand the
|
|
302
|
+
// imperative unit.
|
|
303
|
+
if (skipImperativeTimer()) return true;
|
|
246
304
|
if (process.platform === "darwin") {
|
|
247
305
|
const domain = launchdDomain();
|
|
248
306
|
return existsSync(launchdPlist()) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
|
|
@@ -305,6 +363,8 @@ function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
|
305
363
|
* loaded must deactivate successfully, and an unanswerable probe (service
|
|
306
364
|
* manager unusable) reports false rather than pretending it is gone. */
|
|
307
365
|
function uninstallCheckTimer(): boolean {
|
|
366
|
+
// Nix owns the timer: do not bootout/disable the declarative unit.
|
|
367
|
+
if (skipImperativeTimer()) return true;
|
|
308
368
|
if (process.platform === "darwin") {
|
|
309
369
|
const domain = launchdDomain();
|
|
310
370
|
const loaded = launchdJobLoaded();
|
package/src/lib/slackbridge.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { delay } from "es-toolkit";
|
|
12
|
-
import { createSdkMcpServer, query, tool, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
+
import { createSdkMcpServer, query, tool, type SDKUserMessage, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
13
13
|
import { StreamingMarkdownRenderer, type StreamChunk } from "chat";
|
|
14
14
|
import { ensureBestAccount, pooledOptions, stopHookCheck, type SwapDecision } from "../sdk.ts";
|
|
15
15
|
import { POST_SWAP_COOLDOWN_MS } from "./decide.ts";
|
|
@@ -56,6 +56,14 @@ export const TurnOutcomeSchema = z.object({
|
|
|
56
56
|
* thread's activeTurn marker with resumeAt and the daemon resumes the turn
|
|
57
57
|
* itself, instead of the old "re-send it once the pool recovers" drop. */
|
|
58
58
|
deferUntil: z.number().nullable(),
|
|
59
|
+
/** a steered follow-up's own drained turn FAILED after the primary turn
|
|
60
|
+
* already succeeded (success stays sticky, the loss is announced
|
|
61
|
+
* in-thread): the caller settles the steered messages' reactions as
|
|
62
|
+
* failed, never as done - a lost instruction must not read green.
|
|
63
|
+
* Attribution is per-turn, not per-message (steer() carries text only),
|
|
64
|
+
* so when several messages were steered a successfully folded one can
|
|
65
|
+
* read failed too - accepted: a false re-send ask beats a false green. */
|
|
66
|
+
steerLost: z.boolean(),
|
|
59
67
|
});
|
|
60
68
|
export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
|
|
61
69
|
|
|
@@ -234,6 +242,60 @@ export function serveTurnContext(input: { requesterIds: string[] }): string {
|
|
|
234
242
|
const SegmentChunkSchema = z.union([z.string(), z.custom<StreamChunk>()]);
|
|
235
243
|
type SegmentChunk = z.infer<typeof SegmentChunkSchema>;
|
|
236
244
|
|
|
245
|
+
/** The exact user-message shape the SDK's own string-prompt path writes to the
|
|
246
|
+
* child's stdin (verified in @anthropic-ai/claude-agent-sdk 0.3.214: the SDK
|
|
247
|
+
* serializes yielded messages verbatim, adding nothing). No uuid on purpose:
|
|
248
|
+
* the CLI dedupes stream-json user messages by uuid and silently swallows a
|
|
249
|
+
* reused one, so a uuid derived from a Slack message ts would eat retries.
|
|
250
|
+
* No priority either: the default "next" folds the message into the running
|
|
251
|
+
* turn at the next tool boundary, while "now" is an undocumented hard
|
|
252
|
+
* interrupt that would abort the turn mid-tool (both verified in the claude
|
|
253
|
+
* 2.1.220 binary). */
|
|
254
|
+
function steerUserMessage(text: string): SDKUserMessage {
|
|
255
|
+
return { type: "user", session_id: "", message: { role: "user", content: [{ type: "text", text }] }, parent_tool_use_id: null };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A hand-pushed async iterable of SDK user messages: the streaming-input
|
|
259
|
+
* prompt for one query attempt. The initial prompt is pushed before query()
|
|
260
|
+
* and steered messages join mid-turn; end() closes the child's stdin (the
|
|
261
|
+
* SDK ends the stream when the iterable finishes). */
|
|
262
|
+
function pushableMessages(): {
|
|
263
|
+
iterable: AsyncIterable<SDKUserMessage>;
|
|
264
|
+
push: (m: SDKUserMessage) => void;
|
|
265
|
+
end: () => void;
|
|
266
|
+
} {
|
|
267
|
+
const queued: SDKUserMessage[] = [];
|
|
268
|
+
let cursor = 0;
|
|
269
|
+
let done = false;
|
|
270
|
+
let notify: (() => void) | null = null;
|
|
271
|
+
return {
|
|
272
|
+
push(m) {
|
|
273
|
+
queued.push(m);
|
|
274
|
+
notify?.();
|
|
275
|
+
},
|
|
276
|
+
end() {
|
|
277
|
+
done = true;
|
|
278
|
+
notify?.();
|
|
279
|
+
},
|
|
280
|
+
iterable: {
|
|
281
|
+
async *[Symbol.asyncIterator]() {
|
|
282
|
+
while (true) {
|
|
283
|
+
while (cursor < queued.length) {
|
|
284
|
+
const next = queued[cursor]!;
|
|
285
|
+
cursor += 1;
|
|
286
|
+
yield next;
|
|
287
|
+
}
|
|
288
|
+
if (done) return;
|
|
289
|
+
await new Promise<void>((resolve) => {
|
|
290
|
+
notify = resolve;
|
|
291
|
+
});
|
|
292
|
+
notify = null;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
237
299
|
/** A hand-pushed async iterable: relayThread feeds one of these per Slack
|
|
238
300
|
* message segment while thread.post concurrently drains it. */
|
|
239
301
|
function pushableStream(): {
|
|
@@ -478,8 +540,22 @@ export async function relayThread(input: {
|
|
|
478
540
|
/** daemon shutdown signal: aborts park/retry sleeps so a drain never sits
|
|
479
541
|
* out a depleted-pool countdown. */
|
|
480
542
|
drainSignal?: AbortSignal;
|
|
543
|
+
/** Steering seam. Called with a steer function while a query attempt is
|
|
544
|
+
* live and with null when it ends; steer(text) returns true when the text
|
|
545
|
+
* was written into the RUNNING attempt's stdin (the CLI folds it into the
|
|
546
|
+
* turn at the next tool boundary, or runs it as its own turn in the same
|
|
547
|
+
* child when the fold window is gone - either way it reaches the session,
|
|
548
|
+
* verified against claude 2.1.220), false once the attempt's result
|
|
549
|
+
* arrived or the attempt died - the caller then queues the message as a
|
|
550
|
+
* normal next turn instead. Accepted texts also fold into any RETRY
|
|
551
|
+
* attempt's prompt, mirroring the existing resend-the-full-prompt retry
|
|
552
|
+
* tradeoff (duplication in the resumed transcript beats loss).
|
|
553
|
+
* input.requesterIds is shared by reference on purpose: the caller may
|
|
554
|
+
* push a steer author's id so a retry attempt's UserPromptSubmit context
|
|
555
|
+
* names them (the hook does not fire for folded mid-turn messages). */
|
|
556
|
+
onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
|
|
481
557
|
}): Promise<TurnOutcome> {
|
|
482
|
-
const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null };
|
|
558
|
+
const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null, steerLost: false };
|
|
483
559
|
let segment: ReturnType<typeof pushableStream> | null = null;
|
|
484
560
|
// acc mirrors the segment's pushed text (bounded by SEGMENT_TEXT_MAX plus a
|
|
485
561
|
// small overshoot): fence parity must be computed over the ACCUMULATED text,
|
|
@@ -846,8 +922,15 @@ export async function relayThread(input: {
|
|
|
846
922
|
// deferral notice explains the pause; the raw line would invite a manual
|
|
847
923
|
// re-send of work the daemon resumes itself - cubic catch, PR #44).
|
|
848
924
|
let pendingFailureLine: string | null = null;
|
|
849
|
-
//
|
|
850
|
-
//
|
|
925
|
+
// texts steered into this message's turn, kept across retries: a retry
|
|
926
|
+
// resumes the session and re-sends the attempt prompt (the established
|
|
927
|
+
// duplication-beats-loss tradeoff), so steered text folds into every
|
|
928
|
+
// retry attempt's first message too - a steer written just before a
|
|
929
|
+
// mid-turn limit killed the child must not vanish from the turn it joined.
|
|
930
|
+
const steeredTexts: string[] = [];
|
|
931
|
+
// what the next spawn submits ahead of the steered texts: the original
|
|
932
|
+
// message, until a transient retry swaps in the continuation wrapper (see
|
|
933
|
+
// the retry branch).
|
|
851
934
|
let prompt = input.prompt;
|
|
852
935
|
const runQueryOnce = async () => {
|
|
853
936
|
postedText = false;
|
|
@@ -863,11 +946,47 @@ export async function relayThread(input: {
|
|
|
863
946
|
// inside the try: a malformed claude.json must fail the TURN, not the
|
|
864
947
|
// relay's never-throws contract.
|
|
865
948
|
let spawnOrg: string | null = null;
|
|
949
|
+
// Streaming input: the prompt rides an open stdin stream instead of a
|
|
950
|
+
// one-shot string, which is what lets a mid-turn Slack reply steer the
|
|
951
|
+
// running turn (the CLI folds a queued stream-json user message into the
|
|
952
|
+
// current turn at the next tool boundary; one that misses the last fold
|
|
953
|
+
// window runs as its own turn in the same child before exit, so nothing
|
|
954
|
+
// is ever dropped - both verified against claude 2.1.220 + SDK 0.3.214).
|
|
955
|
+
// steer() refuses the moment the attempt's result arrives: stdin ends
|
|
956
|
+
// then, and the SDK silently drops writes to an ended stdin.
|
|
957
|
+
const stream = pushableMessages();
|
|
958
|
+
let steerable = true;
|
|
959
|
+
const steer = (text: string, onAccept?: () => void): boolean => {
|
|
960
|
+
if (!steerable) return false;
|
|
961
|
+
// the caller's SYNCHRONOUS durable commit runs inside the acceptance,
|
|
962
|
+
// in the same JS tick as the liveness check and BEFORE the child sees
|
|
963
|
+
// the text (adversarial-review catch, round 3): steerable=true here
|
|
964
|
+
// proves the turn has not ended, so the commit's view of the turn
|
|
965
|
+
// state cannot be stale, a crash between commit and push replays a
|
|
966
|
+
// steer the child never saw (duplication over loss), and a refusal
|
|
967
|
+
// commits NOTHING - a stale acceptor invocation racing the turn's end
|
|
968
|
+
// can no longer resurrect a finished turn's marker or clobber
|
|
969
|
+
// post-turn state. A THROWING commit escapes to the caller before
|
|
970
|
+
// anything is pushed or recorded here: the stream, steeredTexts, and
|
|
971
|
+
// the sticky flags are untouched, so the caller can treat the throw
|
|
972
|
+
// as a refusal and a later steer still works.
|
|
973
|
+
onAccept?.();
|
|
974
|
+
steeredTexts.push(text);
|
|
975
|
+
stream.push(steerUserMessage(text));
|
|
976
|
+
// a steer answers a LIVE ask too (codex review catch on PR #50): when
|
|
977
|
+
// need_attention already fired in this attempt, the ask exists only as
|
|
978
|
+
// this sticky flag until settleTurn persists it - left set, the
|
|
979
|
+
// answered ask would still get a question mark and a nudge. A later
|
|
980
|
+
// need_attention call re-arms it for a genuinely new ask.
|
|
981
|
+
outcome.attention = false;
|
|
982
|
+
return true;
|
|
983
|
+
};
|
|
866
984
|
try {
|
|
867
985
|
spawnOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
868
986
|
const pooled = pooledOptions();
|
|
987
|
+
stream.push(steerUserMessage([prompt, ...steeredTexts].join("\n\n")));
|
|
869
988
|
const q = query({
|
|
870
|
-
prompt,
|
|
989
|
+
prompt: stream.iterable,
|
|
871
990
|
options: {
|
|
872
991
|
...pooled,
|
|
873
992
|
// claude >= 2.1.142 emits the structured Task tools by default and
|
|
@@ -934,8 +1053,18 @@ export async function relayThread(input: {
|
|
|
934
1053
|
...(outcome.sessionId ? { resume: outcome.sessionId } : {}),
|
|
935
1054
|
},
|
|
936
1055
|
});
|
|
1056
|
+
// registered only while this attempt is live (cleared in the finally):
|
|
1057
|
+
// the caller's fallback for a refused steer is the normal queued turn.
|
|
1058
|
+
input.onSteer?.(steer);
|
|
937
1059
|
const mapState = newStreamMapState();
|
|
938
1060
|
let result: string | null = null;
|
|
1061
|
+
// reply text streamed since the last result boundary: each turn in the
|
|
1062
|
+
// child (the primary one, plus any post-fold-window steer drained as
|
|
1063
|
+
// its own turn) delivers its answer independently - a tool-only turn's
|
|
1064
|
+
// answer lives ONLY in its result message, and flushing it at that
|
|
1065
|
+
// result is what keeps a trailing turn's notice or result from
|
|
1066
|
+
// suppressing or clobbering it (adversarial-review catch, round 2).
|
|
1067
|
+
let streamedSinceResult = false;
|
|
939
1068
|
for await (const message of q) {
|
|
940
1069
|
if (message.type === "system" && message.subtype === "init") {
|
|
941
1070
|
// persist BEFORE the turn ends so a first-turn kill stays
|
|
@@ -946,41 +1075,82 @@ export async function relayThread(input: {
|
|
|
946
1075
|
}
|
|
947
1076
|
if (message.type === "result") {
|
|
948
1077
|
outcome.sessionId = message.session_id;
|
|
1078
|
+
// any result ends steerability and closes the child's stdin: a
|
|
1079
|
+
// steer arriving now queues as its own next turn instead. A steer
|
|
1080
|
+
// accepted BEFORE this that missed its fold window still runs -
|
|
1081
|
+
// the CLI drains queued commands as their own turns in this same
|
|
1082
|
+
// child before exiting, emitting a further result each time, so
|
|
1083
|
+
// this loop just keeps consuming until the child exits (end() is
|
|
1084
|
+
// idempotent).
|
|
1085
|
+
steerable = false;
|
|
1086
|
+
stream.end();
|
|
949
1087
|
// is_error can ride a "success" subtype (a mid-turn usage limit
|
|
950
1088
|
// arrives exactly that way: result "Claude AI usage limit
|
|
951
1089
|
// reached|<epoch>"), so errored is a field check, not a subtype
|
|
952
1090
|
// check - and only an errored result is ever limit-classified.
|
|
953
1091
|
if (message.is_error || message.subtype !== "success") {
|
|
954
1092
|
const text = erroredResultText(message);
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
//
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1093
|
+
const limited = isRateLimitText({ text });
|
|
1094
|
+
// persist the observation either way: the next spawn decision
|
|
1095
|
+
// must see the limit even when this turn does not retry.
|
|
1096
|
+
if (limited) await recordObservedLimit({ text, now: Date.now(), org: spawnOrg });
|
|
1097
|
+
if (outcome.resultReceived) {
|
|
1098
|
+
// SUCCESS IS STICKY within an attempt (adversarial-review
|
|
1099
|
+
// catch): this errored result belongs to a post-fold-window
|
|
1100
|
+
// steer's own drained turn, arriving AFTER the primary turn
|
|
1101
|
+
// already succeeded and delivered its answer. Marking the turn
|
|
1102
|
+
// failed here would send the whole prompt back through the
|
|
1103
|
+
// retry/defer machinery and re-run completed work - the exact
|
|
1104
|
+
// duplicate-execution the outcome contract forbids. The steer
|
|
1105
|
+
// is announced lost instead (drop-beats-false-promise), and
|
|
1106
|
+
// steerLost settles the steered messages' reactions as failed.
|
|
1107
|
+
outcome.steerLost = true;
|
|
1108
|
+
log("serve.steered_turn_failed", { limited });
|
|
1109
|
+
await notify(
|
|
1110
|
+
limited
|
|
1111
|
+
? "a steered follow-up message hit a usage limit before it could run - please re-send it."
|
|
1112
|
+
: "a steered follow-up message failed - please re-send it.",
|
|
1113
|
+
);
|
|
1114
|
+
} else {
|
|
1115
|
+
outcome.failed = true;
|
|
1116
|
+
// a limit classification is sticky across a child's errored
|
|
1117
|
+
// results (adversarial-review catch, round 2): the drained
|
|
1118
|
+
// steer turn's generic death must not declassify the primary
|
|
1119
|
+
// turn's recoverable limit back to a plain failure.
|
|
1120
|
+
outcome.rateLimited = outcome.rateLimited || limited;
|
|
1121
|
+
// hold the REAL errored text for the terminal diagnostic (codex
|
|
1122
|
+
// review catches: a streamed-then-errored turn used to end with
|
|
1123
|
+
// a truncated answer and only the x reaction, and a no-text
|
|
1124
|
+
// errored turn used to discard the reason for a generic line).
|
|
1125
|
+
// The depleted-pool probe still outranks the line.
|
|
1126
|
+
if (!limited) {
|
|
1127
|
+
const reason = text.slice(0, 200) || "no error detail";
|
|
1128
|
+
pendingFailureLine = postedText
|
|
1129
|
+
? `tokenmaxxing: the turn errored before finishing (${reason}) - the reply above may be incomplete.`
|
|
1130
|
+
: `tokenmaxxing: the turn errored without a result (${reason}) - trying again may help.`;
|
|
1131
|
+
}
|
|
971
1132
|
}
|
|
972
1133
|
} else {
|
|
973
1134
|
result = message.result;
|
|
974
1135
|
outcome.resultReceived = true;
|
|
1136
|
+
// a turn that streamed no reply text (tool-only turns) still
|
|
1137
|
+
// reports: its answer is flushed HERE, per result, not after the
|
|
1138
|
+
// loop - a later turn in the same child must not suppress it. A
|
|
1139
|
+
// paragraph break ahead of it when text already posted, or two
|
|
1140
|
+
// result-only answers would concatenate mid-line (codex review
|
|
1141
|
+
// catch on PR #50).
|
|
1142
|
+
if (!streamedSinceResult && result) await pushText(`${postedText ? "\n\n" : ""}${result}`);
|
|
975
1143
|
}
|
|
1144
|
+
streamedSinceResult = false;
|
|
976
1145
|
}
|
|
977
1146
|
for (const part of agentEventChunks({ state: mapState, message })) {
|
|
978
1147
|
if (part instanceof Object) await push(part);
|
|
979
|
-
else
|
|
1148
|
+
else {
|
|
1149
|
+
if (part.trim() !== "") streamedSinceResult = true;
|
|
1150
|
+
await pushText(part);
|
|
1151
|
+
}
|
|
980
1152
|
}
|
|
981
1153
|
}
|
|
982
|
-
// a turn that produced no streamed text (tool-only turns) still reports.
|
|
983
|
-
if (!postedText && result) await pushText(result);
|
|
984
1154
|
} catch (e) {
|
|
985
1155
|
outcome.failed = true;
|
|
986
1156
|
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
@@ -991,6 +1161,13 @@ export async function relayThread(input: {
|
|
|
991
1161
|
// deferral's own notice explains the pause better than a raw child
|
|
992
1162
|
// error that reads as "please re-send".
|
|
993
1163
|
if (!outcome.rateLimited) pendingFailureLine = `tokenmaxxing: turn failed: ${detail}`;
|
|
1164
|
+
} finally {
|
|
1165
|
+
// a died attempt must stop accepting steers (they would be silent
|
|
1166
|
+
// drops on an ended stdin) and must release the caller's steer hook
|
|
1167
|
+
// before the retry loop decides anything.
|
|
1168
|
+
steerable = false;
|
|
1169
|
+
stream.end();
|
|
1170
|
+
input.onSteer?.(null);
|
|
994
1171
|
}
|
|
995
1172
|
};
|
|
996
1173
|
|
package/src/lib/slackstate.ts
CHANGED
|
@@ -133,6 +133,15 @@ const ActiveTurnSchema = z.object({
|
|
|
133
133
|
* Absent on older records: recovery falls back to the streamable
|
|
134
134
|
* handle's newest-author derivation. */
|
|
135
135
|
requesterIds: z.array(z.string()).optional(),
|
|
136
|
+
/** Slack ids of messages STEERED into this turn mid-run: they carry the
|
|
137
|
+
* same hourglass-to-terminal reaction lifecycle as the triggering
|
|
138
|
+
* message, so a killed or deferred turn's recovery must settle them too.
|
|
139
|
+
* Their text is already folded into `prompt` at steer time, which is what
|
|
140
|
+
* makes replays and retries include what the user steered in. An inbound
|
|
141
|
+
* takeover of a DEFERRED turn also adopts the held turn's unsettled ids
|
|
142
|
+
* here: the takeover serves their held prompt, and without the adoption
|
|
143
|
+
* the old trigger's hourglass would read "processing" forever. */
|
|
144
|
+
steeredMessageIds: z.array(z.string()).optional(),
|
|
136
145
|
});
|
|
137
146
|
export type ActiveTurn = z.infer<typeof ActiveTurnSchema>;
|
|
138
147
|
|