Hmbown/CodeWhale · error · Error

Unsupported pet snapshot

Error message

Unsupported pet snapshot

What it means

Thrown by `poll()` in the shared companion page when a `/v1/frame` response does not match the expected snapshot contract: `frame.version` must be 1, `frame.points` must be an array of exactly 980 entries. Anything else is treated as an unsupported pet snapshot so the renderer never consumes a structurally different frame. The error is caught locally and displayed in `#message`; polling then continues on its 33 ms timer.

Solutions

  1. Update the Codewhale companion server (or TUI) so both sides agree on frame version 1 with 980 points.
  2. Restart the companion process and reopen the page from Codewhale to ensure a matching pair.
  3. Check what `/v1/frame` actually returns (curl it) and fix any proxy or wrapper rewriting the response.
  4. If you run a custom companion, emit `{version:1, points:[...980 items], ...}` exactly.

Example fix

// before (server response)
{ "version": 2, "points": [ /* 500 points */ ] }
// after
{ "version": 1, "points": [ /* exactly 980 [x,y] entries */ ], "epoch": 1, "tick": 42, ... }
Defensive patterns

Strategy: type-guard

Validate before calling

const frame = await request('/v1/frame');
if (frame?.version !== 1 || !Array.isArray(frame?.points) || frame.points.length !== 980) {
  console.warn('Companion frame shape mismatch; versions likely skewed.');
}

Type guard

const isPetFrame = (f) => f !== null && typeof f === 'object' && f.version === 1 && Array.isArray(f.points) && f.points.length === 980;

Try / catch

try {
  const frame = await request('/v1/frame');
  if (!isPetFrame(frame)) throw new Error('Unsupported pet snapshot');
  render(frame);
} catch (e) {
  showMessage(e.message); // poll loop keeps retrying every 33ms
}

Prevention

When it happens

Trigger: The local companion server responds to GET `/v1/frame` with a payload whose version is not 1, whose points array is missing or has a length other than 980, or which is otherwise a valid HTTP response but an unexpected shape.

Common situations: Version skew between an updated web UI and an older companion binary (or vice versa); a proxy returning an HTML error page or different JSON; a custom/self-written companion implementation emitting a different particle count.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/8c8e446dff3bd61d. Report an issue: GitHub.

Appendix: source

Thrown at pet/public/shared.html:45

