Add local Fleet web dashboard - #5
Conversation
There was a problem hiding this comment.
💡 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".
| const cwdForPid = (pid: string): string | null => { | ||
| const cwd = Bun.spawnSync({ | ||
| cmd: ["lsof", "-a", "-p", pid, "-d", "cwd", "-Fn"], | ||
| }); |
There was a problem hiding this comment.
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 👍 / 👎.
| const prior = agentsByName.get(agent.agent); | ||
| agentsByName.set(agent.agent, { | ||
| agent: agent.agent, |
There was a problem hiding this comment.
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 👍 / 👎.
| return fleetSessionDetail( | ||
| readFileSync(card.path, "utf8").slice(-48_000), | ||
| redactLocalDetail, |
There was a problem hiding this comment.
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 👍 / 👎.
| 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, | ||
| }; |
There was a problem hiding this comment.
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 👍 / 👎.
| continue; | ||
| } | ||
| for (const rollout of listChildPaths( | ||
| session, | ||
| ctx.policy, |
There was a problem hiding this comment.
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 👍 / 👎.
| function sessionView(card: IndexCard, now: number): FleetSession { | ||
| return { | ||
| provider: card.host, | ||
| name: card.name, | ||
| ...freshness(card.mtime_ms, now), |
There was a problem hiding this comment.
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 👍 / 👎.
| { | ||
| host: "gemini", | ||
| provider: "Gemini", | ||
| matches: (card) => card.host === "gemini", |
There was a problem hiding this comment.
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 👍 / 👎.
| 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>`; |
There was a problem hiding this comment.
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 👍 / 👎.
| activity: "No session heartbeat", | ||
| worktrees: [], | ||
| sessions: [], | ||
| instructions: instructionCards, | ||
| skills: skillCards, |
There was a problem hiding this comment.
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 👍 / 👎.
| ".system_generated", | ||
| "logs", | ||
| "transcript_full.jsonl", | ||
| ); | ||
| if (!pathExists(transcript)) { |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
Test plan