tokenmaxxing 0.19.1 → 1.0.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.
Files changed (61) hide show
  1. package/DESIGN.md +34 -25
  2. package/README.md +6 -5
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexrm.ts +49 -0
  10. package/src/cli/codexswitch.ts +20 -2
  11. package/src/cli/config.ts +25 -1
  12. package/src/cli/doctor.ts +3 -3
  13. package/src/cli/init.ts +54 -32
  14. package/src/cli/onboard.ts +62 -45
  15. package/src/cli/rename.ts +20 -0
  16. package/src/cli/render.ts +0 -16
  17. package/src/cli/rm.ts +40 -2
  18. package/src/cli/serve.ts +638 -115
  19. package/src/cli/status.ts +69 -23
  20. package/src/cli/switch.ts +54 -19
  21. package/src/entries/codexstophook.ts +123 -4
  22. package/src/entries/codexsupervisor.ts +87 -13
  23. package/src/entries/sessionstart.ts +1 -1
  24. package/src/entries/statusline.ts +56 -20
  25. package/src/entries/stophook.ts +23 -9
  26. package/src/entries/supervisor.ts +184 -20
  27. package/src/lib/atomic.ts +28 -6
  28. package/src/lib/claudebin.ts +2 -2
  29. package/src/lib/claudejson.ts +5 -5
  30. package/src/lib/claudelock.ts +112 -37
  31. package/src/lib/codexauth.ts +18 -3
  32. package/src/lib/codexbin.ts +1 -1
  33. package/src/lib/codexdecide.ts +149 -19
  34. package/src/lib/codexpick.ts +17 -6
  35. package/src/lib/codexpresence.ts +59 -21
  36. package/src/lib/codexsample.ts +17 -8
  37. package/src/lib/codexswap.ts +10 -1
  38. package/src/lib/credstore.ts +6 -2
  39. package/src/lib/decide.ts +136 -49
  40. package/src/lib/install.ts +125 -17
  41. package/src/lib/keychain.ts +41 -15
  42. package/src/lib/lock.ts +57 -35
  43. package/src/lib/log.ts +36 -7
  44. package/src/lib/oauth.ts +18 -11
  45. package/src/lib/paths.ts +17 -11
  46. package/src/lib/picker.ts +11 -3
  47. package/src/lib/proc.ts +37 -0
  48. package/src/lib/sample.ts +91 -31
  49. package/src/lib/sessions.ts +23 -1
  50. package/src/lib/settings.ts +59 -18
  51. package/src/lib/slackbridge.ts +581 -81
  52. package/src/lib/slackstate.ts +159 -12
  53. package/src/lib/slackstream.ts +123 -20
  54. package/src/lib/state.ts +131 -35
  55. package/src/lib/swap.ts +109 -47
  56. package/src/lib/types.ts +92 -38
  57. package/src/lib/usage.ts +114 -16
  58. package/src/main.ts +70 -9
  59. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  60. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  61. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
@@ -5,14 +5,19 @@
5
5
  // without losing threads). Verified against @anthropic-ai/claude-agent-sdk
6
6
  // 0.3.214 and code.claude.com/docs 2026-07-18; both change monthly.
7
7
 
8
- import { existsSync, mkdirSync } from "node:fs";
8
+ import { spawn } from "node:child_process";
9
9
  import { join } from "node:path";
10
10
  import { z } from "zod";
