Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Optional: share AutoHub's Live + recent inventory with its voice TUI.
# Copy to .env, uncomment the base URL, then run npm start.
# AUTOHUB_HUB_BASE=http://127.0.0.1:8767
# AUTOHUB_HUB_TOKEN=
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ when launching that way.
Set `AUTOHUB_HUB_TOKEN` in `.env` or the server environment if that Hub requires a bearer
token. In this mode CottageCode follows every `/v1/tasks` cursor page and reads
canonical task detail, timelines, checklist data, and control capabilities.
It does not open SQLite or scan Claude transcripts. The Hub owns discovery;
The default map follows the same **Live + recent** inventory as the voice TUI.
Its count spans every project town, not just HubTown. Older tasks stay available
with **settled** enabled; historical PR links do not bring them back onto the
default map. It does not open SQLite or scan Claude transcripts. The Hub owns discovery;
CottageCode keeps its existing towns, cottages, inspectors, and message receipts.
If the Hub is unavailable, the last complete feed stays visible as stale.
Credentials and raw upstream errors are never returned to the browser.
Expand Down
15 changes: 13 additions & 2 deletions docs/COTTAGE_FEED.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Full shape (everything else is optional):
| `role` | string | Fallback for `town` if `town` is missing. |
| `status` | string | One of `working` `idle` `blocked` `done` `offline`. |
| `occupancy` | string | `live` `recent` `settled`. Optional. If omitted, the townmap classifies from `status` + timestamps (done ages out after ~2h). |
| `inventoryScope` | string | Optional canonical Hub membership: `dashboard` keeps Live + recent tasks visible; `history` keeps older tasks settled, including those with historical PR links. Omit for standalone/custom classification. |
| `parent` | string \| null | Id of the parent cottage. Kids render as sheds in the yard. |
| `task` | string | Short description of the current work. Separate from the original request. |
| `taskId` | string \| null | Explicit task/run identity. Changes reset the room and activity cache for that cottage. |
Expand Down Expand Up @@ -447,8 +448,18 @@ npx --yes http-server . -p 9999 --cors

### Shared Hub inventory mode

