Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions cli/src/agents/daemon.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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() });
}
}
9 changes: 6 additions & 3 deletions cli/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
5 changes: 3 additions & 2 deletions specs/system/headless-runtime.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading