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 +3 -1
- package/package.json +1 -1
- package/src/cli/serve.ts +61 -3
- package/src/lib/slackbridge.ts +14 -0
- package/src/lib/slackstream.ts +4 -1
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
|
|
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.
|
|
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
|
|
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>(() => {});
|
package/src/lib/slackbridge.ts
CHANGED
|
@@ -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.
|
package/src/lib/slackstream.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|