tokenmaxxing 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -103,7 +103,8 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
103
103
  - **Usage-limit auto-resume** (2026-07-21, superseding the drop-beats-false-promise rule for the known-wake case): a turn that ends at a usage limit with a KNOWN recovery time defers instead of dropping. relayThread reports `deferUntil`, the thread's durable activeTurn marker keeps a `resumeAt`, and a per-thread daemon timer (cancelled on drain, re-armed from the record at startup: future wakes schedule, past wakes recover immediately) fires the same interrupted-turn recovery path - serialized chain, fresh-reload supersede check, pool-recovery notice, `resumeDecision` capping total resumes. The defer triggers: known recovery past the parking deadline or recovery budget, retries exhausted while a post-burn pool probe reports a wake, and an unclassifiable child failure (no limit phrase in the error) converted by a pool probe reporting exhaustion - the pool state is the evidence the error text did not carry. Only an unknown recovery time still posts the honest re-send drop.
104
104
  - **Slack-native output hygiene**: relayed turns run a small standalone `systemPrompt` telling the model replies render as Slack markdown, never HTML (a live turn once answered with a literal `<br>`; the SDK's default system prompt is minimal since 0.1.0, so the string replaces nothing).
105
105
  - **Slash commands** (2026-07-18): a mention-stripped thread message that starts with `/` runs as a claude slash command - the SDK delivers a string prompt as one stream-json user message and the CLI routes a leading-slash prompt through its command table, on fresh and resumed sessions alike (live-verified with free `/usage` and `/context` reads). Local command output (`num_turns` 0) arrives as a non-streamed assistant message plus `result.result`, which relayThread's no-text fallback posts; `slackstream` also maps the documented `system/local_command_output` wire subtype in case a claude update flips the emitter. `/goal` (built-in since 2.1.139) works headless and its active goal is restored on resume, so it survives the per-message resume pattern. Slack gotcha: the composer eats ANY message whose first character is `/` client-side (channels and thread replies; registered app commands cannot even dispatch from threads), so the supported forms are `@bot /usage` (mention-first) and ` /usage` (leading space, Slack's own documented workaround) - both normalize to a position-0 command via `stripLeadingMention`.