11
- import { query } from "@anthropic-ai/claude-agent-sdk";
11
+ import { delay } from "es-toolkit";
12
+ import { createSdkMcpServer, query, tool, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
12
13
  import type { StreamChunk } from "chat";
13
- import { ensureBestAccount, pooledOptions, stopHookCheck } from "../sdk.ts";
14
- import { paths } from "./paths.ts";
15
- import { threadKey, type SlackLink } from "./slackstate.ts";
14
+ import { ensureBestAccount, pooledOptions, stopHookCheck, type SwapDecision } from "../sdk.ts";
15
+ import { POST_SWAP_COOLDOWN_MS } from "./decide.ts";
16
+ import { readOAuthAccount } from "./claudejson.ts";
17
+ import { http, safeErrorDetail } from "./http.ts";
18
+ import { loadLastSwapAt } from "./state.ts";
19
+ import { fmtResetShort, recordObservedLimit } from "./usage.ts";
20
+ import { deleteSlackThread, type SlackLink } from "./slackstate.ts";
16
21
  import { agentEventChunks, newStreamMapState, SegmentBreakSchema } from "./slackstream.ts";
17
22
  import { log } from "./log.ts";
18
23
 
@@ -27,9 +32,169 @@ const SLACK_SYSTEM_PROMPT =
27
32
  export const TurnOutcomeSchema = z.object({
28
33
  sessionId: z.string().nullable(),
29
34
  failed: z.boolean(),
35
+ /** the FAILED turn hit a usage/rate limit (or the pool was depleted before
36
+ * it could spawn). Only an errored result is ever limit-classified. */
37
+ rateLimited: z.boolean(),
38
+ /** the model called finish_thread this turn: garbage-collect after the turn. */
39
+ finish: z.boolean(),
40
+ /** relayThread posted a TERMINAL drop notice for this message ("dropped;
41
+ * re-send it"): a drain must NOT presume a killed child and retain the
42
+ * resume marker, or startup replays work the user was told to resend. */
43
+ announcedDrop: z.boolean(),
44
+ /** the LAST attempt's claude child ran to a SUCCESSFUL result: the work is
45
+ * done even if Slack delivery later failed (textLost sets failed for the
46
+ * operator's benefit). A drain must not read that delivery failure as a
47
+ * killed child and re-run completed work (adversarial-review catch). */
48
+ resultReceived: z.boolean(),
30
49
  });
31
50
  export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
32
51
 
52
+ // ---- depleted-pool recovery policy (pure, unit-tested) ---------------------
53
+
54
+ /** TOTAL parking budget for one Slack message (a single deadline across all
55
+ * its parks, not per park): the parked handler holds the thread's queue slot,
56
+ * so recovery further out gets an honest drop notice instead of a hostage
57
+ * handler, and the daemon's queue-entry TTL is sized to outlast a full
58
+ * park + turn so follow-ups fold instead of silently expiring. */
59
+ export const PARK_MAX_MS = 840_000;
60
+ /** spawn slightly after the reset passes, never right on the boundary. */
61
+ const PARK_GRACE_MS = 5_000;
62
+ /** post-limit short retry: one beat for the pool to observe the limit and
63
+ * swap (slaude's parkShortRetry - a successful swap makes it invisible). */
64
+ const RETRY_DELAY_MS = 10_000;
65
+ /** parks + retries per Slack message; keeps a stale usage cache from looping
66
+ * a thread forever. */
67
+ export const MAX_RECOVERIES = 3;
68
+
69
+ const ParkPlanSchema = z.union([
70
+ z.object({ kind: z.literal("proceed") }),
71
+ z.object({ kind: z.literal("park"), wakeAt: z.number() }),
72
+ z.object({ kind: z.literal("drop"), recoversAt: z.number().nullable() }),
73
+ ]);
74
+ export type ParkPlan = z.infer<typeof ParkPlanSchema>;
75
+
76
+ /** What to do with a spawn-boundary switch decision: proceed on a usable pool,
77
+ * park until the soonest recovery when it lands inside the message's one
78
+ * shared deadline, drop honestly otherwise (dropping beats a false
79
+ * will-resume promise - slaude's recorded rationale). The deadline is fixed
80
+ * when the message's relay starts, so chained parks can never hold the queue
81
+ * slot longer than PARK_MAX_MS in total. */
82
+ export function parkPlan(input: { decision: SwapDecision; recoveries: number; deadline: number }): ParkPlan {
83
+ const depleted = input.decision.reason === "all-depleted" || input.decision.reason === "depleted-wait";
84
+ if (!depleted) return { kind: "proceed" };
85
+ const wake = input.decision.waitUntil ?? null;
86
+ // the grace counts against the deadline too: the promised total hold is
87
+ // exact, not deadline-plus-grace (review catch, PR #18).
88
+ if (wake == null || wake + PARK_GRACE_MS > input.deadline || input.recoveries >= MAX_RECOVERIES) {
89
+ return { kind: "drop", recoversAt: wake };
90
+ }
91
+ return { kind: "park", wakeAt: wake + PARK_GRACE_MS };
92
+ }
93
+
94
+ /** Phrases claude's ERRORED results carry at a usage/rate limit (ported from
95
+ * slaude's battle-tested set). Checked only against errored results: a
96
+ * successful answer that merely discusses usage limits (routine in this
97
+ * repo's own threads) must never be discarded and re-run. */
98
+ const RATE_LIMIT_PHRASES = [
99
+ "usage limit reached",
100
+ "rate limit reached",
101
+ "rate limit exceeded",
102
+ "rate limit hit",
103
+ "hit your usage limit",
104
+ "hit your weekly limit",
105
+ "limit will reset",
106
+ "5-hour limit",
107
+ "out of extra usage",
108
+ ];
109
+
110
+ export function isRateLimitText(input: { text: string }): boolean {
111
+ const lower = input.text.toLowerCase();
112
+ return RATE_LIMIT_PHRASES.some((phrase) => lower.includes(phrase));
113
+ }
114
+
115
+ /** The CLI puts an errored result's reason in `result` even on non-success
116
+ * subtypes (the exact shape PingResultSchema in usage.ts handles), while the
117
+ * SDK's error type declares only `errors` - so limit text is gathered
118
+ * loosely from both fields. */
119
+ const ResultTextSchema = z.looseObject({
120
+ result: z.string().optional(),
121
+ errors: z.array(z.string()).optional(),
122
+ });
123
+
124
+ function erroredResultText(message: unknown): string {
125
+ const parsed = ResultTextSchema.safeParse(message);
126
+ if (!parsed.success) return "";
127
+ return [parsed.data.result, ...(parsed.data.errors ?? [])]
128
+ .filter((t): t is string => t != null && t !== "")
129
+ .join("\n");
130
+ }
131
+
132
+ // ---- workspace identity ----------------------------------------------------
133
+
134
+ const AuthTestSchema = z.looseObject({
135
+ ok: z.boolean(),
136
+ team_id: z.string().optional(),
137
+ error: z.string().optional(),
138
+ });
139
+
140
+ /** auth.test: the home workspace (team) id the bot token belongs to - the
141
+ * reference `isOutsideAuthor` compares message origins against. Errors carry
142
+ * the Slack error code only, never the token. */
143
+ export async function fetchWorkspaceTeamId(input: { botToken: string }): Promise<string> {
144
+ const res = await http
145
+ .post("https://slack.com/api/auth.test", {
146
+ headers: { authorization: `Bearer ${input.botToken}` },
147
+ })
148
+ .catch((e: unknown) => {
149
+ // a thrown ky error (timeout, network) carries its Request with the
150
+ // Authorization header - rethrow message-only so no caller can ever
151
+ // log the token.
152
+ throw new Error(`Slack auth.test failed: ${e instanceof Error ? e.message : String(e)}`);
153
+ });
154
+ const text = await res.text();
155
+ if (!res.ok) throw new Error(`Slack auth.test failed: HTTP ${res.status} (${safeErrorDetail({ text })})`);
156
+ const body: unknown = (() => {
157
+ try {
158
+ return JSON.parse(text);
159
+ } catch {
160
+ return null;
161
+ }
162
+ })();
163
+ const parsed = AuthTestSchema.safeParse(body);
164
+ if (!parsed.success || !parsed.data.ok || !parsed.data.team_id) {
165
+ throw new Error(
166
+ `Slack auth.test failed: ${parsed.success ? (parsed.data.error ?? "no team_id in response") : "unrecognized response"}`,
167
+ );
168
+ }
169
+ return parsed.data.team_id;
170
+ }
171
+
172
+ /** The serve plugin shipped inside the package (src/serve-plugin/): skills
173
+ * that teach a relayed session how to behave in a Slack thread, loaded per
174
+ * turn via the SDK's local-plugin option and namespaced `tokenmaxxing:...`. */
175
+ const SERVE_PLUGIN_DIR = join(import.meta.dir, "..", "serve-plugin");
176
+
177
+ /**
178
+ * The per-turn context a UserPromptSubmit hook injects. The skills are static
179
+ * files, so the one dynamic fact they cannot carry - WHO asked - rides in
180
+ * here as the requester's raw mention token (`<@U...>` passes verbatim
181
+ * through the streamed markdown_text path, and the post-and-edit fallback's
182
+ * finalize leaves an already-formed mention intact). Wording is load-bearing:
183
+ * the ask-the-user skill points at the "Slack relay context" note.
184
+ */
185
+ export function serveTurnContext(input: { requesterIds: string[] }): string {
186
+ const tokens = input.requesterIds.map((id) => `<@${id}>`);
187
+ let requester = "The requesting user is unknown this turn, so no mention token is available.";
188
+ if (tokens.length === 1) requester = `The requesting user's Slack mention token is ${tokens[0]}; include it literally in reply text to notify them.`;
189
+ if (tokens.length > 1) requester = `This turn folds messages from several users; their Slack mention tokens are ${tokens.join(" ")}. Include the relevant user's token literally in reply text to notify them.`;
190
+ return [
191
+ "Slack relay context: this session is relayed into a Slack thread by tokenmaxxing serve, and your reply posts back into the thread.",
192
+ requester,
193
+ "When you need the user's decision, approval, or input, follow the tokenmaxxing:ask-the-user skill (tag them, ask, end the turn).",
194
+ "The tokenmaxxing:serve-session skill explains how this session runs.",
195
+ ].join(" ");
196
+ }
197
+
33
198
  const SegmentChunkSchema = z.union([z.string(), z.custom<StreamChunk>()]);
34
199
  type SegmentChunk = z.infer<typeof SegmentChunkSchema>;
35
200
 
@@ -67,35 +232,115 @@ function pushableStream(): {
67
232
  };
68
233
  }
