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