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.
- package/DESIGN.md +34 -25
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +184 -20
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +18 -3
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +136 -49
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/slackbridge.ts
CHANGED
|
@@ -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 {
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import {
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
}
|