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
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ and accepting that JSON shape over teaching callers about Autohub internals.
Keep task identity and task start separate from session identity and session
start. Missing data stays unavailable. Preserve genuine requests separately
from assistant updates; never export raw private thinking blocks. A missing PR
is unknown, not confirmed none. Shared PRs count once, and stale or conflicting
evidence cannot show ready. GitHub and transcript adapters remain read-only.
is unknown, not confirmed none. Shared PRs count once. For standalone sources,
stale or conflicting evidence cannot show ready. Hub-backed tasks use Hub as
the authority for readiness and head validation: preserve its canonical state
and freshness, including the last verified state during an outage. Raw labels
and client cache age must not override Hub status. GitHub and transcript
adapters remain read-only.
Task writes require an explicit user Send through a supported messaging route;
never send autonomous test messages to real agents. Recheck identity, status,
and transport before sending. Preserve receipt deduplication across restarts;
Expand Down
14 changes: 12 additions & 2 deletions docs/COTTAGE_FEED.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,8 @@ 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.
Both dashboard and history retain the PR evidence supplied by the Hub without
independent GitHub polling.
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
Expand All @@ -472,3 +472,13 @@ and capabilities; a null target is read-only. Replies also use Hub's optional
`expected_attention_version` guard when supplied by detail. The existing
receipt ledger and uncertain-delivery behavior remain in effect. No new cancel
or autonomous messaging behavior is added.

### Canonical Hub delivery lifecycle

When `AUTOHUB_HUB_BASE` is configured, Hub owns PR evidence and inventory membership. CottageCode does not independently query GitHub for those tasks. `pr` is the canonical Hub observation; an absent observation stays unknown. Standalone discovery retains its read-only GitHub adapter.

Hub cottages also expose `executionStatus`, `version`, `finalization` (`status`, `disposition`, `summary`, `blocker`) and `cleanup` (`status`, `reason`, `checkedAt`). `ready_for_merge` renders as idle with “Ready for merge”; `blocked` renders blocked. Only successful completed work renders done. Failed and cancelled runs render offline, with their precise task state retained.

Hub PR observations carry `authority: "hub"`: `reviewState` comes from canonical finalization, while raw `labels` remain evidence. The browser preserves that verified state during an outage and marks its evidence stale. It does not infer readiness from a stale ready label or overrule Hub's head-change blocker. Standalone observations retain conservative freshness-based classification.

