From ec0767a07230cbdbca522aa472556e027c483bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rog=C3=A9rio=20Chaves?= Date: Thu, 28 May 2026 10:31:08 +0200 Subject: [PATCH] fix(daemon): send self-compact nudge straight away, not queued for Stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-compact guard queued the 500k/600k/700k self-compact nudges and relied on auto-send when the agent next emits a Stop. A freshly-resumed or idle session never emits a Stop, so those gentle nudges never fired and context coasted up to the 750k hard /compact — which then collided with whatever prompt landed next (e.g. the morning nudge), losing it. Paste the nudge straight into the session instead. Claude queues pasted input and runs it after the current turn, so a busy agent still finishes gracefully, while a resumed/idle session self-compacts immediately and is well below the limit by the time its next prompt arrives. --- cli/src/agents/daemon.ts | 19 ++++++++----------- cli/src/daemon.test.ts | 9 ++++++--- specs/system/headless-runtime.feature | 5 +++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/cli/src/agents/daemon.ts b/cli/src/agents/daemon.ts index 88c0b270..c5cb1f8f 100644 --- a/cli/src/agents/daemon.ts +++ b/cli/src/agents/daemon.ts @@ -1,5 +1,4 @@ import { existsSync, statSync, openSync, readSync, closeSync } from "node:fs"; -import { randomUUID } from "node:crypto"; import { hookEventsPath } from "../paths.js"; import { readLinks, readSessionContext, pasteTmuxPrompt } from "../data.js"; import { upsertCard, isoNow } from "../cards.js"; @@ -286,7 +285,14 @@ export class Daemon { if (rule.thresholdTokens <= (this.lastTriggered.get(sessionId) ?? 0)) continue; if (rule.action === "queuePrompt") { - this.enqueueOnce(card.id, rule.message); + // Send the self-compact nudge straight away rather than queueing it for + // the next Stop. A resumed/idle session never hits Stop, so a queued + // nudge would never fire and context would coast up to the hard + // /compact (which then collides with whatever prompt lands next, e.g. + // the morning nudge). Claude queues pasted input and runs it after the + // current turn, so a busy agent still finishes gracefully while an idle + // one self-compacts immediately. + this.paste(sessionName, rule.message); } else { this.paste(sessionName, "/compact"); this.announce(card.name ?? "", `🧹 context over ${Math.round(rule.thresholdTokens / 1000)}k - sending /compact`); @@ -317,13 +323,4 @@ export class Daemon { }; upsertCard(next); } - - private enqueueOnce(cardId: string, body: string): void { - const card = readLinks().find((c) => c.id === cardId); - if (!card) return; - const queue = card.queuedPrompts ?? []; - if (queue.some((p) => p.body.trim() === body.trim())) return; // already queued - const prompt: QueuedPrompt = { id: randomUUID(), body, sendAutomatically: true }; - upsertCard({ ...card, queuedPrompts: [...queue, prompt], updatedAt: isoNow() }); - } } diff --git a/cli/src/daemon.test.ts b/cli/src/daemon.test.ts index 159da4ca..68214e55 100644 --- a/cli/src/daemon.test.ts +++ b/cli/src/daemon.test.ts @@ -101,17 +101,20 @@ describe("daemon (sandboxed, injected paste)", () => { assert.equal(readLinks()[0].queuedPrompts?.length, 0, "stale warning dropped from queue"); }); - test("auto-compact queues the crossed warning once (no re-queue)", () => { + test("auto-compact sends the crossed warning straight away, once (no re-send)", () => { writeLinks([card()]); writeContextPct(55); // 550k -> crosses 500k queuePrompt rule const d = newDaemon(); const acted = d.evaluateAutoCompact(); assert.deepEqual(acted, [{ sessionId: SID, action: "queuePrompt", thresholdTokens: 500_000 }]); - assert.equal(readLinks()[0].queuedPrompts?.[0].body, DEFAULT_SELF_COMPACT_RULES[0].message); + // Pasted straight into the session, not parked in the queue (a resumed/idle + // session never hits Stop, so a queued nudge would never fire). + assert.deepEqual(pastes, [["daemon-agent", DEFAULT_SELF_COMPACT_RULES[0].message]]); + assert.equal(readLinks()[0].queuedPrompts?.length ?? 0, 0, "not queued"); const again = d.evaluateAutoCompact(); assert.equal(again.length, 0, "must not re-trigger the same threshold"); - assert.equal(readLinks()[0].queuedPrompts?.length, 1, "no duplicate queued warning"); + assert.equal(pastes.length, 1, "no duplicate nudge"); }); test("auto-compact sends /compact at the hard threshold", () => { diff --git a/specs/system/headless-runtime.feature b/specs/system/headless-runtime.feature index 18de5a79..cfe12670 100644 --- a/specs/system/headless-runtime.feature +++ b/specs/system/headless-runtime.feature @@ -28,10 +28,11 @@ Feature: Headless runtime engine (no macOS app) Scenario: Auto-compaction protects long-running sessions Given an agent has been running for a long time When its current context usage crosses 500k tokens - Then a prompt instructing it to self-compact is queued + Then a prompt instructing it to self-compact is sent to it straight away (not parked in the queue waiting for a Stop) + And so a resumed or idle session, which never emits a Stop, still gets the nudge and self-compacts before context grows further + And the same threshold is not nudged twice When usage crosses the hard threshold (750k) Then "/compact" is sent to the agent automatically - And a stale self-compact warning is dropped if context already dropped back below its threshold Scenario: The session runs forever across compactions Given the agent has compacted multiple times