tokenmaxxing 0.19.0 → 0.19.1

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
@@ -92,7 +92,9 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
92
92
  - **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (never a persistent streaming query - 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.
93
93
  - **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).
94
94
  - **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).
95
- - **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 (a restarted daemon revives an old thread on the next mention); queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 900s; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
95
+ - **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; queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 900s; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
96
+ - **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`), tracked in-flight turns get up to 300s to finish, then `Chat.shutdown()`; a second signal forces exit. When a `thread.post` rejects mid-turn (e.g. Slack finalizes an idle stream: `message_not_in_streaming_state`), relayThread drops the dead segment so the rest of the turn opens fresh messages instead of vanishing.
97
+ - **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), and whitespace-only text deltas do not count as reply text for segment breaking (no stranded near-blank messages).
96
98
  - **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), reply text as native markdown with rendered code fences, and a segment break whenever a tool starts after streamed text, so one turn posts as separate ordered Slack messages around its tool runs.
97
99
  - **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens worktree + session, replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, worktree idempotency, stream mapping, fail-fast paths.
98
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
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
@@ -14,6 +14,7 @@
14
14
  // serve run the daemon
15
15
 
16
16
  import { existsSync, realpathSync } from "node:fs";
17
+ import { delay } from "es-toolkit";
17
18
  import { Chat, type StreamChunk } from "chat";
18
19
  import { createSlackAdapter } from "@chat-adapter/slack";
19
20
  import { createMemoryState } from "@chat-adapter/state-memory";
@@ -21,6 +22,7 @@ import {
21
22
  bareChannelId,
22
23
  isChannelId,
23
24
  linkForChannel,
25
+ listSlackThreads,
24
26
  loadSlackConfig,
25
27
  loadSlackThread,
26
28
  removeLink,
@@ -211,10 +213,14 @@ async function runDaemon(): Promise<number> {
211
213
  // so the values are inlined).
212
214
  webClientOptions: { retryConfig: { retries: 5, factor: 3.86 }, timeout: 15_000 },
213
215
  });
216
+ // held directly (not only via Chat) so startup can re-subscribe recorded
217
+ // threads: subscriptions live in this in-memory state and die with the
218
+ // process, and only a fresh mention would otherwise revive a thread.
219
+ const state = createMemoryState();
214
220
  const bot = new Chat({
215
221
  userName: "tokenmaxxing",
216
222
  adapters: { slack },
217
- state: createMemoryState(),
223
+ state,
218
224
  // per-thread lock with queueing: a message landing mid-turn waits its turn
219
225
  // instead of racing a second claude spawn on the same cwd. The default
220
226
  // 90s queue-entry TTL silently discards anything queued behind a turn
@@ -226,12 +232,25 @@ async function runDaemon(): Promise<number> {
226
232
  logger: "warn",
227
233
  });
228
234
 
235
+ // in-flight turns, tracked so a shutdown signal can drain them instead of
236
+ // killing a half-streamed answer (live incident 2026-07-18: a deploy
237
+ // restart cut a turn mid-sentence and the answer never reached Slack).
238
+ const activeTurns = new Set<Promise<void>>();
239
+ let draining = false;
240
+
229
241
  const handleTurn = async (input: {
230
242
  thread: { id: string; channelId: string; post: (m: AsyncIterable<string | StreamChunk>) => Promise<unknown>; subscribe: () => Promise<void>; startTyping: () => Promise<void> };
231
243
  texts: string[];
232
244
  isMention: boolean;
233
245
  }) => {
234
246
  const { thread, texts, isMention } = input;
247
+ if (draining) {
248
+ // the socket stays connected until the drain finishes; anything landing
249
+ // in that window is dropped loudly rather than spawning an unwaitable
250
+ // turn. The user re-sends after the restart.
251
+ log("serve.drain_dropped", { thread: thread.id });
252
+ return;
253
+ }
235
254
  const link = linkForChannel(cfg, bareChannelId(thread.channelId));
236
255
  if (!link) {
237
256
  log("serve.unlinked_channel", { channel: thread.channelId });
@@ -274,14 +293,23 @@ async function runDaemon(): Promise<number> {
274
293
 
275
294
  const relayable = (m: { author: { isMe: boolean; isBot?: boolean | "unknown" } }) => !m.author.isMe && m.author.isBot !== true;
276
295
 
296
+ const tracked = async (turn: Promise<void>) => {
297
+ activeTurns.add(turn);
298
+ try {
299
+ await turn;
300
+ } finally {
301
+ activeTurns.delete(turn);
302
+ }
303
+ };
304
+
277
305
  bot.onNewMention(async (thread, message, context) => {
278
306
  const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
279
- await handleTurn({ thread, texts, isMention: true });
307
+ await tracked(handleTurn({ thread, texts, isMention: true }));
280
308
  });
281
309
  bot.onSubscribedMessage(async (thread, message, context) => {
282
310
  if (!relayable(message)) return; // never relay our own posts
283
311
  const texts = [...(context?.skipped ?? []).filter(relayable), message].map((m) => m.text);
284
- await handleTurn({ thread, texts, isMention: false });
312
+ await tracked(handleTurn({ thread, texts, isMention: false }));
285
313
  });
286
314
 
287
315
  // initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
@@ -291,6 +319,36 @@ async function runDaemon(): Promise<number> {
291
319
  // and awaiting it in a loop starved the event loop so hard the WebSocket
292
320
  // never delivered a single event (live incident 2026-07-18).
293
321
  await bot.initialize();
322
+
323
+ // subscriptions live in the memory state and died with the previous daemon;
324
+ // the durable slack-threads/ records say which threads are ours, so restore
325
+ // routing for them (message routing checks stateAdapter.isSubscribed,
326
+ // verified in chat 4.34.0). Without this a restart leaves every open thread
327
+ // deaf to non-mention follow-ups.
328
+ const records = listSlackThreads();
329
+ for (const record of records) await state.subscribe(record.threadId);
330
+ log("serve.resubscribed", { threads: records.length });
331
+
332
+ // drain instead of dying mid-answer: stop taking new turns, let in-flight
333
+ // ones finish (bounded - a hung claude turn must not block a restart
334
+ // forever), then disconnect. A second signal forces an immediate exit.
335
+ const DRAIN_MS = 300_000;
336
+ const shutdown = async (signal: string) => {
337
+ if (draining) {
338
+ log("serve.forced_exit", { signal });
339
+ process.exit(1);
340
+ }
341
+ draining = true;
342
+ log("serve.draining", { signal, turns: activeTurns.size });
343
+ console.log(`${c.yellow("●")} ${signal}: draining ${count({ n: activeTurns.size, noun: "in-flight turn" })} (again to force)`);
344
+ await Promise.race([Promise.allSettled([...activeTurns]), delay(DRAIN_MS)]);
345
+ await bot.shutdown();
346
+ log("serve.stopped", { dropped: activeTurns.size });
347
+ process.exit(0);
348
+ };
349
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
350
+ process.on("SIGINT", () => void shutdown("SIGINT"));
351
+
294
352
  console.log(`${c.green("●")} serving ${count({ n: cfg.links.length, noun: "linked channel" })} over Slack Socket Mode - mention the bot in a linked channel to open a session (Ctrl-C to stop)`);
295
353
  log("serve.started", { links: cfg.links.length });
296
354
  await new Promise<never>(() => {});
@@ -16,6 +16,14 @@ import { threadKey, type SlackLink } from "./slackstate.ts";
16
16
  import { agentEventChunks, newStreamMapState, SegmentBreakSchema } from "./slackstream.ts";
17
17
  import { log } from "./log.ts";
18
18
 
19
+ /** With systemPrompt omitted the SDK runs a MINIMAL system prompt (the
20
+ * claude_code preset is opt-in since SDK 0.1.0, re-verified for 0.3.214
21
+ * 2026-07-18), so this small standalone prompt replaces nothing. It exists
22
+ * because a relayed model once answered with a literal "<br>": Slack renders
23
+ * markdown, never HTML. */
24
+ const SLACK_SYSTEM_PROMPT =
25
+ "Your replies are relayed into a Slack thread and render as Slack-flavored markdown. Write plain markdown only - never HTML tags such as <br> (use real line breaks).";
26
+
19
27
  export const TurnOutcomeSchema = z.object({
20
28
  sessionId: z.string().nullable(),
21
29
  failed: z.boolean(),
@@ -118,9 +126,14 @@ export async function relayThread(input: {
118
126
  await lastPost; // strict message order: previous segment fully posted first
119
127
  seg = pushableStream();
120
128
  segment = seg;
129
+ const posted = seg;
121
130
  lastPost = input.post(seg.iterable).catch((e: unknown) => {
122
131
  const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
123
132
  log("serve.post_error", { err: detail });
133
+ // the consumer is gone (e.g. Slack finalized an idle stream:
134
+ // message_not_in_streaming_state) - drop the dead segment so the
135
+ // next chunk opens a fresh message instead of vanishing into it.
136
+ if (segment === posted) segment = null;
124
137
  });
125
138
  }
126
139
  if (!postedText && !(chunk instanceof Object)) postedText = true;
@@ -142,6 +155,7 @@ export async function relayThread(input: {
142
155
  // the SDK refuses bypassPermissions without this explicit opt-in.
143
156
  ...(input.link.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
144
157
  includePartialMessages: true,
158
+ systemPrompt: SLACK_SYSTEM_PROMPT,
145
159
  // no one can answer an interactive question dialog through Slack;
146
160
  // without the tool the model asks in prose and the user's thread
147
161
  // reply becomes the next turn.
@@ -133,7 +133,10 @@ export function agentEventChunks(input: { state: StreamMapState; message: SDKMes
133
133
  const open = state.open[`${message.parent_tool_use_id ?? "main"}:${event.index}`];
134
134
  if (event.delta.type === "text_delta") {
135
135
  if (!isMain) return [];
136
- state.textSinceBreak = true;
136
+ // whitespace-only deltas must not count as reply text: a "\n\n"
137
+ // before a tool call would otherwise break the segment and strand a
138
+ // near-blank Slack message.
139
+ if (event.delta.text.trim() !== "") state.textSinceBreak = true;
137
140
  return [event.delta.text];
138
141
  }
139
142
  if (event.delta.type === "thinking_delta" && open) open.acc += event.delta.thinking;