const rgb=h=>h.match(/[a-f\d]{2}/gi).map(n=>parseInt(n,16)),hex=c=>'#'+c.map(n=>n.toString(16).padStart(2,'0')).join('');
const base={background:[8,15,21],backgroundTop:[24,45,56],particle:[135,221,219],eventColors:true,brightness:1.25,dotScale:1,glow:.5,environment:true};
const presets=[['Ocean','#080f15','#182d38','#87dddb'],['Chalk','#e8edee','#fbfdfb','#285967'],['Graphite','#141518','#28292e','#d9e0e3'],['Linen','#e9e1d2','#f8f2e7','#79573a'],['Forest','#0c1916','#20382d','#c5db9b'],['Plum','#201a29','#3b2e4b','#e7b8d0'],['Ember','#211610','#49291b','#edb177'],['Cobalt','#101934','#263b60','#b2d7f2']].map(([name,b,t,p])=>({name,...base,background:rgb(b),backgroundTop:rgb(t),particle:rgb(p),eventColors:false,brightness:name==='Chalk'||name==='Linen'?1.8:1.35}));
let appearance={...base}, selected='Ocean', current, previous, lastTickAt=0, received=0, client=crypto.randomUUID(), seq=0, pending=null, nextAppearance=null, busy=false, sound=false;
let preview=new URLSearchParams(location.search).has('preview'), pet, points, demoTick=0, demoRunning=false, demoDone=false, actionChoice='thinking', actionTick=0, lastDraw=0, accumulator=0, galleryTick=0, previewSaved=false;
const media=matchMedia('(prefers-reduced-motion: reduce)');$('still').checked=media.matches;media.onchange=()=>{$('still').checked=media.matches};
function valid(a){return a&&Object.keys(base).every(k=>k in a)&&['background','backgroundTop','particle'].every(k=>Array.isArray(a[k])&&a[k].length===3&&a[k].every(n=>Number.isInteger(n)&&n>=0&&n<=255))&&typeof a.eventColors==='boolean'&&typeof a.environment==='boolean'&&[['brightness',.25,2],['dotScale',.65,1.8],['glow',0,1]].every(([k,lo,hi])=>Number.isFinite(a[k])&&a[k]>=lo&&a[k]<=hi)}
function clean(a){if(!valid(a))throw Error('This appearance has invalid colors or control ranges.');return Object.fromEntries(Object.keys(base).map(k=>[k,a[k]]))}
try{const saved=JSON.parse(localStorage.getItem('codewhale-pet-appearance-v1'));if(valid(saved))appearance=clean(saved)}catch{}
function presetName(a){return presets.find(p=>JSON.stringify(clean(p))===JSON.stringify(clean(a)))?.name||'Custom'}
selected=presetName(appearance);
function syncControls(){for(const k of ['background','backgroundTop','particle'])$(k).value=hex(appearance[k]);for(const k of ['eventColors','environment'])$(k).checked=appearance[k];for(const k of ['brightness','dotScale','glow']){$(k).value=appearance[k];$(k+'-value').value=k==='glow'?Math.round(appearance[k]*100)+'%':appearance[k].toFixed(2)+'×'}for(const b of $('presets').children)b.setAttribute('aria-pressed',String(b.textContent===selected));}
function saveAppearance(){try{localStorage.setItem('codewhale-pet-appearance-v1',JSON.stringify(appearance))}catch{}if(preview){$('message').textContent='Appearance saved in this preview. Export it to keep a portable copy.'}else{nextAppearance=clean(appearance);$('message').textContent='Saving appearance to the companion…';void flushAction()}}
for(const a of presets){const b=document.createElement('button');b.type='button';b.setAttribute('aria-pressed','false');const sw=document.createElement('span');sw.className='swatch';sw.style.background=hex(a.background);const dot=document.createElement('i');dot.style.background=hex(a.particle);sw.append(dot);b.append(sw,document.createTextNode(a.name));b.onclick=()=>{appearance=clean(a);selected=a.name;syncControls();saveAppearance()};$('presets').append(b);const f=document.createElement('figure'),c=document.createElement('canvas'),label=document.createElement('figcaption');c.setAttribute('role','img');c.setAttribute('aria-label',a.name+' appearance');label.textContent=a.name;label.style.color=readable(a.background);f.append(c,label);$('gallery').append(f)}
for(const key of Object.keys(base))$(key).oninput=()=>{appearance[key]=['background','backgroundTop','particle'].includes(key)?rgb($(key).value):['eventColors','environment'].includes(key)?$(key).checked:Number($(key).value);if(key==='particle')appearance.eventColors=false;selected='Custom';syncControls();saveAppearance()};
syncControls();
async function request(path,body,headers={}){const r=await fetch(path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json',...headers},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(2500)});if(!r.ok){let e;try{e=await r.json()}catch{}const error=Error(e?.error||'Local pet unavailable. Reopen from Codewhale or use the preview.');error.rejected=r.status===409&&!error.message.includes('storage');throw error}return r.json()}
async function flushAction(){if(busy||!current||preview)return;if(!pending&&nextAppearance){pending={identity:current.identity,client,seq:seq+1,source_revision:current.sourceRevision,action:{kind:'appearance',appearance:nextAppearance}};nextAppearance=null}if(!pending)return;busy=true;try{await request('/v1/action',pending);seq=pending.seq;pending=null;$('message').textContent='Appearance and interactions saved by the shared companion.'}catch(e){if(e.rejected)pending=null;$('message').textContent=e.message}finally{busy=false}}
async function poll(){if(preview)return;try{const frame=await request('/v1/frame');if(frame.version!==1||!Array.isArray(frame.points)||frame.points.length!==980)throw Error('Unsupported pet snapshot');if(current?.epoch!==frame.epoch){previous=null;lastTickAt=performance.now()}else if(frame.tick!==current.tick){previous=current;lastTickAt=performance.now()}current=frame;received=performance.now();if(!nextAppearance&&!pending&&valid(frame.appearance)&&JSON.stringify(appearance)!==JSON.stringify(frame.appearance)){appearance=clean(frame.appearance);selected=presetName(appearance);syncControls()}$('identity').textContent='Pet '+frame.identity+' · '+frame.source+' · tick '+frame.tick+' · '+frame.digest;if(frame.audioUnavailable&&sound){sound=false;$('sound').textContent='Sound unavailable';$('sound').setAttribute('aria-pressed','false')}if(pending||nextAppearance)await flushAction()}catch(e){$('message').textContent=e.message}finally{if(!preview)setTimeout(poll,33)}}
function interact(food){if(preview){pet?.interact(food?'food':'attention',.2,-.15);return}if(!current||performance.now()-lastTickAt>800||pending)return;pending={identity:current.identity,client,seq:seq+1,source_revision:current.sourceRevision,action:{kind:'interact',food,x:.2,y:-.15}};void flushAction()}
$('focus').onclick=()=>interact(false);$('pulse').onclick=()=>interact(true);canvas.onpointerdown=()=>interact(false);
$('sound').onclick=async()=>{if(preview)return;try{const r=await request('/v1/audio',{client,enabled:!sound});sound=r.granted;$('sound').textContent=sound?'Sound on':'Sound off';$('sound').setAttribute('aria-pressed',String(sound))}catch(e){$('message').textContent=e.message}};
setInterval(()=>{if(sound&&!document.hidden&&performance.now()-lastTickAt<500)request('/v1/audio',{client,enabled:true}).catch(()=>{sound=false})},500);
document.addEventListener('visibilitychange',()=>{if(document.hidden&&sound){sound=false;void request('/v1/audio',{client,enabled:false});$('sound').textContent='Sound off';$('sound').setAttribute('aria-pressed','false')}lastDraw=0;accumulator=0});
$('compare').onclick=()=>{document.body.classList.toggle('gallery-mode');$('compare').setAttribute('aria-pressed',String(document.body.classList.contains('gallery-mode')))};
$('configure').onclick=()=>{document.body.classList.toggle('no-settings');$('configure').setAttribute('aria-pressed',String(!document.body.classList.contains('no-settings')))};
function immersive(on){document.body.classList.toggle('immersive',on);$('expand').textContent=on?'Back to studio':'Full habitat';if(on)document.body.classList.remove('gallery-mode')}
$('expand').onclick=()=>immersive(!document.body.classList.contains('immersive'));window.addEventListener('keydown',e=>{if(e.key==='Escape'){immersive(false);document.body.classList.remove('gallery-mode');$('compare').setAttribute('aria-pressed','false')}});
function download(name,data){const u=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'})),a=document.createElement('a');a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000)}
$('export-config').onclick=()=>download('codewhale-appearance.json',{version:1,appearance:clean(appearance)});$('import-config').onclick=()=>$('config-file').click();$('config-file').onchange=async()=>{try{const f=$('config-file').files[0];if(!f)return;if(f.size>4096)throw Error('Choose an appearance file smaller than 4 KiB.');const value=JSON.parse(await f.text());if(value.version!==1)throw Error('Unsupported appearance version.');appearance=clean(value.appearance);selected='Custom';syncControls();saveAppearance()}catch(e){$('message').textContent=e.message}finally{$('config-file').value=''}};
$('save').onclick=async()=>{try{download(preview?'codewhale-preview-replay.json':'codewhale-shared-pet.json',preview?JSON.parse(pet.recording(true)):await request('/v1/export'))}catch(e){$('message').textContent=e.message}};
async function makePreview(){if(!points){const r=await fetch('whale-points.tsv');if(!r.ok)throw Error('Preview points could not load.');points=JSON.stringify((await r.text()).trim().split('\n').map(row=>row.trim().split(/\s+/).map(Number)))}if(!preview){previewSaved=true;if(sound){sound=false;void request('/v1/audio',{client,enabled:false})}}preview=true;$('return-live').hidden=!previewSaved;pet=new PetNative(points,'','[]',true);demoTick=0;actionTick=0;previous=null;current=null;$('connection-label').textContent='Isolated preview · no task running';$('sound').disabled=true;$('sound').textContent='Preview is silent';$('identity').textContent='Preview world · canonical 980-particle core · no live session';$('message').textContent='This preview is isolated. Its colors can be exported; it does not change a live pet.'}
function showResult(on){const el=$('result');if(!on&&el.contains(document.activeElement))$('work').focus();el.inert=!on;el.classList.toggle('visible',on);el.setAttribute('aria-hidden',String(!on));if(on&&document.activeElement===$('finish'))el.focus()}
async function startWork(){try{await makePreview();actionChoice='thinking';demoRunning=true;demoDone=false;showResult(false);document.body.classList.add('preview-running');immersive(true)}catch(e){$('message').textContent=e.message}}
function finishWork(){if(!pet)return;demoRunning=false;demoDone=true;pet.observeEngine(JSON.stringify({event:'turn_complete'}),demoTick*1000/30);showResult(true);document.body.classList.remove('preview-running')}
$('return-live').onclick=()=>{preview=false;pet=null;demoDone=false;demoRunning=false;showResult(false);$('return-live').hidden=true;$('sound').disabled=false;$('sound').textContent='Sound off';$('connection-label').textContent='Shared habitat';document.body.classList.remove('preview-running');void poll()};
$('work').onclick=startWork;$('replay').onclick=startWork;$('finish').onclick=finishWork;

View on GitHub (pinned to 433685b202)