Skip to content

Commit efe029a

Browse files
committed
feat: persist session pool records so reuse survives restarts
The pool was in-memory only: an opencode restart lost the fingerprint records, so the first turn of every resumed conversation classified as "new" and paid a cache-cold full-transcript replay even though the Cursor agent (and its conversation, in Cursor's checkpoint store) was still resumable. Persist the records best-effort to ~/.cache/opencode-cursor/session-pool.json following the model-cache pattern (never throws, optimization-only). Records carry updatedAt and are pruned to a 7-day TTL and a 200-entry most-recently-used cap. The in-memory pool lazily hydrates from disk (memory wins on conflict); concurrent opencode processes are last-write-wins on the whole file, where a lost record costs exactly one self-healing full replay. clearAgentPool() now wipes the disk store too; a new resetSessionPoolMemory() test hook simulates process restarts.
1 parent 6c089c9 commit efe029a

6 files changed

Lines changed: 296 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ All notable changes to this project will be documented in this file.
2020
full replay — never worse than the old default. `session: true` is now an
2121
alias for `"auto"`; `session: false` keeps the always-fresh behavior.
2222
Set `OPENCODE_CURSOR_DEBUG=1` to log per-turn classification and cache usage.
23+
- **Session reuse survives opencode restarts.** The pool's fingerprint records
24+
persist (best-effort) to `~/.cache/opencode-cursor/session-pool.json` (7-day
25+
TTL, 200-entry LRU cap), so the first turn after a restart resumes the
26+
session's Cursor agent — whose conversation lives in Cursor's own checkpoint
27+
store — instead of paying a cache-cold full-transcript replay.
2328
- **Tool outputs are included (truncated) in flattened transcripts.** The
2429
fresh/divergence/`session: false` replay paths previously dropped Cursor tool
2530
results to bare `[result of X]` placeholders, so a fresh agent re-read a

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ The worst case on any misclassification is a single full-transcript replay that
181181
next turn — never worse than `session: false`. A failed resume also degrades to a fresh replay. The
182182
resumed agent is named after the session and visible in Cursor's dashboard; the opencode session id
183183
reaches the provider via the plugin's `chat.params` hook (`providerOptions.cursor.sessionID`).
184+
Fingerprint records persist (best-effort) to `~/.cache/opencode-cursor/session-pool.json`, so
185+
session reuse survives opencode restarts — the conversation itself lives in Cursor's own local
186+
checkpoint store, and the next turn resumes it instead of replaying the transcript.
184187

185188
- `session: true` is an alias for `"auto"`.
186189
- `session: false` restores the original behavior: always a fresh agent + full transcript, every

src/provider/session-pool.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,54 @@ import type {
66
SettingSource,
77
} from "@cursor/sdk";
88
import { loadAgentBackend, type AgentLike } from "./agent-backend.js";
9+
import {
10+
deleteSessionStore,
11+
loadSessionRecords,
12+
saveSessionRecords,
13+
type StoredSessionRecord,
14+
} from "./session-store.js";
915
import type { TranscriptRecord } from "./transcript-fingerprint.js";
1016

1117
/** sessionID -> fingerprint record, so a session reuses one Cursor agent across turns. */
12-
const pool = new Map<string, TranscriptRecord>();
18+
const pool = new Map<string, StoredSessionRecord>();
19+
20+
/**
21+
* Lazily merge disk-persisted records into the in-memory pool (memory wins),
22+
* so `session: "auto"` resumes a session's Cursor agent even after an opencode
23+
* restart. The agent's conversation itself lives in Cursor's checkpoint store;
24+
* this only restores our agentId + fingerprint bookkeeping.
25+
*/
26+
let hydrated = false;
27+
function hydrate(): void {
28+
if (hydrated) return;
29+
hydrated = true;
30+
for (const [key, record] of loadSessionRecords()) {
31+
if (!pool.has(key)) pool.set(key, record);
32+
}
33+
}
1334

1435
/** Read the fingerprint record pooled for a session (undefined if none). */
1536
export function getSessionRecord(
1637
sessionID: string,
1738
): TranscriptRecord | undefined {
39+
hydrate();
1840
return pool.get(sessionID);
1941
}
2042

2143
/** Test/diagnostic helpers. */
2244
export function getPooledAgentId(sessionID: string): string | undefined {
45+
hydrate();
2346
return pool.get(sessionID)?.agentId;
2447
}
2548
export function clearAgentPool(): void {
2649
pool.clear();
50+
hydrated = true; // don't re-hydrate stale disk state into a cleared pool
51+
deleteSessionStore();
52+
}
53+
/** Test hook: drop in-memory state only, as if the process restarted. */
54+
export function resetSessionPoolMemory(): void {
55+
pool.clear();
56+
hydrated = false;
2757
}
2858

2959
export interface AcquireAgentParams {
@@ -107,14 +137,18 @@ export async function acquireAgent(
107137

108138
const pooling = params.poolKey !== undefined;
109139
if (pooling && params.record) {
140+
hydrate();
110141
pool.set(params.poolKey!, {
111142
agentId: agent.agentId,
112143
systemHash: params.record.systemHash,
113144
userHashes: params.record.userHashes,
114145
...(params.record.mcpHash !== undefined
115146
? { mcpHash: params.record.mcpHash }
116147
: {}),
148+
updatedAt: Date.now(),
117149
});
150+
// Persist so session reuse survives opencode restarts (best-effort).
151+
saveSessionRecords(pool);
118152
}
119153

120154
const release = () => {

src/provider/session-store.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { homedir, tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import type { TranscriptRecord } from "./transcript-fingerprint.js";
5+
6+
/**
7+
* Best-effort disk persistence for the session pool's fingerprint records, so
8+
* `session: "auto"` survives opencode restarts: the pool can re-resume a
9+
* session's Cursor agent (whose conversation lives in Cursor's own checkpoint
10+
* store) instead of paying a cache-cold full-transcript replay.
11+
*
12+
* Follows the model-cache pattern: JSON under `~/.cache/opencode-cursor/`,
13+
* never throws, treats the file as an optimization only. Multiple opencode
14+
* processes write last-wins on the whole file — a lost record costs exactly
15+
* one self-healing full replay, which is the same as not having the store.
16+
*/
17+
18+
/** A record persists this long after its last turn before being pruned. */
19+
const ENTRY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20+
/** Cap stored sessions (most recently used win) to bound file growth. */
21+
const MAX_ENTRIES = 200;
22+
23+
export interface StoredSessionRecord extends TranscriptRecord {
24+
updatedAt: number;
25+
}
26+
27+
interface StoreEnvelope {
28+
sessions: Record<string, StoredSessionRecord>;
29+
}
30+
31+
function storeDir(): string {
32+
const base =
33+
process.env.XDG_CACHE_HOME?.trim() ||
34+
(homedir() ? join(homedir(), ".cache") : tmpdir());
35+
return join(base, "opencode-cursor");
36+
}
37+
38+
function storeFile(): string {
39+
return join(storeDir(), "session-pool.json");
40+
}
41+
42+
function isStoredRecord(value: unknown): value is StoredSessionRecord {
43+
if (typeof value !== "object" || value === null) return false;
44+
const v = value as Record<string, unknown>;
45+
return (
46+
typeof v["agentId"] === "string" &&
47+
typeof v["systemHash"] === "string" &&
48+
Array.isArray(v["userHashes"]) &&
49+
(v["userHashes"] as unknown[]).every((h) => typeof h === "string") &&
50+
typeof v["updatedAt"] === "number"
51+
);
52+
}
53+
54+
/** Load persisted records, dropping expired/corrupt entries. Never throws. */
55+
export function loadSessionRecords(now = Date.now()): Map<string, StoredSessionRecord> {
56+
const out = new Map<string, StoredSessionRecord>();
57+
try {
58+
const parsed = JSON.parse(readFileSync(storeFile(), "utf8")) as StoreEnvelope;
59+
if (typeof parsed?.sessions !== "object" || parsed.sessions === null) return out;
60+
for (const [key, value] of Object.entries(parsed.sessions)) {
61+
if (!isStoredRecord(value)) continue;
62+
if (now - value.updatedAt > ENTRY_TTL_MS) continue;
63+
out.set(key, value);
64+
}
65+
} catch {
66+
// Missing/corrupt store: start empty.
67+
}
68+
return out;
69+
}
70+
71+
/** Persist records (pruned to TTL + entry cap). Best-effort; never throws. */
72+
export function saveSessionRecords(
73+
records: ReadonlyMap<string, StoredSessionRecord>,
74+
now = Date.now(),
75+
): void {
76+
try {
77+
const live = [...records.entries()]
78+
.filter(([, r]) => now - r.updatedAt <= ENTRY_TTL_MS)
79+
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
80+
.slice(0, MAX_ENTRIES);
81+
mkdirSync(storeDir(), { recursive: true });
82+
const envelope: StoreEnvelope = { sessions: Object.fromEntries(live) };
83+
writeFileSync(storeFile(), JSON.stringify(envelope), "utf8");
84+
} catch {
85+
// Persistence is an optimization; ignore write failures.
86+
}
87+
}
88+
89+
/** Delete the store file (test/diagnostic helper). Never throws. */
90+
export function deleteSessionStore(): void {
91+
try {
92+
rmSync(storeFile(), { force: true });
93+
} catch {
94+
// best effort
95+
}
96+
}

test/session-pool.test.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1+
import { mkdtempSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
14
import { afterEach, describe, expect, it, vi } from "vitest";
25

6+
// Sandbox the on-disk session store away from the user's real cache dir.
7+
process.env.XDG_CACHE_HOME = mkdtempSync(join(tmpdir(), "cursor-pool-test-"));
8+
39
const create = vi.fn();
410
const resume = vi.fn();
511

612
vi.mock("../src/cursor-runtime.js", () => ({
713
loadCursorSdk: async () => ({ Agent: { create, resume } }),
814
}));
915

10-
const { acquireAgent, clearAgentPool, getPooledAgentId, getSessionRecord } =
11-
await import("../src/provider/session-pool.js");
16+
const {
17+
acquireAgent,
18+
clearAgentPool,
19+
getPooledAgentId,
20+
getSessionRecord,
21+
resetSessionPoolMemory,
22+
} = await import("../src/provider/session-pool.js");
1223

1324
function fakeAgent(agentId: string) {
1425
return { agentId, close: vi.fn() };
@@ -45,7 +56,7 @@ describe("acquireAgent", () => {
4556
const r = await acquireAgent({ ...base, poolKey: "s1", record: rec });
4657
expect(r.resumed).toBe(false);
4758
expect(getPooledAgentId("s1")).toBe("a1");
48-
expect(getSessionRecord("s1")).toEqual({ agentId: "a1", ...rec });
59+
expect(getSessionRecord("s1")).toMatchObject({ agentId: "a1", ...rec });
4960
r.release();
5061
expect(r.agent.close).not.toHaveBeenCalled(); // pooled agents persist
5162
});
@@ -83,7 +94,7 @@ describe("acquireAgent", () => {
8394
poolKey: "s1",
8495
record: { ...rec, mcpHash: "mcp-v1" },
8596
});
86-
expect(getSessionRecord("s1")).toEqual({
97+
expect(getSessionRecord("s1")).toMatchObject({
8798
agentId: "a1",
8899
...rec,
89100
mcpHash: "mcp-v1",
@@ -98,7 +109,44 @@ describe("acquireAgent", () => {
98109
create.mockResolvedValueOnce(fakeAgent("a2"));
99110
const next = { systemHash: "sys", userHashes: ["u1", "u2", "edited"] };
100111
await acquireAgent({ ...base, poolKey: "s1", record: next });
101-
expect(getSessionRecord("s1")).toEqual({ agentId: "a2", ...next });
112+
expect(getSessionRecord("s1")).toMatchObject({ agentId: "a2", ...next });
113+
});
114+
115+
it("survives a process restart: records rehydrate from disk", async () => {
116+
create.mockResolvedValue(fakeAgent("a1"));
117+
await acquireAgent({
118+
...base,
119+
poolKey: "s1",
120+
record: { ...rec, mcpHash: "mcp-v1" },
121+
});
122+
123+
// Simulate an opencode restart: in-memory pool gone, disk store intact.
124+
resetSessionPoolMemory();
125+
expect(getSessionRecord("s1")).toMatchObject({
126+
agentId: "a1",
127+
...rec,
128+
mcpHash: "mcp-v1",
129+
});
130+
});
131+
132+
it("prefers in-memory state over stale disk state when both exist", async () => {
133+
create.mockResolvedValueOnce(fakeAgent("a1"));
134+
await acquireAgent({ ...base, poolKey: "s1", record: rec });
135+
136+
// Restart, rehydrate, then advance the conversation in-memory.
137+
resetSessionPoolMemory();
138+
create.mockResolvedValueOnce(fakeAgent("a2"));
139+
const next = { systemHash: "sys", userHashes: ["u1", "u2"] };
140+
await acquireAgent({ ...base, poolKey: "s1", record: next });
141+
expect(getSessionRecord("s1")).toMatchObject({ agentId: "a2", ...next });
142+
});
143+
144+
it("clearAgentPool wipes the disk store too", async () => {
145+
create.mockResolvedValue(fakeAgent("a1"));
146+
await acquireAgent({ ...base, poolKey: "s1", record: rec });
147+
clearAgentPool();
148+
resetSessionPoolMemory(); // would rehydrate if the file survived
149+
expect(getSessionRecord("s1")).toBeUndefined();
102150
});
103151

104152
it("resumes an explicit agent without pooling (no poolKey)", async () => {

test/session-store.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { beforeEach, describe, expect, it } from "vitest";
5+
import {
6+
deleteSessionStore,
7+
loadSessionRecords,
8+
saveSessionRecords,
9+
type StoredSessionRecord,
10+
} from "../src/provider/session-store.js";
11+
12+
function record(
13+
agentId: string,
14+
updatedAt: number,
15+
extra?: Partial<StoredSessionRecord>,
16+
): StoredSessionRecord {
17+
return {
18+
agentId,
19+
systemHash: "sys",
20+
userHashes: ["u1"],
21+
updatedAt,
22+
...extra,
23+
};
24+
}
25+
26+
const DAY = 24 * 60 * 60 * 1000;
27+
28+
beforeEach(() => {
29+
process.env.XDG_CACHE_HOME = mkdtempSync(join(tmpdir(), "cursor-store-test-"));
30+
});
31+
32+
describe("session store", () => {
33+
it("round-trips records, including mcpHash", () => {
34+
const now = Date.now();
35+
const map = new Map([
36+
["s1", record("a1", now, { mcpHash: "mcp-v1" })],
37+
["s2", record("a2", now)],
38+
]);
39+
saveSessionRecords(map, now);
40+
const loaded = loadSessionRecords(now);
41+
expect(loaded.get("s1")).toMatchObject({ agentId: "a1", mcpHash: "mcp-v1" });
42+
expect(loaded.get("s2")).toMatchObject({ agentId: "a2" });
43+
});
44+
45+
it("prunes entries older than the TTL on load", () => {
46+
const now = Date.now();
47+
const map = new Map([
48+
["fresh", record("a1", now - 1 * DAY)],
49+
["stale", record("a2", now - 8 * DAY)],
50+
]);
51+
saveSessionRecords(map, now - 8 * DAY); // bypass save-side pruning for "stale"
52+
// Re-save with both to exercise load-side pruning at `now`.
53+
saveSessionRecords(map, now - 1 * DAY);
54+
const loaded = loadSessionRecords(now);
55+
expect(loaded.has("fresh")).toBe(true);
56+
expect(loaded.has("stale")).toBe(false);
57+
});
58+
59+
it("caps stored entries to the most recently used", () => {
60+
const now = Date.now();
61+
const map = new Map<string, StoredSessionRecord>();
62+
for (let i = 0; i < 250; i++) {
63+
map.set(`s${i}`, record(`a${i}`, now - i));
64+
}
65+
saveSessionRecords(map, now);
66+
const loaded = loadSessionRecords(now);
67+
expect(loaded.size).toBe(200);
68+
expect(loaded.has("s0")).toBe(true); // newest kept
69+
expect(loaded.has("s249")).toBe(false); // oldest dropped
70+
});
71+
72+
it("returns empty on a missing store", () => {
73+
expect(loadSessionRecords().size).toBe(0);
74+
});
75+
76+
it("returns empty on a corrupt store and skips malformed entries", () => {
77+
const dir = join(process.env.XDG_CACHE_HOME!, "opencode-cursor");
78+
mkdirSync(dir, { recursive: true });
79+
writeFileSync(join(dir, "session-pool.json"), "not json", "utf8");
80+
expect(loadSessionRecords().size).toBe(0);
81+
82+
const now = Date.now();
83+
writeFileSync(
84+
join(dir, "session-pool.json"),
85+
JSON.stringify({
86+
sessions: {
87+
good: record("a1", now),
88+
bad: { agentId: 42, updatedAt: "nope" },
89+
},
90+
}),
91+
"utf8",
92+
);
93+
const loaded = loadSessionRecords(now);
94+
expect(loaded.has("good")).toBe(true);
95+
expect(loaded.has("bad")).toBe(false);
96+
});
97+
98+
it("deleteSessionStore removes the file", () => {
99+
const now = Date.now();
100+
saveSessionRecords(new Map([["s1", record("a1", now)]]), now);
101+
deleteSessionStore();
102+
expect(loadSessionRecords(now).size).toBe(0);
103+
});
104+
});

0 commit comments

Comments
 (0)