tokenmaxxing 1.3.0 → 1.5.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 +4 -2
- package/package.json +1 -1
- package/src/cli/serve.ts +458 -67
- package/src/lib/install.ts +3 -2
- package/src/lib/slackbridge.ts +334 -22
- package/src/lib/slackstate.ts +9 -0
package/src/lib/install.ts
CHANGED
|
@@ -406,8 +406,9 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
|
406
406
|
/** Remove ONLY the marker-tagged PATH line this tool added; a hand-added
|
|
407
407
|
* PATH entry without the marker is the user's own. A stale `# tokenmaxxing
|
|
408
408
|
* PATH` line pointing at an emptied binDir is exactly how the supervisor
|
|
409
|
-
* recursion incident started (
|
|
410
|
-
* one behind (closing-review catch).
|
|
409
|
+
* recursion incident started (.memory/supervisor-recursion-guards.md), so
|
|
410
|
+
* uninstall must not leave one behind (closing-review catch).
|
|
411
|
+
* Returns true when a line was removed. */
|
|
411
412
|
export function removePathFromRc(rc: string): boolean {
|
|
412
413
|
if (!existsSync(rc)) return false;
|
|
413
414
|
// same symlink + mode treatment as ensurePathInRc: write through a
|
package/src/lib/slackbridge.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { delay } from "es-toolkit";
|
|
12
|
-
import { createSdkMcpServer, query, tool, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
+
import { createSdkMcpServer, query, tool, type SDKUserMessage, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
13
13
|
import { StreamingMarkdownRenderer, type StreamChunk } from "chat";
|
|
14
14
|
import { ensureBestAccount, pooledOptions, stopHookCheck, type SwapDecision } from "../sdk.ts";
|
|
15
15
|
import { POST_SWAP_COOLDOWN_MS } from "./decide.ts";
|
|
@@ -56,6 +56,14 @@ export const TurnOutcomeSchema = z.object({
|
|
|
56
56
|
* thread's activeTurn marker with resumeAt and the daemon resumes the turn
|
|
57
57
|
* itself, instead of the old "re-send it once the pool recovers" drop. */
|
|
58
58
|
deferUntil: z.number().nullable(),
|
|
59
|
+
/** a steered follow-up's own drained turn FAILED after the primary turn
|
|
60
|
+
* already succeeded (success stays sticky, the loss is announced
|
|
61
|
+
* in-thread): the caller settles the steered messages' reactions as
|
|
62
|
+
* failed, never as done - a lost instruction must not read green.
|
|
63
|
+
* Attribution is per-turn, not per-message (steer() carries text only),
|
|
64
|
+
* so when several messages were steered a successfully folded one can
|
|
65
|
+
* read failed too - accepted: a false re-send ask beats a false green. */
|
|
66
|
+
steerLost: z.boolean(),
|
|
59
67
|
});
|
|
60
68
|
export type TurnOutcome = z.infer<typeof TurnOutcomeSchema>;
|
|
61
69
|
|
|
@@ -75,6 +83,24 @@ const RETRY_DELAY_MS = 10_000;
|
|
|
75
83
|
/** parks + retries per Slack message; keeps a stale usage cache from looping
|
|
76
84
|
* a thread forever. */
|
|
77
85
|
export const MAX_RECOVERIES = 3;
|
|
86
|
+
/** extra spawns for a NON-limit failure whose child never completed (crash,
|
|
87
|
+
* API blip, errored result): each resumes the same session, so a retry
|
|
88
|
+
* continues the turn instead of re-running it. Separate from MAX_RECOVERIES
|
|
89
|
+
* on purpose - that budget bounds limit-driven parking, this one bounds
|
|
90
|
+
* quota spent chasing a possibly-permanent error. */
|
|
91
|
+
export const MAX_TRANSIENT_RETRIES = 2;
|
|
92
|
+
/** Slack expires a native stream SERVER-SIDE on undocumented timers
|
|
93
|
+
* (Slack-maintainer-confirmed in slackapi/python-slack-sdk#1859: idle
|
|
94
|
+
* around 30s, total lifetime around 300s measured), and an expired stream
|
|
95
|
+
* freezes in the Slack client as a grey "Something went wrong" pill - the
|
|
96
|
+
* exact 2026-07-27 report. The salvage path recovers the CONTENT but
|
|
97
|
+
* cannot un-freeze the pill, so the fix is to never let Slack expire a
|
|
98
|
+
* stream we own: rotate the open segment - a clean end() that the adapter
|
|
99
|
+
* finishes with a proper stream stop - before either timer can fire, and
|
|
100
|
+
* let the next chunk open a fresh message, the same flow pushText's size
|
|
101
|
+
* splits already use. Thresholds sit well inside Slack's observed margins;
|
|
102
|
+
* read at every arm (a mutable object, the file's test seam pattern). */
|
|
103
|
+
export const SEGMENT_ROTATION = { idleMs: 20_000, maxAgeMs: 240_000 };
|
|
78
104
|
|
|
79
105
|
const ParkPlanSchema = z.union([
|
|
80
106
|
z.object({ kind: z.literal("proceed") }),
|
|
@@ -216,6 +242,60 @@ export function serveTurnContext(input: { requesterIds: string[] }): string {
|
|
|
216
242
|
const SegmentChunkSchema = z.union([z.string(), z.custom<StreamChunk>()]);
|
|
217
243
|
type SegmentChunk = z.infer<typeof SegmentChunkSchema>;
|
|
218
244
|
|
|
245
|
+
/** The exact user-message shape the SDK's own string-prompt path writes to the
|
|
246
|
+
* child's stdin (verified in @anthropic-ai/claude-agent-sdk 0.3.214: the SDK
|
|
247
|
+
* serializes yielded messages verbatim, adding nothing). No uuid on purpose:
|
|
248
|
+
* the CLI dedupes stream-json user messages by uuid and silently swallows a
|
|
249
|
+
* reused one, so a uuid derived from a Slack message ts would eat retries.
|
|
250
|
+
* No priority either: the default "next" folds the message into the running
|
|
251
|
+
* turn at the next tool boundary, while "now" is an undocumented hard
|
|
252
|
+
* interrupt that would abort the turn mid-tool (both verified in the claude
|
|
253
|
+
* 2.1.220 binary). */
|
|
254
|
+
function steerUserMessage(text: string): SDKUserMessage {
|
|
255
|
+
return { type: "user", session_id: "", message: { role: "user", content: [{ type: "text", text }] }, parent_tool_use_id: null };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A hand-pushed async iterable of SDK user messages: the streaming-input
|
|
259
|
+
* prompt for one query attempt. The initial prompt is pushed before query()
|
|
260
|
+
* and steered messages join mid-turn; end() closes the child's stdin (the
|
|
261
|
+
* SDK ends the stream when the iterable finishes). */
|
|
262
|
+
function pushableMessages(): {
|
|
263
|
+
iterable: AsyncIterable<SDKUserMessage>;
|
|
264
|
+
push: (m: SDKUserMessage) => void;
|
|
265
|
+
end: () => void;
|
|
266
|
+
} {
|
|
267
|
+
const queued: SDKUserMessage[] = [];
|
|
268
|
+
let cursor = 0;
|
|
269
|
+
let done = false;
|
|
270
|
+
let notify: (() => void) | null = null;
|
|
271
|
+
return {
|
|
272
|
+
push(m) {
|
|
273
|
+
queued.push(m);
|
|
274
|
+
notify?.();
|
|
275
|
+
},
|
|
276
|
+
end() {
|
|
277
|
+
done = true;
|
|
278
|
+
notify?.();
|
|
279
|
+
},
|
|
280
|
+
iterable: {
|
|
281
|
+
async *[Symbol.asyncIterator]() {
|
|
282
|
+
while (true) {
|
|
283
|
+
while (cursor < queued.length) {
|
|
284
|
+
const next = queued[cursor]!;
|
|
285
|
+
cursor += 1;
|
|
286
|
+
yield next;
|
|
287
|
+
}
|
|
288
|
+
if (done) return;
|
|
289
|
+
await new Promise<void>((resolve) => {
|
|
290
|
+
notify = resolve;
|
|
291
|
+
});
|
|
292
|
+
notify = null;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
219
299
|
/** A hand-pushed async iterable: relayThread feeds one of these per Slack
|
|
220
300
|
* message segment while thread.post concurrently drains it. */
|
|
221
301
|
function pushableStream(): {
|
|
@@ -460,8 +540,22 @@ export async function relayThread(input: {
|
|
|
460
540
|
/** daemon shutdown signal: aborts park/retry sleeps so a drain never sits
|
|
461
541
|
* out a depleted-pool countdown. */
|
|
462
542
|
drainSignal?: AbortSignal;
|
|
543
|
+
/** Steering seam. Called with a steer function while a query attempt is
|
|
544
|
+
* live and with null when it ends; steer(text) returns true when the text
|
|
545
|
+
* was written into the RUNNING attempt's stdin (the CLI folds it into the
|
|
546
|
+
* turn at the next tool boundary, or runs it as its own turn in the same
|
|
547
|
+
* child when the fold window is gone - either way it reaches the session,
|
|
548
|
+
* verified against claude 2.1.220), false once the attempt's result
|
|
549
|
+
* arrived or the attempt died - the caller then queues the message as a
|
|
550
|
+
* normal next turn instead. Accepted texts also fold into any RETRY
|
|
551
|
+
* attempt's prompt, mirroring the existing resend-the-full-prompt retry
|
|
552
|
+
* tradeoff (duplication in the resumed transcript beats loss).
|
|
553
|
+
* input.requesterIds is shared by reference on purpose: the caller may
|
|
554
|
+
* push a steer author's id so a retry attempt's UserPromptSubmit context
|
|
555
|
+
* names them (the hook does not fire for folded mid-turn messages). */
|
|
556
|
+
onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
|
|
463
557
|
}): Promise<TurnOutcome> {
|
|
464
|
-
const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null };
|
|
558
|
+
const outcome: TurnOutcome = { sessionId: input.sessionId, failed: false, rateLimited: false, finish: false, attention: false, announcedDrop: false, resultReceived: false, deferUntil: null, steerLost: false };
|
|
465
559
|
let segment: ReturnType<typeof pushableStream> | null = null;
|
|
466
560
|
// acc mirrors the segment's pushed text (bounded by SEGMENT_TEXT_MAX plus a
|
|
467
561
|
// small overshoot): fence parity must be computed over the ACCUMULATED text,
|
|
@@ -469,6 +563,14 @@ export async function relayThread(input: {
|
|
|
469
563
|
// a ``` split across two deltas would be invisible to per-chunk counting
|
|
470
564
|
// (pullfrog review catch, PR #42).
|
|
471
565
|
let segmentMeta: { text: boolean; acc: string; reopenFence: boolean } | null = null;
|
|
566
|
+
// the open segment's rotation clock (see armSegmentTimer).
|
|
567
|
+
let segmentTimer: ReturnType<typeof setTimeout> | null = null;
|
|
568
|
+
let segmentOpenedAt = 0;
|
|
569
|
+
// a timer rotation closed the segment inside a code fence: the next
|
|
570
|
+
// segment must reopen it, exactly like pushText's size splits do (codex
|
|
571
|
+
// review catch). Consumed by openSegment; the salvage handler's own
|
|
572
|
+
// delivered-prefix recomputation overrides it there.
|
|
573
|
+
let pendingReopenFence = false;
|
|
472
574
|
let lastPost: Promise<unknown> = Promise.resolve();
|
|
473
575
|
// Reply TEXT that died with a rejected segment and was neither salvaged into
|
|
474
576
|
// a follow-on message nor re-delivered by a later text-bearing segment: the
|
|
@@ -496,6 +598,8 @@ export async function relayThread(input: {
|
|
|
496
598
|
const openSegment = () => {
|
|
497
599
|
const seg = pushableStream();
|
|
498
600
|
segment = seg;
|
|
601
|
+
segmentOpenedAt = Date.now();
|
|
602
|
+
armSegmentTimer();
|
|
499
603
|
// reopenFence: the delivered prefix of a REJECTED predecessor left a code
|
|
500
604
|
// fence open, so the first TEXT entering this salvage segment must be
|
|
501
605
|
// preceded by a reopen or it renders outside the code block. Pending
|
|
@@ -503,7 +607,8 @@ export async function relayThread(input: {
|
|
|
503
607
|
// salvage would otherwise either skip the reopen (later text joining the
|
|
504
608
|
// segment renders unfenced) or dangle an empty open fence at message end
|
|
505
609
|
// when no text ever follows. Materialized by BOTH text entry points.
|
|
506
|
-
const meta = { text: false, acc: "", reopenFence:
|
|
610
|
+
const meta = { text: false, acc: "", reopenFence: pendingReopenFence };
|
|
611
|
+
pendingReopenFence = false;
|
|
507
612
|
segmentMeta = meta;
|
|
508
613
|
lastPost = input.post(seg.iterable).then(
|
|
509
614
|
() => {
|
|
@@ -608,9 +713,19 @@ export async function relayThread(input: {
|
|
|
608
713
|
// the salvage segment here keeps the salvaged content ordered ahead
|
|
609
714
|
// of any push still awaiting lastPost; the salvage segment's own
|
|
610
715
|
// settle then decides whether its text counts as delivered.
|
|
716
|
+
// A salvage always recomputes its fence state from the delivered
|
|
717
|
+
// prefix (the override below), so it must never CONSUME a pending
|
|
718
|
+
// rotation reopen that belongs to a different segment's
|
|
719
|
+
// continuation - a rejected notice settling after notify()
|
|
720
|
+
// restored the flag would otherwise eat it (cubic review catch,
|
|
721
|
+
// round 4). Held across the lost-chunk re-push too: the salvaged
|
|
722
|
+
// text is the dying segment's, not the continuation's.
|
|
723
|
+
const heldReopen = pendingReopenFence;
|
|
724
|
+
pendingReopenFence = false;
|
|
611
725
|
const next = openSegment();
|
|
612
726
|
next.meta.reopenFence = deliveredFenceOpen;
|
|
613
727
|
for (const c of lost) next.pushInto(c);
|
|
728
|
+
pendingReopenFence = heldReopen;
|
|
614
729
|
} else if (textRemainder !== "") {
|
|
615
730
|
textLost = true;
|
|
616
731
|
textLostDetail = detail;
|
|
@@ -637,6 +752,7 @@ export async function relayThread(input: {
|
|
|
637
752
|
meta.acc += chunk;
|
|
638
753
|
}
|
|
639
754
|
seg.push(chunk);
|
|
755
|
+
if (segment === seg) armSegmentTimer();
|
|
640
756
|
};
|
|
641
757
|
return { seg, meta, pushInto };
|
|
642
758
|
};
|
|
@@ -649,6 +765,17 @@ export async function relayThread(input: {
|
|
|
649
765
|
}
|
|
650
766
|
const target = segment ?? openSegment().seg;
|
|
651
767
|
if (!(chunk instanceof Object)) {
|
|
768
|
+
// a pending rotation reopen can outlive its openSegment hand-off when
|
|
769
|
+
// text JOINS a segment a rejection handler opened during the wait
|
|
770
|
+
// (the salvage recomputes its own fence state without consuming the
|
|
771
|
+
// flag): adopt it here so the continuation still reopens its fence,
|
|
772
|
+
// UNLESS the joined segment already sits inside an open fence - then
|
|
773
|
+
// the state is satisfied and a second marker would close it (cubic
|
|
774
|
+
// review catch, round 4).
|
|
775
|
+
if (pendingReopenFence) {
|
|
776
|
+
pendingReopenFence = false;
|
|
777
|
+
if (!fenceOpen()) segmentMeta!.reopenFence = true;
|
|
778
|
+
}
|
|
652
779
|
// materialize a salvage segment's pending fence reopen (see
|
|
653
780
|
// openSegment) before the first text from this entry point too.
|
|
654
781
|
if (segmentMeta!.reopenFence) {
|
|
@@ -661,14 +788,42 @@ export async function relayThread(input: {
|
|
|
661
788
|
segmentMeta!.acc += chunk;
|
|
662
789
|
}
|
|
663
790
|
target.push(chunk);
|
|
791
|
+
if (segment === target) armSegmentTimer();
|
|
664
792
|
};
|
|
665
793
|
// parity by occurrence count over the segment's accumulated text: an odd
|
|
666
794
|
// number of ``` markers means the segment currently sits inside a fence.
|
|
667
795
|
const fenceOpen = () => segmentMeta !== null && (segmentMeta.acc.split("```").length - 1) % 2 === 1;
|
|
668
796
|
const breakSegment = () => {
|
|
797
|
+
if (segmentTimer !== null) {
|
|
798
|
+
clearTimeout(segmentTimer);
|
|
799
|
+
segmentTimer = null;
|
|
800
|
+
}
|
|
801
|
+
// a segment dying with an armed-but-unmaterialized reopen (no text ever
|
|
802
|
+
// arrived - a card-only segment that consumed the flag at open) folds it
|
|
803
|
+
// back: the reply still logically sits inside an open fence, and the
|
|
804
|
+
// next text-bearing segment must reopen it (cubic review catch, round
|
|
805
|
+
// 4: the errored result's closing card ate the rotation's reopen).
|
|
806
|
+
if (segmentMeta?.reopenFence) pendingReopenFence = true;
|
|
669
807
|
segment?.end();
|
|
670
808
|
segment = null;
|
|
671
809
|
};
|
|
810
|
+
const armSegmentTimer = () => {
|
|
811
|
+
if (segmentTimer !== null) clearTimeout(segmentTimer);
|
|
812
|
+
const ageLeft = segmentOpenedAt + SEGMENT_ROTATION.maxAgeMs - Date.now();
|
|
813
|
+
segmentTimer = setTimeout(() => {
|
|
814
|
+
segmentTimer = null;
|
|
815
|
+
// a rotation mid-fence closes the fence and arms the reopen, so both
|
|
816
|
+
// message halves render as code - pushText's split contract. An
|
|
817
|
+
// armed-but-unmaterialized reopen needs no close marker; breakSegment
|
|
818
|
+
// folds it forward.
|
|
819
|
+
if (segment !== null && fenceOpen()) {
|
|
820
|
+
segmentMeta!.acc += "\n```";
|
|
821
|
+
segment.push("\n```");
|
|
822
|
+
pendingReopenFence = true;
|
|
823
|
+
}
|
|
824
|
+
breakSegment();
|
|
825
|
+
}, Math.max(0, Math.min(SEGMENT_ROTATION.idleMs, ageLeft)));
|
|
826
|
+
};
|
|
672
827
|
/** Reply text routed through here splits across Slack messages before the
|
|
673
828
|
* msg_too_long cap (see SEGMENT_TEXT_MAX): a break prefers the last newline
|
|
674
829
|
* inside the remaining room, and a break forced inside a code fence closes
|
|
@@ -723,8 +878,16 @@ export async function relayThread(input: {
|
|
|
723
878
|
// streamed segment.
|
|
724
879
|
const notify = async (text: string) => {
|
|
725
880
|
breakSegment();
|
|
881
|
+
// a notice must never inherit a rotation's pending fence reopen: the
|
|
882
|
+
// reopen belongs to the interrupted reply's continuation, not to the
|
|
883
|
+
// status line - consuming it here would render the notice as a code
|
|
884
|
+
// block AND strand the continuation unfenced (cubic review catch,
|
|
885
|
+
// round 3). Held aside and restored for the real continuation.
|
|
886
|
+
const heldReopen = pendingReopenFence;
|
|
887
|
+
pendingReopenFence = false;
|
|
726
888
|
await push(text);
|
|
727
889
|
breakSegment();
|
|
890
|
+
pendingReopenFence = heldReopen;
|
|
728
891
|
};
|
|
729
892
|
/** notify + confirm the post actually landed in Slack. A drop notice that
|
|
730
893
|
* never reached the user must NOT count as announced. DURING A DRAIN an
|
|
@@ -759,6 +922,16 @@ export async function relayThread(input: {
|
|
|
759
922
|
// deferral notice explains the pause; the raw line would invite a manual
|
|
760
923
|
// re-send of work the daemon resumes itself - cubic catch, PR #44).
|
|
761
924
|
let pendingFailureLine: string | null = null;
|
|
925
|
+
// texts steered into this message's turn, kept across retries: a retry
|
|
926
|
+
// resumes the session and re-sends the attempt prompt (the established
|
|
927
|
+
// duplication-beats-loss tradeoff), so steered text folds into every
|
|
928
|
+
// retry attempt's first message too - a steer written just before a
|
|
929
|
+
// mid-turn limit killed the child must not vanish from the turn it joined.
|
|
930
|
+
const steeredTexts: string[] = [];
|
|
931
|
+
// what the next spawn submits ahead of the steered texts: the original
|
|
932
|
+
// message, until a transient retry swaps in the continuation wrapper (see
|
|
933
|
+
// the retry branch).
|
|
934
|
+
let prompt = input.prompt;
|
|
762
935
|
const runQueryOnce = async () => {
|
|
763
936
|
postedText = false;
|
|
764
937
|
pendingFailureLine = null;
|
|
@@ -773,11 +946,47 @@ export async function relayThread(input: {
|
|
|
773
946
|
// inside the try: a malformed claude.json must fail the TURN, not the
|
|
774
947
|
// relay's never-throws contract.
|
|
775
948
|
let spawnOrg: string | null = null;
|
|
949
|
+
// Streaming input: the prompt rides an open stdin stream instead of a
|
|
950
|
+
// one-shot string, which is what lets a mid-turn Slack reply steer the
|
|
951
|
+
// running turn (the CLI folds a queued stream-json user message into the
|
|
952
|
+
// current turn at the next tool boundary; one that misses the last fold
|
|
953
|
+
// window runs as its own turn in the same child before exit, so nothing
|
|
954
|
+
// is ever dropped - both verified against claude 2.1.220 + SDK 0.3.214).
|
|
955
|
+
// steer() refuses the moment the attempt's result arrives: stdin ends
|
|
956
|
+
// then, and the SDK silently drops writes to an ended stdin.
|
|
957
|
+
const stream = pushableMessages();
|
|
958
|
+
let steerable = true;
|
|
959
|
+
const steer = (text: string, onAccept?: () => void): boolean => {
|
|
960
|
+
if (!steerable) return false;
|
|
961
|
+
// the caller's SYNCHRONOUS durable commit runs inside the acceptance,
|
|
962
|
+
// in the same JS tick as the liveness check and BEFORE the child sees
|
|
963
|
+
// the text (adversarial-review catch, round 3): steerable=true here
|
|
964
|
+
// proves the turn has not ended, so the commit's view of the turn
|
|
965
|
+
// state cannot be stale, a crash between commit and push replays a
|
|
966
|
+
// steer the child never saw (duplication over loss), and a refusal
|
|
967
|
+
// commits NOTHING - a stale acceptor invocation racing the turn's end
|
|
968
|
+
// can no longer resurrect a finished turn's marker or clobber
|
|
969
|
+
// post-turn state. A THROWING commit escapes to the caller before
|
|
970
|
+
// anything is pushed or recorded here: the stream, steeredTexts, and
|
|
971
|
+
// the sticky flags are untouched, so the caller can treat the throw
|
|
972
|
+
// as a refusal and a later steer still works.
|
|
973
|
+
onAccept?.();
|
|
974
|
+
steeredTexts.push(text);
|
|
975
|
+
stream.push(steerUserMessage(text));
|
|
976
|
+
// a steer answers a LIVE ask too (codex review catch on PR #50): when
|
|
977
|
+
// need_attention already fired in this attempt, the ask exists only as
|
|
978
|
+
// this sticky flag until settleTurn persists it - left set, the
|
|
979
|
+
// answered ask would still get a question mark and a nudge. A later
|
|
980
|
+
// need_attention call re-arms it for a genuinely new ask.
|
|
981
|
+
outcome.attention = false;
|
|
982
|
+
return true;
|
|
983
|
+
};
|
|
776
984
|
try {
|
|
777
985
|
spawnOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
778
986
|
const pooled = pooledOptions();
|
|
987
|
+
stream.push(steerUserMessage([prompt, ...steeredTexts].join("\n\n")));
|
|
779
988
|
const q = query({
|
|
780
|
-
prompt:
|
|
989
|
+
prompt: stream.iterable,
|
|
781
990
|
options: {
|
|
782
991
|
...pooled,
|
|
783
992
|
// claude >= 2.1.142 emits the structured Task tools by default and
|
|
@@ -844,8 +1053,18 @@ export async function relayThread(input: {
|
|
|
844
1053
|
...(outcome.sessionId ? { resume: outcome.sessionId } : {}),
|
|
845
1054
|
},
|
|
846
1055
|
});
|
|
1056
|
+
// registered only while this attempt is live (cleared in the finally):
|
|
1057
|
+
// the caller's fallback for a refused steer is the normal queued turn.
|
|
1058
|
+
input.onSteer?.(steer);
|
|
847
1059
|
const mapState = newStreamMapState();
|
|
848
1060
|
let result: string | null = null;
|
|
1061
|
+
// reply text streamed since the last result boundary: each turn in the
|
|
1062
|
+
// child (the primary one, plus any post-fold-window steer drained as
|
|
1063
|
+
// its own turn) delivers its answer independently - a tool-only turn's
|
|
1064
|
+
// answer lives ONLY in its result message, and flushing it at that
|
|
1065
|
+
// result is what keeps a trailing turn's notice or result from
|
|
1066
|
+
// suppressing or clobbering it (adversarial-review catch, round 2).
|
|
1067
|
+
let streamedSinceResult = false;
|
|
849
1068
|
for await (const message of q) {
|
|
850
1069
|
if (message.type === "system" && message.subtype === "init") {
|
|
851
1070
|
// persist BEFORE the turn ends so a first-turn kill stays
|
|
@@ -856,36 +1075,82 @@ export async function relayThread(input: {
|
|
|
856
1075
|
}
|
|
857
1076
|
if (message.type === "result") {
|
|
858
1077
|
outcome.sessionId = message.session_id;
|
|
1078
|
+
// any result ends steerability and closes the child's stdin: a
|
|
1079
|
+
// steer arriving now queues as its own next turn instead. A steer
|
|
1080
|
+
// accepted BEFORE this that missed its fold window still runs -
|
|
1081
|
+
// the CLI drains queued commands as their own turns in this same
|
|
1082
|
+
// child before exiting, emitting a further result each time, so
|
|
1083
|
+
// this loop just keeps consuming until the child exits (end() is
|
|
1084
|
+
// idempotent).
|
|
1085
|
+
steerable = false;
|
|
1086
|
+
stream.end();
|
|
859
1087
|
// is_error can ride a "success" subtype (a mid-turn usage limit
|
|
860
1088
|
// arrives exactly that way: result "Claude AI usage limit
|
|
861
1089
|
// reached|<epoch>"), so errored is a field check, not a subtype
|
|
862
1090
|
// check - and only an errored result is ever limit-classified.
|
|
863
1091
|
if (message.is_error || message.subtype !== "success") {
|
|
864
1092
|
const text = erroredResultText(message);
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1093
|
+
const limited = isRateLimitText({ text });
|
|
1094
|
+
// persist the observation either way: the next spawn decision
|
|
1095
|
+
// must see the limit even when this turn does not retry.
|
|
1096
|
+
if (limited) await recordObservedLimit({ text, now: Date.now(), org: spawnOrg });
|
|
1097
|
+
if (outcome.resultReceived) {
|
|
1098
|
+
// SUCCESS IS STICKY within an attempt (adversarial-review
|
|
1099
|
+
// catch): this errored result belongs to a post-fold-window
|
|
1100
|
+
// steer's own drained turn, arriving AFTER the primary turn
|
|
1101
|
+
// already succeeded and delivered its answer. Marking the turn
|
|
1102
|
+
// failed here would send the whole prompt back through the
|
|
1103
|
+
// retry/defer machinery and re-run completed work - the exact
|
|
1104
|
+
// duplicate-execution the outcome contract forbids. The steer
|
|
1105
|
+
// is announced lost instead (drop-beats-false-promise), and
|
|
1106
|
+
// steerLost settles the steered messages' reactions as failed.
|
|
1107
|
+
outcome.steerLost = true;
|
|
1108
|
+
log("serve.steered_turn_failed", { limited });
|
|
1109
|
+
await notify(
|
|
1110
|
+
limited
|
|
1111
|
+
? "a steered follow-up message hit a usage limit before it could run - please re-send it."
|
|
1112
|
+
: "a steered follow-up message failed - please re-send it.",
|
|
1113
|
+
);
|
|
1114
|
+
} else {
|
|
1115
|
+
outcome.failed = true;
|
|
1116
|
+
// a limit classification is sticky across a child's errored
|
|
1117
|
+
// results (adversarial-review catch, round 2): the drained
|
|
1118
|
+
// steer turn's generic death must not declassify the primary
|
|
1119
|
+
// turn's recoverable limit back to a plain failure.
|
|
1120
|
+
outcome.rateLimited = outcome.rateLimited || limited;
|
|
1121
|
+
// hold the REAL errored text for the terminal diagnostic (codex
|
|
1122
|
+
// review catches: a streamed-then-errored turn used to end with
|
|
1123
|
+
// a truncated answer and only the x reaction, and a no-text
|
|
1124
|
+
// errored turn used to discard the reason for a generic line).
|
|
1125
|
+
// The depleted-pool probe still outranks the line.
|
|
1126
|
+
if (!limited) {
|
|
1127
|
+
const reason = text.slice(0, 200) || "no error detail";
|
|
1128
|
+
pendingFailureLine = postedText
|
|
1129
|
+
? `tokenmaxxing: the turn errored before finishing (${reason}) - the reply above may be incomplete.`
|
|
1130
|
+
: `tokenmaxxing: the turn errored without a result (${reason}) - trying again may help.`;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
871
1133
|
} else {
|
|
872
1134
|
result = message.result;
|
|
873
1135
|
outcome.resultReceived = true;
|
|
1136
|
+
// a turn that streamed no reply text (tool-only turns) still
|
|
1137
|
+
// reports: its answer is flushed HERE, per result, not after the
|
|
1138
|
+
// loop - a later turn in the same child must not suppress it. A
|
|
1139
|
+
// paragraph break ahead of it when text already posted, or two
|
|
1140
|
+
// result-only answers would concatenate mid-line (codex review
|
|
1141
|
+
// catch on PR #50).
|
|
1142
|
+
if (!streamedSinceResult && result) await pushText(`${postedText ? "\n\n" : ""}${result}`);
|
|
874
1143
|
}
|
|
1144
|
+
streamedSinceResult = false;
|
|
875
1145
|
}
|
|
876
1146
|
for (const part of agentEventChunks({ state: mapState, message })) {
|
|
877
1147
|
if (part instanceof Object) await push(part);
|
|
878
|
-
else
|
|
1148
|
+
else {
|
|
1149
|
+
if (part.trim() !== "") streamedSinceResult = true;
|
|
1150
|
+
await pushText(part);
|
|
1151
|
+
}
|
|
879
1152
|
}
|
|
880
1153
|
}
|
|
881
|
-
// a turn that produced no streamed text (tool-only turns) still reports.
|
|
882
|
-
if (!postedText && result) await pushText(result);
|
|
883
|
-
if (!postedText && !result && outcome.failed && !outcome.rateLimited) {
|
|
884
|
-
// held back until the depleted-pool probe rules: a "trying again may
|
|
885
|
-
// help" line right before a deferral notice invites a manual re-send
|
|
886
|
-
// of work the daemon is about to resume itself (cubic catch, PR #44).
|
|
887
|
-
pendingFailureLine = "the turn ended without a result - trying again may help";
|
|
888
|
-
}
|
|
889
1154
|
} catch (e) {
|
|
890
1155
|
outcome.failed = true;
|
|
891
1156
|
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
@@ -896,10 +1161,18 @@ export async function relayThread(input: {
|
|
|
896
1161
|
// deferral's own notice explains the pause better than a raw child
|
|
897
1162
|
// error that reads as "please re-send".
|
|
898
1163
|
if (!outcome.rateLimited) pendingFailureLine = `tokenmaxxing: turn failed: ${detail}`;
|
|
1164
|
+
} finally {
|
|
1165
|
+
// a died attempt must stop accepting steers (they would be silent
|
|
1166
|
+
// drops on an ended stdin) and must release the caller's steer hook
|
|
1167
|
+
// before the retry loop decides anything.
|
|
1168
|
+
steerable = false;
|
|
1169
|
+
stream.end();
|
|
1170
|
+
input.onSteer?.(null);
|
|
899
1171
|
}
|
|
900
1172
|
};
|
|
901
1173
|
|
|
902
1174
|
let recoveries = 0;
|
|
1175
|
+
let transientRetries = 0;
|
|
903
1176
|
const parkDeadline = Date.now() + PARK_MAX_MS;
|
|
904
1177
|
while (true) {
|
|
905
1178
|
// the switch decision runs at the spawn boundary, same as the CLI hooks.
|
|
@@ -956,10 +1229,14 @@ export async function relayThread(input: {
|
|
|
956
1229
|
// the evidence the error text did not carry. A completed result stays
|
|
957
1230
|
// terminal, and a usable pool keeps the plain failure.
|
|
958
1231
|
if (!outcome.resultReceived) {
|
|
1232
|
+
// outranks the transient retry below even when the recovery time is
|
|
1233
|
+
// unknown (cubic review catch): a depleted pool explains the failure,
|
|
1234
|
+
// and respawning against it would just burn doomed attempts.
|
|
1235
|
+
let probeDepleted = false;
|
|
959
1236
|
try {
|
|
960
1237
|
const verdict = await ensureBestAccount();
|
|
961
|
-
|
|
962
|
-
const wake =
|
|
1238
|
+
probeDepleted = verdict.reason === "all-depleted" || verdict.reason === "depleted-wait";
|
|
1239
|
+
const wake = probeDepleted ? verdict.waitUntil ?? null : null;
|
|
963
1240
|
if (wake != null) {
|
|
964
1241
|
outcome.rateLimited = true;
|
|
965
1242
|
outcome.deferUntil = wake + PARK_GRACE_MS;
|
|
@@ -971,8 +1248,43 @@ export async function relayThread(input: {
|
|
|
971
1248
|
// keep the original failure; the probe must never mask it.
|
|
972
1249
|
log("serve.defer_probe_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
973
1250
|
}
|
|
1251
|
+
// a transient non-limit failure (child crash, API blip, errored
|
|
1252
|
+
// result) against a usable pool retries silently into the same
|
|
1253
|
+
// session, mirroring the rate-limit path's invisible short retry
|
|
1254
|
+
// (2026-07-27 report: these turns died terminally, telling the user
|
|
1255
|
+
// "trying again may help" instead of trying again). resultReceived
|
|
1256
|
+
// stays terminal above - a completed answer must never re-run - and
|
|
1257
|
+
// the probe's deferral outranks a retry: an exhausted pool explains
|
|
1258
|
+
// the failure better than "transient".
|
|
1259
|
+
if (!probeDepleted && outcome.deferUntil === null && transientRetries < MAX_TRANSIENT_RETRIES) {
|
|
1260
|
+
transientRetries += 1;
|
|
1261
|
+
breakSegment();
|
|
1262
|
+
log("serve.transient_retry", { attempt: transientRetries });
|
|
1263
|
+
// once a session exists, the failed attempt may have executed
|
|
1264
|
+
// side-effectful tools before dying, so the retry must CONTINUE,
|
|
1265
|
+
// never re-instruct: replaying the original prompt verbatim into
|
|
1266
|
+
// the resumed session reads as "do it again" (codex review catch).
|
|
1267
|
+
// The wrapper mirrors resumeDecision's deferral resume. A pre-init
|
|
1268
|
+
// death ran nothing, so the original replays verbatim there.
|
|
1269
|
+
if (outcome.sessionId !== null) {
|
|
1270
|
+
prompt = `Your previous turn was interrupted by an error mid-run; this session's transcript already holds any work it completed, including tool calls whose side effects already happened. Pick up exactly where you left off and finish the task without re-running completed side-effectful steps. If the work was already complete, just summarize the final state. The original request was:\n\n${input.prompt}`;
|
|
1271
|
+
}
|
|
1272
|
+
if (!(await sleep(RETRY_DELAY_MS))) {
|
|
1273
|
+
// a drain aborted the retry sleep: same as a killed child, the
|
|
1274
|
+
// marker survives (presumedKilled) and the next daemon start
|
|
1275
|
+
// resumes the session where it stopped.
|
|
1276
|
+
await notify("tokenmaxxing is restarting - this turn resumes after the restart.");
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (pendingFailureLine !== null) {
|
|
1283
|
+
// same fence-reopen bypass as notify: a diagnostic line opening a
|
|
1284
|
+
// fresh segment must not render inside a reopened code fence.
|
|
1285
|
+
pendingReopenFence = false;
|
|
1286
|
+
await push(transientRetries > 0 ? `${pendingFailureLine} (after ${transientRetries + 1} attempts)` : pendingFailureLine);
|
|
974
1287
|
}
|
|
975
|
-
if (pendingFailureLine !== null) await push(pendingFailureLine);
|
|
976
1288
|
break;
|
|
977
1289
|
}
|
|
978
1290
|
if (recoveries >= MAX_RECOVERIES) {
|
package/src/lib/slackstate.ts
CHANGED
|
@@ -133,6 +133,15 @@ const ActiveTurnSchema = z.object({
|
|
|
133
133
|
* Absent on older records: recovery falls back to the streamable
|
|
134
134
|
* handle's newest-author derivation. */
|
|
135
135
|
requesterIds: z.array(z.string()).optional(),
|
|
136
|
+
/** Slack ids of messages STEERED into this turn mid-run: they carry the
|
|
137
|
+
* same hourglass-to-terminal reaction lifecycle as the triggering
|
|
138
|
+
* message, so a killed or deferred turn's recovery must settle them too.
|
|
139
|
+
* Their text is already folded into `prompt` at steer time, which is what
|
|
140
|
+
* makes replays and retries include what the user steered in. An inbound
|
|
141
|
+
* takeover of a DEFERRED turn also adopts the held turn's unsettled ids
|
|
142
|
+
* here: the takeover serves their held prompt, and without the adoption
|
|
143
|
+
* the old trigger's hourglass would read "processing" forever. */
|
|
144
|
+
steeredMessageIds: z.array(z.string()).optional(),
|
|
136
145
|
});
|
|
137
146
|
export type ActiveTurn = z.infer<typeof ActiveTurnSchema>;
|
|
138
147
|
|