pet: consume the typed owner contract end to end

- pet-engine.ts: parent work leads the projection over delegated agents;
  a failed operation is counted once on the tape; a resumed span keeps its
  start time.
- shared.html (pet/public and the TUI copy): the preview emits
  turn_started{turn_id}, operation_activity_* and
  turn_complete{turn_id,turn_outcome}; the caption and marks read
  authoritativePresence/activityKind/parallelAgentCount through a local
  label map; new `computer` preview option and mark.
- Delete the dead toolActivity() from codewhale.ts.
- Regenerate pet-native.js for the TUI and iOS (npm run sync). Main's
  committed bundle was verified identical to a build of main's source, so
  the bundle diff is this change only.
- owner.rs: factor bind_activity() (session + cursor binding) and test
  the committed bundle through it: Missing when unattached, Fresh/Working
  with `computer`, Done only after a completed turn with doneEffectId ==
  turnId, interrupted turn never Done, old tool_call_started refused; each
  projection must deserialize with deny_unknown_fields and pass is_valid.
- pet tests moved to the new schema.

Evidence (node 22.20.0; Homebrew node 25 is broken on this host):
tsc --noEmit exit 0; node scripts/build-pet.mjs --tui --apple --study ok;
node --test tests/*.test.mjs -> tests 69, pass 69, fail 0.
shared.html inline script passes node --check; a headless Chrome load
was inconclusive (preview never advanced without animation frames), so
the preview is not browser-verified.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 03:39:14 -07:00
1 parent eb07b1bf69
commit f56f7e5dda
9 files changed
+775 -1639

No files matched your search

+106 -11
View File
@@ -556,17 +556,7 @@ fn run_world(
frame["cursor"] = json!(saved.cursor);
frame["source"] = json!(saved.source);
frame["sourceRevision"] = json!(saved.source_revision);
if let Some(activity) = frame["activity"].as_object_mut() {
activity.insert(
"sessionId".into(),
if saved.source == "unattached" {
Value::Null
} else {
json!(saved.source)
},
);
activity.insert("cursor".into(), json!(saved.cursor));
}
bind_activity(&mut frame, &saved.source, saved.cursor);
// Presentation material keeps missing coverage legible. The core
// pigment, score, particle digest and recording are unchanged.
for key in ["", "still"] {
@@ -848,3 +838,108 @@ fn save(context: &Context, saved: &mut Saved, store: &mut Store) -> anyhow::Resu
}
Ok(())
}
/// Bind the reducer's account-neutral owner projection to the session and
/// cursor this owner serves. The reducer never knows either; `Scene::valid`
/// rejects a frame whose projection disagrees with them.
fn bind_activity(frame: &mut Value, source: &str, cursor: u64) {
if let Some(activity) = frame["activity"].as_object_mut() {
let session = if source == "unattached" {
Value::Null
} else {
json!(source)
};
activity.insert("sessionId".into(), session);
activity.insert("cursor".into(), json!(cursor));
}
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_protocol::engine_owner::{
EngineOwnerProjection, OwnerActivityKind, OwnerFreshness, OwnerPresence,
};
/// Drive the committed `pet-native.js` bundle (the one the owner and the
/// worker actually evaluate) and read back its projection the way the
/// live client does: strict schema, then the contract's invariants.
fn projection(ctx: &rquickjs::Ctx<'_>, source: &str, cursor: u64) -> EngineOwnerProjection {
let text: String = ctx.eval("pet.presentation()").unwrap();
let mut frame: Value = serde_json::from_str(&text).unwrap();
bind_activity(&mut frame, source, cursor);
let activity: EngineOwnerProjection =
serde_json::from_value(frame["activity"].clone()).expect("bundle matches the contract");
assert!(activity.is_valid(), "invalid projection: {activity:?}");
assert_eq!(activity.cursor, cursor);
activity
}
#[test]
fn committed_bundle_projection_satisfies_the_owner_contract() {
let runtime = Runtime::new().unwrap();
let context = Context::full(&runtime).unwrap();
context.with(|ctx| {
let points: Vec<Vec<f64>> = include_str!("../ambient_life/whale-points.tsv")
.lines()
.map(|line| line.split_whitespace().filter_map(|n| n.parse().ok()).collect())
.collect();
ctx.globals()
.set("points", serde_json::to_string(&points).unwrap())
.unwrap();
ctx.eval::<(), _>(include_bytes!("pet-native.js").as_slice())
.unwrap();
ctx.eval::<(), _>("globalThis.pet = new PetNative(points, '', '[]', true)")
.unwrap();
let unattached = projection(&ctx, "unattached", 0);
assert_eq!(unattached.freshness, OwnerFreshness::Missing);
assert_eq!(unattached.session_id, None);
let feed = |events: &str, at: f64| {
ctx.globals().set("events", events).unwrap();
ctx.globals().set("at", at).unwrap();
ctx.eval::<(), _>("pet.observeEngineBatch(events, at); pet.advanceEngine(at + 50, true, false)")
.unwrap();
};
feed(
r#"[{"event":"turn_started","turn_id":"turn-1"},
{"event":"operation_activity_started","span_id":"call-1","activity_kind":"computer"}]"#,
0.0,
);
let working = projection(&ctx, "session-a", 3);
assert_eq!(working.session_id.as_deref(), Some("session-a"));
assert_eq!(working.freshness, OwnerFreshness::Fresh);
assert_eq!(working.authoritative_presence, OwnerPresence::Working);
assert_eq!(working.activity_kind, Some(OwnerActivityKind::Computer));
// The old tool-name feed is refused at the boundary.
ctx.globals()
.set(
"events",
r#"[{"event":"tool_call_started","tool_call_id":"a","tool_name":"exec_shell"}]"#,
)
.unwrap();
assert!(ctx.eval::<(), _>("pet.observeEngineBatch(events, 100)").is_err());
let _ = ctx.catch();
feed(
r#"[{"event":"operation_activity_completed","span_id":"call-1","activity_kind":"computer","outcome":"succeeded"},
{"event":"turn_complete","turn_id":"turn-1","turn_outcome":"completed"}]"#,
200.0,
);
let done = projection(&ctx, "session-a", 4);
assert_eq!(done.authoritative_presence, OwnerPresence::Done);
assert_eq!(done.done_effect_id.as_deref(), Some("turn-1"));
feed(
r#"[{"event":"turn_started","turn_id":"turn-2"},
{"event":"turn_complete","turn_id":"turn-2","turn_outcome":"interrupted"}]"#,
400.0,
);
let interrupted = projection(&ctx, "session-a", 5);
assert_ne!(interrupted.authoritative_presence, OwnerPresence::Done);
assert_eq!(interrupted.done_effect_id, None);
});
}
}
File diff suppressed because it is too large. Load diff
+13 -8
View File
@@ -16,7 +16,7 @@
<div class="habitat"><canvas id="tank" tabindex="0" role="img" aria-label="Codewhale habitat"></canvas><article class="result" id="result" tabindex="-1" aria-live="polite" aria-hidden="true" inert><h2>The work is ready.</h2><div class="label">Preview result · no task was run</div><p>The whale held the space while the task was running. Now the answer comes forward, with the habitat still present behind it.</p><p>In Codewhale, this space will show the completed assistant response from your existing conversation. Your draft, history, and selection stay intact.</p><button id="replay">Replay transition</button></article><div class="activity" id="activity" aria-live="polite" aria-atomic="true"><h2 id="activity-title">A moment between tasks.</h2><div class="eyebrow" id="activity-evidence">Local companion</div><div class="detail" id="activity-detail">Activity unobserved</div><span class="parallel" id="activity-parallel"></span></div><div class="caption"><strong id="caption">Connecting…</strong><small id="clock"></small></div></div>
<div class="gallery" id="gallery" aria-label="The same whale across eight appearances"></div>
<div class="controls"><button id="focus">Focus</button><button id="pulse">Pulse</button><button id="sound" aria-pressed="false">Sound off</button><label><input id="still" type="checkbox"> Still</label><button id="expand">Full habitat</button><button id="work">Preview work → result</button><button id="finish">Finish preview now</button><button id="return-live" hidden>Return to live pet</button><span class="work-label" id="work-label">Escape returns</span></div><div class="identity" id="identity"></div></section>
<aside class="settings" aria-label="Appearance settings"><h2>Behavior</h2><label class="action-field">Try a preview action<select id="action-preview"><option value="thinking">Thinking</option><option value="reading">Reading files</option><option value="searching">Searching</option><option value="editing">Editing files</option><option value="executing">Running a command</option><option value="testing">Running tests</option><option value="browsing">Using the browser</option><option value="memory">Retrieving context</option><option value="delegating">Three parallel agents</option><option value="waiting">Waiting for you</option><option value="responding">Writing a response</option><option value="error">An operation failed</option><option value="unknown">No telemetry</option></select></label><p class="behavior-note">Preview actions are simulated. Motion follows a seed; your Focus and Pulse interactions become part of the replay.</p><h2>Appearance</h2><p>Presets are starting points. Every color is yours to change.</p><div class="presets" id="presets"></div>
<aside class="settings" aria-label="Appearance settings"><h2>Behavior</h2><label class="action-field">Try a preview action<select id="action-preview"><option value="thinking">Thinking</option><option value="reading">Reading files</option><option value="searching">Searching</option><option value="editing">Editing files</option><option value="executing">Running a command</option><option value="testing">Running tests</option><option value="browsing">Using the browser</option><option value="computer">Controlling the desktop</option><option value="memory">Retrieving context</option><option value="delegating">Three parallel agents</option><option value="waiting">Waiting for you</option><option value="responding">Writing a response</option><option value="error">An operation failed</option><option value="unknown">No telemetry</option></select></label><p class="behavior-note">Preview actions are simulated. Motion follows a seed; your Focus and Pulse interactions become part of the replay.</p><h2>Appearance</h2><p>Presets are starting points. Every color is yours to change.</p><div class="presets" id="presets"></div>
<label class="field">Background <input type="color" id="background" value="#080f15"></label><label class="field">Upper light <input type="color" id="backgroundTop" value="#182d38"></label><label class="field">Particle color <input type="color" id="particle" value="#87dddb"></label><label class="field"><span>Colors follow activity</span><input type="checkbox" id="eventColors" checked></label>
<label class="field range"><span>Brightness <output id="brightness-value"></output></span><input type="range" id="brightness" min="0.25" max="2" step="0.05" value="1.25"></label><label class="field range"><span>Dot size <output id="dotScale-value"></output></span><input type="range" id="dotScale" min="0.65" max="1.8" step="0.05" value="1"></label><label class="field range"><span>Glow <output id="glow-value"></output></span><input type="range" id="glow" min="0" max="1" step="0.05" value="0.5"></label><label class="field"><span>Water and horizon</span><input type="checkbox" id="environment" checked></label>
<div class="config-actions"><button id="export-config">Save appearance</button><button id="import-config">Load appearance</button><input id="config-file" type="file" accept="application/json,.json" hidden></div><p class="status" id="message" role="status">Opening the habitat…</p><div class="connection"><button id="save">Save pet replay</button><br><a href="pet.html" id="join">Join a live pet from the standalone viewer</a></div></aside></main>
@@ -58,20 +58,25 @@ $('save').onclick=async()=>{try{download(preview?'codewhale-preview-replay.json'
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')}
function finishWork(){if(!pet)return;demoRunning=false;demoDone=true;pet.observeEngine(JSON.stringify({event:'turn_complete',turn_id:'preview-turn-'+previewTurn,turn_outcome:'completed'}),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;
const actionTools={reading:'read_file',searching:'search_files',editing:'apply_patch',executing:'exec_command',testing:'run_tests',browsing:'browser_navigate',memory:'retrieve_context'};
const workPhases=['thinking','reading','searching','browsing','memory','delegating','editing','executing','testing','waiting','responding'];
function beginAction(kind,at){pet.observeEngine(JSON.stringify({event:'turn_started'}),at);const emit=e=>pet.observeEngine(JSON.stringify(e),at);if(actionTools[kind])emit({event:'tool_call_started',tool_call_id:'preview-tool',tool_name:actionTools[kind]});else if(kind==='waiting')emit({event:'user_input_required',id:'preview-question'});else if(kind==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_spawned',id:'preview-worker-'+i})}else if(kind==='thinking'||kind==='responding')emit({event:kind==='thinking'?'thinking_started':'message_started',index:0});else if(kind==='error')emit({event:'error'});actionTick=0}
const operationKinds=['reading','searching','editing','executing','testing','browsing','computer','memory'];
const activityLabels={reading:'Reading files',editing:'Editing files',searching:'Searching',testing:'Running tests',executing:'Running a command',browsing:'Using the browser',computer:'Controlling the desktop',memory:'Retrieving context',tool:'Using a tool',thinking:'Thinking',responding:'Writing the response',delegating:'Coordinating agents'};
/* The owner projection carries typed presence and kind only; labels are local. */
function cue(a){if(!a||a.freshness!=='fresh')return null;if(a.authoritativePresence==='needs_you')return{kind:'waiting',label:'Waiting for you'};if(a.authoritativePresence==='done')return{kind:'done',label:'The work is done.'};if(!a.activeSpans.length&&a.failedToolAge)return{kind:a.failedToolAge.activityKind,label:'An operation failed'};return a.activityKind?{kind:a.activityKind,label:activityLabels[a.activityKind]||'Working'}:null}
let previewTurn=0,previewSpan=0;
const workPhases=['thinking','reading','searching','browsing','computer','memory','delegating','editing','executing','testing','waiting','responding'];
function beginAction(kind,at){const emit=e=>pet.observeEngine(JSON.stringify(e),at);emit({event:'turn_started',turn_id:'preview-turn-'+(++previewTurn)});if(operationKinds.includes(kind))emit({event:'operation_activity_started',span_id:'preview-op-'+(++previewSpan),activity_kind:kind});else if(kind==='waiting')emit({event:'user_input_required',id:'preview-question'});else if(kind==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_spawned',id:'preview-worker-'+i})}else if(kind==='thinking'||kind==='responding')emit({event:kind==='thinking'?'thinking_started':'message_started',index:0});else if(kind==='error'){const span='preview-op-'+(++previewSpan);emit({event:'operation_activity_started',span_id:span,activity_kind:'executing'});emit({event:'operation_activity_completed',span_id:span,activity_kind:'executing',outcome:'failed'})}actionTick=0}
$('action-preview').onchange=async()=>{try{if(!preview)await makePreview();demoRunning=false;demoDone=false;actionChoice=$('action-preview').value;beginAction(actionChoice,demoTick*1000/30);showResult(false);document.body.classList.remove('preview-running')}catch(e){$('message').textContent=e.message}};
function advancePreview(){const at=demoTick*1000/30;if(!demoDone){if(demoRunning){const phase=Math.min(workPhases.length-1,Math.floor(demoTick/120));if(demoTick%120===0){actionChoice=workPhases[phase];$('action-preview').value=actionChoice;beginAction(actionChoice,at)}}else if(demoTick===0)beginAction(actionChoice,at);if(actionTick%6===0){const emit=e=>pet.observeEngine(JSON.stringify(e),at);if(actionTools[actionChoice])emit({event:'tool_call_heartbeat'});else if(actionChoice==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_progress',id:'preview-worker-'+i,worker_status:'running'})}else if(actionChoice==='thinking'||actionChoice==='responding')emit({event:'response_delta',index:0,channel:actionChoice==='thinking'?'reasoning':'text'});else if(actionChoice==='error')emit({event:'error'})}}actionTick++;pet.advanceEngine(++demoTick*1000/30,true,!demoDone&&actionChoice==='waiting');if(demoRunning&&demoTick>=120*workPhases.length)finishWork();previous=current;current=JSON.parse(pet.presentation());lastTickAt=received=performance.now();current.producerConnected=!demoDone&&actionChoice!=='unknown';current.epoch='preview';}
function advancePreview(){const at=demoTick*1000/30;if(!demoDone){if(demoRunning){const phase=Math.min(workPhases.length-1,Math.floor(demoTick/120));if(demoTick%120===0){actionChoice=workPhases[phase];$('action-preview').value=actionChoice;beginAction(actionChoice,at)}}else if(demoTick===0)beginAction(actionChoice,at);if(actionTick%6===0){const emit=e=>pet.observeEngine(JSON.stringify(e),at);if(operationKinds.includes(actionChoice))emit({event:'tool_call_heartbeat'});else if(actionChoice==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_progress',id:'preview-worker-'+i,worker_status:'running'})}else if(actionChoice==='thinking'||actionChoice==='responding')emit({event:'response_delta',index:0,channel:actionChoice==='thinking'?'reasoning':'text'});}}actionTick++;pet.advanceEngine(++demoTick*1000/30,true,!demoDone&&actionChoice==='waiting');if(demoRunning&&demoTick>=120*workPhases.length)finishWork();previous=current;current=JSON.parse(pet.presentation());lastTickAt=received=performance.now();current.producerConnected=!demoDone&&actionChoice!=='unknown';current.epoch='preview';}
// View-only marks use the owner's fixed tick. They consume no random stream,
// claim no results, and freeze under Still. The moving body remains canonical.
function actionMarks(ctx,kind,t,w,h,ink,parallel){const cx=w*.5,cy=h*.55,r=Math.min(w*.29,h*.26),phase=t*.8;ctx.save();ctx.strokeStyle=ctx.fillStyle=ink;ctx.lineWidth=1;ctx.globalAlpha=.24;const line=(x1,y1,x2,y2)=>{ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke()},circle=(x,y,r)=>{ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.stroke()};
if(['reading','memory','files'].includes(kind)){for(const side of [-1,1]){const x=cx+side*r*1.35;for(let j=0;j<6;j++){const y=cy-42+j*15;ctx.globalAlpha=.1+.16*(.5+.5*Math.sin(phase-j*.7));line(x-21,y,x+21-(j%3)*6,y)}}}
else if(kind==='searching'||kind==='browsing'||kind==='network'){circle(cx,cy,r*1.3);const a=phase%(Math.PI*2);line(cx+Math.cos(a)*r,cy+Math.sin(a)*r,cx+Math.cos(a)*r*1.42,cy+Math.sin(a)*r*1.42);for(let i=0;i<5;i++){const a=i*Math.PI*2/5;circle(cx+Math.cos(a)*r*1.3,cy+Math.sin(a)*r*1.3,3+i%2)}}
else if(['editing','executing','tool'].includes(kind)){for(const side of [-1,1]){const x=cx+side*r*1.3;line(x,cy-32,x,cy+32);line(x,cy-32,x-side*12,cy-32);line(x,cy+32,x-side*12,cy+32)}ctx.globalAlpha=.2+.2*(.5+.5*Math.sin(phase*3));line(cx+r*.9,cy+44,cx+r*.9+18,cy+44)}
else if(kind==='computer'){const sw=r*1.45,sh=r*.95;ctx.strokeRect(cx-sw,cy-sh,sw*2,sh*2);const px=cx+Math.cos(phase)*sw*.8,py=cy+Math.sin(phase*1.3)*sh*.7;ctx.globalAlpha=.45;ctx.beginPath();ctx.moveTo(px,py);ctx.lineTo(px+9,py+12);ctx.lineTo(px+3,py+11);ctx.closePath();ctx.stroke()}
else if(kind==='testing'){for(let i=0;i<12;i++){const a=i*Math.PI/6;ctx.globalAlpha=.08+.35*Math.max(0,Math.cos(a-phase));line(cx+Math.cos(a)*r*1.25,cy+Math.sin(a)*r*1.25,cx+Math.cos(a)*r*1.38,cy+Math.sin(a)*r*1.38)}}
else if(kind==='waiting'){for(let i=0;i<3;i++){ctx.globalAlpha=.08+.05*(.5+.5*Math.sin(phase-i));circle(cx,cy,r*(1.12+i*.14))}}
else if(kind==='responding'){for(let i=0;i<5;i++){const x=cx-r*.7+i*r*.35;ctx.globalAlpha=.12+.22*(.5+.5*Math.sin(phase*2-i));line(x,cy+r*1.3,x+r*.22,cy+r*1.3)}}
@@ -81,11 +86,11 @@ function readable(c){return c[0]*.2126+c[1]*.7152+c[2]*.0722>145?'#28383d':'#d4e
function paint(c,f,a,now,small=false){const w=c.clientWidth,h=c.clientHeight,d=small?1:Math.min(devicePixelRatio||1,2);if(!w||!h)return;if(c.width!==Math.round(w*d)||c.height!==Math.round(h*d)){c.width=Math.round(w*d);c.height=Math.round(h*d)}const ctx=c.getContext('2d');ctx.setTransform(d,0,0,d,0,0);const gradient=ctx.createRadialGradient(w*.5,0,0,w*.5,h*.18,w*.9);gradient.addColorStop(0,hex(a.backgroundTop));gradient.addColorStop(1,hex(a.background));ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);if(!f)return;const still=$('still').checked,pose=still?f.still:f,state=pose.state,style=pose.style,stale=!preview&&(now-lastTickAt>800||now-received>800),t=still?0:f.timeMs/1000;
if(a.environment){ctx.strokeStyle=readable(a.background);ctx.globalAlpha=.12;ctx.lineWidth=.7;ctx.beginPath();ctx.moveTo(20,26);ctx.lineTo(w-20,26);ctx.stroke();for(let lane=0;lane<6;lane++){ctx.globalAlpha=.025;ctx.beginPath();for(let x=0;x<=w;x+=8){const y=h*.85+Math.sin(x/110+lane+t*.18)*7+lane*5;x?ctx.lineTo(x,y):ctx.moveTo(x,y)}ctx.stroke()}}ctx.globalAlpha=1;
const complete=demoDone&&!small,bounds=pose.points.reduce((b,p)=>[Math.max(b[0],Math.abs(p[0])),Math.max(b[1],Math.abs(p[1]))],[.7,.7]),scale=Math.min(w*.38/bounds[0],h*(complete?.18:.28)/bounds[1]),ox=w*(.5+(still?0:state.roamX)*.06),oy=h*(complete?.25:.56),flip=state.flip,mix=previous&&!still&&!stale?Math.min(1,(now-lastTickAt)/33.334):1;
if(!small&&!complete&&!stale&&f.activity?.observed)actionMarks(ctx,f.activity.kind,t,w,h,readable(a.background),f.activity.parallel);
const shown=cue(f.activity);if(!small&&!complete&&!stale&&shown)actionMarks(ctx,shown.kind,t,w,h,readable(a.background),f.activity.parallelAgentCount);
const color=a.eventColors?[style.r,style.g,style.b]:a.particle,material='rgb('+color.map(Math.round).join(',')+')',radius=Math.max(.65,Math.min(w,h)*.0026)*a.dotScale,hollow=stale||!f.producerConnected||state.observed<.92;
const alpha=preview?Math.max(style.alpha,hollow?.32:.6)*a.brightness:style.alpha/(f.appearance?.brightness||1)*a.brightness;
for(let i=0;i<980;i++){const q=pose.points[i],p=previous?.points[i],x=ox+(p&&!still?p[0]+(q[0]-p[0])*mix:q[0])*scale*flip,y=oy+(p&&!still?p[1]+(q[1]-p[1])*mix:q[1])*scale,depth=.65+.35*(i*37%101)/100;ctx.fillStyle=ctx.strokeStyle=material;ctx.globalAlpha=Math.min(1,alpha*depth*(stale?.5:1));ctx.lineWidth=Math.max(.65,radius*.5);ctx.beginPath();ctx.arc(x,y,radius*depth,0,Math.PI*2);hollow?ctx.stroke():ctx.fill();if(!hollow&&a.glow>0&&i%5===0){ctx.globalAlpha=.055*a.glow;ctx.beginPath();ctx.arc(x,y,radius*(1+a.glow*5),0,Math.PI*2);ctx.fill()}}ctx.globalAlpha=1;}
function draw(now){requestAnimationFrame(draw);if(document.hidden)return;const dt=lastDraw?Math.min(.1,(now-lastDraw)/1000):0;lastDraw=now;if(preview&&pet){accumulator+=dt;let count=0;while(accumulator>=1/30&&count++<3){advancePreview();accumulator-=1/30}}if(document.body.classList.contains('gallery-mode')){if(++galleryTick%3===0)Array.from($('gallery').querySelectorAll('canvas')).forEach((c,i)=>paint(c,current,presets[i],now,true))}else paint(canvas,current,appearance,now);if(current){const p=$('still').checked?current.still:current,activity=current.activity,known=activity?.observed&&(preview||now-lastTickAt<800),title=demoDone?'Settling back into the habitat.':known?activity.label:'A moment between tasks.',detail=demoDone?'Preview complete':known?activity.tool||(preview?'Simulated lifecycle event':'Observed lifecycle event'):'Activity unobserved',evidence=preview?'Simulated preview':known?'Observed in Codewhale':'Local companion';for(const [id,text] of [['activity-title',title],['activity-detail',detail],['activity-evidence',evidence]])if($(id).textContent!==text)$(id).textContent=text;$('activity-parallel').textContent=known&&activity.parallel?activity.parallel+' parallel agent'+(activity.parallel===1?'':'s')+' active':'';$('caption').textContent=(preview?'Preview · ':'')+p.style.channel+' · '+p.style.arch+(current.state.observed<.92?' · unobserved':'')+(demoDone?' · complete':'');$('clock').textContent=Math.floor(current.timeMs/60000)+':'+String(Math.floor(current.timeMs/1000)%60).padStart(2,'0');canvas.setAttribute('aria-label',title+'; '+detail+'; '+$('caption').textContent+'; '+(selected==='Custom'?'custom':selected)+' appearance')}}
function draw(now){requestAnimationFrame(draw);if(document.hidden)return;const dt=lastDraw?Math.min(.1,(now-lastDraw)/1000):0;lastDraw=now;if(preview&&pet){accumulator+=dt;let count=0;while(accumulator>=1/30&&count++<3){advancePreview();accumulator-=1/30}}if(document.body.classList.contains('gallery-mode')){if(++galleryTick%3===0)Array.from($('gallery').querySelectorAll('canvas')).forEach((c,i)=>paint(c,current,presets[i],now,true))}else paint(canvas,current,appearance,now);if(current){const p=$('still').checked?current.still:current,activity=current.activity,c=cue(activity),known=c&&(preview||now-lastTickAt<800),title=demoDone?'Settling back into the habitat.':known?c.label:'A moment between tasks.',detail=demoDone?'Preview complete':known?(preview?'Simulated lifecycle event':'Observed lifecycle event'):'Activity unobserved',evidence=preview?'Simulated preview':known?'Observed in Codewhale':'Local companion';for(const [id,text] of [['activity-title',title],['activity-detail',detail],['activity-evidence',evidence]])if($(id).textContent!==text)$(id).textContent=text;$('activity-parallel').textContent=known&&activity.parallelAgentCount?activity.parallelAgentCount+' parallel agent'+(activity.parallelAgentCount===1?'':'s')+' active':'';$('caption').textContent=(preview?'Preview · ':'')+p.style.channel+' · '+p.style.arch+(current.state.observed<.92?' · unobserved':'')+(demoDone?' · complete':'');$('clock').textContent=Math.floor(current.timeMs/60000)+':'+String(Math.floor(current.timeMs/1000)%60).padStart(2,'0');canvas.setAttribute('aria-label',title+'; '+detail+'; '+$('caption').textContent+'; '+(selected==='Custom'?'custom':selected)+' appearance')}}
async function connect(){if(preview){await makePreview();return}const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);if(token){if(!/^[a-f0-9]{64}$/i.test(token))throw Error('Invalid local connection');await request('/v1/attach',{}, {Authorization:'Bearer '+token})}$('join').hidden=true;$('message').textContent='One live pet. Appearance changes are shared; preview work stays isolated.';await poll()}
requestAnimationFrame(draw);connect().catch(e=>{$('message').textContent=e.message});
</script></html>
File diff suppressed because it is too large. Load diff
+13 -8
View File
@@ -16,7 +16,7 @@
<div class="habitat"><canvas id="tank" tabindex="0" role="img" aria-label="Codewhale habitat"></canvas><article class="result" id="result" tabindex="-1" aria-live="polite" aria-hidden="true" inert><h2>The work is ready.</h2><div class="label">Preview result · no task was run</div><p>The whale held the space while the task was running. Now the answer comes forward, with the habitat still present behind it.</p><p>In Codewhale, this space will show the completed assistant response from your existing conversation. Your draft, history, and selection stay intact.</p><button id="replay">Replay transition</button></article><div class="activity" id="activity" aria-live="polite" aria-atomic="true"><h2 id="activity-title">A moment between tasks.</h2><div class="eyebrow" id="activity-evidence">Local companion</div><div class="detail" id="activity-detail">Activity unobserved</div><span class="parallel" id="activity-parallel"></span></div><div class="caption"><strong id="caption">Connecting…</strong><small id="clock"></small></div></div>
<div class="gallery" id="gallery" aria-label="The same whale across eight appearances"></div>
<div class="controls"><button id="focus">Focus</button><button id="pulse">Pulse</button><button id="sound" aria-pressed="false">Sound off</button><label><input id="still" type="checkbox"> Still</label><button id="expand">Full habitat</button><button id="work">Preview work → result</button><button id="finish">Finish preview now</button><button id="return-live" hidden>Return to live pet</button><span class="work-label" id="work-label">Escape returns</span></div><div class="identity" id="identity"></div></section>
<aside class="settings" aria-label="Appearance settings"><h2>Behavior</h2><label class="action-field">Try a preview action<select id="action-preview"><option value="thinking">Thinking</option><option value="reading">Reading files</option><option value="searching">Searching</option><option value="editing">Editing files</option><option value="executing">Running a command</option><option value="testing">Running tests</option><option value="browsing">Using the browser</option><option value="memory">Retrieving context</option><option value="delegating">Three parallel agents</option><option value="waiting">Waiting for you</option><option value="responding">Writing a response</option><option value="error">An operation failed</option><option value="unknown">No telemetry</option></select></label><p class="behavior-note">Preview actions are simulated. Motion follows a seed; your Focus and Pulse interactions become part of the replay.</p><h2>Appearance</h2><p>Presets are starting points. Every color is yours to change.</p><div class="presets" id="presets"></div>
<aside class="settings" aria-label="Appearance settings"><h2>Behavior</h2><label class="action-field">Try a preview action<select id="action-preview"><option value="thinking">Thinking</option><option value="reading">Reading files</option><option value="searching">Searching</option><option value="editing">Editing files</option><option value="executing">Running a command</option><option value="testing">Running tests</option><option value="browsing">Using the browser</option><option value="computer">Controlling the desktop</option><option value="memory">Retrieving context</option><option value="delegating">Three parallel agents</option><option value="waiting">Waiting for you</option><option value="responding">Writing a response</option><option value="error">An operation failed</option><option value="unknown">No telemetry</option></select></label><p class="behavior-note">Preview actions are simulated. Motion follows a seed; your Focus and Pulse interactions become part of the replay.</p><h2>Appearance</h2><p>Presets are starting points. Every color is yours to change.</p><div class="presets" id="presets"></div>
<label class="field">Background <input type="color" id="background" value="#080f15"></label><label class="field">Upper light <input type="color" id="backgroundTop" value="#182d38"></label><label class="field">Particle color <input type="color" id="particle" value="#87dddb"></label><label class="field"><span>Colors follow activity</span><input type="checkbox" id="eventColors" checked></label>
<label class="field range"><span>Brightness <output id="brightness-value"></output></span><input type="range" id="brightness" min="0.25" max="2" step="0.05" value="1.25"></label><label class="field range"><span>Dot size <output id="dotScale-value"></output></span><input type="range" id="dotScale" min="0.65" max="1.8" step="0.05" value="1"></label><label class="field range"><span>Glow <output id="glow-value"></output></span><input type="range" id="glow" min="0" max="1" step="0.05" value="0.5"></label><label class="field"><span>Water and horizon</span><input type="checkbox" id="environment" checked></label>
<div class="config-actions"><button id="export-config">Save appearance</button><button id="import-config">Load appearance</button><input id="config-file" type="file" accept="application/json,.json" hidden></div><p class="status" id="message" role="status">Opening the habitat…</p><div class="connection"><button id="save">Save pet replay</button><br><a href="pet.html" id="join">Join a live pet from the standalone viewer</a></div></aside></main>
@@ -58,20 +58,25 @@ $('save').onclick=async()=>{try{download(preview?'codewhale-preview-replay.json'
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')}
function finishWork(){if(!pet)return;demoRunning=false;demoDone=true;pet.observeEngine(JSON.stringify({event:'turn_complete',turn_id:'preview-turn-'+previewTurn,turn_outcome:'completed'}),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;
const actionTools={reading:'read_file',searching:'search_files',editing:'apply_patch',executing:'exec_command',testing:'run_tests',browsing:'browser_navigate',memory:'retrieve_context'};
const workPhases=['thinking','reading','searching','browsing','memory','delegating','editing','executing','testing','waiting','responding'];
function beginAction(kind,at){pet.observeEngine(JSON.stringify({event:'turn_started'}),at);const emit=e=>pet.observeEngine(JSON.stringify(e),at);if(actionTools[kind])emit({event:'tool_call_started',tool_call_id:'preview-tool',tool_name:actionTools[kind]});else if(kind==='waiting')emit({event:'user_input_required',id:'preview-question'});else if(kind==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_spawned',id:'preview-worker-'+i})}else if(kind==='thinking'||kind==='responding')emit({event:kind==='thinking'?'thinking_started':'message_started',index:0});else if(kind==='error')emit({event:'error'});actionTick=0}
const operationKinds=['reading','searching','editing','executing','testing','browsing','computer','memory'];
const activityLabels={reading:'Reading files',editing:'Editing files',searching:'Searching',testing:'Running tests',executing:'Running a command',browsing:'Using the browser',computer:'Controlling the desktop',memory:'Retrieving context',tool:'Using a tool',thinking:'Thinking',responding:'Writing the response',delegating:'Coordinating agents'};
/* The owner projection carries typed presence and kind only; labels are local. */
function cue(a){if(!a||a.freshness!=='fresh')return null;if(a.authoritativePresence==='needs_you')return{kind:'waiting',label:'Waiting for you'};if(a.authoritativePresence==='done')return{kind:'done',label:'The work is done.'};if(!a.activeSpans.length&&a.failedToolAge)return{kind:a.failedToolAge.activityKind,label:'An operation failed'};return a.activityKind?{kind:a.activityKind,label:activityLabels[a.activityKind]||'Working'}:null}
let previewTurn=0,previewSpan=0;
const workPhases=['thinking','reading','searching','browsing','computer','memory','delegating','editing','executing','testing','waiting','responding'];
function beginAction(kind,at){const emit=e=>pet.observeEngine(JSON.stringify(e),at);emit({event:'turn_started',turn_id:'preview-turn-'+(++previewTurn)});if(operationKinds.includes(kind))emit({event:'operation_activity_started',span_id:'preview-op-'+(++previewSpan),activity_kind:kind});else if(kind==='waiting')emit({event:'user_input_required',id:'preview-question'});else if(kind==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_spawned',id:'preview-worker-'+i})}else if(kind==='thinking'||kind==='responding')emit({event:kind==='thinking'?'thinking_started':'message_started',index:0});else if(kind==='error'){const span='preview-op-'+(++previewSpan);emit({event:'operation_activity_started',span_id:span,activity_kind:'executing'});emit({event:'operation_activity_completed',span_id:span,activity_kind:'executing',outcome:'failed'})}actionTick=0}
$('action-preview').onchange=async()=>{try{if(!preview)await makePreview();demoRunning=false;demoDone=false;actionChoice=$('action-preview').value;beginAction(actionChoice,demoTick*1000/30);showResult(false);document.body.classList.remove('preview-running')}catch(e){$('message').textContent=e.message}};
function advancePreview(){const at=demoTick*1000/30;if(!demoDone){if(demoRunning){const phase=Math.min(workPhases.length-1,Math.floor(demoTick/120));if(demoTick%120===0){actionChoice=workPhases[phase];$('action-preview').value=actionChoice;beginAction(actionChoice,at)}}else if(demoTick===0)beginAction(actionChoice,at);if(actionTick%6===0){const emit=e=>pet.observeEngine(JSON.stringify(e),at);if(actionTools[actionChoice])emit({event:'tool_call_heartbeat'});else if(actionChoice==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_progress',id:'preview-worker-'+i,worker_status:'running'})}else if(actionChoice==='thinking'||actionChoice==='responding')emit({event:'response_delta',index:0,channel:actionChoice==='thinking'?'reasoning':'text'});else if(actionChoice==='error')emit({event:'error'})}}actionTick++;pet.advanceEngine(++demoTick*1000/30,true,!demoDone&&actionChoice==='waiting');if(demoRunning&&demoTick>=120*workPhases.length)finishWork();previous=current;current=JSON.parse(pet.presentation());lastTickAt=received=performance.now();current.producerConnected=!demoDone&&actionChoice!=='unknown';current.epoch='preview';}
function advancePreview(){const at=demoTick*1000/30;if(!demoDone){if(demoRunning){const phase=Math.min(workPhases.length-1,Math.floor(demoTick/120));if(demoTick%120===0){actionChoice=workPhases[phase];$('action-preview').value=actionChoice;beginAction(actionChoice,at)}}else if(demoTick===0)beginAction(actionChoice,at);if(actionTick%6===0){const emit=e=>pet.observeEngine(JSON.stringify(e),at);if(operationKinds.includes(actionChoice))emit({event:'tool_call_heartbeat'});else if(actionChoice==='delegating'){for(let i=0;i<3;i++)emit({event:'agent_progress',id:'preview-worker-'+i,worker_status:'running'})}else if(actionChoice==='thinking'||actionChoice==='responding')emit({event:'response_delta',index:0,channel:actionChoice==='thinking'?'reasoning':'text'});}}actionTick++;pet.advanceEngine(++demoTick*1000/30,true,!demoDone&&actionChoice==='waiting');if(demoRunning&&demoTick>=120*workPhases.length)finishWork();previous=current;current=JSON.parse(pet.presentation());lastTickAt=received=performance.now();current.producerConnected=!demoDone&&actionChoice!=='unknown';current.epoch='preview';}
// View-only marks use the owner's fixed tick. They consume no random stream,
// claim no results, and freeze under Still. The moving body remains canonical.
function actionMarks(ctx,kind,t,w,h,ink,parallel){const cx=w*.5,cy=h*.55,r=Math.min(w*.29,h*.26),phase=t*.8;ctx.save();ctx.strokeStyle=ctx.fillStyle=ink;ctx.lineWidth=1;ctx.globalAlpha=.24;const line=(x1,y1,x2,y2)=>{ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke()},circle=(x,y,r)=>{ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.stroke()};
if(['reading','memory','files'].includes(kind)){for(const side of [-1,1]){const x=cx+side*r*1.35;for(let j=0;j<6;j++){const y=cy-42+j*15;ctx.globalAlpha=.1+.16*(.5+.5*Math.sin(phase-j*.7));line(x-21,y,x+21-(j%3)*6,y)}}}
else if(kind==='searching'||kind==='browsing'||kind==='network'){circle(cx,cy,r*1.3);const a=phase%(Math.PI*2);line(cx+Math.cos(a)*r,cy+Math.sin(a)*r,cx+Math.cos(a)*r*1.42,cy+Math.sin(a)*r*1.42);for(let i=0;i<5;i++){const a=i*Math.PI*2/5;circle(cx+Math.cos(a)*r*1.3,cy+Math.sin(a)*r*1.3,3+i%2)}}
else if(['editing','executing','tool'].includes(kind)){for(const side of [-1,1]){const x=cx+side*r*1.3;line(x,cy-32,x,cy+32);line(x,cy-32,x-side*12,cy-32);line(x,cy+32,x-side*12,cy+32)}ctx.globalAlpha=.2+.2*(.5+.5*Math.sin(phase*3));line(cx+r*.9,cy+44,cx+r*.9+18,cy+44)}
else if(kind==='computer'){const sw=r*1.45,sh=r*.95;ctx.strokeRect(cx-sw,cy-sh,sw*2,sh*2);const px=cx+Math.cos(phase)*sw*.8,py=cy+Math.sin(phase*1.3)*sh*.7;ctx.globalAlpha=.45;ctx.beginPath();ctx.moveTo(px,py);ctx.lineTo(px+9,py+12);ctx.lineTo(px+3,py+11);ctx.closePath();ctx.stroke()}
else if(kind==='testing'){for(let i=0;i<12;i++){const a=i*Math.PI/6;ctx.globalAlpha=.08+.35*Math.max(0,Math.cos(a-phase));line(cx+Math.cos(a)*r*1.25,cy+Math.sin(a)*r*1.25,cx+Math.cos(a)*r*1.38,cy+Math.sin(a)*r*1.38)}}
else if(kind==='waiting'){for(let i=0;i<3;i++){ctx.globalAlpha=.08+.05*(.5+.5*Math.sin(phase-i));circle(cx,cy,r*(1.12+i*.14))}}
else if(kind==='responding'){for(let i=0;i<5;i++){const x=cx-r*.7+i*r*.35;ctx.globalAlpha=.12+.22*(.5+.5*Math.sin(phase*2-i));line(x,cy+r*1.3,x+r*.22,cy+r*1.3)}}
@@ -81,11 +86,11 @@ function readable(c){return c[0]*.2126+c[1]*.7152+c[2]*.0722>145?'#28383d':'#d4e
function paint(c,f,a,now,small=false){const w=c.clientWidth,h=c.clientHeight,d=small?1:Math.min(devicePixelRatio||1,2);if(!w||!h)return;if(c.width!==Math.round(w*d)||c.height!==Math.round(h*d)){c.width=Math.round(w*d);c.height=Math.round(h*d)}const ctx=c.getContext('2d');ctx.setTransform(d,0,0,d,0,0);const gradient=ctx.createRadialGradient(w*.5,0,0,w*.5,h*.18,w*.9);gradient.addColorStop(0,hex(a.backgroundTop));gradient.addColorStop(1,hex(a.background));ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);if(!f)return;const still=$('still').checked,pose=still?f.still:f,state=pose.state,style=pose.style,stale=!preview&&(now-lastTickAt>800||now-received>800),t=still?0:f.timeMs/1000;
if(a.environment){ctx.strokeStyle=readable(a.background);ctx.globalAlpha=.12;ctx.lineWidth=.7;ctx.beginPath();ctx.moveTo(20,26);ctx.lineTo(w-20,26);ctx.stroke();for(let lane=0;lane<6;lane++){ctx.globalAlpha=.025;ctx.beginPath();for(let x=0;x<=w;x+=8){const y=h*.85+Math.sin(x/110+lane+t*.18)*7+lane*5;x?ctx.lineTo(x,y):ctx.moveTo(x,y)}ctx.stroke()}}ctx.globalAlpha=1;
const complete=demoDone&&!small,bounds=pose.points.reduce((b,p)=>[Math.max(b[0],Math.abs(p[0])),Math.max(b[1],Math.abs(p[1]))],[.7,.7]),scale=Math.min(w*.38/bounds[0],h*(complete?.18:.28)/bounds[1]),ox=w*(.5+(still?0:state.roamX)*.06),oy=h*(complete?.25:.56),flip=state.flip,mix=previous&&!still&&!stale?Math.min(1,(now-lastTickAt)/33.334):1;
if(!small&&!complete&&!stale&&f.activity?.observed)actionMarks(ctx,f.activity.kind,t,w,h,readable(a.background),f.activity.parallel);
const shown=cue(f.activity);if(!small&&!complete&&!stale&&shown)actionMarks(ctx,shown.kind,t,w,h,readable(a.background),f.activity.parallelAgentCount);
const color=a.eventColors?[style.r,style.g,style.b]:a.particle,material='rgb('+color.map(Math.round).join(',')+')',radius=Math.max(.65,Math.min(w,h)*.0026)*a.dotScale,hollow=stale||!f.producerConnected||state.observed<.92;
const alpha=preview?Math.max(style.alpha,hollow?.32:.6)*a.brightness:style.alpha/(f.appearance?.brightness||1)*a.brightness;
for(let i=0;i<980;i++){const q=pose.points[i],p=previous?.points[i],x=ox+(p&&!still?p[0]+(q[0]-p[0])*mix:q[0])*scale*flip,y=oy+(p&&!still?p[1]+(q[1]-p[1])*mix:q[1])*scale,depth=.65+.35*(i*37%101)/100;ctx.fillStyle=ctx.strokeStyle=material;ctx.globalAlpha=Math.min(1,alpha*depth*(stale?.5:1));ctx.lineWidth=Math.max(.65,radius*.5);ctx.beginPath();ctx.arc(x,y,radius*depth,0,Math.PI*2);hollow?ctx.stroke():ctx.fill();if(!hollow&&a.glow>0&&i%5===0){ctx.globalAlpha=.055*a.glow;ctx.beginPath();ctx.arc(x,y,radius*(1+a.glow*5),0,Math.PI*2);ctx.fill()}}ctx.globalAlpha=1;}
function draw(now){requestAnimationFrame(draw);if(document.hidden)return;const dt=lastDraw?Math.min(.1,(now-lastDraw)/1000):0;lastDraw=now;if(preview&&pet){accumulator+=dt;let count=0;while(accumulator>=1/30&&count++<3){advancePreview();accumulator-=1/30}}if(document.body.classList.contains('gallery-mode')){if(++galleryTick%3===0)Array.from($('gallery').querySelectorAll('canvas')).forEach((c,i)=>paint(c,current,presets[i],now,true))}else paint(canvas,current,appearance,now);if(current){const p=$('still').checked?current.still:current,activity=current.activity,known=activity?.observed&&(preview||now-lastTickAt<800),title=demoDone?'Settling back into the habitat.':known?activity.label:'A moment between tasks.',detail=demoDone?'Preview complete':known?activity.tool||(preview?'Simulated lifecycle event':'Observed lifecycle event'):'Activity unobserved',evidence=preview?'Simulated preview':known?'Observed in Codewhale':'Local companion';for(const [id,text] of [['activity-title',title],['activity-detail',detail],['activity-evidence',evidence]])if($(id).textContent!==text)$(id).textContent=text;$('activity-parallel').textContent=known&&activity.parallel?activity.parallel+' parallel agent'+(activity.parallel===1?'':'s')+' active':'';$('caption').textContent=(preview?'Preview · ':'')+p.style.channel+' · '+p.style.arch+(current.state.observed<.92?' · unobserved':'')+(demoDone?' · complete':'');$('clock').textContent=Math.floor(current.timeMs/60000)+':'+String(Math.floor(current.timeMs/1000)%60).padStart(2,'0');canvas.setAttribute('aria-label',title+'; '+detail+'; '+$('caption').textContent+'; '+(selected==='Custom'?'custom':selected)+' appearance')}}
function draw(now){requestAnimationFrame(draw);if(document.hidden)return;const dt=lastDraw?Math.min(.1,(now-lastDraw)/1000):0;lastDraw=now;if(preview&&pet){accumulator+=dt;let count=0;while(accumulator>=1/30&&count++<3){advancePreview();accumulator-=1/30}}if(document.body.classList.contains('gallery-mode')){if(++galleryTick%3===0)Array.from($('gallery').querySelectorAll('canvas')).forEach((c,i)=>paint(c,current,presets[i],now,true))}else paint(canvas,current,appearance,now);if(current){const p=$('still').checked?current.still:current,activity=current.activity,c=cue(activity),known=c&&(preview||now-lastTickAt<800),title=demoDone?'Settling back into the habitat.':known?c.label:'A moment between tasks.',detail=demoDone?'Preview complete':known?(preview?'Simulated lifecycle event':'Observed lifecycle event'):'Activity unobserved',evidence=preview?'Simulated preview':known?'Observed in Codewhale':'Local companion';for(const [id,text] of [['activity-title',title],['activity-detail',detail],['activity-evidence',evidence]])if($(id).textContent!==text)$(id).textContent=text;$('activity-parallel').textContent=known&&activity.parallelAgentCount?activity.parallelAgentCount+' parallel agent'+(activity.parallelAgentCount===1?'':'s')+' active':'';$('caption').textContent=(preview?'Preview · ':'')+p.style.channel+' · '+p.style.arch+(current.state.observed<.92?' · unobserved':'')+(demoDone?' · complete':'');$('clock').textContent=Math.floor(current.timeMs/60000)+':'+String(Math.floor(current.timeMs/1000)%60).padStart(2,'0');canvas.setAttribute('aria-label',title+'; '+detail+'; '+$('caption').textContent+'; '+(selected==='Custom'?'custom':selected)+' appearance')}}
async function connect(){if(preview){await makePreview();return}const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);if(token){if(!/^[a-f0-9]{64}$/i.test(token))throw Error('Invalid local connection');await request('/v1/attach',{}, {Authorization:'Bearer '+token})}$('join').hidden=true;$('message').textContent='One live pet. Appearance changes are shared; preview work stays isolated.';await poll()}
requestAnimationFrame(draw);connect().catch(e=>{$('message').textContent=e.message});
</script></html>
-20
View File
@@ -75,26 +75,6 @@ function classify(name: string): Category {
if (/tool/.test(n)) return 'tool';
return CATEGORIES.includes(n as Category) ? n as Category : 'other';
}
/** Presentation vocabulary beside the canonical category classifier. Only the
* witnessed tool name is used; command contents are never inferred. */
export function toolActivity(name: string): { kind: string; label: string } {
const n = name.toLowerCase().replace(/-/g, '_');
if (/search|grep|glob|find_file/.test(n)) return { kind: 'searching', label: 'Searching' };
if (/read_file|list_dir|read_text|open_file/.test(n)) return { kind: 'reading', label: 'Reading files' };
if (/apply_patch|write_file|edit_file|replace_text/.test(n)) return { kind: 'editing', label: 'Editing files' };
if (/run_test|pytest|test_suite/.test(n)) return { kind: 'testing', label: 'Running tests' };
const category = toolCategory(name);
return ({ browser: { kind: 'browsing', label: 'Using the browser' },
filesystem: { kind: 'files', label: 'Working with files' },
code: { kind: 'executing', label: 'Running a command' },
network: { kind: 'network', label: 'Calling a service' },
agent: { kind: 'delegating', label: 'Coordinating agents' },
memory: { kind: 'memory', label: 'Retrieving context' },
reasoning: { kind: 'thinking', label: 'Thinking' },
communication: { kind: 'communicating', label: 'Communicating' },
} as Record<string, {kind: string; label: string}>)[category] ?? { kind: 'tool', label: 'Using a tool' };
}
export function toolCategory(name: string): Category {
const category = classify(name);
return category === 'other' ? 'tool' : category;
+14 -7
View File
@@ -4,7 +4,8 @@ import { compilePetTelemetry, PET_BIN_MS, type PetBucket } from './pet-telemetry
// Engine emits a liveness pulse every 10 seconds for a running operation.
// Keep a small scheduling margin so one delayed pulse does not erase a valid
// long operation; after that, the projection becomes generic.
// long operation from the owner projection; after that, it becomes generic.
// The physics tape keeps its own, shorter coverage rule (see `pulse`).
export const ENGINE_OWNER_STALE_MS = 12_000;
export type OwnerActivityKind = 'reading' | 'editing' | 'searching' | 'testing' | 'executing'
| 'browsing' | 'computer' | 'memory' | 'tool' | 'thinking' | 'responding' | 'delegating';
@@ -93,7 +94,10 @@ export class PetEngineTelemetry {
const active = fresh
? [...this.active.values()]
.filter(span => at >= span.startedAtMs && at - span.event.endTime <= ENGINE_OWNER_STALE_MS)
.sort((a, b) => b.startedAtMs - a.startedAtMs || a.key.localeCompare(b.key))
// The parent's own work leads; delegated agents are counted in
// `parallelAgentCount` and only lead when nothing else is active.
.sort((a, b) => Number(a.group === 'agent') - Number(b.group === 'agent')
|| b.startedAtMs - a.startedAtMs || a.key.localeCompare(b.key))
: [];
const agents = active.filter(span => span.group === 'agent').length;
const terminalFresh = fresh && this.terminalAt !== undefined
@@ -105,7 +109,6 @@ export class PetEngineTelemetry {
else if (terminalFresh && this.turnOutcome) authoritativePresence = 'idle';
else if (active.length > 0 || this.turnId) authoritativePresence = 'working';
}
const terminal = this.turnOutcome !== undefined && this.turnId !== undefined;
const activityKind = fresh && authoritativePresence !== 'needs_you'
&& authoritativePresence !== 'done' && authoritativePresence !== 'idle'
? active[0]?.activityKind
@@ -171,15 +174,17 @@ export class PetEngineTelemetry {
private pulse(key: string, at: number): void {
const span = this.active.get(key);
if (!span) return;
if (at - span.event.endTime > ENGINE_OWNER_STALE_MS) {
// A resumed stream does not assert tape coverage across its silent
// interval; the span itself (and its start) stays active.
if (at - span.event.endTime > PET_BIN_MS * 2) {
const event = this.add(span.event.name, span.event.category, at, span.event.agentId, true);
this.active.set(key, { ...span, startedAtMs: at, event });
this.active.set(key, { ...span, event });
} else {
span.event.endTime = at;
}
}
private finish(key: string, at: number, status: 'success' | 'error' | 'unknown' = 'success'): ActiveSpan | undefined {
private finish(key: string, at: number, status: 'success' | 'unknown' = 'success'): ActiveSpan | undefined {
if (!this.active.has(key)) return undefined;
this.pulse(key, at);
const span = this.active.get(key);
@@ -303,8 +308,10 @@ export class PetEngineTelemetry {
if (!OPERATION_OUTCOMES.includes(event.outcome as OwnerOperationOutcome))
throw new Error('Missing Engine operation outcome.');
const outcome = event.outcome as OwnerOperationOutcome;
// A failure is recorded once, as its own onset event below; marking
// the span `error` too would count it twice on the tape.
const completed = this.finish(`operation:${spanId}`, at,
outcome === 'failed' ? 'error' : outcome === 'succeeded' ? 'success' : 'unknown');
outcome === 'succeeded' ? 'success' : 'unknown');
if (completed && this.addOnce(this.completedSpans, spanId, 4096)) {
if (outcome === 'failed') {
this.add('operation_failed', 'error', at).status = 'error';
+1 -1
View File
@@ -62,7 +62,7 @@ test('native rotation retains a bounded active history and does not retrigger ol
let archived = 0, largest = 0;
for (let step = 0; step < 180; step++) {
const at = step * 5000;
live.observeEngine(JSON.stringify({ event: 'tool_call_started', tool_call_id: String(step), tool_name: 'exec_command' }), at);
live.observeEngine(JSON.stringify({ event: 'operation_activity_started', span_id: String(step), activity_kind: 'executing' }), at);
live.advanceEngine(at + 5000, false, false);
largest = Math.max(largest, JSON.parse(live.recording()).tape.length);
if (live.needsSegment()) {
+50 -18
View File
@@ -7,29 +7,61 @@ import { compilePetTelemetry, encodePetJSONL } from '../dist/core/pet-telemetry.
import { petDemoEvents } from '../dist/core/pet-demo.js';
const points = JSON.stringify(readFileSync(new URL('../public/whale-points.tsv', import.meta.url), 'utf8').trim().split('\n').map(row => row.trim().split(/\s+/).map(Number)));
test('action receipts distinguish tool names without inventing shell contents and expire', () => {
test('typed operation activity carries a kind, never a tool name, and goes stale', () => {
const e = new PetEngineTelemetry();
for (const [i, name, kind] of [[0,'read_file','reading'],[1,'search_files','searching'],[2,'apply_patch','editing'],[3,'exec_command','executing'],[4,'run_tests','testing'],[5,'browser_navigate','browsing']]) {
e.observe({event:'turn_started'},i*1000);
e.observe({event:'tool_call_started',tool_call_id:'private-id',tool_name:name},i*1000);
const activity=e.activity(i*1000+100);
assert.equal(activity.kind,kind); assert.equal(activity.tool,name);
assert.equal(JSON.stringify(activity).includes('private-id'),false);
assert.equal(e.activity(i*1000+801).observed,false);
for (const [i, kind] of ['reading','searching','editing','executing','testing','browsing','computer'].entries()) {
const at = i * 20_000;
e.observe({event:'turn_started',turn_id:'turn-'+i},at);
e.observe({event:'operation_activity_started',span_id:'private-span-'+i,activity_kind:kind},at);
const activity=e.activity(at+100);
assert.equal(activity.activityKind,kind); assert.equal(activity.authoritativePresence,'working');
assert.equal(activity.freshness,'fresh'); assert.equal(activity.turnId,'turn-'+i);
assert.deepEqual(Object.keys(activity).sort(),['activeSpans','activityKind','authoritativePresence','cursor','doneEffectId',
'failedToolAge','freshness','observed','observedAtMs','parallelAgentCount','schemaVersion','sessionId','turnId','turnOutcome']);
assert.equal(JSON.stringify(activity).includes('private-span'),false);
const stale=e.activity(at+12_001);
assert.equal(stale.freshness,'stale'); assert.equal(stale.activityKind,null); assert.deepEqual(stale.activeSpans,[]);
}
// The pre-contract events carried tool names; the boundary refuses them.
assert.throws(() => e.observe({event:'tool_call_started',tool_call_id:'a',tool_name:'read_file'},200_000),/Invalid Engine pet metadata fields/);
assert.throws(() => e.observe({event:'turn_started'},200_000),/Missing Engine turn_id/);
assert.throws(() => e.observe({event:'operation_activity_started',span_id:'a',activity_kind:'shell'},200_000));
});
test('waiting and concurrent action receipts stay bounded and clear after disconnect', () => {
test('only a completed turn is Done; interrupted and failed turns are idle', () => {
for (const [outcome, presence] of [['completed','done'],['interrupted','idle'],['failed','idle']]) {
const e = new PetEngineTelemetry();
e.observe({event:'turn_started',turn_id:'t'},0);
e.observe({event:'operation_activity_started',span_id:'s',activity_kind:'editing'},10);
e.observe({event:'turn_complete',turn_id:'t',turn_outcome:outcome},20);
const activity=e.activity(30);
assert.equal(activity.authoritativePresence,presence,outcome);
assert.equal(activity.doneEffectId,outcome==='completed'?'t':null);
assert.equal(activity.activityKind,null); assert.deepEqual(activity.activeSpans,[]);
}
const untracked = new PetEngineTelemetry();
untracked.observe({event:'turn_complete',turn_outcome:'completed'},0);
assert.equal(untracked.activity(10).authoritativePresence,'idle');
assert.throws(() => untracked.observe({event:'turn_complete',turn_id:null,turn_outcome:'completed'},20));
});
test('waiting and concurrent activity stay bounded and clear after disconnect', () => {
const pet=new PetNative(points,'','[]',true);
pet.observeEngineBatch(JSON.stringify([{event:'tool_call_started',tool_call_id:'a',tool_name:'read_file'},
pet.observeEngineBatch(JSON.stringify([{event:'turn_started',turn_id:'t'},{event:'operation_activity_started',span_id:'a',activity_kind:'computer'},
...Array.from({length:3},(_,i)=>({event:'agent_spawned',id:'private-'+i}))]),0);
pet.advanceEngine(200,true,false);
const frame=JSON.parse(pet.presentation());
assert.equal(frame.activity.kind,'reading');assert.equal(frame.activity.parallel,3);
assert.equal(frame.activity.activityKind,'computer');assert.equal(frame.activity.parallelAgentCount,3);
assert.equal(JSON.stringify(frame.activity).includes('private-'),false);
pet.observeEngine(JSON.stringify({event:'approval_required',id:'private'}),200);
pet.advanceEngine(600,true,true);assert.equal(JSON.parse(pet.presentation()).activity.kind,'waiting');
pet.advanceEngine(600,true,true);
const waiting=JSON.parse(pet.presentation()).activity;
assert.equal(waiting.authoritativePresence,'needs_you');assert.equal(waiting.activityKind,null);
pet.observeEngine(JSON.stringify({event:'approval_resolved',id:'private',outcome:'denied'}),700);
pet.advanceEngine(800,true,false);
assert.equal(JSON.parse(pet.presentation()).activity.authoritativePresence,'working');
const before=pet.recording(true);pet.presentation();assert.equal(pet.recording(true),before);
pet.disconnectEngine();assert.equal(JSON.parse(pet.presentation()).activity.observed,false);
pet.disconnectEngine();
const gone=JSON.parse(pet.presentation()).activity;
assert.equal(gone.observed,false);assert.equal(gone.freshness,'missing');
});
test('the incremental bucket range uses the same measured projection as full replay', () => {
const events = petDemoEvents(), full = compilePetTelemetry(events, 80_000);
@@ -37,12 +69,12 @@ test('the incremental bucket range uses the same measured projection as full rep
});
test('Engine pulses expire; a late failed completion tears at receipt time without rewriting history', () => {
const engine = new PetEngineTelemetry();
engine.observe({ event: 'tool_call_started', tool_call_id: 'a', tool_name: 'exec_command' }, 0);
engine.observe({ event: 'operation_activity_started', span_id: 'a', activity_kind: 'executing' }, 0);
engine.observe({ event: 'tool_call_heartbeat' }, 300);
const first = engine.bucket(0);
assert.equal(first.channel, 'code'); assert.equal(first.activeMs[3], 300);
assert.equal(engine.bucket(3).observed, 0);
engine.observe({ event: 'tool_call_complete', tool_call_id: 'a', tool_name: 'exec_command', failed: true }, 5900);
engine.observe({ event: 'operation_activity_completed', span_id: 'a', activity_kind: 'executing', outcome: 'failed' }, 5900);
assert.equal(engine.bucket(13).observed, 0);
assert.equal(engine.bucket(14).channel, 'error'); assert.equal(engine.bucket(14).errors, 1);
assert.deepEqual(engine.bucket(0), first);
@@ -57,7 +89,7 @@ test('an authoritative waiting request escalates, accepted tape replays, and sil
for (let i = 0; i < 900; i++) replay.step(1 / 30, true);
assert.deepEqual(JSON.parse(replay.snapshot()).state, JSON.parse(pet.snapshot()).state);
assert.equal(JSON.parse(replay.snapshot()).digest, JSON.parse(pet.snapshot()).digest);
pet.observeEngine(JSON.stringify({ event: 'turn_complete' }), 30_100);
pet.observeEngine(JSON.stringify({ event: 'turn_complete', turn_outcome: 'interrupted' }), 30_100);
pet.advanceEngine(32_000, true, false);
assert.equal(JSON.parse(pet.snapshot()).state.observed, 0);
});
@@ -85,10 +117,10 @@ test('successive shared batches preserve active and waiting coverage exactly', (
let shared = new PetEngineTelemetry();
const direct = new PetEngineTelemetry();
for (const [at, event, waiting] of [
[0, {event:'tool_call_started', tool_call_id:'build', tool_name:'exec_command'}, false],
[0, {event:'operation_activity_started', span_id:'build', activity_kind:'executing'}, false],
[300, {event:'tool_call_heartbeat'}, false],
[600, {event:'tool_call_heartbeat'}, false],
[800, {event:'tool_call_complete', tool_call_id:'build'}, false],
[800, {event:'operation_activity_completed', span_id:'build', activity_kind:'executing', outcome:'succeeded'}, false],
[900, {event:'approval_required', id:'permission'}, true],
[1200, {event:'agent_spawned', id:'worker'}, true],
[1500, {event:'agent_progress', id:'worker', worker_status:'running'}, true],