106
- - **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output) that Slack groups into ONE collapsible plan block per turn (the serve edge wraps every posted segment in a `StreamingPlan` with `groupTasks: "plan"`, Slack's `task_display_mode: "plan"`; user ask 2026-07-20 "squash them into one dropdown", superseding the 2026-07-18 separate-messages shape), and reply text as native markdown with rendered code fences - so one turn streams as a single Slack message: the reply plus one dropdown of its activity. A later main text block (typically post-tool prose) opens on a `\n\n` paragraph break (`textStreamed`), restoring the separation the per-tool message split used to provide; whitespace-only deltas do not arm it. Notices still post as their own messages, and a rejected append (Slack's message cap, a finalized idle stream) carries its undelivered chunks into a follow-on message via the dead-segment salvage.
106
+ - **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output) that Slack groups into ONE collapsible plan block per turn (the serve edge wraps every posted segment in a `StreamingPlan` with `groupTasks: "plan"`, Slack's `task_display_mode: "plan"`; user ask 2026-07-20 "squash them into one dropdown", superseding the 2026-07-18 separate-messages shape), and reply text as native markdown with rendered code fences - so one turn streams as a single Slack message: the reply plus one dropdown of its activity. Reply text also splits at `SEGMENT_TEXT_MAX` (10,000 chars): Slack rejects an over-long message with `msg_too_long` (the streamed message accumulates server-side toward the documented 12,000-char `markdown_text` envelope) and nothing in the chat stack bounds or splits text, so one over-cap append used to reject the whole post and lose the reply (live incident 2026-07-20); a break forced inside a code fence closes and reopens the fence, fence parity is tracked over the segment's accumulated text (SDK deltas do not respect markdown token boundaries), and a cut never slices through a backtick run. A later main text block (typically post-tool prose) opens on a `\n\n` paragraph break (`textStreamed`), restoring the separation the per-tool message split used to provide; whitespace-only deltas do not arm it. Notices still post as their own messages, and a rejected append (Slack's message cap, a finalized idle stream) carries its undelivered chunks into a follow-on message via the dead-segment salvage.
107
+ - **One Slack app per daemon** (live incident 2026-07-20): Slack Socket Mode delivers each event envelope to exactly one of an app's open connections, so two serve daemons sharing one Slack app steal each other's events - mentions usually survive (Slack emits `app_mention` plus `message.channels` for one mention and the Chat SDK dedupes them per-process), but a non-mention thread follow-up rides a single envelope and silently dies when the wrong daemon receives it. No code can route Slack's load-balancing (the hello frame's `num_connections` is discarded by `@slack/socket-mode` before any event fires), so the constraint is documented (docs limitations page) and diagnosed: `serve.unlinked_channel` logs once per channel per run with the shared-app explanation. Every host needs its own Slack app with its own tokens.
107
108
  - **Todo checklist card**: TodoWrite is bookkeeping, not a real tool run, so it never opens a generic card. Instead each stream gets one stable-id "Todos" card (id `todos`, subagents `todos-<parent_tool_use_id>`) that updates in place on every TodoWrite: `✅ content` for completed, `🔄 activeForm` for the in-progress item (live narration), `⬜ content` for pending (the Chat SDK Plan object's own iconography); card status goes complete only when every item is completed. The "Todos have been modified successfully" tool_result is suppressed, but a FAILED TodoWrite flips the card to error (the optimistic checklist must not claim a state that never took effect). Card ids do not carry across messages, so a TodoWrite after a dead-segment recovery starts a fresh card in the follow-on message: accepted, the latest state is always in the newest message. claude >= 2.1.142 defaults to the structured Task tools and never emits TodoWrite, so relayThread sets `CLAUDE_CODE_ENABLE_TASKS: "0"` in the spawn env (the documented opt-out; without it the checklist card is inert).
108
109
  - **Terminal echo** (2026-07-18): the daemon registers a `log()` echo (`setLogEcho` in log.ts, off by default so hooks and the statusline keep their stdout protocols clean), so every event while it runs - serve.* plus the in-process swap/decision events from `ensureBestAccount()`/`stopHookCheck` - also prints one colored line in the foreground terminal (`formatLogLine`: dim HH:MM:SS, event painted red/yellow/cyan by structural endsWith severity, redacted fields). `serve.turn_done`/`serve.turn_failed` (with seconds) close every relayed turn, so a foreground `xx serve` is observable without tailing `tokenmaxxing.log`.
109
110
  - **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens a session (worktree-per-thread at the time), replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, stream mapping, fail-fast paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/serve.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import { existsSync, realpathSync } from "node:fs";
19
19
  import { delay, omit, uniq } from "es-toolkit";
20
20
  import { z } from "zod";
21
- import { Chat, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
21
+ import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
22
22
  import { createSlackAdapter } from "@chat-adapter/slack";
23
23
  import { createMemoryState } from "@chat-adapter/state-memory";
24
24
  import {
@@ -328,6 +328,8 @@ export function buildServeRuntime(seam: {
328
328
  // holds the restart hostage.
329
329
  const drainAbort = new AbortController();
330
330
  let draining = false;
331
+ // channels already diagnosed as unlinked this run (see handleTurn).
332
+ const unlinkedLogged = new Set<string>();
331
333
 
332
334
  /** One relayed turn with the durable activeTurn marker around it: written
333
335
  * before the spawn, cleared when the turn returns, so a marker surviving
@@ -426,8 +428,17 @@ export function buildServeRuntime(seam: {
426
428
  // checked BEFORE the draining branch: unlinked channels are
427
429
  // contractually log-only silent, and a drain-window drop notice posted
428
430
  // into one would tell a user to resend a message that will never be
429
- // served (closing-review catch).
430
- log("serve.unlinked_channel", { channel: thread.channelId });
431
+ // served (closing-review catch). Logged once per channel per daemon run:
432
+ // with several daemons sharing one Slack app this fires on every
433
+ // load-balanced envelope for a sibling's channel (live incident
434
+ // 2026-07-20), and the diagnosis needs one line, not a stream.
435
+ if (!unlinkedLogged.has(thread.channelId)) {
436
+ unlinkedLogged.add(thread.channelId);
437
+ log("serve.unlinked_channel", {
438
+ channel: thread.channelId,
439
+ note: "not linked on this host; if another tokenmaxxing serve shares this Slack app, Slack delivers each socket event to only ONE of them and thread replies get lost - give every daemon its own Slack app",
440
+ });
441
+ }
431
442
  return; // not a linked channel - stay silent in Slack
432
443
  }
433
444
  if (draining) {
@@ -865,6 +876,11 @@ async function runDaemon(): Promise<number> {
865
876
  // @slack/web-api's fiveRetriesInFiveMinutes literal (dep not declared,
866
877
  // so the values are inlined).
867
878
  webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
879
+ // the adapter's default logger is info-level and prints a line per
880
+ // redelivered socket envelope ("Processing socket mode retry") - steady
881
+ // noise during restart catch-up. warn matches the Chat logger below;
882
+ // real degradations (streaming fallback etc.) are warn-level and survive.
883
+ logger: new ConsoleLogger("warn", "chat-sdk").child("slack"),
868
884
  });
869
885
  // held directly (not only via Chat) so startup can re-subscribe recorded
870
886
  // threads: subscriptions live in this in-memory state and die with the
@@ -263,6 +263,23 @@ function pushableStream(): {
263
263
  };
264
264
  }
265
265
 
266
+ /** Slack rejects an over-long message with msg_too_long, and NOTHING in chat
267
+ * 4.34.0 or the Slack adapter bounds, truncates, or splits reply text
268
+ * (verified in-source 2026-07-20): a natively streamed message accumulates
269
+ * server-side toward the 12,000-char markdown_text envelope (docs.slack.dev
270
+ * documents that limit on chat.postMessage/update and all three streaming
271
+ * methods), the post-and-edit fallback re-sends the FULL accumulated text as
272
+ * markdown_text on every edit, and once anything rendered natively a failed
273
+ * append REJECTS the whole thread.post - the reply dies (live incident
274
+ * 2026-07-20, three failed turns). relayThread therefore splits reply text
275
+ * across Slack messages BEFORE the cap; the 2,000-char margin absorbs the
276
+ * renderer's markdown normalization and mention-linkification expansion.
277
+ * Tradeoff (accepted): the adapter-internal plain-text fallback edits via
278
+ * chat.update `text` (hard 4,000-char cap), but it only engages when the
279
+ * workspace refused native streaming outright - splitting every normal reply
280
+ * 3x tighter to cover that never-hit path is worse than the residual risk. */
281
+ export const SEGMENT_TEXT_MAX = 10_000;
282
+
266
283
  /** Full permission name of the finish_thread tool (mcp__<server>__<tool>):
267
284
  * it must be in allowedTools, because no one can answer a permission prompt
268
285
  * through Slack. */
@@ -431,7 +448,12 @@ export async function relayThread(input: {
431
448
  }): Promise<TurnOutcome> {
432
449
  const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, announcedDrop: false, resultReceived: false, deferUntil: null };
433
450
  let segment: ReturnType<typeof pushableStream> | null = null;
434
- let segmentMeta: { text: boolean } | null = null;
451
+ // acc mirrors the segment's pushed text (bounded by SEGMENT_TEXT_MAX plus a
452
+ // small overshoot): fence parity must be computed over the ACCUMULATED text,
453
+ // never per chunk - SDK deltas do not respect markdown token boundaries, so
454
+ // a ``` split across two deltas would be invisible to per-chunk counting
455
+ // (pullfrog review catch, PR #42).
456
+ let segmentMeta: { text: boolean; acc: string; reopenFence: boolean } | null = null;
435
457
  let lastPost: Promise<unknown> = Promise.resolve();
436
458
  // Reply TEXT that died with a rejected segment and was neither salvaged into
437
459
  // a follow-on message nor re-delivered by a later text-bearing segment: the
@@ -459,7 +481,14 @@ export async function relayThread(input: {
459
481
  const openSegment = () => {
460
482
  const seg = pushableStream();
461
483
  segment = seg;
462
- const meta = { text: false };
484
+ // reopenFence: the delivered prefix of a REJECTED predecessor left a code
485
+ // fence open, so the first TEXT entering this salvage segment must be
486
+ // preceded by a reopen or it renders outside the code block. Pending
487
+ // rather than pushed eagerly (pullfrog catches, PR #42): a card-only
488
+ // salvage would otherwise either skip the reopen (later text joining the
489
+ // segment renders unfenced) or dangle an empty open fence at message end
490
+ // when no text ever follows. Materialized by BOTH text entry points.
491
+ const meta = { text: false, acc: "", reopenFence: false };
463
492
  segmentMeta = meta;
464
493
  lastPost = input.post(seg.iterable).then(
465
494
  () => {
@@ -541,6 +570,22 @@ export async function relayThread(input: {
541
570
  rest = rest.slice(nl + 1);
542
571
  }
543
572
  }
573
+ // A fence OPENED in the delivered prefix leaves later code fenceless
574
+ // in the fresh salvage message (pullfrog catches, PR #42): arm the
575
+ // segment's pending reopen, materialized right before the FIRST text
576
+ // that enters it - whether a salvaged remainder piece here or a later
577
+ // streamed push joining the segment (a card-only salvage must not
578
+ // skip the reopen, and a text-less segment must not dangle one).
579
+ // Fold this dying segment's OWN still-armed reopen into the parity:
580
+ // an armed-but-never-materialized reopenFence means the segment
581
+ // logically BEGAN inside an open fence (a card-only salvage that
582
+ // inherited one and died before any text materialized it), so that
583
+ // open state must propagate to the next salvage or its later text
584
+ // renders unfenced (vercel + cubic chained-salvage catch, PR #42).
585
+ // Once materialized, reopenFence is false and the reopen chunk is in
586
+ // fullRaw, so the XOR is a no-op; a normal segment's flag is false.
587
+ const deliveredFenceOpen =
588
+ ((fullRaw.slice(0, deliveredLen).split("```").length - 1) % 2 === 1) !== meta.reopenFence;
544
589
  if (lost.length > 0 && salvagesLeft > 0) {
545
590
  salvagesLeft -= 1;
546
591
  log("serve.post_salvage", { chunks: lost.length, left: salvagesLeft });
@@ -549,6 +594,7 @@ export async function relayThread(input: {
549
594
  // of any push still awaiting lastPost; the salvage segment's own
550
595
  // settle then decides whether its text counts as delivered.
551
596
  const next = openSegment();
597
+ next.meta.reopenFence = deliveredFenceOpen;
552
598
  for (const c of lost) next.pushInto(c);
553
599
  } else if (textRemainder !== "") {
554
600
  textLost = true;
@@ -561,11 +607,23 @@ export async function relayThread(input: {
561
607
  // at its original push, and postedText is attempt-scoped - a salvage
562
608
  // landing after a retry reset would otherwise re-arm it and suppress
563
609
  // the retry's `!postedText && result` fallback, silently dropping a
564
- // result-only answer (adversarial-review catch on PR #45).
565
- if (!(chunk instanceof Object)) meta.text = true;
610
+ // result-only answer (adversarial-review catch on PR #45). acc still
611
+ // accumulates: it mirrors the segment's FULL text on every entry path,
612
+ // so pushText's room accounting and fence parity see salvaged text too
613
+ // (a salvage segment that continued via pushText could otherwise grow
614
+ // past the msg_too_long cap).
615
+ if (!(chunk instanceof Object)) {
616
+ if (meta.reopenFence) {
617
+ meta.reopenFence = false;
618
+ meta.acc += "```\n";
619
+ seg.push("```\n");
620
+ }
621
+ meta.text = true;
622
+ meta.acc += chunk;
623
+ }
566
624
  seg.push(chunk);
567
625
  };
568
- return { seg, pushInto };
626
+ return { seg, meta, pushInto };
569
627
  };
570
628
  const push = async (chunk: SegmentChunk) => {
571
629
  if (segment === null) {
@@ -576,15 +634,66 @@ export async function relayThread(input: {
576
634
  }
577
635
  const target = segment ?? openSegment().seg;
578
636
  if (!(chunk instanceof Object)) {
637
+ // materialize a salvage segment's pending fence reopen (see
638
+ // openSegment) before the first text from this entry point too.
639
+ if (segmentMeta!.reopenFence) {
640
+ segmentMeta!.reopenFence = false;
641
+ segmentMeta!.acc += "```\n";
642
+ target.push("```\n");
643
+ }
579
644
  postedText = true;
580
645
  segmentMeta!.text = true;
646
+ segmentMeta!.acc += chunk;
581
647
  }
582
648
  target.push(chunk);
583
649
  };
650
+ // parity by occurrence count over the segment's accumulated text: an odd
651
+ // number of ``` markers means the segment currently sits inside a fence.
652
+ const fenceOpen = () => segmentMeta !== null && (segmentMeta.acc.split("```").length - 1) % 2 === 1;
584
653
  const breakSegment = () => {
585
654
  segment?.end();
586
655
  segment = null;
587
656
  };
657
+ /** Reply text routed through here splits across Slack messages before the
658
+ * msg_too_long cap (see SEGMENT_TEXT_MAX): a break prefers the last newline
659
+ * inside the remaining room, and a break forced inside a code fence closes
660
+ * it and reopens it in the next message so both halves render as code. */
661
+ const pushText = async (text: string) => {
662
+ for (let rest = text; rest !== "";) {
663
+ const room = SEGMENT_TEXT_MAX - (segment === null ? 0 : segmentMeta!.acc.length);
664
+ // a fence-close suffix can nudge a segment a few chars past the cap;
665
+ // a full segment just breaks and the loop re-measures a fresh one.
666
+ if (room <= 0) {
667
+ breakSegment();
668
+ continue;
669
+ }
670
+ if (rest.length <= room) {
671
+ await push(rest);
672
+ return;
673
+ }
674
+ // prefer a newline cut only when it lands in the back half of the room:
675
+ // an early newline followed by one giant unbroken run would otherwise
676
+ // make no progress and (with the fence-reopen prefix) loop forever.
677
+ const nl = rest.lastIndexOf("\n", room - 1);
678
+ let cut = nl >= Math.floor(room / 2) ? nl + 1 : room;
679
+ // never slice through a backtick run: a cut inside ``` would strand a
680
+ // partial delimiter on each side and break both halves' rendering
681
+ // (cubic review catch, PR #42). Walk the cut left past the run; a run
682
+ // reaching position 0 keeps the original cut (progress beats rendering
683
+ // for pathological all-backtick input).
684
+ if (rest[cut - 1] === "`" && rest[cut] === "`") {
685
+ let backedUp = cut;
686
+ while (backedUp > 0 && rest[backedUp - 1] === "`") backedUp -= 1;
687
+ if (backedUp > 0) cut = backedUp;
688
+ }
689
+ const head = rest.slice(0, cut);
690
+ await push(head);
691
+ const reopen = fenceOpen();
692
+ if (reopen) await push("\n```");
693
+ breakSegment();
694
+ rest = (reopen ? "```\n" : "") + rest.slice(head.length);
695
+ }
696
+ };
588
697
  // settle the whole post chain: a rejection handler may replace lastPost with
589
698
  // a salvage segment's post, which still needs ending and settling.
590
699
  const settlePosts = async () => {
@@ -741,10 +850,13 @@ export async function relayThread(input: {
741
850
  outcome.resultReceived = true;
742
851
  }
743
852
  }
744
- for (const part of agentEventChunks({ state: mapState, message })) await push(part);
853
+ for (const part of agentEventChunks({ state: mapState, message })) {
854
+ if (part instanceof Object) await push(part);
855
+ else await pushText(part);
856
+ }
745
857
  }
746
858
  // a turn that produced no streamed text (tool-only turns) still reports.
747
- if (!postedText && result) await push(result);
859
+ if (!postedText && result) await pushText(result);
748
860
  if (!postedText && !result && outcome.failed && !outcome.rateLimited) {
749
861
  // held back until the depleted-pool probe rules: a "trying again may
750
862
  // help" line right before a deferral notice invites a manual re-send