diff --git a/AGENTS.md b/AGENTS.md
index a2a8afa..6570ab1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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;
diff --git a/docs/COTTAGE_FEED.md b/docs/COTTAGE_FEED.md
index c816bb0..154764d 100644
--- a/docs/COTTAGE_FEED.md
+++ b/docs/COTTAGE_FEED.md
@@ -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
@@ -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.
diff --git a/src/feed-client.mjs b/src/feed-client.mjs
index c9dd7a5..841fc60 100644
--- a/src/feed-client.mjs
+++ b/src/feed-client.mjs
@@ -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;
diff --git a/src/feed.mjs b/src/feed.mjs
index 1bac076..421a387 100644
--- a/src/feed.mjs
+++ b/src/feed.mjs
@@ -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) || []]));
@@ -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();
},
diff --git a/src/hub-inventory.mjs b/src/hub-inventory.mjs
index b5b1de5..d85b925 100644
--- a/src/hub-inventory.mjs
+++ b/src/hub-inventory.mjs
@@ -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 : {};
@@ -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';
diff --git a/src/hub.mjs b/src/hub.mjs
index 5b0590a..2e7ff41 100644
--- a/src/hub.mjs
+++ b/src/hub.mjs
@@ -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" ||
@@ -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";
diff --git a/src/messages.mjs b/src/messages.mjs
index 0774ccb..a75e924 100644
--- a/src/messages.mjs
+++ b/src/messages.mjs
@@ -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.');
diff --git a/src/observatory.mjs b/src/observatory.mjs
index f949786..59e2a25 100644
--- a/src/observatory.mjs
+++ b/src/observatory.mjs
@@ -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)?''+esc(text)+'':'';
+export function worktreeActionsHtml(agent) {
+ const path=agent.worktreePath;
+ if(!path)return '';
+ const open=agent.cleanup?.status!=='removed'&&path.startsWith('/')?'Open worktree':'';
+ return '
'+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')+'