Skip to content

Add local Fleet web dashboard - #5

Merged
0xLeif merged 17 commits into
mainfrom
leif/fleet-web
Aug 1, 2026
Merged

Add local Fleet web dashboard#5
0xLeif merged 17 commits into
mainfrom
leif/fleet-web

Conversation

@0xLeif

@0xLeif 0xLeif commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add local-only, read-only Fleet dashboard.
  • Surface sanitized Let worktree, session, instruction, and skill metadata.
  • Add docs, contract, and focused coverage.

Test plan

  • bun test test/web.test.ts
  • Biome on changed files
  • bun run build
  • fledge spec check
  • Full lane: blocked by existing shared host-catalog ambiguity tests in this machine.

@0xLeif
0xLeif marked this pull request as ready for review August 1, 2026 18:04
@0xLeif
0xLeif merged commit 79f98d0 into main Aug 1, 2026
4 checks passed
@0xLeif
0xLeif deleted the leif/fleet-web branch August 1, 2026 18:05

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4413921eab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/web.ts
Comment on lines +240 to +243
const cwdForPid = (pid: string): string | null => {
const cwd = Bun.spawnSync({
cmd: ["lsof", "-a", "-p", pid, "-d", "cwd", "-Fn"],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve process CWD without requiring lsof

On Linux installations without lsof, including this review environment, the first recognized agent causes Bun.spawnSync to throw, the outer catch returns [], and Fleet reports no working agents. Use a platform-available fallback such as /proc/<pid>/cwd, or treat a missing lsof result per process instead of discarding the entire probe.

Useful? React with 👍 / 👎.

Comment thread src/web.ts
Comment on lines +658 to +660
const prior = agentsByName.get(agent.agent);
agentsByName.set(agent.agent, {
agent: agent.agent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve concurrent agents from the same provider

When two processes from the same provider run concurrently, such as two Codex agents in different worktrees, both iterations use the provider name as the map key, so the later process overwrites the earlier one. The rendered supervisor consumes fleet.agents, not workingNow, and therefore hides one of the active agents; key these records by a stable per-process or work-context identity instead.

Useful? React with 👍 / 👎.

Comment thread src/web.ts
Comment on lines +438 to +440
return fleetSessionDetail(
readFileSync(card.path, "utf8").slice(-48_000),
redactLocalDetail,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep indexed sessions metadata-only

When Fleet selects a session card, this directly reads its transcript and returns prompt/output text through /api/fleet, bypassing the catalog's metadata-only treatment of sessions. Keep Fleet on index-card metadata rather than loading session bodies, as required by the repository's progressive-disclosure contract.

AGENTS.md reference: AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

Comment thread src/run.ts
Comment on lines +239 to +243
return {
code: 0,
text: `Let Fleet is running at ${dashboard.url}\nLocal-only supervisor: redacted agent command, progress, and session detail; no controls.\n`,
keepAlive: true,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return an envelope for web --json

When an agent invokes let web --json or sets LET_JSON=1, this branch ignores the computed JSON mode and emits two plain-text lines without an envelope, so callers cannot parse the command consistently. Return the dashboard URL and server state in the standard envelope when JSON mode is requested.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

Comment thread src/catalog/codex.ts
Comment on lines +107 to +111
continue;
}
for (const rollout of listChildPaths(
session,
ctx.policy,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Codex session discovery shallow

For a populated Codex history, this adds another nested directory enumeration beneath the existing year, month, and day loops, allowing a large multiplicative scan on every session query and exceeding the repository's shallow-root ScanPolicy invariant. Index a bounded shallow card or use a purpose-built fixed lookup rather than recursively composing listChildPaths.

AGENTS.md reference: AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

Comment thread src/web.ts
Comment on lines +371 to +375
function sessionView(card: IndexCard, now: number): FleetSession {
return {
provider: card.host,
name: card.name,
...freshness(card.mtime_ms, now),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize session card names before returning them

Some adapters use an absolute path as the session card name, notably Grok's decoded user-session directories, and this copies that name directly into both matched and unmatched Fleet sessions. Consequently /api/fleet can contain raw values such as /Users/alice/private-project even though other fields are path-sanitized; convert session names to safe provider labels or basenames before returning them.

Useful? React with 👍 / 👎.

Comment thread src/fleet-adapters.ts
Comment on lines +52 to +55
{
host: "gemini",
provider: "Gemini",
matches: (card) => card.host === "gemini",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude Gemini registry cards from session activity

The Gemini adapter matches every Gemini card, including projects_json registry entries and project-directory markers that do not represent a session. On machines with Gemini configuration but no actual transcript, Fleet therefore creates an Earlier session or Recent activity agent row instead of the explicit unavailable state; restrict presentation matching to cards that carry real session evidence.

Useful? React with 👍 / 👎.

Comment thread src/web.ts
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Let Fleet</title><script>try{const t=new URLSearchParams(location.search).get('theme')||localStorage.getItem('corvid-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch{}</script><link rel="stylesheet" href="/assets/tokens.css"><script defer src="/assets/theme.js"></script><style>
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 var(--font-display)}main{max-width:1080px;margin:auto;padding:30px 24px}.masthead{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:start;padding-bottom:20px;border-bottom:1px solid var(--hairline);position:relative}.masthead:after{content:'';position:absolute;height:4px;left:0;right:0;bottom:-1px;background:var(--iridescence)}h1{margin:4px 0;font-size:clamp(2.1rem,5vw,4rem);line-height:.95;letter-spacing:-.055em}.eyebrow,.meta,.row span:first-child{font:12px/1.4 var(--font-mono);letter-spacing:.04em}.eyebrow{text-transform:uppercase;color:var(--sheen-strong)}.meta{color:var(--text-faint)}.local{margin:20px 0;padding:12px 14px;border:1px solid var(--hairline);border-left:4px solid var(--sheen);background:var(--surface)}.header-actions{display:flex;gap:9px;align-items:center}.corvid-theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;background:none;border:1px solid var(--hairline);color:var(--ink-70);cursor:pointer}.corvid-theme-toggle:hover{border-color:var(--sheen);color:var(--sheen)}.corvid-theme-toggle:focus-visible,.view:focus-visible,summary:focus-visible{outline:2px solid var(--sheen);outline-offset:3px}.corvid-theme-toggle svg{width:17px;height:17px}.moon{display:none}:root[data-theme="dark"] .sun{display:none}:root[data-theme="dark"] .moon{display:inline}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]) .sun{display:none}:root:not([data-theme="light"]) .moon{display:inline}}:root[data-theme="light"] .sun{display:inline}:root[data-theme="light"] .moon{display:none}.views{display:flex;gap:4px;margin:22px 0 14px;border-bottom:1px solid var(--hairline)}.view{border:0;border-bottom:2px solid transparent;padding:10px 12px;background:transparent;color:var(--text-faint);font:600 12px var(--font-mono);cursor:pointer}.view[aria-pressed="true"]{color:var(--sheen-strong);border-color:var(--sheen)}.card{border:1px solid var(--hairline);background:var(--surface);margin:9px 0;padding:16px}.card.working{border-left:4px solid var(--success)}.line{font-size:19px;font-weight:700;letter-spacing:-.025em}.row{display:flex;justify-content:space-between;gap:18px;padding:9px 0;border-top:1px solid var(--hairline)}.row span:last-child{color:var(--text-muted);text-align:right}.row.activity span:last-child{max-width:68%;text-align:left}details summary{cursor:pointer;color:var(--sheen-strong);padding:11px 0;font-weight:600}.output{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink-70);background:var(--surface-strong);padding:12px;font:12px/1.5 var(--font-mono)}.notice{color:var(--text-faint);font-size:12px;margin-top:24px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:740px){main{padding:20px 14px}.masthead{grid-template-columns:1fr}.row{display:block}.row span{display:block;margin:3px 0}.row span:last-child{text-align:left}.row.activity span:last-child{max-width:none}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}</style></head><body><main><header class="masthead"><div><div class="eyebrow">Let Fleet / local control room</div><h1>Supervise local agents.</h1><p class="meta">A read-only view built from Let’s own federated index.</p></div><div class="header-actions"><div class="meta" id="stamp">Loading…</div><button type="button" class="corvid-theme-toggle" data-corvid-theme-toggle aria-pressed="false" aria-label="Switch to dark theme" title="Switch theme"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.4M12 19v2.4M4.2 4.2l1.7 1.7M18.1 18.1l1.7 1.7M2.6 12h2.4M19 12h2.4M4.2 19.8l1.7-1.7M18.1 5.9l1.7-1.7"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A8.6 8.6 0 1 1 11.2 3a6.7 6.7 0 0 0 9.8 9.8z"/></svg></button></div></header><aside class="local"><strong>Local only.</strong> Fleet never sends your agent or session data away. Details are redacted before display.</aside><nav class="views" aria-label="Fleet view"><button class="view" data-view="agents" aria-pressed="true">Agents</button><button class="view" data-view="projects" aria-pressed="false">Projects</button></nav><div id="app"></div><p class="notice" id="notice"></p><p class="sr-only" id="changes" role="status" aria-live="polite" aria-atomic="true"></p></main><script>
let fleet={agents:[],recentActivity:[],history:[]},view='agents';const ui={openKeys:new Set(),scroll:{left:0,top:0},focus:null};const e=s=>{const d=document.createElement('div');d.textContent=String(s??'Unavailable');return d.innerHTML};const human=s=>String(s||'Unassigned running agent').replace(/[-_]+/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase());const plural=(n,w)=>n+' '+w+(n===1?'':'s');const keyForAgent=a=>'agent:'+String(a.agent).toLowerCase();const keyForProject=r=>'project:'+String(r.project).toLowerCase();const preview=s=>String(s||'Waiting for a local session update').replace(/\\s+/g,' ').slice(0,220);const detailsKey=(key,content)=>'<details data-fleet-key="'+e(key)+'">'+content+'</details>';const context=a=>{const labels=[a.project?'<span>Project · '+e(human(a.project))+'</span>':'',a.worktree?'<span>Worktree · '+e(a.worktree)+'</span>':'',a.branch?'<span>Branch · '+e(a.branch)+'</span>':''].filter(Boolean);return labels.join('')||'<span>Project context not available yet</span>'};const agent=a=>{const contextText=[a.project,a.worktree,a.branch].filter(Boolean).join(' · ');const details=detailsKey(keyForAgent(a),'<summary>Show supervision details</summary><div class="row"><span>Task</span><span>'+e(a.operation)+'</span></div><div class="row"><span>Context</span><span>'+e(contextText||'Project context not available yet')+'</span></div><div class="row"><span>Current command</span><span>'+e(a.command||'Unavailable')+'</span></div><div class="row"><span>Started</span><span>'+e(a.startedAt||'Unavailable')+'</span></div><div class="row"><span>Latest prompt or update</span><span>'+e(a.latestMessage||'Unavailable')+'</span></div><div class="row"><span>Detail source</span><span>'+e(a.evidence)+' · '+e(a.detailAvailability)+'</span></div>'+(a.recentActivity.length?'<div><p class="meta">Recent output</p><div class="output">'+a.recentActivity.map(e).join('\\n\\n')+'</div></div>':'<p class="meta">Recent output unavailable.</p>'));return '<article class="card '+e(a.status)+'"><div class="line">'+e(a.agent)+' · '+e(a.status==='working'?'Working now':a.status==='recent'?'Recent activity':'Earlier activity')+'</div><div class="row"><span>Task · '+e(a.operation)+'</span><span>Last update · '+e(a.lastAction)+'</span></div><div class="row">'+context(a)+'</div><div class="row activity"><span>Latest prompt or update</span><span>'+e(preview(a.latestMessage))+'</span></div>'+details+'</article>'};const project=r=>detailsKey(keyForProject(r),'<summary><strong>'+e(human(r.project))+'</strong> · '+e(plural(r.worktrees.length,'worktree'))+'</summary>'+r.worktrees.map(w=>'<div class="row"><span>Worktree · '+e(w.worktree)+'</span><span>Branch · '+e(w.branch)+'</span></div>').join(''));const captureUiState=()=>{ui.openKeys=new Set([...document.querySelectorAll('details[data-fleet-key][open]')].map(panel=>panel.dataset.fleetKey));ui.scroll={left:window.scrollX,top:window.scrollY};const focused=document.activeElement;const focusedPanel=focused&&focused.closest('details[data-fleet-key]');ui.focus=focusedPanel?{kind:'panel',key:focusedPanel.dataset.fleetKey}:focused&&focused.matches('[data-view]')?{kind:'view',key:focused.dataset.view}:null};const availableKeys=()=>[...fleet.agents.map(keyForAgent),...[...fleet.recentActivity,...fleet.history].map(keyForProject)];const restoreUiState=()=>{const available=new Set(availableKeys());ui.openKeys=new Set([...ui.openKeys].filter(key=>available.has(key)));document.querySelectorAll('details[data-fleet-key]').forEach(panel=>{panel.open=ui.openKeys.has(panel.dataset.fleetKey)});if(ui.focus){const target=ui.focus.kind==='view'?document.querySelector('[data-view="'+ui.focus.key+'"]'):document.querySelector('details[data-fleet-key="'+ui.focus.key+'"]').querySelector('summary');target&&target.focus({preventScroll:true})}window.scrollTo(ui.scroll.left,ui.scroll.top)};const announceChanges=(before,after)=>{if(!before.agents.length)return '';const oldState=new Map(before.agents.map(agent=>[agent.agent,agent.status])),newState=new Map(after.agents.map(agent=>[agent.agent,agent.status])),changes=[];after.agents.forEach(agent=>{const prior=oldState.get(agent.agent);if(!prior)changes.push(agent.agent+' is now visible.');else if(prior!==agent.status)changes.push(agent.agent+' is now '+agent.status+'.')});before.agents.forEach(agent=>{if(!newState.has(agent.agent))changes.push(agent.agent+' is no longer visible.')});return changes.join(' ')};function render(){const projects=[...fleet.recentActivity,...fleet.history];document.querySelector('#stamp').textContent=plural(fleet.agents.filter(a=>a.status==='working').length,'agent')+' working · '+plural(projects.length,'project');document.querySelector('#notice').textContent=fleet.policy;document.querySelector('#app').innerHTML=view==='agents'?fleet.agents.map(agent).join('')||'<p class="meta">No local agent or session metadata is available.</p>':projects.map(project).join('')||'<p class="meta">No projects are available.</p>';document.querySelectorAll('[data-view]').forEach(button=>button.addEventListener('click',()=>{captureUiState();view=button.dataset.view;render()}));restoreUiState()}async function refresh(){try{const next=await fetch('/api/fleet').then(response=>response.json());captureUiState();const message=announceChanges(fleet,next);fleet=next;render();if(message)document.querySelector('#changes').textContent=message}catch{document.querySelector('#changes').textContent='Fleet refresh failed. Showing the last known local data.'}}refresh();setInterval(refresh,20000);</script></body></html>`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape project keys before using them in selectors

When a repository name contains a valid filename character such as " or ], keyForProject places it in data-fleet-key, and focus restoration later interpolates the raw dataset value into a CSS selector. querySelector then throws a syntax error during view changes or refreshes, leaving the dashboard stale; use an opaque stable identifier or CSS.escape rather than the display name.

Useful? React with 👍 / 👎.

Comment thread src/web.ts
Comment on lines +549 to +553
activity: "No session heartbeat",
worktrees: [],
sessions: [],
instructions: instructionCards,
skills: skillCards,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope instructions and skills to their repository

These arrays are selected once from the current all-scope catalog and then copied into every repository object. When Fleet lists external worktrees, each repository therefore claims the current project or user's same first eight instructions and skills, even when those assets do not belong to it; filter cards by each repository or leave the fields empty when attribution is unavailable.

Useful? React with 👍 / 👎.

Comment thread src/catalog/gemini.ts
Comment on lines +28 to +32
".system_generated",
"logs",
"transcript_full.jsonl",
);
if (!pathExists(transcript)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Confine Antigravity transcript paths to the scan root

If an Antigravity session directory or its transcript is a symlink outside brainRoot, pathExists follows it and this code indexes the external file without applying ScanPolicy's within-root symlink check. Build these cards through the policy-aware child helpers or verify the real transcript remains under the fixed root before indexing it.

AGENTS.md reference: AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant