diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..574765f --- /dev/null +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 163b6de..879e49e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/COTTAGE_FEED.md b/docs/COTTAGE_FEED.md index 2ab0569..c816bb0 100644 --- a/docs/COTTAGE_FEED.md +++ b/docs/COTTAGE_FEED.md @@ -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. | @@ -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 diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index f161840..89cfc9e 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -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{ @@ -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/); @@ -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()=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);'); @@ -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