Hub `durationMs` is the recorded execution time; review and merge waits do not inflate elapsed time. Hub also owns PR freshness, including immutable merged observations. Once cleanup is `removed`, the historical worktree path remains copyable but no longer offers an open action.
2 changes: 2 additions & 0 deletions src/feed-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export function mergeActivity(current,incoming){
}).sort((a,b)=>a.order-b.order).map(item=>item.event);
}
export function elapsedMs(agent,now=Date.now()){
// Delivery may finish long after execution, when its PR merges.
if(Number.isFinite(agent.durationMs)&&agent.durationMs>=0)return agent.durationMs;
const start=validTime(agent.taskStartedAt);
if(!start)return null;
const terminal=['done','offline'].includes(agent.status)||agent.terminal===true;
Expand Down
14 changes: 6 additions & 8 deletions src/feed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,12 @@ export function createFeed({
if (agent.inputRequest && nextErrors.length) agent.inputRequest = { ...agent.inputRequest, stale: true };
}
let enriched = 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);
// Hub reconciles its own PRs. Independent enrichment could replace a newer
// shared task state and make CottageCode disagree with the TUI again.
if (!hubInventory.configured) {
try { enriched = await enrich(await resolveRepos(combined)); }
catch { nextErrors.push("PR metadata is temporarily unavailable"); }
}
catch { nextErrors.push("PR metadata is temporarily unavailable"); }
cache = stampOccupancy(sortCottages(enriched), now());
// Retain activity for retained PR cottages; discard unrelated old sessions.
activity = new Map(cache.map(agent => [agent.id, nextActivity.get(agent.id) || activity.get(agent.id) || []]));
Expand Down Expand Up @@ -426,7 +424,7 @@ export function createFeed({
return scanning;
},
async flushEnrichment() {
if (typeof enrich.flush !== "function") return snapshot();
if (hubInventory.configured || typeof enrich.flush !== "function") return snapshot();
await enrich.flush();
return doScan();
},
Expand Down
20 changes: 19 additions & 1 deletion src/hub-inventory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {toCottage} from './hub.mjs';
import {timestampMs,safeActivityUrl} from './activity.mjs';
import {taskText} from './task-text.mjs';
import {classifyOccupancy} from './occupancy.mjs';
import {normalizePr} from './pr.mjs';

export function toInventoryCottage(task, now=Date.now()) {
const context=typeof task.context==='object' && task.context ? task.context : {};
Expand All @@ -19,13 +20,30 @@ export function toInventoryCottage(task, now=Date.now()) {
status:task.normalizedStatus==='completed_without_report'?'completed':task.normalizedStatus || task.status,
queued_at:task.queuedAt,started_at:task.startedAt,completed_at:task.completedAt,updated_at:task.updatedAt,
input_tokens:task.inputTokens,output_tokens:task.outputTokens,cache_write_tokens:task.cacheWriteTokens,cache_read_tokens:task.cacheReadTokens,total_cost:task.totalCost,
result_summary:task.resultSummary || task.displayResult,
result_summary:task.displayResult || task.resultSummary,
context:{...context,projectPath:task.projectPath || context.projectPath,workFolder:task.worktreePath || task.projectPath,
branch:task.branch,originalAsk:task.originalRequest,
...(prLink ? {pr:{url:prLink.url,state:'unknown',source:'context'}} : {})},
},now);
const request=taskText(task.originalRequest);
cottage.taskId=task.id;
// The shared inventory owns lifecycle evidence. Old local context and prose
// must never override a fresher reconciliation (or invent a missing PR).
const finalizationState=task.finalization?.status;
const blocker=taskText(typeof task.finalization?.blocker==='string' ? task.finalization.blocker : task.finalization?.blocker?.message);
cottage.pr=normalizePr(task.pr ? {...task.pr,authority:'hub',
reviewState:['ready','blocked'].includes(finalizationState) ? finalizationState : task.pr.reviewState,
reason:blocker || task.pr.reason,
} : (prLink ? {url:prLink.url,state:'unknown',source:'hub',authority:'hub'} : null),now);
cottage.durationMs=Number.isFinite(task.durationMs)&&task.durationMs>=0?task.durationMs:null;
cottage.executionStatus=task.normalizedStatus || task.status;
cottage.finalization=task.finalization ? {
status:task.finalization.status,disposition:task.finalization.disposition,
summary:taskText(task.finalization.summary),
blocker:taskText(typeof task.finalization.blocker==='string' ? task.finalization.blocker : task.finalization.blocker?.message),
} : null;
cottage.cleanup=task.cleanup ? {status:task.cleanup.status,reason:taskText(task.cleanup.reason),checkedAt:task.cleanup.checkedAt} : null;
cottage.version=task.version || 0;
cottage.originalAsk=request.slice(0,32768);
cottage.originalAskTruncated=request.length>32768;
cottage.originalAskSource=task.recordKind==='external_session'?'session':'task';
Expand Down
5 changes: 3 additions & 2 deletions src/hub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ export function mapHubStatus(row, now = Date.now()) {
const stale = Number.isFinite(updated) && now - updated > STALE_MS;

if (status === "running") return stale ? "offline" : "working";
if (status === "pending" || status === "queued") return "idle";
if (status === "pending" || status === "queued" || status === "ready_for_merge") return "idle";
if (status === "blocked") return "blocked";
if (
status === "awaiting_input" ||
status === "needs_input" ||
Expand All @@ -106,7 +107,7 @@ export function mapHubStatus(row, now = Date.now()) {
}
if (status === "failed" || status === "cancelled" || status === "interrupted") {
if (!attentionResolved(row) && (row.attention_type || row.attention_message)) return "blocked";
return aged(updated) ? "offline" : "done";
return "offline";
}
if (status === "stale") return "offline";
return "idle";
Expand Down
2 changes: 1 addition & 1 deletion src/messages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function createHubMessenger({
// redirect: wait until Hub offers the matching response route.
else if(shownInput)return unavailable('This task has a pending input request. Wait for AutoHub to expose its response route.');
else if(target.taskStatus==='running'&&(canonical ? target.capabilities?.canSteer===true : target.transport==='tmux'&&target.supportsRedirection===true))mode='redirect';
else return unavailable(['completed','failed','cancelled','interrupted'].includes(target.taskStatus)?'This task has finished. Start any follow-up in its original workflow.':target.transport==='direct'?'This direct session does not support mid-task messages.':'This task has no supported live message route.');
else return unavailable(['completed','failed','cancelled','interrupted','blocked','ready_for_merge'].includes(target.taskStatus)?'This task has finished. Start any follow-up in its original workflow.':target.transport==='direct'?'This direct session does not support mid-task messages.':'This task has no supported live message route.');
const resolution=agent.inputRequestResolution;
const newerThanUnidentifiedResolution=!resolution?.id&&shownInput?.updatedAt&&resolution?.resolvedAt&&shownInput.updatedAt>resolution.resolvedAt;
if(mode==='respond'&&resolution&&(!shownInput||(resolution.id?shownInput.id===resolution.id:!newerThanUnidentifiedResolution)))return unavailable('This input request was already resolved. Refresh before replying.');
Expand Down
22 changes: 19 additions & 3 deletions src/observatory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ export function inspectorPrCounts(agents,{showSettled=false,now=Date.now()}={}){
return prCounts(agents.filter(agent=>agent.inventoryScope!=='history'||isCottageVisible(agent,{showSettled,now})),now);
}
const link=(url,text)=>safeUrl(url)?'<a href="'+esc(safeUrl(url))+'" target="_blank" rel="noreferrer">'+esc(text)+'</a>':'';
export function worktreeActionsHtml(agent) {
const path=agent.worktreePath;
if(!path)return '';
const open=agent.cleanup?.status!=='removed'&&path.startsWith('/')?'<a href="cursor://file'+path.split('/').map(encodeURIComponent).join('/')+'">Open worktree</a>':'';
return '<div class="launch">'+button('copy','Copy worktree path')+open+'</div>';
}

export function lifecycleHtml(agent) {
const label={ready_for_merge:'Ready for merge',completed:'Complete',blocked:'Blocked',failed:'Failed',cancelled:'Cancelled',interrupted:'Interrupted'}[agent.executionStatus];
const f=agent.finalization,c=agent.cleanup;
if(!label&&!f&&!c)return '';
return '<dl class="task-lifecycle">'+(label?'<dt>Task</dt><dd>'+esc(label)+'</dd>':'')+
(f?.blocker?'<dt>Blocker</dt><dd>'+esc(f.blocker)+'</dd>':'')+
(c?'<dt>Cleanup</dt><dd>'+esc([c.status,c.reason].filter(Boolean).join(' · '))+'</dd>':'')+'</dl>';
}

export function prSnapshotHtml(value){
const pr=normalizePr(value),stage=pr.stage,s=STAGES[stage],ci=prCi(pr);
const labels=pr.labels.length?'<ul class="pr-labels" aria-label="GitHub labels">'+pr.labels.map(label=>'<li>'+esc(label)+'</li>').join('')+'</ul>':'<span class="pr-muted">No GitHub labels supplied.</span>';
Expand Down Expand Up @@ -496,20 +512,20 @@ export function createObservatory(api){
const presentation=activityJournalPresentation(cache);
content='<div class="section-head"><h3>At the workbench</h3>'+activityPagingButtons(cache)+'</div><p class="hint activity-'+esc(presentation.state)+'">'+esc(presentation.text)+'</p>'+(cache.warning?'<p class="hint">'+esc(cache.warning)+'</p>':'')+(cache.unavailable?'<p class="hint">This source has not made a task journal available.</p>':'<ol class="journal" tabindex="0" aria-label="Task activity journal">'+eventHtml(cache.events)+'</ol>');
}else if(tab==='review'){
content='<h3>Review desk</h3>'+prSnapshotHtml(pr)+'<p class="hint">The parcel opens the PR. Review and merge stay in your existing workflow.</p>';
content='<h3>Review desk</h3>'+lifecycleHtml(a)+prSnapshotHtml(pr)+'<p class="hint">The parcel opens the PR. Review and merge stay in your existing workflow.</p>';
}else if(tab==='artifacts'){
const arts=[...(Array.isArray(a.artifacts)?a.artifacts:[]),...cache.events.filter(e=>e.url).map(e=>({url:e.url,title:e.text}))];
if(pr.url)arts.unshift({url:pr.url,title:'PR #'+pr.number+' · '+(pr.title||s.label)});
const seen=new Set();const rows=arts.filter(e=>safeUrl(e.url)&&!seen.has(e.url)&&seen.add(e.url));
content='<h3>On the shelves</h3>'+(a.result?'<div class="request-paper">'+esc(a.result)+'</div>':'')+(rows.length?'<ul class="artifact-list">'+rows.map(e=>'<li>'+link(e.url,e.title||'Artifact')+'</li>').join('')+'</ul>':'<p class="hint">Artifacts appear here when the feed supplies them.</p>');
if(stage==='merged')content='<h3>A keepsake from this work</h3><p class="hint">Merged · '+esc(clock(pr.mergedAt))+'</p>'+content;
}else{
content='<h3>Today’s work</h3><p>'+esc(a.task&&a.task!=='-'?a.task:'Task description unavailable')+'</p><div class="current-activity">'+esc(latestLine(a)||'No current activity supplied.')+'</div>'+'<h3>Pull request</h3>'+prSnapshotHtml(pr)+'<dl><dt>Task started</dt><dd>'+esc(clock(a.taskStartedAt))+'</dd><dt>Elapsed</dt><dd>'+esc(elapsed(a))+'</dd><dt>Session started</dt><dd>'+esc(clock(a.sessionStartedAt))+'</dd><dt>Last signal</dt><dd>'+esc(clock(a.updatedAt))+'</dd><dt>Model</dt><dd>'+esc(a.model||'Unavailable')+'</dd><dt>Branch</dt><dd>'+esc(a.branch||'Unavailable')+'</dd></dl><h3>Pinned request</h3><p class="request-preview">'+esc(a.originalAsk?a.originalAsk.slice(0,230)+(a.originalAsk.length>230?'…':''):'Original request not supplied.')+'</p>'+button('tab:request','Read the pinned note');
content='<h3>Today’s work</h3><p>'+esc(a.task&&a.task!=='-'?a.task:'Task description unavailable')+'</p><div class="current-activity">'+esc(latestLine(a)||'No current activity supplied.')+'</div>'+lifecycleHtml(a)+'<h3>Pull request</h3>'+prSnapshotHtml(pr)+'<dl><dt>Task started</dt><dd>'+esc(clock(a.taskStartedAt))+'</dd><dt>Elapsed</dt><dd>'+esc(elapsed(a))+'</dd><dt>Session started</dt><dd>'+esc(clock(a.sessionStartedAt))+'</dd><dt>Last signal</dt><dd>'+esc(clock(a.updatedAt))+'</dd><dt>Model</dt><dd>'+esc(a.model||'Unavailable')+'</dd><dt>Branch</dt><dd>'+esc(a.branch||'Unavailable')+'</dd></dl><h3>Pinned request</h3><p class="request-preview">'+esc(a.originalAsk?a.originalAsk.slice(0,230)+(a.originalAsk.length>230?'…':''):'Original request not supplied.')+'</p>'+button('tab:request','Read the pinned note');
}
const nav=[['overview','Clock'],['request','Request'],['journal','Journal'],['todos','To-do'],['review','PR desk'],['artifacts','Shelves']].map(([key,label])=>button('tab:'+key,label,'aria-pressed="'+(tab===key)+'"')).join('');
const header='<div class="inspector-heading"><span class="eyebrow">'+esc(a.town)+' · '+(mode==='room'?'INSIDE':'COTTAGE')+'</span><h2>'+esc(a.name)+'</h2></div><div class="inspector-chips"><span class="status-chip">'+esc(a.status)+'</span><span class="pr-chip" style="--pr-color:'+s.color+'">'+s.symbol+' '+s.label+'</span></div>';
const actions='<div class="cottage-actions">'+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+button('postcard','Make a postcard')+'</div>';
const worktree=a.worktreePath?'<div class="launch">'+button('copy','Copy worktree path')+(a.worktreePath.startsWith('/')?'<a href="cursor://file'+a.worktreePath.split('/').map(encodeURIComponent).join('/')+'">Open worktree</a>':'')+'</div>':'';
const worktree=worktreeActionsHtml(a);
replacePanel(header+actions+'<nav class="room-tabs" aria-label="Cottage objects">'+nav+'</nav>'+content+worktree,(mode==='room'?interiorId:id)+'|'+(a.taskId||'')+':'+tab);
return true;
}
Expand Down
Loading
Loading