diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md
index 7c9a6d0..404fbd0 100644
--- a/docs/OBSERVABILITY.md
+++ b/docs/OBSERVABILITY.md
@@ -225,6 +225,27 @@ secondary tab row directly beneath the main Observability tab; the Left/Right, H
also move between these two views when the tab row has focus. Their canonical hashes are
`#observability/live` and `#observability/history`.
+### History loading, pagination, and coverage
+
+History is loaded newest-first in bounded pages. The browser requests an initial page of up to
+100 sessions, automatically asks for the next opaque continuation token as the list approaches
+its sentinel, and keeps a visible **Load older sessions** fallback when automatic loading is not
+available. The project browser uses the server's full project metadata, so a page containing only
+the newest sessions does not make a project's total look smaller than it is.
+
+The server keeps the history scan separate from the live tailer. It discovers eligible Claude and
+Codex transcript files by file modification time, materializes a stable short-lived snapshot, and
+then pages that snapshot. Each paginated response reports `pagination.total`, `hasMore`, and an
+opaque `nextPageToken`; `coverage` reports the per-host candidate/returned file counts, the file
+limit, the scan time, and whether discovery was complete. An incomplete scan is disclosed in the
+History view rather than presented as an authoritative empty or complete result.
+
+This was added compatibly. `GET /api/live/history` without `limit`, `pageToken`, or `projectKey`
+continues to return the pre-pagination snapshot shape. Clients that understand pagination opt in
+with those query parameters and receive the same snapshot fields plus additive `pagination` and
+`coverage` fields. Continuation tokens are short-lived and scoped to their project/window snapshot;
+an expired or malformed token returns `400` so a client can restart from the first page.
+
### Live and Review playback
An active session opens in **Live** mode and follows new topology and transcript
@@ -394,6 +415,7 @@ unbounded content snapshot.
| Symptom | Explanation |
|---------|-------------|
| No sessions | No supported metadata was found within the bounded newest-first discovery set |
+| History says the scan is incomplete | The per-host discovery bound was reached; older files may not be represented, so widen the configured source or rerun after reducing the corpus |
| Live shows 0 projects | No retained root currently satisfies the Live predicate; switch to History for past sessions |
| History shows 0 projects | No retained non-live root exists; current work, if any, remains in Live |
| Green or moving content appears in History | This violates the Observability contract; refresh, then report it as a presentation defect if it remains |
diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs
index 982f7f4..be49050 100644
--- a/src/lib/dashboard-server.mjs
+++ b/src/lib/dashboard-server.mjs
@@ -961,9 +961,20 @@ export function startDashboard({
return;
}
const sinceMs = windowToSinceMs(query.get('window'));
- sendJson(res, 200, publicLivePayload(await service.historySnapshot({ sinceMs })));
- } catch {
- sendJson(res, 503, { error: 'live telemetry unavailable' });
+ const pageRequested = query.has('limit') || query.has('pageToken') || query.has('projectKey');
+ const payload = pageRequested && typeof service.historyPage === 'function'
+ ? await service.historyPage({
+ sinceMs,
+ projectKey: query.get('projectKey') || null,
+ pageToken: query.get('pageToken') || null,
+ limit: clampInt(query.get('limit'), 100, 1, 250),
+ })
+ : await service.historySnapshot({ sinceMs });
+ sendJson(res, 200, publicLivePayload(payload));
+ } catch (error) {
+ sendJson(res, error?.code === 'INVALID_HISTORY_PAGE_TOKEN' ? 400 : 503,
+ { error: error?.code === 'INVALID_HISTORY_PAGE_TOKEN'
+ ? 'invalid history page token' : 'live telemetry unavailable' });
} finally {
scheduleLiveIdle();
}
diff --git a/src/lib/dashboard/live/client.mjs b/src/lib/dashboard/live/client.mjs
index 0aed186..f6ce9b9 100644
--- a/src/lib/dashboard/live/client.mjs
+++ b/src/lib/dashboard/live/client.mjs
@@ -20,7 +20,7 @@ export const LIVE_JS = `
function dashSseUrl(u){return DASH_TOKEN?u+(u.indexOf("?")<0?"?":"&")+"token="+encodeURIComponent(DASH_TOKEN):u;}
var PAUSE_LIMIT=256,MAX_TURNS=500,TRANSCRIPT_COLLAPSE_KEY="ak-dash-transcript-collapsed",TERMINAL={completed:1,failed:1,cancelled:1};
function storedTranscriptCollapsed(){try{return localStorage.getItem(TRANSCRIPT_COLLAPSE_KEY)==="true";}catch(_){return false;}}
- var state={snapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},events:[],project:null,selected:null,node:null,browserLevel:"projects",scope:"live",historyWindow:"14d",historySnapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},historyLoading:false,historyError:false,paused:false,pending:[],overflow:false,source:null,resyncing:false,lastAt:0,active:false,connection:{key:"connecting",text:"Connecting to local session telemetry…"},positions:{},pinned:{},seen:{},camera:{x:24,y:24,k:1},cameraEpoch:0,fitFor:null,pointer:null,transcriptCollapsed:storedTranscriptCollapsed(),playback:{mode:"live",events:[],items:[],index:0,playing:false,speed:1,timer:null,startAt:null,endAt:null,truncated:false,gap:false},transcript:{source:null,turns:[],seen:{},query:"",follow:true,unread:0,status:"idle",session:null}};
+ var state={snapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},events:[],project:null,selected:null,node:null,browserLevel:"projects",scope:"live",historyWindow:"14d",historySnapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},historyLoading:false,historyLoadingMore:false,historyError:false,historyPagination:null,historyCoverage:null,historyRequest:0,historyProjectKey:null,historyObserver:null,paused:false,pending:[],overflow:false,source:null,resyncing:false,lastAt:0,active:false,connection:{key:"connecting",text:"Connecting to local session telemetry…"},positions:{},pinned:{},seen:{},camera:{x:24,y:24,k:1},cameraEpoch:0,fitFor:null,pointer:null,transcriptCollapsed:storedTranscriptCollapsed(),playback:{mode:"live",events:[],items:[],index:0,playing:false,speed:1,timer:null,startAt:null,endAt:null,truncated:false,gap:false},transcript:{source:null,turns:[],seen:{},query:"",follow:true,unread:0,status:"idle",session:null}};
function el(id){return document.getElementById(id);}
function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});}
function short(s,n){s=String(s||"");return s.length>n?s.slice(0,n-1)+"…":s;}
@@ -51,7 +51,9 @@ export const LIVE_JS = `
// buckets (not one shared state.snapshot) so a live delta arriving while
// History is on screen can never silently overwrite the historical view.
function currentSnapshot(){return state.scope==="history"?state.historySnapshot:state.snapshot;}
- function renderConnection(){var history=state.scope==="history",c=history?(state.historyLoading?{key:"connecting",text:"Loading "+state.historyWindow+" of history…"}:state.historyError?{key:"offline",text:"History unavailable · try again"}:{key:"history",text:"History · "+state.historyWindow+" · retained local sessions"}):state.connection;el("live-state-dot").dataset.state=c.key;el("live-state-text").textContent=c.text;el("live-browser-state-dot").dataset.state=c.key;el("live-browser-state").textContent=c.text;}
+ function historyLoadedCount(){return currentSnapshot().sessions.filter(function(s){return isNavigationRoot(s)&&!isLiveSession(s);}).length;}
+ function historyTotal(){var p=state.project&&projectCatalog().find(function(item){return item.id===state.project;});return p&&Number.isFinite(Number(p.historicalCount))?Number(p.historicalCount):state.historyPagination&&state.historyPagination.total;}
+ function renderConnection(){var history=state.scope==="history",p=state.historyPagination,c=history?(state.historyLoading?{key:"connecting",text:"Loading "+state.historyWindow+" of history…"}:state.historyLoadingMore?{key:"connecting",text:"Loading older sessions…"}:state.historyError?{key:"offline",text:"History unavailable · try again"}:{key:"history",text:"History · "+state.historyWindow+" · "+historyLoadedCount()+""+(historyTotal()!=null?" of "+historyTotal():"")+" retained sessions"}):state.connection;el("live-state-dot").dataset.state=c.key;el("live-state-text").textContent=c.text;el("live-browser-state-dot").dataset.state=c.key;el("live-browser-state").textContent=c.text;}
function setConnection(k,t){state.connection={key:k,text:t};renderConnection();}
function sessionKey(s){return String(s&&s.key||(hostOf(s)+":"+(s&&s.id||"")));}
function presenceState(s){return s&&s.presence&&s.presence.state||"unknown";}
@@ -87,7 +89,10 @@ export const LIVE_JS = `
// receiveSnapshot()/state.snapshot: that bucket is fed by the live SSE
// stream (still connected in the background so switching back to Live is
// instant), and a delta arriving mid-browse must never clobber this view.
- function fetchHistory(){state.historyLoading=true;state.historyError=false;render();fetch("/api/live/history?window="+encodeURIComponent(state.historyWindow),{cache:"no-store",headers:{"x-dash-token":DASH_TOKEN}}).then(function(r){if(!r.ok)throw Error();return r.json();}).then(function(d){state.historyLoading=false;state.historySnapshot=sessionsOf(d);state.lastAt=Date.now();state.events=[];state.positions={};state.seen={};state.node=null;state.fitFor=null;var projects=projectCatalog();if(!projects.some(function(p){return p.id===state.project;}))state.project=projects[0]&&projects[0].id||null;var list=visibleSessions();selectSession(list[0]&&sessionKey(list[0]),true);render();}).catch(function(){state.historyLoading=false;state.historyError=true;render();});}
+ function historyUrl(projectKey,pageToken){var q="/api/live/history?window="+encodeURIComponent(state.historyWindow)+"&limit=100";if(projectKey)q+="&projectKey="+encodeURIComponent(projectKey);if(pageToken)q+="&pageToken="+encodeURIComponent(pageToken);return q;}
+ function mergeHistoryPage(d,append){var incoming=sessionsOf(d);if(!append){state.historySnapshot=incoming;return;}var by={};state.historySnapshot.sessions.concat(incoming.sessions).forEach(function(s){by[sessionKey(s)]=s;});state.historySnapshot=Object.assign({},incoming,{sessions:Object.keys(by).map(function(k){return by[k];})});}
+ function fetchHistory(options){options=options||{};var append=!!options.append,projectKey=options.projectKey===undefined?state.historyProjectKey:options.projectKey;if(state.historyLoading||state.historyLoadingMore)return;if(append&&(!state.historyPagination||!state.historyPagination.hasMore))return;var request=++state.historyRequest;state.historyProjectKey=projectKey||null;state.historyLoading=!append;state.historyLoadingMore=append;state.historyError=false;if(!append){state.historyPagination=null;state.historyCoverage=null;state.selected=null;state.node=null;}render();var token=append&&state.historyPagination&&state.historyPagination.nextPageToken,url=historyUrl(state.historyProjectKey,token);fetch(url,{cache:"no-store",headers:{"x-dash-token":DASH_TOKEN}}).then(function(r){if(!r.ok)throw Error();return r.json();}).then(function(d){if(request!==state.historyRequest)return;mergeHistoryPage(d,append);state.historyPagination=d.pagination||null;state.historyCoverage=d.coverage||null;state.historyLoading=false;state.historyLoadingMore=false;state.lastAt=Date.now();state.events=[];if(!append){state.positions={};state.seen={};state.fitFor=null;var projects=projectCatalog();if(!projects.some(function(p){return p.id===state.project;}))state.project=projects[0]&&projects[0].id||null;}var list=visibleSessions();if(!state.selected||!sessionById(state.selected))selectSession(list[0]&&sessionKey(list[0]),true);render();}).catch(function(){if(request!==state.historyRequest)return;state.historyLoading=false;state.historyLoadingMore=false;state.historyError=true;render();});}
+ function loadMoreHistory(){fetchHistory({append:true});}
function graphData(s){
var all=s&&s.nodes||[],edges=s&&s.edges||[],isTool={tool:1,skill:1,plugin:1,mcp:1},allTools=all.filter(function(n){return isTool[n.kind];}),nodes=all.filter(function(n){return!isTool[n.kind];}),owner={},history={};
edges.forEach(function(e){var t=allTools.find(function(n){return n.id===e.target;}),src=allTools.find(function(n){return n.id===e.source;});if(t)owner[t.id]=e.source;if(src)owner[src.id]=e.target;});
@@ -119,13 +124,16 @@ export const LIVE_JS = `
function showTooltip(target){if(!target||!target.dataset.node)return;var found=tooltipValue(target.dataset.node),value=found.value;if(!value)return;var view=displayEntityState(value,found.session),owner=found.data.owner[value.id],ownerNode=found.data.nodes.find(function(n){return n.id===owner;}),tip=el("live-tooltip"),r=target.getBoundingClientRect();tip.innerHTML=""+esc(label(value))+""+esc(kindName(value.kind)+" · "+view.label)+(ownerNode?" Owned by "+esc(label(ownerNode)):"")+" "+esc(hostName(value)+" · "+inferenceProviderName(value))+" Evidence: "+esc(value.confidence||"not reported");tip.hidden=false;var w=Math.min(300,window.innerWidth-24),left=Math.max(12,Math.min(window.innerWidth-w-12,r.right+10)),top=Math.max(12,Math.min(window.innerHeight-tip.offsetHeight-12,r.top));tip.style.width=w+"px";tip.style.left=left+"px";tip.style.top=top+"px";target.setAttribute("aria-describedby","live-tooltip");}
function hideTooltip(target){if(target&&document.activeElement===target)return;el("live-tooltip").hidden=true;if(target)target.removeAttribute("aria-describedby");}
function showRowTooltip(target){if(!target)return;var tip=el("live-tooltip"),r=target.getBoundingClientRect(),heading=target.querySelector(".live-session-main span,.live-project span"),description=target.getAttribute("aria-label")||target.title||target.textContent;tip.innerHTML=""+esc(heading&&heading.textContent||"Session")+""+esc(description);tip.hidden=false;var w=Math.min(300,window.innerWidth-24),left=Math.max(12,Math.min(window.innerWidth-w-12,r.right+10)),top=Math.max(12,Math.min(window.innerHeight-tip.offsetHeight-12,r.top));tip.style.width=w+"px";tip.style.left=left+"px";tip.style.top=top+"px";target.setAttribute("aria-describedby","live-tooltip");}
- function projectCatalog(){var snap=currentSnapshot(),given=Array.isArray(snap.projects)?snap.projects:[],by={};snap.sessions.forEach(function(s){if(!s.project||s.project==="unknown")return;var id=String(s.projectKey||projectName(s)),p=by[id]||(by[id]={id:id,name:projectName(s),sessionCount:0,liveCount:0,historicalCount:0,liveChildCount:0,historicalChildCount:0,updatedAt:s.updatedAt}),live=isLiveSession(s);if(isNavigationRoot(s)){p.sessionCount++;if(live)p.liveCount++;else p.historicalCount++;}else if(live)p.liveChildCount++;else p.historicalChildCount++;if(Date.parse(s.updatedAt||0)>Date.parse(p.updatedAt||0))p.updatedAt=s.updatedAt;});given.forEach(function(p){var name=p.label||p.name||p.project;if(!name||name==="unknown")return;var id=String(p.id||p.key||p.projectKey||name),derived=by[id]||{};if(Object.keys(derived).length)by[id]=Object.assign({},p,derived,{id:id,name:name||derived.name});});return Object.keys(by).map(function(k){return by[k];}).sort(function(a,b){return Number(b.liveCount||0)-Number(a.liveCount||0)||Date.parse(b.updatedAt||0)-Date.parse(a.updatedAt||0)||a.name.localeCompare(b.name);});}
+ function projectCatalog(){var snap=currentSnapshot(),given=Array.isArray(snap.projects)?snap.projects:[],by={};snap.sessions.forEach(function(s){if(!s.project||s.project==="unknown")return;var id=String(s.projectKey||projectName(s)),p=by[id]||(by[id]={id:id,name:projectName(s),sessionCount:0,liveCount:0,historicalCount:0,liveChildCount:0,historicalChildCount:0,updatedAt:s.updatedAt}),live=isLiveSession(s);if(isNavigationRoot(s)){p.sessionCount++;if(live)p.liveCount++;else p.historicalCount++;}else if(live)p.liveChildCount++;else p.historicalChildCount++;if(Date.parse(s.updatedAt||0)>Date.parse(p.updatedAt||0))p.updatedAt=s.updatedAt;});given.forEach(function(p){var name=p.label||p.name||p.project;if(!name||name==="unknown")return;var id=String(p.id||p.key||p.projectKey||name),derived=by[id],base=Object.assign({},p,{id:id,name:name});if(state.scope==="history"){var count=Number(p.historicalCount!=null?p.historicalCount:(p.sessionCount!=null?p.sessionCount:(derived&&derived.sessionCount)||0)),children=Number(p.historicalChildCount!=null?p.historicalChildCount:(p.childSessionCount!=null?p.childSessionCount:(derived&&derived.historicalChildCount)||0));by[id]=Object.assign({},derived||{},base,{id:id,name:name,historicalCount:count,historicalChildCount:children,sessionCount:count,liveCount:0,liveChildCount:0});}else if(derived)by[id]=Object.assign({},base,derived,{id:id,name:name});});return Object.keys(by).map(function(k){return by[k];}).sort(function(a,b){return Number(b.liveCount||0)-Number(a.liveCount||0)||Date.parse(b.updatedAt||0)-Date.parse(a.updatedAt||0)||a.name.localeCompare(b.name);});}
function duration(s){var start=s.startedAt||s.createdAt,end=s.endedAt||s.completedAt||((s.status==="completed"||s.status==="failed"||s.status==="cancelled")&&s.updatedAt),a=Date.parse(start||""),b=Date.parse(end||""),ms=b-a;if(!Number.isFinite(a)||!Number.isFinite(b)||ms<=0)return ago(s.updatedAt)||"activity time unavailable";var m=Math.max(1,Math.round(ms/60000));return m<60?m+"m":Math.floor(m/60)+"h "+m%60+"m";}
function sessionMarkup(s,child){var live=state.scope==="live"&&isLiveSession(s),key=sessionKey(s),view=displaySessionState(s),identity=hostName(s)+" · "+inferenceProviderName(s),name=s.title||s.summary||(child?"Worker thread":hostName(s)+" session"),description=name+". "+view.label+". "+duration(s)+". "+workspaceDescription(s);return'";}
function workerMarkup(s,n){var view=displayEntityState(n,s),worker=n&&n.host?n:s,description=(label(n)||"Worker")+". Worker view. "+view.label+". "+workspaceDescription(s);return'";}
function renderSessions(){var allProjects=projectCatalog(),projects=allProjects.filter(function(p){return Number(state.scope==="live"?p.liveCount:p.historicalCount)>0;}),list=visibleSessions(),current=projects.find(function(p){return p.id===state.project;}),selected=sessionById(state.selected),browser=el("live-browser"),scopedRoots=currentSnapshot().sessions.filter(function(s){return isNavigationRoot(s)&&(state.scope==="live"?isLiveSession(s):!isLiveSession(s));});if(!current){state.project=projects[0]&&projects[0].id||null;current=projects[0];list=visibleSessions();}browser.dataset.level=state.browserLevel;el("live-view-summary").textContent=state.scope==="live"?(scopedRoots.length?scopedRoots.length+" active session"+(scopedRoots.length===1?"":"s")+" across "+projects.length+" project"+(projects.length===1?"":"s"):"No live sessions across 0 projects"):(scopedRoots.length+" historical session"+(scopedRoots.length===1?"":"s")+" across "+projects.length+" project"+(projects.length===1?"":"s"));el("live-browser-kicker").textContent=state.browserLevel==="sessions"?"PROJECT":state.scope==="live"?"LIVE WORKSPACES":"HISTORY";el("live-browser-title").textContent=state.browserLevel==="sessions"&¤t?short(current.name,26):"Projects";el("live-project-count").textContent=String(projects.length);el("live-project-list").innerHTML=projects.map(function(p){var sessions=Number(state.scope==="live"?p.liveCount:p.historicalCount),workers=Number(state.scope==="live"?p.liveChildCount:p.historicalChildCount),description=p.name+". "+sessions+" "+(state.scope==="live"?"live":"historical")+" sessions"+(workers?" and "+workers+" workers":"")+". "+(ago(p.updatedAt)||"Activity time unavailable");return'";}).join("")||'
'+(state.scope==="live"?"No projects have a live session.":"No retained project history found.")+"
No '+(state.scope==="live"?"live":"historical")+' sessions for this project.
';el("live-session-context-identity").textContent=selected?hostName(selected)+" · "+inferenceProviderName(selected):"—";el("live-session-context-project").textContent=selected?projectName(selected):"Choose a session";var selectedState=displaySessionState(selected);el("live-session-context-status").textContent=selected?selectedState.label+" · "+duration(selected):"Waiting for local evidence";}
+ function historyMoreMarkup(){var p=state.historyPagination,c=state.historyCoverage;if(state.scope!=="history"||!p)return"";if(state.historyLoadingMore)return'
Loading older sessions…
';if(state.historyError)return'
Could not load older sessions.
';if(p.hasMore)return'
More retained sessions available.
';if(c&&!c.complete)return'
History scan incomplete; some retained files were not included.
';return'
All retained sessions loaded.
';}
+ function observeHistorySentinel(){if(state.historyObserver){state.historyObserver.disconnect();state.historyObserver=null;}if(state.scope!=="history"||!state.historyPagination||!state.historyPagination.hasMore||typeof IntersectionObserver!=="function")return;var root=el("live-session-list"),target=el("live-history-more");if(!root||!target)return;state.historyObserver=new IntersectionObserver(function(entries){if(entries.some(function(entry){return entry.isIntersecting;}))loadMoreHistory();},{root:root,rootMargin:"180px"});state.historyObserver.observe(target);}
+ function renderHistorySessions(){var allProjects=projectCatalog(),projects=allProjects.filter(function(p){return Number(p.historicalCount||0)>0;}),list=visibleSessions(),current=projects.find(function(p){return p.id===state.project;}),selected=sessionById(state.selected),browser=el("live-browser");if(!current){state.project=projects[0]&&projects[0].id||null;current=projects[0];list=visibleSessions();}var total=historyTotal(),exact=state.historyPagination&&state.historyPagination.totalExact,summary;if(!state.project)summary=projects.length+" project"+(projects.length===1?"":"s")+" with retained history";else summary=(list.length?"Showing ":"No ")+list.length+(total!=null?" of "+(exact?total:"at least "+total):"")+" historical session"+(list.length===1?"":"s")+" across "+projects.length+" project"+(projects.length===1?"":"s");if(state.historyCoverage&&!state.historyCoverage.complete)summary+=" · scan incomplete";browser.dataset.level=state.browserLevel;el("live-view-summary").textContent=summary;el("live-browser-kicker").textContent=state.browserLevel==="sessions"?"PROJECT":"HISTORY";el("live-browser-title").textContent=state.browserLevel==="sessions"&¤t?short(current.name,26):"Projects";el("live-project-count").textContent=String(projects.length);el("live-project-list").innerHTML=projects.map(function(p){var sessions=Number(p.historicalCount||0),workers=Number(p.historicalChildCount||0),hosts=p.hosts?Object.keys(p.hosts).sort().map(function(host){return hostName({host:host})+" "+p.hosts[host];}).join(" · "):"",description=p.name+". "+sessions+" historical sessions"+(workers?" and "+workers+" workers":"")+". "+(hosts?hosts+" · ":"")+(ago(p.updatedAt)||"Activity time unavailable");return'';}).join("")||'