Restored agents (existingAgents) never render as characters — only live-created agents do
Summary
Agents restored on (re)connect via the existingAgents message never actually render as characters in the office — on every fresh connection, no matter how many agents exist server-side, how many times you reload, or what you do in the layout editor. The office renders furniture and pets fine, just never any agent character, unless that specific agent was created by a live agentCreated broadcast while the tab was already open.
This looks like it's probably the single most common "why don't I see my agents" report this project will get, since it reproduces on a completely fresh install with zero user error involved.
Root cause
server/src/clientMessageHandler.ts's handleWebviewReady always sends, in order:
layoutLoaded(line ~172)- ... settings ...
existingAgents(line ~216) — after layout
webview-ui/src/hooks/useExtensionMessages.ts buffers agents from existingAgents into a local pendingAgents array (line ~117, ~252 before fix), and only flushes that buffer inside the layoutLoaded message handler (line ~174 before fix):
} else if (msg.type === 'existingAgents') {
...
for (const id of incoming) {
const m = meta[id];
pendingAgents.push({ id, palette: m?.palette, hueShift: m?.hueShift, seatId: m?.seatId, folderName: folderNames[id] });
}
...Since layoutLoaded is always sent before existingAgents in the current server code, by the time existingAgents arrives and populates pendingAgents, the one and only layoutLoaded flush point has already run (with an empty buffer). No further layoutLoaded message is ever sent afterward (saveLayout from the editor does not trigger a server rebroadcast to any client), so pendingAgents sits forever unflushed. The agent IDs do get added to React state (setAgents), so anything reading that list (agent count, sidebar, etc.) looks correct — only the actual OfficeState.addAgent() call (which places the character sprite) never happens.
Agents created while a tab is already connected go through the separate agentCreated message type, whose handler calls os.addAgent(...) directly with no buffering — so those render fine. This asymmetry is what makes the bug easy to miss in testing: if you spawn an agent and watch it appear, it works; the second you reload the page (or open a second tab), it's gone.
Steps to reproduce
node dist/cli.js, open the webview.- Get any agent tracked (hook events or
watchAllSessions: true+ an active Claude Code session in a tracked project). - Reload the page (or open a new tab to the same server).
- The agent is nowhere in the office — furniture, pets, everything else renders; character does not.
Verified directly (not just by reading code): confirmed via the persisted ~/.pixel-agents/standalone-state.json that the agent(s) + valid seats exist, confirmed via server logs that hook delivery to the known agent is working continuously, and confirmed via a screenshot of an actual browser session that zero characters render despite that. Also reproduced by injecting a synthetic brand-new agent (fake SessionStart + Stop hook pair) while a tab was open — it also fails to render once delivered via existingAgents on any subsequent connection.
Fix (implemented and confirmed working locally)
Make the existingAgents handler flush immediately when the layout has already loaded (the normal case, given current send order), and only fall back to buffering if layout genuinely hasn't arrived yet — so it's correct regardless of message order:
} else if (msg.type === 'existingAgents') {
const incoming = msg.agents as number[];
const meta = (msg.agentMeta || {}) as Record<
number,
{ palette?: number; hueShift?: number; seatId?: string }
>;
const folderNames = (msg.folderNames || {}) as Record<number, string>;
- // Buffer agents — they'll be added in layoutLoaded after seats are built
+ // The server always sends existingAgents AFTER layoutLoaded, so layoutReadyRef
+ // is normally already true here — add agents immediately in that case. Only
+ // buffer (for the layoutLoaded handler to flush) if layout truly hasn't arrived
+ // yet, so this stays correct even if the send order ever changes.
for (const id of incoming) {
const m = meta[id];
- pendingAgents.push({
+ const entry = {
id,
palette: m?.palette,
hueShift: m?.hueShift,
seatId: m?.seatId,
folderName: folderNames[id],
- });
+ };
+ if (layoutReadyRef.current) {
+ os.addAgent(entry.id, entry.palette, entry.hueShift, entry.seatId, true, entry.folderName);
+ } else {
+ pendingAgents.push(entry);
+ }
+ }
+ if (layoutReadyRef.current && os.characters.size > 0) {
+ saveAgentSeats(os);
}
setAgents((prev) => {Rebuilt (npm run build:webview), restarted the standalone CLI, reloaded the browser: both previously-invisible agents appeared immediately, correctly seated and animating. Happy to open this as a PR if useful — posting as an issue first in case there's a preferred approach (e.g. reordering the server sends instead).
Environment
- OS: Ubuntu 24.04.4 LTS
- Node: v22.0.0
- Standalone CLI, built from source at current
main
Source: pixel-agents-hq/pixel-agents