Skip to main content
  1. Posts/

Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't "does the right session get evicted" — it's "do the four survivors stay on the keys they were already on."

(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:

  1. Eviction is LRU. The session I haven’t touched in the longest time is the one I care about least. Drop that one.
  2. 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 Map preserves insertion order, and that’s a primitive you almost never need to fight. When you want LRU behavior on a Map, delete+set moves an entry to the back. When you don’t, set leaves 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-cache when 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
#

Related

Two Stream Deck SDK quirks that cost me a weekend

Two undocumented behaviours in the Elgato Stream Deck SDK ate most of a weekend: a per-key title-alignment cache that silently ignores manifest updates, and a `willAppear` event that doesn't always re-fire after a plugin restart. The fixes are short. Finding them was not. (Skip this paragraph if you’ve shipped a Stream Deck plugin before. The Stream Deck is Elgato’s USB grid of programmable LCD keys — common on streamer desks for scene switching. A “plugin” is a small program — TypeScript, in my case — that runs as a child process of Elgato’s Stream Deck app, registers one or more actions the user can drag onto keys, and reacts to events like “key pressed” or “key visible.” The SDK is @elgato/streamdeck from npm. A manifest is a manifest.json next to the plugin that declares its actions, supported devices, default icons, and per-state defaults like title alignment.)

TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.

My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed — the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. I’m building a Stream Deck plugin called ClaudeDeck — Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one. The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability — automating other apps — through System Settings → Privacy & Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. So on first install I added the daemon, toggled it on, and got back to work.

What replaced CGEventPost in my Stream Deck daemon

I press the Stream Deck key. The daemon logs the press, synthesizes `Cmd+Opt+;` through CoreGraphics, and exits cleanly. Wispr Flow does nothing. Three Apple subsystems and one decompiled Electron bundle later, the working trigger turned out to be a one-line URL. The plan was the boring kind: Stream Deck key (the physical button on Elgato’s programmable USB grid) → WebSocket message → my daemon (long-running background process) → synthesized global hotkey → Wispr Flow’s hands-free dictation starts — Wispr Flow is the voice-to-text Mac app that types your speech into the focused window — → I talk → words show up in my editor. I’d done variants of this with osascript (macOS’s command-line AppleScript runner) years ago. Should have taken an afternoon.