diff --git a/docs/api-reference.md b/docs/api-reference.md index c5942039..92507a65 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1152,6 +1152,14 @@ that sends the report, decided when the pane starts: `injected` (it does), `coll `.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or `remote` (remote and Docker sessions). Absent when unknown. +`contextWindowSize` is the model's context window in tokens from the same report (200000, +or 1000000 with extended context), absent until a report carries it and dropped, like +`promptCache`, on a post-`/clear` conversation switch. It is the upper bound for +`autoCompactThreshold` and `autoClearThreshold` (1000000 while it is absent): a higher value +is stored as the window, and Session Options stops its two inputs there. The thresholds +themselves are compared with the report's `context_window.total_input_tokens`, cache reads +included, and with the session's token counter for a launch without the exporter. + ## Placing a new session in a tab group (`tabGroupId`) `POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f2da86af..871b0580 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -189,7 +189,11 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa **After a /compact or /clear, the report from before the command is ignored by value, not by time.** A report carries the usage of the last API response, so that number stays the newest one until the command finishes. The first report that differs came from the finished command (0 on Claude Code 2.1.319) or from a later response. If the command was swallowed, that report is higher and the threshold fires again, so nothing can lock the automation off. A threshold below what a compact leaves behind fires again on the next response, like the counter always did. A counter summed from JSON usage never drops after a compact, so it counts from its value at the last one. The pane parser's counter is the absolute number on screen and only moves past its highest value, so any move clears that baseline (`noteAbsoluteCounter()`). Neither value survives a restart. -**Thresholds go up to the reported window** (`_maxThreshold()`, 1M when unknown), and a higher value stops at the window when it is set. A threshold set before the window was known can sit above it and never fire; Claude Code's own compaction still does. +**The decision is made again when the command goes out** (`_decideAtSend()`). A report fires the trigger in the middle of a turn, and the command then waits for that turn to end (`executeWhenIdle`). What was true at the trigger can be false by then: Claude Code compacted by itself and the report fell (a `/clear` at 140k would otherwise wipe a conversation that is back at 20k), the option was switched off or its threshold raised, or the turn ended on a usage limit. So each action first asks again and returns without sending when the answer is no. That return stamps nothing (neither the report's value nor the counter's baseline), the cooldown releases the flag, and the next report is judged like any other. The counter never falls, so on the fallback the second answer repeats the first. + +**Nothing is sent into a usage-limit pause** (`_heldByLimit()`: from the limit message until auto-resume's `continue` went out, the gap after its Escape included). A `/clear` sent there is followed by that `continue`, typed into an empty conversation; the respawn controller blocks its own `/clear` for the same reason (`isLimitPaused` in `onIdleDetected`). The checks start nothing while it holds, and an action that waited through the start of one returns unsent. Only auto-resume watches for the limit message, so with auto-resume off nothing is held. + +**Thresholds go up to the reported window** (`_maxThreshold()`, 1M when unknown, never below the 1,000 minimum), and a higher value stops at the window when it is set. A threshold set before the window was known can sit above it and never fire; Claude Code's own compaction still does. ### Prompt-cache keep-warm diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 78dc5219..7eb19a56 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -5,7 +5,9 @@ * configurable thresholds are reached. The size comes from the statusline report * when the session has one and from the session's token counter otherwise (see * decideAutoThreshold). Waits for Claude to be idle before sending commands, with - * retry logic and mutual exclusion (compact and clear never run simultaneously). + * retry logic and mutual exclusion (compact and clear never run simultaneously), + * and decides again when the command would go out (see _decideAtSend): nothing is + * sent into a context that fell during the wait or into a usage-limit pause. * * Also implements auto-resume on usage limit ("token pause" control): * when enabled and Claude stops on a usage-limit message ("5-hour limit @@ -439,7 +441,6 @@ export class SessionAutoOps extends EventEmitter { // Threshold checks // ============================================================================ - /** The highest threshold that can ever fire: the reported window, or 1M when it is unknown. */ /** * A value above the window stops at the window rather than falling to the default: a threshold * saved under a larger window comes back on every save of the options, and the default would @@ -470,8 +471,47 @@ export class SessionAutoOps extends EventEmitter { this._counterBaseline = 0; } + /** + * The highest threshold that can ever fire: the reported window, or 1M when it is unknown. + * Never below the minimum, so a nonsense window (a report of 500) cannot store a threshold + * that every later save would then refuse. + */ private _maxThreshold(): number { - return this.callbacks.getContextReport()?.windowSize ?? MAX_AUTO_THRESHOLD; + return Math.max(MIN_AUTO_THRESHOLD, this.callbacks.getContextReport()?.windowSize ?? MAX_AUTO_THRESHOLD); + } + + /** + * True from a usage-limit message until the resume prompt went out. A /clear sent in that + * window is followed by auto-resume's `continue`, typed into an empty conversation, and a + * /compact needs the API the limit just closed. Only auto-resume watches for the limit, so + * with it off this is always false, as before. + */ + private _heldByLimit(): boolean { + return this._limitPaused || this._resumeFollowupTimer !== null; + } + + /** + * The decision at the moment the command would go out, or null when it must not. A report + * fires the trigger in the middle of a turn and the command then waits for that turn to end, + * which can outlast a compaction by Claude Code itself (the context is small again), a change + * of the option or its threshold, and the start of a usage-limit pause. The counter never + * falls, so on the fallback this only repeats the trigger's answer. + * + * A null stamps nothing: the caller returns, the cooldown releases its flag, and the next + * report is judged like any other. + */ + private _decideAtSend(name: string, enabled: boolean, threshold: number): AutoThresholdDecision | null { + const skipped = (why: string): null => { + console.log(`[SessionAutoOps ${this.callbacks.getSessionId()}] ${name} not sent: ${why}`); + return null; + }; + if (!enabled) return skipped('switched off while it waited for the turn to end'); + if (this._heldByLimit()) return skipped('the session is paused on a usage limit'); + const decision = this._decide(threshold); + if (!decision.trigger) { + return skipped(`${decision.tokens} tokens (${decision.source}) is no longer at the ${threshold} threshold`); + } + return decision; } private _decide(threshold: number): AutoThresholdDecision { @@ -491,16 +531,18 @@ export class SessionAutoOps extends EventEmitter { checkAutoCompact(): void { if (this.callbacks.isStopped()) return; if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return; + if (this._heldByLimit()) return; const decision = this._decide(this._autoCompactThreshold); if (decision.trigger) { - const totalTokens = decision.tokens; this._isCompacting = true; console.log( - `[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens (${decision.source}) >= ${this._autoCompactThreshold} threshold` + `[SessionAutoOps] Auto-compact triggered: ${decision.tokens} tokens (${decision.source}) >= ${this._autoCompactThreshold} threshold` ); const action = async () => { + const atSend = this._decideAtSend('Auto-compact', this._autoCompactEnabled, this._autoCompactThreshold); + if (!atSend) return; // One line: this fires unattended, and a multi-line focus prompt would go out // as a multi-line paste (see toSingleLine). const compactCmd = this._autoCompactPrompt @@ -511,7 +553,7 @@ export class SessionAutoOps extends EventEmitter { this._counterBaseline = this.callbacks.getTotalTokens(); await this.callbacks.writeCommand(compactCmd); this.emit('autoCompact', { - tokens: totalTokens, + tokens: atSend.tokens, threshold: this._autoCompactThreshold, prompt: this._autoCompactPrompt || undefined, }); @@ -547,21 +589,23 @@ export class SessionAutoOps extends EventEmitter { checkAutoClear(): void { if (this.callbacks.isStopped()) return; if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return; + if (this._heldByLimit()) return; const decision = this._decide(this._autoClearThreshold); if (decision.trigger) { - const totalTokens = decision.tokens; this._isClearing = true; console.log( - `[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens (${decision.source}) >= ${this._autoClearThreshold} threshold` + `[SessionAutoOps] Auto-clear triggered: ${decision.tokens} tokens (${decision.source}) >= ${this._autoClearThreshold} threshold` ); const action = async () => { + const atSend = this._decideAtSend('Auto-clear', this._autoClearEnabled, this._autoClearThreshold); + if (!atSend) return; this._reportTokensAtCommand = this.callbacks.getContextReport()?.tokens ?? null; // The session zeroes its counter on 'autoClear', so the counter restarts from 0. this._counterBaseline = 0; await this.callbacks.writeCommand('/clear\r'); - this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold }); + this.emit('autoClear', { tokens: atSend.tokens, threshold: this._autoClearThreshold }); }; if (!this.callbacks.isStopped()) { diff --git a/test/session-auto-ops-threshold.test.ts b/test/session-auto-ops-threshold.test.ts index 03cceb89..0a17d445 100644 --- a/test/session-auto-ops-threshold.test.ts +++ b/test/session-auto-ops-threshold.test.ts @@ -99,6 +99,7 @@ describe('SessionAutoOps thresholds', () => { let report: ContextReport | null; let counter: number; let writes: string[]; + let working: boolean; let ops: SessionAutoOps; const settle = () => vi.advanceTimersByTimeAsync(60_000); @@ -108,12 +109,13 @@ describe('SessionAutoOps thresholds', () => { report = null; counter = 0; writes = []; + working = false; ops = new SessionAutoOps({ writeCommand: async (cmd) => { writes.push(cmd); return true; }, - isWorking: () => false, + isWorking: () => working, isStopped: () => false, getTotalTokens: () => counter, getContextReport: () => report, @@ -254,6 +256,168 @@ describe('SessionAutoOps thresholds', () => { expect(writes).toEqual(['/compact\r', '/clear\r', '/compact\r']); }); + // A report fires the trigger in the middle of a turn, and the command waits for that turn to + // end. What was true at the trigger can be false by then, so the decision is made again there. + describe('when the command goes out', () => { + it('does not clear a context that fell while it waited for the turn to end', async () => { + ops.setAutoClear(true, 140_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoClear(); + await vi.advanceTimersByTimeAsync(5_000); + expect(writes).toEqual([]); + + report = { tokens: 20_000 }; // Claude Code compacted by itself during the turn + working = false; + await settle(); + expect(writes).toEqual([]); + expect(ops.isClearing).toBe(false); + + // Nothing was stamped by the skipped command, so the same number fires when it comes back. + report = { tokens: 150_000 }; + ops.checkAutoClear(); + await settle(); + expect(writes).toEqual(['/clear\r']); + }); + + it('does not compact a context that fell while it waited for the turn to end', async () => { + ops.setAutoCompact(true, 100_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoCompact(); + await vi.advanceTimersByTimeAsync(5_000); + + report = { tokens: 20_000 }; + working = false; + await settle(); + expect(writes).toEqual([]); + expect(ops.isCompacting).toBe(false); + + report = { tokens: 150_000 }; + ops.checkAutoCompact(); + await settle(); + expect(writes).toEqual(['/compact\r']); + }); + + it('reports the size at the moment the command went out', async () => { + const fired = vi.fn(); + ops.on('autoCompact', fired); + ops.setAutoCompact(true, 100_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoCompact(); + await vi.advanceTimersByTimeAsync(5_000); + + report = { tokens: 180_000 }; + working = false; + await settle(); + expect(fired).toHaveBeenCalledWith(expect.objectContaining({ tokens: 180_000 })); + }); + + it('sends nothing once the option was switched off during the wait', async () => { + ops.setAutoCompact(true, 100_000); + ops.setAutoClear(true, 140_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoClear(); + await vi.advanceTimersByTimeAsync(5_000); + ops.setAutoClear(false); + working = false; + await settle(); + expect(writes).toEqual([]); + + // The compact it held off while it waited is free to run again. + ops.checkAutoCompact(); + await settle(); + expect(writes).toEqual(['/compact\r']); + }); + + it('follows a threshold that was raised during the wait', async () => { + ops.setAutoClear(true, 140_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoClear(); + await vi.advanceTimersByTimeAsync(5_000); + ops.setAutoClear(true, 300_000); + working = false; + await settle(); + expect(writes).toEqual([]); + }); + }); + + // With auto-resume on, a /clear sent into a usage-limit pause is followed by the resume + // prompt: `continue` typed into an empty conversation. + describe('during a usage-limit pause', () => { + const limitLine = (resetInMs: number) => + `Claude AI usage limit reached|${Math.floor((Date.now() + resetInMs) / 1000)}`; + + beforeEach(() => { + vi.setSystemTime(Math.floor(Date.now() / 1000) * 1000); + ops.setAutoResume(true); + }); + + it('holds a clear that fired before the limit hit, and lets the next report decide after the resume', async () => { + ops.setAutoClear(true, 140_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoClear(); + await vi.advanceTimersByTimeAsync(5_000); + + ops.processCleanData(limitLine(60 * 60_000)); // the turn ends on the limit + working = false; + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(ops.isLimitPaused).toBe(true); + expect(writes).toEqual([]); + + await vi.advanceTimersByTimeAsync(40 * 60_000); // past the reset and its buffer + expect(writes).toEqual(['\x1b', 'continue\r']); + + // The resumed turn ran and ended; the context is still above the threshold. + ops.notifyWorking(); + ops.checkAutoClear(); + await settle(); + expect(writes).toEqual(['\x1b', 'continue\r', '/clear\r']); + }); + + it('holds a compact the same way', async () => { + ops.setAutoCompact(true, 100_000); + working = true; + report = { tokens: 150_000 }; + ops.checkAutoCompact(); + await vi.advanceTimersByTimeAsync(5_000); + + ops.processCleanData(limitLine(60 * 60_000)); + working = false; + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(writes).toEqual([]); + }); + + it('starts nothing on a report that arrives while the session is paused', async () => { + ops.setAutoCompact(true, 100_000); + ops.setAutoClear(true, 140_000); + ops.processCleanData(limitLine(60 * 60_000)); + report = { tokens: 150_000 }; + ops.checkAutoCompact(); + ops.checkAutoClear(); + expect([ops.isCompacting, ops.isClearing]).toEqual([false, false]); + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(writes).toEqual([]); + }); + + it('does not send between the Escape and the resume prompt', async () => { + ops.setAutoClear(true, 140_000); + ops.processCleanData(limitLine(60_000)); + await vi.advanceTimersByTimeAsync(60_000 + 2 * 60_000); // the Escape went out + expect(writes).toEqual(['\x1b']); + + report = { tokens: 150_000 }; // the Escape redraws the statusline + ops.checkAutoClear(); + expect(ops.isClearing).toBe(false); + await vi.advanceTimersByTimeAsync(600); + expect(writes).toEqual(['\x1b', 'continue\r']); + }); + }); + describe('threshold cap', () => { it('allows up to 1M while the window is unknown', () => { ops.setAutoCompact(true, 1_000_000); @@ -274,6 +438,14 @@ describe('SessionAutoOps thresholds', () => { expect(ops.autoClearThreshold).toBe(140_000); }); + it('never stores a threshold below the minimum, whatever window was reported', () => { + report = { tokens: 1, windowSize: 500 }; + ops.setAutoCompact(true, 5_000); + ops.setAutoClear(true, 5_000); + expect(ops.autoCompactThreshold).toBe(1_000); + expect(ops.autoClearThreshold).toBe(1_000); + }); + it('follows the reported window', () => { report = { tokens: 1, windowSize: 200_000 }; ops.setAutoCompact(true, 200_000); @@ -307,6 +479,34 @@ describe('Session.noteContextReport', () => { expect(s.toState().contextWindowSize).toBe(1_000_000); s.setAutoCompact(false); }); + + it('drops the report when the conversation changes, so the old size cannot fire on the new one', async () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const s = session(); + const write = vi.spyOn(s, 'writeViaMux').mockResolvedValue(true); + s.adoptClaudeSessionId('11111111-1111-4111-8111-111111111111'); + s.noteContextReport({ tokens: 90_000, windowSize: 200_000 }); + expect(s.toState().contextWindowSize).toBe(200_000); + + s.adoptClaudeSessionId('22222222-2222-4222-8222-222222222222'); // a /clear switched it + expect(s.toState().contextWindowSize).toBeUndefined(); + + // 90k is above this threshold, and it belonged to the conversation that is gone. + s.setAutoCompact(true, 50_000); + (s as never as { _autoOps: SessionAutoOps })._autoOps.checkAutoCompact(); + await vi.advanceTimersByTimeAsync(60_000); + expect(write).not.toHaveBeenCalled(); + s.setAutoCompact(false); + }); + + it('keeps the report on a reattach to the same conversation', () => { + const s = session(); + s.adoptClaudeSessionId('11111111-1111-4111-8111-111111111111'); + s.noteContextReport({ tokens: 90_000, windowSize: 200_000 }); + s.adoptClaudeSessionId('11111111-1111-4111-8111-111111111111'); + expect(s.toState().contextWindowSize).toBe(200_000); + }); }); describe('the pane token parser under the raised cap', () => {