tokenmaxxing 1.3.0 → 1.5.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/package.json +1 -1
- package/src/cli/serve.ts +458 -67
- package/src/lib/install.ts +3 -2
- package/src/lib/slackbridge.ts +334 -22
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|