The Node process caches successful inventory reads for five seconds; it follows
all cursor pages before replacing a snapshot. Failed pages keep the previous
The Node process caches dashboard reads for five seconds and history for one
minute, using up to 500 tasks per page. It allows 45 seconds for a Hub request
to finish during database contention instead of abandoning and requeuing it.
It follows
all cursor pages for both `history` and `dashboard` before replacing a snapshot.
Dashboard membership controls the default map, matching the voice TUI's
Live + recent inventory across all towns. Remaining history is available through
the settled toggle. External children excluded by the Hub dashboard retain their
parent identities in history; historical PR links do not override this membership.
Background GitHub enrichment is reserved for dashboard tasks; history retains
the PR evidence supplied by the Hub without polling hundreds of old links.
Failed pages in either scope keep the previous
complete snapshot and mark the feed stale. `id`, `parent`, and `taskId` retain
Hub canonical task identities, including observed external sessions. Their
`sessionId` stays separate. Town labels still derive from recorded project
Expand Down
57 changes: 55 additions & 2 deletions scripts/browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async function until(code,message,timeout=7000){
}
const state=()=>evaluate('window.cottageObservatory.state');
async function key(key,ms=60){
return evaluate(`(async()=>{const c=document.getElementById(window.cottageObservatory.mode==='room'?'room-canvas':'town');c.focus();c.dispatchEvent(new KeyboardEvent('keydown',{key:${JSON.stringify(key)},bubbles:true}));await new Promise(r=>setTimeout(r,${ms}));window.dispatchEvent(new KeyboardEvent('keyup',{key:${JSON.stringify(key)},bubbles:true}));return window.cottageObservatory.state;})()`);
return evaluate(`(async()=>{const c=document.getElementById(window.cottageObservatory.mode==='room'?'room-canvas':'town');c.focus();c.dispatchEvent(new KeyboardEvent('keydown',{key:${JSON.stringify(key)},bubbles:true}));if(${ms}>0)await new Promise(r=>setTimeout(r,${ms}));window.dispatchEvent(new KeyboardEvent('keyup',{key:${JSON.stringify(key)},bubbles:true}));return window.cottageObservatory.state;})()`);
}
function pass(label){process.stdout.write('PASS '+label+'\n');}
try{
Expand All @@ -67,6 +67,8 @@ try{
assert.equal(await evaluate('document.activeElement.classList.contains(\'journal\')'),true);
await click('[data-action="older"]');
await until('document.querySelectorAll(\'.journal-event\').length===131','Older entries did not load');
await click('[data-action="latest"]');
await until('!window.cottageObservatory.state.activity.browsingHistory','Journal did not return to live entries');
pass('walk indoors, inspect request, stream and paginate journal without scroll jumps');
await click('[data-action="tab:review"]');
assert.match(await evaluate('document.querySelector(\'.pr-snapshot\').textContent'),/Ready to merge/);
Expand Down Expand Up @@ -96,7 +98,9 @@ try{
for(const target of path){let p=window.cottageObservatory.state.roomPlayer;const horizontal=Math.abs(target.x-p.x)>Math.abs(target.y-p.y),axis=horizontal?'x':'y',sign=Math.sign(target[axis]-p[axis]);if(Math.abs(target[axis]-p[axis])<1)continue;const key=horizontal?(sign>0?'ArrowRight':'ArrowLeft'):(sign>0?'ArrowDown':'ArrowUp');canvas.dispatchEvent(new KeyboardEvent('keydown',{key,bubbles:true}));const end=performance.now()+800;while(sign*(target[axis]-window.cottageObservatory.state.roomPlayer[axis])>0&&performance.now()<end)await new Promise(requestAnimationFrame);window.dispatchEvent(new KeyboardEvent('keyup',{key,bubbles:true}));}
if(Math.hypot(window.cottageObservatory.state.roomPlayer.x-room.resident.x,window.cottageObservatory.state.roomPlayer.y-room.resident.y)>=30)throw new Error('Did not reach host');
})()`);
await key('e');assert.equal((await state()).tab,'talk');assert.equal((await state()).talking,true);assert.equal((await state()).sound,false);
// Check the short murmur animation in the keypress response, before bridge
// round trips can outlast its 1.5-second lifetime.
const talking=await key('e',0);assert.equal(talking.tab,'talk');assert.equal(talking.talking,true);assert.equal(talking.sound,false);
await browser('snapshot');
await browser('fill',['--fields',JSON.stringify({'#agent-message':'Please keep the cottage furniture stable.'})]);
await evaluate('const input=document.getElementById(\'agent-message\');input.focus();input.setSelectionRange(7,11);');
Expand Down Expand Up @@ -226,6 +230,55 @@ try{
await until('window.cottageObservatory.state.replayIndex>'+reducedReplay,'Reduced motion incorrectly disabled replay');
await key('Escape');
pass('emulated reduced-motion preference preserves walking and recorded replay');
const historical=Array.from({length:500},(_,i)=>({id:'archived-'+i,name:'Old task '+i,town:'HubTown',status:'offline',occupancy:'settled',inventoryScope:'history',worktreePath:i===0?'/fixture/shared':'/fixture/history/'+i,pr:{...pr,number:i+1000,url:'https://github.com/example/history/pull/'+(i+1000),labels:['babysit:blocked']}}));
agents=[...historical,
{id:'current',name:'Current work',town:'HubTown',status:'working',occupancy:'live',inventoryScope:'dashboard',worktreePath:'/fixture/shared',task:'Current task',pr},
{id:'recent',name:'Recent work',town:'AppTown',status:'offline',occupancy:'recent',inventoryScope:'dashboard',task:'Recent task',pr:{state:'none'}},
];
handoffs=[];
await browser('goto',['--url',url]);
await until('window.cottageObservatory?.state.selected===\'current\'','Hub history stole the initial selection');
assert.equal(await evaluate('window.cottageState().plots.length'),2);
assert.equal(await evaluate('document.querySelectorAll(\'#cottage-list button\').length'),2);
assert.equal(await evaluate('!!document.querySelector(\'[data-pr="blocked"]\')'),false);
assert.equal(await evaluate('document.querySelector(\'[data-pr="ready"] span\').textContent'),'1');
const compact=await evaluate('({height:window.cottageState().height,plot:window.cottageState().plots.find(p=>p.id===\'current\')})');
assert.ok(compact.height<1000);assert.deepEqual(compact.plot.roommates,[]);
await click('#settled');
await until('document.querySelectorAll(\'#cottage-list button\').length===502','Settled toggle did not expose canonical history');
assert.deepEqual(await evaluate('window.cottageState().plots.find(p=>p.id===\'current\').roommates'),['archived-0'],'The current task remains the shared-worktree host while history is visible');
assert.equal(await evaluate('document.querySelector(\'[data-pr="blocked"] span\').textContent'),'500');
await click('[data-pr="blocked"]');
await click('#settled');
await until('document.querySelectorAll(\'#cottage-list button\').length===2','Settled history did not hide again');
assert.equal(await evaluate('window.cottageState().height'),compact.height);
assert.deepEqual(await evaluate('window.cottageState().plots.find(p=>p.id===\'current\')'),compact.plot);
assert.equal(await evaluate('!!document.querySelector(\'[data-pr="blocked"]\')'),false);
assert.equal((await state()).prFilter,null,'A hidden history-only PR filter cannot strand the village');
await click('#settled');
const historicalPlot=await evaluate('window.cottageState().plots.find(p=>p.id===\'archived-400\')');
agents=[...agents.slice(0,500).reverse(),...agents.slice(500)];
await until('window.cottageState().agents[0].id===\'archived-499\'','Reordered history did not refresh');
assert.deepEqual(await evaluate('window.cottageState().plots.find(p=>p.id===\'archived-400\')'),historicalPlot,'Ordinary history refreshes preserve existing homes');
agents.push({...agents.at(-2),id:'arrival',name:'New arrival',worktreePath:'/fixture/new'});
await until('window.cottageState().plots.some(p=>p.id===\'arrival\')','New dashboard arrival did not get a plot while history was visible');
const expandedArrival=await evaluate('window.cottageState().plots.find(p=>p.id===\'arrival\')');
assert.equal(await evaluate('new Set(window.cottageState().plots.map(p=>p.x+\",\"+p.y)).size===window.cottageState().plots.length'),true,'Live arrivals never overlap historical cottages');
await click('#settled');
await until('window.cottageState().plots.length===3','New dashboard arrival did not get a plot');
assert.deepEqual(await evaluate('window.cottageState().plots.find(p=>p.id===\'arrival\')'),expandedArrival,'A live cottage arriving while history is visible keeps its canonical position');
assert.equal(await evaluate('window.cottageState().height'),compact.height,'Historical plots must not push new live cottages into distant annexes');
await click('#settled');
await click('.cottage-directory summary');
await click('[data-cottage="archived-400"]');
await click('[data-action="enter"]');
await click('#settled');
await key('Escape');
assert.equal(await evaluate('window.cottageObservatory.state.player.y<window.cottageState().height'),true,'Leaving a hidden historical cottage must return Jack inside the compact village');
agents.push(...Array.from({length:6},(_,i)=>({...agents.find(a=>a.id==='current'),id:'later-'+i,worktreePath:'/fixture/later/'+i})));
await until('window.cottageState().plots.length===9','Later dashboard arrivals did not get plots after the historical visit');
assert.equal(await evaluate('window.cottageState().height'),compact.height,'Visiting history must not consume the eight live HubTown slots');
pass('canonical live inventory keeps history out of plots, selection and PR counts; settled toggle restores stable homes');
await browser('screenshot');
process.stdout.write('Browser journey passed. Fixture: '+url+'\n');
}finally{server.close();}
20 changes: 19 additions & 1 deletion src/feed-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,27 @@ export function normalizeCottage(a,i,{town,model,occupancy,now=Date.now()}){
activity:String(a.activity||''),lastLine:String(a.lastLine||''),result:String(a.result||''),
attention:String(a.attention||''),model:model(a.model),tokens:Number(a.tokens)||0,cost:Number(a.cost)||0,
dispatchedBy:String(a.dispatchedBy||a.parent||'unknown'),pr};
result.occupancy=hasOutstandingPr(result,now)?'live':(a.occupancy||occupancy(result,now));
// The Hub owns dashboard membership. A historical PR link is still useful
// at the desk, but it cannot bring an archived task back into the village.
result.occupancy=a.inventoryScope==='history'?'settled':
a.inventoryScope==='dashboard'?(a.occupancy||(['working','blocked','idle'].includes(status)?'live':'recent')):
hasOutstandingPr(result,now)?'live':(a.occupancy||occupancy(result,now));
return result;
}
export function isCottageVisible(agent,{showSettled=false,interiorId=null,now=Date.now()}={}){
return showSettled||interiorId===agent.id||
(agent.inventoryScope!=='history'&&(agent.occupancy!=='settled'||hasOutstandingPr(agent,now)));
}
export function layoutCottages(agents,options={}){
// Standalone feeds retain their stable household anchors. Canonical history
// gets plots only when requested, so it cannot crowd out the live village.
return agents.filter(agent=>agent.inventoryScope!=='history'||isCottageVisible(agent,options));
}
export function feedStatusNote(agents,{source='',stale=false}={}){
const visible=agents.filter(agent=>isCottageVisible(agent)).length;
const sourceName=({hub:'AutoHub',claude:'Claude sessions','hub+claude':'AutoHub + Claude sessions',codex:'Codex tasks',demo:'Demo village',none:'Local feed'})[source]||source||'Custom feed';
return `${stale?'Stale snapshot · ':''}${sourceName} · ${visible} live + recent · ${agents.length-visible} settled`;
}
export function activityAddress(agent,endpoint){
if(!agent.activityUrl)return null;
try{const url=new URL(agent.activityUrl,endpoint);return ['http:','https:'].includes(url.protocol)?url:null;}catch{return null;}
Expand Down
13 changes: 10 additions & 3 deletions src/feed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,9 @@ export function createFeed({
});
for (const agent of claude.agents) if (!suppressedLocalIds.has(agent.id)) combined.push({ ...agent });
const seen = new Set(combined.map(agent => agent.id));
// A live PR outlives a transcript window or DB scan window.
for (const old of cache) if (!seen.has(old.id) && hasOutstandingPr(old, now())) combined.push({ ...old, status: "offline" });
// A live PR outlives standalone scan windows. Shared Hub membership is
// authoritative, including removals when a task is archived upstream.
for (const old of cache) if (!hubInventory.configured && !seen.has(old.id) && hasOutstandingPr(old, now())) combined.push({ ...old, status: "offline" });
for (const agent of combined) {
const stored = remoteTodos.get(`${agent.id}\n${agent.taskId || ""}\n${agent.sessionId || ""}`);
const supplied = normalizeTodos(agent.todos);
Expand All @@ -365,7 +366,13 @@ export function createFeed({
if (agent.inputRequest && nextErrors.length) agent.inputRequest = { ...agent.inputRequest, stale: true };
}
let enriched = combined;
try { enriched = await enrich(await resolveRepos(combined)); }
try {
// Historical inventories can contain hundreds of old PRs. Keep their
// supplied evidence without spending live GitHub polling on hidden work.
const current = hubInventory.configured ? combined.filter(agent => agent.inventoryScope !== "history") : combined;
const observations = new Map((await enrich(await resolveRepos(current))).map(agent => [agent.id, agent]));
enriched = combined.map(agent => observations.get(agent.id) || agent);
}
catch { nextErrors.push("PR metadata is temporarily unavailable"); }
cache = stampOccupancy(sortCottages(enriched), now());
// Retain activity for retained PR cottages; discard unrelated old sessions.
Expand Down
Loading
Loading