diff --git a/console/index.html b/console/index.html
index 1deea95..3d6942b 100644
--- a/console/index.html
+++ b/console/index.html
@@ -59,6 +59,16 @@
Chat
diff --git a/console/src/agentConsole.ts b/console/src/agentConsole.ts
index 2502798..09350e8 100644
--- a/console/src/agentConsole.ts
+++ b/console/src/agentConsole.ts
@@ -13,6 +13,7 @@
import type { Source } from "./source";
import type { AgentEndpointView } from "./types";
import { createChatPanel, type ChatPanel } from "./chatPanel";
+import { createFileBrowser, type FileBrowser } from "./fileBrowser";
import { renderAgentList, agentConsoleHeaderHtml } from "./render";
export interface AgentConsoleConfig {
@@ -46,6 +47,9 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole {
const send = document.getElementById("ac-chat-send") as HTMLButtonElement | null;
const stop = document.getElementById("ac-chat-stop") as HTMLButtonElement | null;
const conn = document.getElementById("ac-chat-conn");
+ const fbList = document.getElementById("ac-files-list");
+ const fbViewer = document.getElementById("ac-files-viewer");
+ const fbTitle = document.getElementById("ac-files-title");
const noop: AgentConsole = {
refresh: async () => {},
@@ -57,6 +61,7 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole {
let agents: AgentEndpointView[] = [];
let openName: string | null = null;
let panel: ChatPanel | null = null;
+ let fileBrowser: FileBrowser | null = null;
const ac = new AbortController();
const { signal } = ac;
@@ -98,6 +103,8 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole {
openName = null;
panel?.dispose();
panel = null;
+ fileBrowser?.dispose();
+ fileBrowser = null;
cfg.panels.delete(name);
if (consoleEl) consoleEl.hidden = true;
// Fire-and-forget teardown; a failed disconnect is logged, not fatal.
@@ -136,6 +143,15 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole {
);
cfg.panels.set(name, panel);
}
+ // Mount the read-only file browser for this agent (Part D). It probes fs
+ // capability itself and shows a "pending the fs MCP files server" placeholder
+ // when the endpoint has no fs support — which is every real endpoint today.
+ if (fbList && fbViewer && fbTitle) {
+ fileBrowser = createFileBrowser(
+ { list: fbList, viewer: fbViewer, title: fbTitle },
+ { agent: name, source: cfg.source, note: cfg.note },
+ );
+ }
try {
await cfg.source.remoteConnect(name);
cfg.note("info", `agents: opened console for "${name}" — dialing ${a.url}`);
diff --git a/console/src/fileBrowser.ts b/console/src/fileBrowser.ts
new file mode 100644
index 0000000..fff7804
--- /dev/null
+++ b/console/src/fileBrowser.ts
@@ -0,0 +1,164 @@
+// The remote file editor's **read** path (ADR agent-consoles Part D): a
+// directory browser over an agent's filesystem + a read-only viewer, mounted in
+// an open agent console. It is capability-gated — fs is an MCP files server the
+// target agent exposes (reached Studio-brokered via the `oab` relay), and that
+// server does not exist yet, so on a real endpoint `fsCapability` reports
+// unsupported and this renders a "pending the fs MCP files server" placeholder.
+// The browser build's mock source serves a fixture filesystem so the surface is
+// still demonstrable.
+//
+// The listing HTML is pure (`render.ts`, unit-tested); this owns the imperative
+// shell: the fetch/navigate lifecycle, the delegated click handler, and the
+// read-only CodeMirror viewer. The **write** path (Apply) is slice 4.
+
+import { EditorView, basicSetup } from "codemirror";
+import { EditorState, type Extension } from "@codemirror/state";
+import { StreamLanguage } from "@codemirror/language";
+import { toml } from "@codemirror/legacy-modes/mode/toml";
+import type { Source } from "./source";
+import { fsListingHtml, fsUnavailableHtml } from "./render";
+
+export interface FileBrowserElements {
+ // The listing container (directory rows).
+ list: HTMLElement;
+ // The read-only CodeMirror mount.
+ viewer: HTMLElement;
+ // The open-file path / status line.
+ title: HTMLElement;
+}
+
+export interface FileBrowserOptions {
+ // The registry endpoint name whose filesystem is browsed.
+ agent: string;
+ source: Source;
+ note: (level: "info" | "error", msg: string) => void;
+}
+
+export interface FileBrowser {
+ dispose(): void;
+}
+
+const UNAVAILABLE = "Remote file editor unavailable — pending the fs MCP files server.";
+
+function errText(e: unknown): string {
+ return e instanceof Error ? e.message : String(e);
+}
+
+function dirname(path: string): string {
+ const cut = path.replace(/\/+$/, "").replace(/\/[^/]+$/, "");
+ return cut === "" ? "/" : cut;
+}
+
+export function createFileBrowser(
+ els: FileBrowserElements,
+ opts: FileBrowserOptions,
+): FileBrowser {
+ let roots: string[] = [];
+ let cwd = "";
+ let selectedPath: string | null = null;
+ let view: EditorView | null = null;
+ const ac = new AbortController();
+ const { signal } = ac;
+
+ function destroyViewer(): void {
+ view?.destroy();
+ view = null;
+ }
+
+ // Show a file's text in a fresh read-only editor. `.toml` gets TOML highlighting
+ // (the mode already bundled for the config editor); everything else is plain.
+ function showFile(path: string, text: string, truncated: boolean): void {
+ destroyViewer();
+ const ext: Extension[] = [
+ basicSetup,
+ EditorState.readOnly.of(true),
+ EditorView.editable.of(false),
+ ];
+ if (path.endsWith(".toml")) ext.push(StreamLanguage.define(toml));
+ view = new EditorView({
+ parent: els.viewer,
+ state: EditorState.create({ doc: text, extensions: ext }),
+ });
+ els.title.textContent = truncated ? `${path} · truncated` : path;
+ }
+
+ // The "up one level" affordance shows while we're below an editable root.
+ function canGoUp(): boolean {
+ return !roots.includes(cwd) && cwd !== "/" && cwd !== "";
+ }
+
+ function renderList(listing: Parameters[0]): void {
+ els.list.innerHTML = fsListingHtml(listing, {
+ selectedPath,
+ canGoUp: canGoUp(),
+ });
+ }
+
+ async function loadDir(path: string): Promise {
+ try {
+ const listing = await opts.source.fsList(path, opts.agent);
+ cwd = listing.path || path;
+ renderList(listing);
+ } catch (e) {
+ els.list.innerHTML = fsUnavailableHtml(`cannot list ${path} — ${errText(e)}`);
+ }
+ }
+
+ async function openFile(path: string): Promise {
+ try {
+ const file = await opts.source.fsRead(path, opts.agent);
+ selectedPath = file.path || path;
+ showFile(selectedPath, file.text, file.truncated);
+ // Re-render the current listing so the open row is marked.
+ await loadDir(cwd);
+ } catch (e) {
+ opts.note("error", `files: read ${path} failed — ${errText(e)}`);
+ els.title.textContent = `${path} · read failed`;
+ }
+ }
+
+ async function init(): Promise {
+ els.title.textContent = "files";
+ let cap;
+ try {
+ cap = await opts.source.fsCapability(opts.agent);
+ } catch (e) {
+ els.list.innerHTML = fsUnavailableHtml(`fs capability check failed — ${errText(e)}`);
+ return;
+ }
+ if (!cap.supported) {
+ els.list.innerHTML = fsUnavailableHtml(UNAVAILABLE);
+ return;
+ }
+ roots = cap.roots.length ? cap.roots : ["/"];
+ await loadDir(roots[0]);
+ }
+
+ els.list.addEventListener(
+ "click",
+ (ev) => {
+ const t = ev.target as HTMLElement;
+ const dir = t.closest("[data-fs-dir]");
+ if (dir?.dataset.fsDir) {
+ void loadDir(dir.dataset.fsDir);
+ return;
+ }
+ const file = t.closest("[data-fs-file]");
+ if (file?.dataset.fsFile) {
+ void openFile(file.dataset.fsFile);
+ return;
+ }
+ if (t.closest("[data-fs-up]")) void loadDir(dirname(cwd));
+ },
+ { signal },
+ );
+
+ void init();
+
+ return {
+ dispose: () => {
+ destroyViewer();
+ ac.abort();
+ },
+ };
+}
diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts
index 02c43bd..521e2c1 100644
--- a/console/src/fixtures.ts
+++ b/console/src/fixtures.ts
@@ -3,10 +3,48 @@ import type {
Deployment,
FleetConfig,
RegistryConfig,
+ FsCapability,
+ FsEntry,
RemoteConfig,
RuntimeContext,
} from "./types";
+// Stand-in remote filesystem so the browser build can demonstrate the read-only
+// file browser without a live gateway (the desktop build shows "pending the fs
+// MCP files server" because no real endpoint serves fs yet). A small tree under
+// an editable root, read-only.
+export const FIXTURE_FS_CAPABILITY: FsCapability = {
+ supported: true,
+ roots: ["/home/node"],
+ writable: false,
+};
+
+// Directory listings keyed by path (what `fsList` resolves).
+export const FIXTURE_FS_DIRS: Record = {
+ "/home/node": [
+ { name: "agent_profiling", path: "/home/node/agent_profiling", kind: "dir" },
+ { name: "CLAUDE.md", path: "/home/node/CLAUDE.md", kind: "file", size: 812 },
+ { name: "notes.md", path: "/home/node/notes.md", kind: "file", size: 140 },
+ ],
+ "/home/node/agent_profiling": [
+ {
+ name: "identity.md",
+ path: "/home/node/agent_profiling/identity.md",
+ kind: "file",
+ size: 512,
+ },
+ ],
+};
+
+// File bodies keyed by path (what `fsRead` resolves).
+export const FIXTURE_FS_FILES: Record = {
+ "/home/node/CLAUDE.md":
+ "# Orca\n\nECS-resident agent. This is a fixture rendering in the browser\nbuild's read-only file browser.\n",
+ "/home/node/notes.md": "- push early\n- state is ephemeral on Fargate Spot\n",
+ "/home/node/agent_profiling/identity.md":
+ "# Identity\n\n- **Name**: Orca\n- **Codename**: ecs-claude\n",
+};
+
// Stand-in endpoint registry so the browser build renders the agent-console
// selector without a core. Mirrors src-tauri's `remote_agents`: one management
// entry (backs the top-level console + reverse-MCP grant) plus ordinary agent
diff --git a/console/src/render.test.ts b/console/src/render.test.ts
index 84257d4..5749a31 100644
--- a/console/src/render.test.ts
+++ b/console/src/render.test.ts
@@ -6,6 +6,8 @@ import {
remoteHtml,
agentListHtml,
agentConsoleHeaderHtml,
+ fsListingHtml,
+ fsUnavailableHtml,
filterByMembers,
serviceName,
deploymentKey,
@@ -21,6 +23,7 @@ import {
AGENT_STATES,
type AgentEndpointView,
type Deployment,
+ type FsListing,
type RuntimeContext,
} from "./types";
@@ -452,7 +455,74 @@ describe("agentConsoleHeaderHtml", () => {
expect(html.toLowerCase()).not.toContain("token");
});
- it("notes the read-only editor limitation until the fs/* wire lands", () => {
+ it("notes the read-only editor limitation until the fs MCP files server lands", () => {
expect(agentConsoleHeaderHtml(orca, "disconnected")).toContain("Read-only");
});
});
+
+describe("fsListingHtml", () => {
+ const listing: FsListing = {
+ path: "/home/node",
+ entries: [
+ { name: "notes.md", path: "/home/node/notes.md", kind: "file", size: 140 },
+ { name: "agent_profiling", path: "/home/node/agent_profiling", kind: "dir" },
+ { name: "CLAUDE.md", path: "/home/node/CLAUDE.md", kind: "file", size: 2048 },
+ ],
+ };
+
+ it("sorts directories before files, each alphabetically", () => {
+ const html = fsListingHtml(listing);
+ const iDir = html.indexOf("agent_profiling");
+ const iClaude = html.indexOf("CLAUDE.md");
+ const iNotes = html.indexOf("notes.md");
+ expect(iDir).toBeLessThan(iClaude); // dir before any file
+ expect(iClaude).toBeLessThan(iNotes); // files alphabetical
+ });
+
+ it("hooks dirs and files with the right navigation attributes", () => {
+ const html = fsListingHtml(listing);
+ expect(html).toContain('data-fs-dir="/home/node/agent_profiling"');
+ expect(html).toContain('data-fs-file="/home/node/CLAUDE.md"');
+ });
+
+ it("shows a human-readable size for files only", () => {
+ const html = fsListingHtml(listing);
+ expect(html).toContain("2.0 KB"); // CLAUDE.md
+ expect(html).toContain("140 B"); // notes.md
+ });
+
+ it("renders the breadcrumb path", () => {
+ expect(fsListingHtml(listing)).toContain("/home/node");
+ });
+
+ it("marks the open file", () => {
+ const html = fsListingHtml(listing, { selectedPath: "/home/node/CLAUDE.md" });
+ expect(html).toMatch(/is-open[^>]*data-fs-file="\/home\/node\/CLAUDE\.md"/);
+ });
+
+ it("shows an up affordance only when canGoUp", () => {
+ expect(fsListingHtml(listing, { canGoUp: true })).toContain("data-fs-up");
+ expect(fsListingHtml(listing, { canGoUp: false })).not.toContain("data-fs-up");
+ });
+
+ it("renders an empty-directory note when there are no entries and no up", () => {
+ expect(fsListingHtml({ path: "/x", entries: [] })).toContain("empty directory");
+ });
+
+ it("escapes entry names and paths", () => {
+ const html = fsListingHtml({
+ path: "/x",
+ entries: [{ name: "