(Two bits of context for anyone new to the stack: Stream Deck is Elgato’s
USB grid of programmable LCD keys, and a “session” here is a single
Claude Code conversation — claude running in one terminal tab, with its
own working directory, its own context window, its own history. LRU
stands for “least-recently used,” the standard cache-eviction policy:
when you need to make room, drop the entry nobody has touched in the
longest time.)
After I’d worked through the Stream Deck SDK quirks and finally had keys repainting reliably, this is the puzzle I hit next. It looks small until you try to write it.
The constraint #
Five physical keys. MAX_SESSIONS = 5 (in
daemon/src/types.ts).
Each key shows the working directory and state of one Claude Code
session. Open a sixth session and one of the existing slots has to go.
Two rules, and they fight each other:
- Eviction is LRU. The session I haven’t touched in the longest time is the one I care about least. Drop that one.
- Surviving keys do not move. If session B was on key 2 before the eviction, it is still on key 2 after. My finger already knows where it lives.
Pure LRU breaks rule 2: the canonical implementation moves the touched entry to the front of a linked list, so the visible order changes every time any session updates. Pure FIFO satisfies rule 2 but breaks rule 1: it evicts whichever session started earliest, regardless of whether I just used it.
You can do both at once if you stop thinking of “order of eviction” and “order on screen” as the same order.
Why the obvious approaches fail #
The first thing I reached for was a hand-rolled doubly-linked list — a proper LRU cache. (Doubly-linked because each node points at both its previous and next neighbours, so splicing a node out and pasting it in elsewhere is O(1) — three pointer updates.) The most-recently-used end is the head; the least-recently-used is the tail; on touch you splice the node to the head; on eviction you drop the tail. Textbook.
The problem is that “splice to the head” is exactly the operation I
need to never happen. Every time the daemon ships me a session:update
(every assistant turn, every state flip from idle to thinking),
the touched session bubbles to position 0 and every other slot index
shifts. From the Stream Deck’s perspective, the keys rearrange under
my fingers between turns. Unusable.
The second thing I reached for was lru-cache off npm. Same problem
in nicer packaging — it’s designed to maximize cache-hit rate by
making recent entries cheaper to find, and it sorts by recency
internally.
What I actually want is more like a fixed-size circular buffer that
happens to know how to pick a victim. The slot a session occupies on
the Stream Deck should be determined by when the session first
appeared, not by when it was last touched. The lastActivityAt
field is only consulted at eviction time.
The primitive that solves it #
JavaScript’s Map preserves insertion order. (Map is the built-in
key-value collection — like Object for general lookups, but with
any-type keys and a guaranteed iteration order. Unlike a plain
{}, iterating a Map gives you entries back in the order you
inserted them.) That’s not a coincidence or an implementation
detail — it’s in the spec, and every engine has implemented
it that way since ES2015. Iterate .keys(), .values(), or
.entries() and you get items back in the order they were first
set().
The key subtlety: updating an existing key does not change its
position. map.set("a", 1); map.set("b", 2); map.set("a", 99); gives
you ["a", "b"] on iteration, not ["b", "a"]. The position is fixed
at first insertion. To move a key to the back, you have to delete
it first and then set it again — a pattern the spec leaves available
precisely so you can build an LRU on top.
So one Map gives me two views for free:
- Layout view — iterate it. Items come back in insertion order, which is also the order they appeared on the Stream Deck.
- Eviction view — scan the values for the minimum
lastActivityAt. That’s the LRU pick.
The thing that bridges them is a discipline: on update, set the value without touching the key’s position.
The implementation #
The whole thing lives in
plugin/src/sessionSlots.ts.
It’s about 120 lines. Here’s the part that matters:
export class SessionSlotManager extends EventEmitter {
private readonly slots = new Map<string, SessionSnapshot>();
private readonly now: () => number;
private readonly cap: number;
upsert(session: SessionSnapshot): void {
const existing = this.slots.get(session.sessionId);
if (existing) {
this.slots.set(session.sessionId, session);
return;
}
if (this.slots.size >= this.cap) {
this.evictOldest();
}
this.slots.set(session.sessionId, session);
}That if (existing) { set; return; } is doing a lot of work. It’s
the entire reason rule 2 holds. The set writes a new
SessionSnapshot (with a fresh lastActivityAt) into the existing
slot. Because the key is already in the Map, its insertion-order
position doesn’t budge. The visible layout is stable; the recency
data updates underneath.
Compare with the daemon-side store in
daemon/src/stateStore.ts,
which does the opposite on register:
if (existing) {
existing.lastActivityAt = t;
this.sessions.delete(input.sessionId);
this.sessions.set(input.sessionId, existing);
// ...
}That delete + set is the classic Map-based LRU move: it bumps the
re-registered session to the back of the iteration order. The daemon
wants that, because the daemon doesn’t render anything — it just
needs the iteration order to track recency so its own eviction is
trivial. The plugin doesn’t, because the plugin renders to physical
keys.
Same data structure, two opposite update disciplines, because they have two different jobs.
Slot lookup is then just walking the Map:
slotOf(sessionId: string): number | undefined {
let i = 0;
for (const id of this.slots.keys()) {
if (id === sessionId) return i;
i++;
}
return undefined;
}O(N) where N ≤ 5. (Big-O on a five-entry collection is a punchline,
not a complexity argument — the constant-factor cost of not introducing
a second data structure to keep in sync is much larger than the cost
of scanning five entries.) I considered keeping a parallel
Map<sessionId, slotIndex> for O(1), then remembered N is five. Five.
Eviction is the other linear scan:
private evictOldest(): void {
let oldestId: string | undefined;
let oldestAt = Number.POSITIVE_INFINITY;
void this.now();
for (const s of this.slots.values()) {
if (s.lastActivityAt < oldestAt) {
oldestAt = s.lastActivityAt;
oldestId = s.sessionId;
}
}
if (oldestId !== undefined) {
this.slots.delete(oldestId);
this.emit("evicted", oldestId);
}
}When the sixth session arrives, this finds the entry with the smallest
lastActivityAt — the LRU pick — and deletes it. The eviction emits
an "evicted" event so the plugin can clear the vacated key (see
plugin/src/plugin.ts,
line 531). The four survivors’ positions in the Map are unchanged, so
their slot indices are unchanged, so my finger still knows where they
live.
The one slot that does shift is the vacated one. If session A was at key 2 and gets evicted, the new session lands at key 4 (the now-empty tail), and the keys at indices 3 and 4 from before become indices 2 and 3 — because they were inserted later than A. That’s the trade. You can’t have both stable indices and contiguous filling unless you track empty slots explicitly, and contiguous filling won.
What the tests pin down #
The test file
(plugin/src/sessionSlots.test.ts)
exists mostly to lock in the two non-obvious behaviors:
test("upsert of existing session updates in place without changing its slot", () => {
const mgr = makeMgr();
mgr.upsert(makeSnap("s1", 100));
mgr.upsert(makeSnap("s2", 110));
mgr.upsert(makeSnap("s3", 120));
mgr.upsert(makeSnap("s2", 200, { state: "thinking" }));
expect(mgr.slotOf("s2")).toBe(1); // ← rule 2
expect(mgr.sessionAt(1)?.state).toBe("thinking");
});
test("updating an existing session refreshes its lastActivityAt so it survives eviction", () => {
const mgr = makeMgr();
for (let i = 0; i < 5; i++) mgr.upsert(makeSnap(`s${i}`, 100 + i * 10));
mgr.upsert(makeSnap("s0", 999)); // touch the oldest
mgr.upsert(makeSnap("s5", 1000));
expect(mgr.slotOf("s0")).toBeDefined(); // ← rule 1
expect(mgr.slotOf("s1")).toBeUndefined();
});If anyone ever switches upsert to the daemon’s delete-then-set
pattern thinking it’s more “correct”, the first test catches it. If
anyone tries to make eviction iteration-order-based (“the first one
inserted is the oldest”), the second test catches it.
Lessons #
-
JavaScript’s
Mappreserves insertion order, and that’s a primitive you almost never need to fight. When you want LRU behavior on aMap,delete+setmoves an entry to the back. When you don’t,setleaves it where it is. Two opposite disciplines, same data structure. -
LRU and stable layout look like they conflict. They don’t, if you separate “which entry to evict” from “where each entry sits.” Eviction reads
lastActivityAt. Layout reads iteration order. Decouple them and both rules hold. -
Don’t reach for
lru-cachewhen N is five. The package solves a problem you don’t have (hot-path cache-hit performance) and prevents the trick you need (decoupling recency from position). A linear scan over five entries is faster than the call to load the dependency. -
A test that pins down the boring property is worth more than ten tests of the interesting one. “Updates don’t move slots” is the property a future me will accidentally break. The eviction logic is obvious enough that it survives refactors; the update-in-place discipline doesn’t.
References #
- Plugin-side slot manager (the post’s subject):
plugin/src/sessionSlots.ts - Plugin-side tests:
plugin/src/sessionSlots.test.ts - Daemon-side store with the opposite (delete-then-set) discipline:
daemon/src/stateStore.ts - Eviction listener that triggers a key repaint:
plugin/src/plugin.ts#L531 MAX_SESSIONSdefinition:daemon/src/types.ts- MDN — Map insertion-order iteration: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map