69
234
 
70
- /** Run one git command against a repo; throws with trimmed stderr on failure. */
71
- function git(repo: string, args: string[]): string {
72
- const r = Bun.spawnSync(["git", "-C", repo, ...args], { stdout: "pipe", stderr: "pipe" });
73
- const stderr = new TextDecoder().decode(r.stderr).trim();
74
- if (r.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${stderr.slice(0, 200)}`);
75
- return new TextDecoder().decode(r.stdout).trim();
235
+ /** Full permission name of the finish_thread tool (mcp__<server>__<tool>):
236
+ * it must be in allowedTools, because no one can answer a permission prompt
237
+ * through Slack. */
238
+ const FINISH_THREAD_TOOL = "mcp__tokenmaxxing__finish_thread";
239
+
240
+ /** The per-turn in-process MCP server exposing finish_thread. The handler runs
241
+ * in the daemon process, but it must NOT delete anything inline: the claude
242
+ * subprocess is still mid-turn and segments are still streaming to Slack, so
243
+ * it only records the request and the daemon closes the thread after the
244
+ * turn ends (serve.ts). alwaysLoad keeps the tool visible in the prompt
245
+ * instead of deferred behind tool search: it has to be in view at the exact
246
+ * moment the user says the work is done. */
247
+ function finishToolServer(onFinish: () => void) {
248
+ return createSdkMcpServer({
249
+ name: "tokenmaxxing",
250
+ alwaysLoad: true,
251
+ tools: [
252
+ tool(
253
+ "finish_thread",
254
+ "Close out this Slack thread when the user clearly states the work is finished (shipped, done, clean this up) and wants the thread closed. After this turn ends the daemon drops the thread's session record, unsubscribes, and posts a confirmation; the repo checkout and everything in it are untouched. Do not call this for a merely answered question - only for an explicit wrap-up.",
255
+ {},
256
+ async () => {
257
+ onFinish();
258
+ return { content: [{ type: "text", text: "close-out scheduled - it runs right after this turn ends and posts its own confirmation; just acknowledge the wrap-up now" }] };
259
+ },
260
+ ),
261
+ ],
262
+ });
263
+ }
264
+
265
+ const CleanupOutcomeSchema = z.object({
266
+ /** the thread's state is gone; a fresh @mention starts a new session. */
267
+ removed: z.boolean(),
268
+ message: z.string(),
269
+ });
270
+ export type CleanupOutcome = z.infer<typeof CleanupOutcomeSchema>;
271
+
272
+ /**
273
+ * Close out a finished thread. Threads run IN the linked repo checkout (no
274
+ * per-thread worktree or branch since #14), so there is nothing on disk to
275
+ * collect: dropping the slack-threads record is the whole cleanup, and the
276
+ * shared checkout is never touched. The worktree-era residue gate and branch
277
+ * archiving died with the worktrees themselves.
278
+ */
279
+ export function cleanupThread(input: { threadId: string }): CleanupOutcome {
280
+ deleteSlackThread(input.threadId);
281
+ return { removed: true, message: "thread finished - session closed; a fresh @mention here starts a new one" };
282
+ }
283
+
284
+ /** Live detached process-group leader pids: one process-exit hook SIGTERMs
285
+ * them all, so a forced daemon exit (second signal, drain timeout) cannot
286
+ * leak claude's tool subprocesses. */
287
+ const liveGroups = new Set<number>();
288
+ let groupExitHookArmed = false;
289
+
290
+ /** Exported for serve's orphan reaping: a daemon killed uncatchably (SIGKILL,
291
+ * crash) never runs the exit hook below, so the next generation must be able
292
+ * to terminate a surviving detached group before resuming its turn. */
293
+ export function killGroup(pid: number, signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): void {
294
+ try {
295
+ process.kill(-pid, signal);
296
+ } catch (e) {
297
+ // ESRCH = the group is already gone, which is the state we wanted;
298
+ // anything else (EPERM, a bad pid) must surface, not silently leak.
299
+ if (!(e instanceof Error && "code" in e && e.code === "ESRCH")) throw e;
300
+ }
76
301
  }
77
302
 
78
303
  /**
79
- * The stable cwd a thread's turns run in. Worktree mode (default) creates
80
- * `slack-worktrees/<threadKey>` on branch `tm-slack-<threadKey>` cut from the
81
- * repo's current HEAD, once; both survive daemon restarts because resume is
82
- * cwd-keyed. Worktrees are never auto-deleted (they hold the thread's work):
83
- * clean up with `git worktree remove` when done.
304
+ * Spawns the claude child in its OWN process group (post-0.19.1 review catch):
305
+ * a terminal Ctrl-C delivers SIGINT to the whole foreground group, so a
306
+ * non-detached child died at the same instant the daemon's drain started and
307
+ * the drain could never preserve the in-flight turn. Detached, only the daemon
308
+ * receives the terminal signal. Two consequences the review on PR #16 caught:
309
+ * the SDK's SpawnedProcess contract consumes only stdin/stdout, so stderr must
310
+ * be ignored outright (a piped-but-never-read stderr fills and blocks a chatty
311
+ * child; exit errors lose the stderr tail, an accepted cost of turn survival),
312
+ * and the SDK's abort path kills the lone PID, so the forwarded abort signal
313
+ * and a process-exit hook SIGTERM the whole detached group instead - claude's
314
+ * tool subprocesses must not outlive the daemon or the turn.
84
315
  */
85
- export function ensureThreadCwd(input: { link: SlackLink; threadId: string }): string {
86
- if (!input.link.worktree) return input.link.repo;
87
- const key = threadKey(input.threadId);
88
- const dir = join(paths.slackWorktreesDir, key);
89
- if (existsSync(dir)) return dir;
90
- mkdirSync(paths.slackWorktreesDir, { recursive: true });
91
- const branch = `tm-slack-${key}`;
92
- const branchExists = Bun.spawnSync(["git", "-C", input.link.repo, "rev-parse", "--verify", "--quiet", branch]).exitCode === 0;
93
- // a crash between branch creation and worktree add leaves the branch behind;
94
- // reattach instead of failing on -b collision.
95
- if (branchExists) git(input.link.repo, ["worktree", "add", dir, branch]);
96
- else git(input.link.repo, ["worktree", "add", dir, "-b", branch]);
97
- log("serve.worktree_created", { dir, branch });
98
- return dir;
316
+ export function detachedClaudeSpawn(options: SpawnOptions) {
317
+ const stdio: ["pipe", "pipe", "ignore"] = ["pipe", "pipe", "ignore"];
318
+ const child = spawn(options.command, options.args, {
319
+ cwd: options.cwd,
320
+ env: options.env,
321
+ stdio,
322
+ detached: true,
323
+ });
324
+ if (!groupExitHookArmed) {
325
+ groupExitHookArmed = true;
326
+ process.once("exit", () => {
327
+ for (const pid of liveGroups) killGroup(pid);
328
+ });
329
+ }
330
+ if (child.pid !== undefined) {
331
+ const pid = child.pid;
332
+ liveGroups.add(pid);
333
+ const onAbort = () => killGroup(pid);
334
+ // an already-aborted signal never fires "abort" again (cubic review
335
+ // catch): a cancellation racing the spawn must still kill the group.
336
+ if (options.signal?.aborted) onAbort();
337
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
338
+ child.once("exit", () => {
339
+ liveGroups.delete(pid);
340
+ options.signal?.removeEventListener("abort", onAbort);
341
+ });
342
+ }
343
+ return child;
99
344
  }
100
345
 
101
346
  /**
@@ -108,17 +353,57 @@ export function ensureThreadCwd(input: { link: SlackLink; threadId: string }): s
108
353
  * post resolves. Never throws: a failure posts a short diagnostic line and
109
354
  * sets outcome.failed (the daemon must keep serving other threads). Error
110
355
  * text is message-only - a raw error body could echo request material.
356
+ *
357
+ * Depleted-pool recovery (ported from slaude at its shutdown, reshaped around
358
+ * the pool): the spawn-boundary switch decision is CONSUMED, not discarded -
359
+ * a depleted pool parks BEFORE a doomed spawn burns a failed turn, with an
360
+ * honest in-thread notice either way; a mid-turn limit the cached pool state
361
+ * did not predict is persisted (recordObservedLimit) and retried silently into
362
+ * the same session. Total parking is bounded by one shared PARK_MAX_MS
363
+ * deadline plus MAX_RECOVERIES, and every drop the relay itself performs is
364
+ * announced in-thread (a queue-entry TTL expiry upstream is the one drop it
365
+ * cannot see).
111
366
  */
112
367
  export async function relayThread(input: {
113
368
  cwd: string;
114
369
  sessionId: string | null;
115
370
  prompt: string;
371
+ /** bare Slack user id (U...) of the triggering message's author. */
372
+ requesterIds: string[];
116
373
  link: SlackLink;
117
374
  post: (m: AsyncIterable<SegmentChunk>) => Promise<unknown>;
375
+ /** fires the moment an init message assigns a session id the caller has not
376
+ * persisted yet, so a first-turn kill stays resumable (2026-07-18
377
+ * incident: a restart killed a first turn and the thread record kept
378
+ * sessionId null, stranding the session). Retries resume the same session,
379
+ * so re-fires only on an actual id change. */
380
+ onSessionId?: (sessionId: string) => void;
381
+ /** fires with the DETACHED claude child's pid (= its process-group id) the
382
+ * moment it spawns - once per spawn, so a retry's fresh child replaces the
383
+ * previous pid - so the caller can persist it into the activeTurn marker:
384
+ * a daemon death that skips the exit hook (SIGKILL, crash) leaves that
385
+ * group alive, and the next generation must find and reap it before
386
+ * resuming the turn. */
387
+ onSpawn?: (pid: number) => void;
388
+ /** daemon shutdown signal: aborts park/retry sleeps so a drain never sits
389
+ * out a depleted-pool countdown. */
390
+ drainSignal?: AbortSignal;
118
391
  }): Promise<TurnOutcome> {
119
- const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false };
392
+ const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, announcedDrop: false, resultReceived: false };
120
393
  let segment: ReturnType<typeof pushableStream> | null = null;
394
+ let segmentMeta: { text: boolean } | null = null;
121
395
  let lastPost: Promise<unknown> = Promise.resolve();
396
+ // Reply TEXT that was pushed into a rejected segment and never re-delivered
397
+ // by a later text-bearing segment: the user has not seen the answer. A lost
398
+ // card-only segment never sets this (decoration, not the answer).
399
+ // Tradeoff (flagged and accepted): a later delivered text segment clears the
400
+ // flag even though it is a continuation, because the dominant rejection is
401
+ // Slack finalizing an idle stream - the streamed text WAS delivered, only
402
+ // the append failed - and sticky loss would fail every long turn with a
403
+ // spurious diagnostic; chat 4.34.0 exposes no per-chunk delivery acks to
404
+ // tell that apart from a swallowed first post.
405
+ let textLost = false;
406
+ let textLostDetail: string | null = null;
122
407
  let postedText = false;
123
408
  const push = async (chunk: SegmentChunk) => {
124
409
  let seg = segment;
@@ -127,68 +412,283 @@ export async function relayThread(input: {
127
412
  seg = pushableStream();
128
413
  segment = seg;
129
414
  const posted = seg;
130
- lastPost = input.post(seg.iterable).catch((e: unknown) => {
131
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
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;
137
- });
415
+ const meta = { text: false };
416
+ segmentMeta = meta;
417
+ lastPost = input.post(seg.iterable).then(
418
+ () => {
419
+ // segments settle in order (push awaits lastPost before opening the
420
+ // next), so delivered text supersedes an earlier loss.
421
+ if (meta.text) textLost = false;
422
+ },
423
+ (e: unknown) => {
424
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
425
+ log("serve.post_error", { err: detail });
426
+ if (meta.text) {
427
+ textLost = true;
428
+ textLostDetail = detail;
429
+ }
430
+ // the consumer is gone (e.g. Slack finalized an idle stream:
431
+ // message_not_in_streaming_state) - drop the dead segment so the
432
+ // next chunk opens a fresh message instead of vanishing into it.
433
+ if (segment === posted) segment = null;
434
+ },
435
+ );
436
+ }
437
+ if (!(chunk instanceof Object)) {
438
+ postedText = true;
439
+ segmentMeta!.text = true;
138
440
  }
139
- if (!postedText && !(chunk instanceof Object)) postedText = true;
140
441
  seg.push(chunk);
141
442
  };
142
443
  const breakSegment = () => {
143
444
  segment?.end();
144
445
  segment = null;
145
446
  };
146
- try {
147
- // the switch decision runs at the spawn boundary, same as the CLI hooks.
148
- await ensureBestAccount();
149
- const q = query({
150
- prompt: input.prompt,
151
- options: {
152
- ...pooledOptions(),
153
- cwd: input.cwd,
154
- permissionMode: input.link.permissionMode,
155
- // the SDK refuses bypassPermissions without this explicit opt-in.
156
- ...(input.link.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
157
- includePartialMessages: true,
158
- systemPrompt: SLACK_SYSTEM_PROMPT,
159
- // no one can answer an interactive question dialog through Slack;
160
- // without the tool the model asks in prose and the user's thread
161
- // reply becomes the next turn.
162
- disallowedTools: ["AskUserQuestion"],
163
- hooks: { Stop: [{ hooks: [stopHookCheck] }] },
164
- ...(input.link.model ? { model: input.link.model } : {}),
165
- ...(input.sessionId ? { resume: input.sessionId } : {}),
166
- },
167
- });
168
- const mapState = newStreamMapState();
169
- let result: string | null = null;
170
- for await (const message of q) {
171
- if (message.type === "system" && message.subtype === "init") outcome.sessionId = message.session_id;
172
- if (message.type === "result") {
173
- outcome.sessionId = message.session_id;
174
- if (message.subtype === "success") result = message.result;
175
- else outcome.failed = true;
447
+ // a recovery status line reads as its own Slack message, not part of a
448
+ // streamed segment.
449
+ const notify = async (text: string) => {
450
+ breakSegment();
451
+ await push(text);
452
+ breakSegment();
453
+ };
454
+ /** notify + confirm the post actually landed in Slack. A drop notice that
455
+ * never reached the user must NOT count as announced. DURING A DRAIN an
456
+ * unannounced drop keeps the resume marker so startup replays instead;
457
+ * outside a drain the marker is still cleared ON PURPOSE (closing-review
458
+ * catch corrected this doc, not the behavior): a non-drain drop happens
459
+ * after the turn's spawn decisions ran, and retaining its marker would
460
+ * make the next daemon restart RE-EXECUTE a possibly-metered turn whose
461
+ * outcome the user may already have seen - duplicate execution is worse
462
+ * than a lost message behind an already-broken Slack surface. The
463
+ * unannounced non-drain loss is logged loudly (serve.drop_unannounced) by
464
+ * the caller so it is at least operator-visible. The notice is text, so
465
+ * its own delivery resets textLost. */
466
+ const notifyDelivered = async (text: string) => {
467
+ await notify(text);
468
+ await lastPost;
469
+ return !textLost;
470
+ };
471
+ // false when the daemon started draining mid-sleep.
472
+ const sleep = async (ms: number) => {
473
+ try {
474
+ await delay(Math.max(ms, 0), { signal: input.drainSignal });
475
+ return true;
476
+ } catch {
477
+ return false;
478
+ }
479
+ };
480
+ const inWord = (epochMs: number | null) => (epochMs == null ? "an unknown time" : `~${fmtResetShort(epochMs, Date.now()) || "1m"}`);
481
+
482
+ const runQueryOnce = async () => {
483
+ postedText = false;
484
+ outcome.failed = false;
485
+ outcome.rateLimited = false;
486
+ outcome.resultReceived = false;
487
+ // outcome.finish stays sticky across retries: the tool call already
488
+ // happened in this session, and a limit right after it must not unfinish
489
+ // the thread.
490
+ // the identity this spawn meters: a limit observation is attributed to it,
491
+ // never to whatever account a concurrent thread swaps live mid-turn. Read
492
+ // inside the try: a malformed claude.json must fail the TURN, not the
493
+ // relay's never-throws contract.
494
+ let spawnOrg: string | null = null;
495
+ try {
496
+ spawnOrg = readOAuthAccount()?.organizationUuid ?? null;
497
+ const pooled = pooledOptions();
498
+ const q = query({
499
+ prompt: input.prompt,
500
+ options: {
501
+ ...pooled,
502
+ // claude >= 2.1.142 emits the structured Task tools by default and
503
+ // TodoWrite (the source of the Todos checklist card) never fires;
504
+ // this documented opt-out restores it (agent-sdk todo-tracking docs,
505
+ // verified 2026-07-18 against SDK 0.3.214 + claude 2.1.214). Reuses
506
+ // pooled.env so the scrubbed env copy is built once per turn (cubic
507
+ // review catch on PR #5).
508
+ env: { ...pooled.env, CLAUDE_CODE_ENABLE_TASKS: "0" },
509
+ cwd: input.cwd,
510
+ permissionMode: input.link.permissionMode,
511
+ // the SDK refuses bypassPermissions without this explicit opt-in.
512
+ ...(input.link.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
513
+ includePartialMessages: true,
514
+ systemPrompt: SLACK_SYSTEM_PROMPT,
515
+ // no one can answer an interactive question dialog through Slack;
516
+ // without the tool the model asks in prose and the user's thread
517
+ // reply becomes the next turn.
518
+ disallowedTools: ["AskUserQuestion"],
519
+ spawnClaudeCodeProcess: (spawnOptions) => {
520
+ const child = detachedClaudeSpawn(spawnOptions);
521
+ if (child.pid !== undefined) {
522
+ try {
523
+ input.onSpawn?.(child.pid);
524
+ } catch (e) {
525
+ // a failed marker persist must not leave an untracked group
526
+ // running (cubic review catch): kill it, then fail the spawn
527
+ // loudly through the SDK.
528
+ killGroup(child.pid);
529
+ throw e;
530
+ }
531
+ }
532
+ return child;
533
+ },
534
+ // the user saying "we're done" closes the thread: the model flags it
535
+ // via this in-process tool, the daemon drops the record post-turn.
536
+ mcpServers: { tokenmaxxing: finishToolServer(() => { outcome.finish = true; }) },
537
+ allowedTools: [FINISH_THREAD_TOOL],
538
+ // serve skills (ask-the-user, serve-session); discovered skills are
539
+ // enabled by default, so no `skills` option is needed.
540
+ plugins: [{ type: "local", path: SERVE_PLUGIN_DIR }],
541
+ hooks: {
542
+ UserPromptSubmit: [{
543
+ hooks: [async () => ({
544
+ hookSpecificOutput: {
545
+ hookEventName: "UserPromptSubmit",
546
+ additionalContext: serveTurnContext({ requesterIds: input.requesterIds }),
547
+ },
548
+ })],
549
+ }],
550
+ Stop: [{ hooks: [stopHookCheck] }],
551
+ },
552
+ ...(input.link.model ? { model: input.link.model } : {}),
553
+ // a retry resumes the session the failed attempt opened, so no
554
+ // context is lost across recoveries.
555
+ ...(outcome.sessionId ? { resume: outcome.sessionId } : {}),
556
+ },
557
+ });
558
+ const mapState = newStreamMapState();
559
+ let result: string | null = null;
560
+ for await (const message of q) {
561
+ if (message.type === "system" && message.subtype === "init") {
562
+ // persist BEFORE the turn ends so a first-turn kill stays
563
+ // resumable; compared against the last known id, so retry attempts
564
+ // resuming the same session re-fire only on an actual change.
565
+ if (message.session_id !== outcome.sessionId) input.onSessionId?.(message.session_id);
566
+ outcome.sessionId = message.session_id;
567
+ }
568
+ if (message.type === "result") {
569
+ outcome.sessionId = message.session_id;
570
+ // is_error can ride a "success" subtype (a mid-turn usage limit
571
+ // arrives exactly that way: result "Claude AI usage limit
572
+ // reached|<epoch>"), so errored is a field check, not a subtype
573
+ // check - and only an errored result is ever limit-classified.
574
+ if (message.is_error || message.subtype !== "success") {
575
+ const text = erroredResultText(message);
576
+ outcome.failed = true;
577
+ outcome.rateLimited = isRateLimitText({ text });
578
+ // persist the observation: the retry's decision otherwise re-reads
579
+ // the stale pre-limit snapshot (poll TTL) and respawns the same
580
+ // depleted account - a serve process has no statusLine tee.
581
+ if (outcome.rateLimited) await recordObservedLimit({ text, now: Date.now(), org: spawnOrg });
582
+ } else {
583
+ result = message.result;
584
+ outcome.resultReceived = true;
585
+ }
586
+ }
587
+ for (const part of agentEventChunks({ state: mapState, message })) {
588
+ if (SegmentBreakSchema.safeParse(part).success) breakSegment();
589
+ else await push(SegmentChunkSchema.parse(part));
590
+ }
176
591
  }
177
- for (const part of agentEventChunks({ state: mapState, message })) {
178
- if (SegmentBreakSchema.safeParse(part).success) breakSegment();
179
- else await push(SegmentChunkSchema.parse(part));
592
+ // a turn that produced no streamed text (tool-only turns) still reports.
593
+ if (!postedText && result) await push(result);
594
+ if (!postedText && !result && outcome.failed && !outcome.rateLimited) {
595
+ await push("the turn ended without a result - trying again may help");
180
596
  }
597
+ } catch (e) {
598
+ outcome.failed = true;
599
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
600
+ outcome.rateLimited = isRateLimitText({ text: detail });
601
+ if (outcome.rateLimited) await recordObservedLimit({ text: detail, now: Date.now(), org: spawnOrg });
602
+ log("serve.turn_error", { err: detail });
603
+ if (!outcome.rateLimited) await push(`tokenmaxxing: turn failed: ${detail}`);
604
+ }
605
+ };
606
+
607
+ let recoveries = 0;
608
+ const parkDeadline = Date.now() + PARK_MAX_MS;
609
+ while (true) {
610
+ // the switch decision runs at the spawn boundary, same as the CLI hooks.
611
+ let decision: SwapDecision;
612
+ try {
613
+ decision = await ensureBestAccount();
614
+ } catch (e) {
615
+ outcome.failed = true;
616
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
617
+ log("serve.turn_error", { err: detail });
618
+ await push(`tokenmaxxing: turn failed: ${detail}`);
619
+ break;
620
+ }
621
+ const plan = parkPlan({ decision, recoveries, deadline: parkDeadline });
622
+ if (plan.kind === "drop") {
623
+ outcome.failed = true;
624
+ outcome.rateLimited = true;
625
+ log("serve.pool_depleted_drop", { recoversAt: plan.recoversAt });
626
+ outcome.announcedDrop = await notifyDelivered(`every pooled account is at its usage limit (recovers in ${inWord(plan.recoversAt)}) - this message was dropped; re-send it once the pool recovers.`);
627
+ break;
628
+ }
629
+ if (plan.kind === "park") {
630
+ recoveries += 1;
631
+ log("serve.pool_depleted_park", { wakeAt: plan.wakeAt, recoveries });
632
+ await notify(`every pooled account is at its usage limit - holding this message and retrying in ${inWord(plan.wakeAt)}.`);
633
+ if (!(await sleep(plan.wakeAt - Date.now()))) {
634
+ outcome.failed = true;
635
+ outcome.announcedDrop = await notifyDelivered("tokenmaxxing is restarting - this message was dropped; please re-send it.");
636
+ break;
637
+ }
638
+ continue;
639
+ }
640
+ await runQueryOnce();
641
+ if (!outcome.failed || !outcome.rateLimited) break;
642
+ if (recoveries >= MAX_RECOVERIES) {
643
+ log("serve.rate_limited_drop", { recoveries });
644
+ outcome.announcedDrop = await notifyDelivered("still at a usage limit after retries - this message was dropped; reply when you want to try again.");
645
+ break;
646
+ }
647
+ // a limit the cached pool state did not predict: give the pool one beat
648
+ // to observe it, then re-decide and retry the same prompt into the same
649
+ // session (slaude's silent short retry - a successful swap makes it
650
+ // invisible in the thread).
651
+ recoveries += 1;
652
+ breakSegment();
653
+ log("serve.rate_limited_retry", { recoveries });
654
+ // wait out an active post-swap cooldown too: a swap-then-instant-limit
655
+ // would otherwise burn every retry inside the 45s window where the
656
+ // decision refuses to re-evaluate, respawning the same limited account
657
+ // (review catch, PR #18). The persisted observation then makes the
658
+ // post-cooldown decision see the depleted account immediately.
659
+ // loadLastSwapAt throws on a corrupt swap clock; every failure in this
660
+ // loop must settle the turn in-thread (announced, never a bare throw),
661
+ // same as the ensureBestAccount guard above (review catch, PR #31).
662
+ let cooldownUntil: number;
663
+ try {
664
+ cooldownUntil = (loadLastSwapAt() ?? 0) + POST_SWAP_COOLDOWN_MS + 1_000;
665
+ } catch (e) {
666
+ outcome.failed = true;
667
+ const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
668
+ log("serve.turn_error", { err: detail });
669
+ await push(`tokenmaxxing: turn failed: ${detail}`);
670
+ break;
671
+ }
672
+ if (!(await sleep(Math.max(RETRY_DELAY_MS, cooldownUntil - Date.now())))) {
673
+ outcome.failed = true;
674
+ outcome.announcedDrop = await notifyDelivered("tokenmaxxing is restarting - this message was dropped; please re-send it.");
675
+ break;
181
676
  }
182
- // a turn that produced no streamed text (tool-only turns) still reports.
183
- if (!postedText && result) await push(result);
184
- if (!postedText && !result && outcome.failed) await push("the turn ended without a result (limit or error) - trying again may help");
185
- } catch (e) {
186
- outcome.failed = true;
187
- const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
188
- log("serve.turn_error", { err: detail });
189
- await push(`tokenmaxxing: turn failed: ${detail}`);
190
677
  }
191
678
  breakSegment();
192
679
  await lastPost;
680
+ // Reply text died with a rejected segment and nothing later re-delivered it:
681
+ // the answer silently vanished while the outcome would report success. Fail
682
+ // the turn and make one best-effort fresh-message diagnostic (a fresh post
683
+ // is exactly what the mid-stream recovery relies on succeeding).
684
+ if (textLost) {
685
+ outcome.failed = true;
686
+ const detail = textLostDetail ?? "unknown error";
687
+ await input
688
+ .post((async function* () {
689
+ yield `tokenmaxxing: the reply could not be posted to Slack: ${detail}`;
690
+ })())
691
+ .catch((e: unknown) => log("serve.post_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) }));
692
+ }
193
693
  return outcome;
194
694
  }