Skip to content

Commit 9794e07

Browse files
authored
refactor(server): assign palette/hueShift server-side (#370)
1 parent b1401bd commit 9794e07

21 files changed

Lines changed: 714 additions & 24 deletions

adapters/vscode/PixelAgentsViewProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
116116
parentAgentId: agent.leadAgentId,
117117
teamName: agent.teamName,
118118
hooksOnly: agent.hooksOnly || undefined,
119+
palette: agent.palette,
120+
hueShift: agent.hueShift,
119121
});
120122
});
121123
this.store.on('agentRemoved', (id) => {

adapters/vscode/agentManager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
startFileWatching,
1414
} from '../../server/src/fileWatcher.js';
1515
import { loadLayout } from '../../server/src/layoutPersistence.js';
16+
import { assignPaletteIfNeeded } from '../../server/src/paletteAssigner.js';
1617
import { CLAUDE_TERMINAL_NAME_PREFIX } from '../../server/src/providers/hook/claude/constants.js';
1718
import { claudeProvider } from '../../server/src/providers/index.js';
1819
import { cancelPermissionTimer, cancelWaitingTimer } from '../../server/src/timerManager.js';
@@ -119,6 +120,7 @@ export async function launchNewTerminal(
119120
maxContextTokens: DEFAULT_MAX_CONTEXT_TOKENS,
120121
};
121122

123+
assignPaletteIfNeeded(agent, agents);
122124
agents.set(id, agent);
123125
activeAgentIdRef.current = id;
124126
persistAgents();
@@ -388,8 +390,11 @@ export function restoreAgents(
388390
isTeamLead: p.agentName ? undefined : p.isTeamLead,
389391
leadAgentId: p.leadAgentId,
390392
teamUsesTmux: p.teamUsesTmux,
393+
palette: p.palette,
394+
hueShift: p.hueShift,
391395
};
392396

397+
assignPaletteIfNeeded(agent, store);
393398
store.set(p.id, agent);
394399
knownJsonlFiles.add(p.jsonlFile);
395400
if (isExternal) {

core/asyncapi.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ components:
181181
type: string
182182
isExternal:
183183
type: boolean
184+
palette:
185+
type: integer
186+
description: Server-assigned character palette index.
187+
hueShift:
188+
type: integer
189+
description: Server-assigned hue shift in degrees (0-360).
184190

185191
AgentClosed:
186192
description: An agent has been removed from the office.

core/src/messages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export interface AgentCreated {
7272
id: number;
7373
folderName?: string;
7474
isExternal?: boolean;
75+
palette?: number;
76+
hueShift?: number;
7577
}
7678

7779
export interface AgentClosed {

core/src/paletteUtils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Palette diversity utilities for agent character assignment.
3+
*
4+
* Pure functions with no DOM/sprite dependencies — safe for use in both
5+
* browser (webview) and Node.js (server) environments.
6+
*/
7+
8+
export interface PalettePick {
9+
palette: number;
10+
hueShift: number;
11+
}
12+
13+
const HUE_SHIFT_MIN_DEG = 45;
14+
const HUE_SHIFT_RANGE_DEG = 271;
15+
16+
/**
17+
* Pick a diverse palette based on current palette distribution.
18+
* First N agents each get a unique skin (where N = paletteCount).
19+
* Beyond N, skins repeat in balanced rounds with a random hue shift (≥45°).
20+
*
21+
* @param paletteCount - Total number of available palettes (e.g., 6)
22+
* @param paletteCounts - Array of counts per palette (length must equal paletteCount)
23+
* @returns Selected palette index and hue shift in degrees
24+
*/
25+
export function pickDiversePalette(paletteCount: number, paletteCounts: number[]): PalettePick {
26+
if (paletteCounts.length !== paletteCount) {
27+
throw new Error(
28+
`paletteCounts length (${paletteCounts.length}) must equal paletteCount (${paletteCount})`,
29+
);
30+
}
31+
32+
const minCount = Math.min(...paletteCounts);
33+
const available: number[] = [];
34+
for (let i = 0; i < paletteCount; i++) {
35+
if (paletteCounts[i] === minCount) available.push(i);
36+
}
37+
const palette = available[Math.floor(Math.random() * available.length)];
38+
39+
// First round (minCount === 0): no hue shift. Subsequent rounds: random ≥45°.
40+
let hueShift = 0;
41+
if (minCount > 0) {
42+
hueShift = HUE_SHIFT_MIN_DEG + Math.floor(Math.random() * HUE_SHIFT_RANGE_DEG);
43+
}
44+
45+
return { palette, hueShift };
46+
}

core/src/schemas.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export interface PersistedAgent {
2929
* transcripts are re-adopted after a reload; the spawned children
3030
* themselves are derived state and never persisted. */
3131
backgroundAgentToolIds?: string[];
32+
/** Preferred character palette (0-5). Persisted so colors stay stable
33+
* across server restarts; assignPaletteIfNeeded is a no-op on restore. */
34+
palette?: number;
35+
/** Hue shift in degrees (0-360). Persisted alongside palette. */
36+
hueShift?: number;
3237
}
3338

3439
/** Agent seat assignment with visual identity
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import type { StateAdapter } from '../../core/src/adapter.js';
7+
import { AgentRuntime } from '../src/agentRuntime.js';
8+
import { AgentStateStore } from '../src/agentStateStore.js';
9+
import { claudeProvider } from '../src/providers/hook/claude/claude.js';
10+
import type { AgentState, PersistedAgent } from '../src/types.js';
11+
12+
function createTestAgent(overrides: Partial<AgentState> = {}): AgentState {
13+
return {
14+
id: 1,
15+
sessionId: 'sess-1',
16+
terminalRef: undefined,
17+
isExternal: true,
18+
projectDir: '/test',
19+
jsonlFile: '/test/session.jsonl',
20+
fileOffset: 0,
21+
lineBuffer: '',
22+
activeToolIds: new Set(),
23+
activeToolStatuses: new Map(),
24+
activeToolNames: new Map(),
25+
activeSubagentToolIds: new Map(),
26+
activeSubagentToolNames: new Map(),
27+
backgroundAgentToolIds: new Set(),
28+
isWaiting: false,
29+
permissionSent: false,
30+
hadToolsInTurn: false,
31+
lastDataAt: 0,
32+
linesProcessed: 0,
33+
seenUnknownRecordTypes: new Set(),
34+
hookDelivered: false,
35+
contextTokens: 0,
36+
maxContextTokens: 200_000,
37+
...overrides,
38+
} as AgentState;
39+
}
40+
41+
function createMockAdapter(initial: PersistedAgent[] = []): StateAdapter & {
42+
saved: PersistedAgent[][];
43+
} {
44+
let current = initial;
45+
const saved: PersistedAgent[][] = [];
46+
return {
47+
saved,
48+
loadAgents: () => current,
49+
saveAgents: (agents) => {
50+
current = agents;
51+
saved.push(agents);
52+
},
53+
loadSeats: () => ({}),
54+
saveSeats: () => {},
55+
getSetting: <T>(_key: string, defaultValue: T): T => defaultValue,
56+
setSetting: vi.fn<(key: string, value: unknown) => void>(),
57+
};
58+
}
59+
60+
/**
61+
* Restored agents must keep their palette/hueShift across server restarts.
62+
*
63+
* These tests use a mock adapter (mirror of backgroundAgents.test.ts's
64+
* fakeAdapter) for the in-memory PersistedAgent[] the adapter hands back.
65+
* Disk is touched only for the existence gate: restoreExternalAgents skips
66+
* agents whose jsonlFile doesn't exist, so each test creates one empty file.
67+
* No FileStateAdapter, no HOME/USERPROFILE override, no state-file I/O.
68+
*/
69+
describe('AgentRuntime -- restore preserves palette/hueShift', () => {
70+
let tmpDir: string;
71+
let jsonlPath: string;
72+
let runtime: AgentRuntime | undefined;
73+
74+
beforeEach(() => {
75+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pxl-restore-pal-'));
76+
jsonlPath = path.join(tmpDir, 'session.jsonl');
77+
fs.writeFileSync(jsonlPath, '');
78+
});
79+
80+
afterEach(() => {
81+
// Dispose BEFORE cleaning the tmp dir: AgentRuntime.dispose() calls
82+
// removeAgent() for every tracked agent, which calls store.persist();
83+
// if the runtime is disposed mid-test (between a persist and a fresh
84+
// adapter's load) it can clobber in-memory state.
85+
runtime?.dispose();
86+
fs.rmSync(tmpDir, { recursive: true, force: true });
87+
});
88+
89+
it('restoreExternalAgents keeps palette/hueShift from the persisted record', () => {
90+
const persisted: PersistedAgent[] = [
91+
{
92+
id: 7,
93+
sessionId: 'sess-restore',
94+
terminalName: '',
95+
isExternal: true,
96+
jsonlFile: jsonlPath,
97+
projectDir: tmpDir,
98+
palette: 3,
99+
hueShift: 90,
100+
},
101+
];
102+
const store = new AgentStateStore();
103+
store.setAdapter(createMockAdapter(persisted));
104+
runtime = new AgentRuntime(store, claudeProvider);
105+
106+
runtime.restoreExternalAgents();
107+
108+
const agent = store.get(7);
109+
expect(agent).toBeDefined();
110+
expect(agent?.palette).toBe(3);
111+
expect(agent?.hueShift).toBe(90);
112+
});
113+
114+
it('persist() writes palette/hueShift onto the record that restoreExternalAgents copies back', () => {
115+
// Phase 1: persist an agent with palette/hueShift and capture the
116+
// record the adapter received.
117+
const storeA = new AgentStateStore();
118+
const adapterA = createMockAdapter();
119+
storeA.setAdapter(adapterA);
120+
runtime = new AgentRuntime(storeA, claudeProvider);
121+
storeA.set(
122+
42,
123+
createTestAgent({
124+
id: 42,
125+
sessionId: 'sess-rt',
126+
jsonlFile: jsonlPath,
127+
projectDir: tmpDir,
128+
palette: 5,
129+
hueShift: 270,
130+
}),
131+
);
132+
storeA.persist();
133+
134+
// Capture the record our explicit persist() wrote BEFORE disposing
135+
// the runtime: dispose() walks the store and persists removals, which
136+
// would otherwise clobber the captured record or push an empty entry
137+
// onto `saved`.
138+
expect(adapterA.saved.length).toBeGreaterThanOrEqual(1);
139+
const persisted = adapterA.saved[0];
140+
expect(persisted[0].palette).toBe(5);
141+
expect(persisted[0].hueShift).toBe(270);
142+
143+
runtime.dispose();
144+
runtime = undefined;
145+
146+
// Phase 2: a fresh store + runtime restore from the record phase 1
147+
// wrote. The new adapter hands back exactly what phase 1 persisted.
148+
const storeB = new AgentStateStore();
149+
storeB.setAdapter(createMockAdapter(persisted));
150+
runtime = new AgentRuntime(storeB, claudeProvider);
151+
runtime.restoreExternalAgents();
152+
153+
const restored = storeB.get(42);
154+
expect(restored).toBeDefined();
155+
expect(restored?.palette).toBe(5);
156+
expect(restored?.hueShift).toBe(270);
157+
});
158+
159+
it('assigns a fresh palette when the persisted record has no palette', () => {
160+
const persisted: PersistedAgent[] = [
161+
{
162+
id: 9,
163+
sessionId: 'sess-fresh',
164+
terminalName: '',
165+
isExternal: true,
166+
jsonlFile: jsonlPath,
167+
projectDir: tmpDir,
168+
// palette/hueShift intentionally omitted
169+
},
170+
];
171+
const store = new AgentStateStore();
172+
store.setAdapter(createMockAdapter(persisted));
173+
runtime = new AgentRuntime(store, claudeProvider);
174+
175+
runtime.restoreExternalAgents();
176+
177+
const agent = store.get(9);
178+
expect(agent).toBeDefined();
179+
// assignPaletteIfNeeded fills in [0, 6) with hueShift 0 on an empty
180+
// store (first round). Assert "set to a valid value" rather than
181+
// re-deriving the exact algorithm.
182+
expect(agent?.palette).toBeGreaterThanOrEqual(0);
183+
expect(agent?.palette).toBeLessThan(6);
184+
expect(agent?.hueShift).toBe(0);
185+
});
186+
});

0 commit comments

Comments
 (0)