diff --git a/apps/server/scripts/copy-builtin-plugins.ts b/apps/server/scripts/copy-builtin-plugins.ts
index ecf00a6ac3..6d9fea97bc 100644
--- a/apps/server/scripts/copy-builtin-plugins.ts
+++ b/apps/server/scripts/copy-builtin-plugins.ts
@@ -96,6 +96,18 @@ async function writeRuntimePackageJson(args: {
   );
 }
 
+/**
+ * Runs `<pluginRoot>/scripts/stage-assets.mjs` when a plugin has one. The
+ * script's side effects are its contract: it populates `dist/` with runtime
+ * files the bundlers cannot produce (the Monaco plugin copies Monaco's AMD
+ * build in this way).
+ */
+async function runStageAssets(sourceRoot: string): Promise<void> {
+  const scriptPath = path.join(sourceRoot, "scripts", "stage-assets.mjs");
+  if (!(await exists(scriptPath))) return;
+  await import(pathToFileURL(scriptPath).href);
+}
+
 async function copyBuiltinPlugin(args: {
   bbVersion: string;
   build: boolean;
@@ -120,6 +132,10 @@ async function copyBuiltinPlugin(args: {
     if (packageJson.bb.host !== undefined) {
       await buildPluginHost(args.sourceRoot, args.bbVersion, toolchain);
     }
+    // A plugin that needs files on disk at runtime (rather than bundled into
+    // its server/app) stages them into `dist/` here, because `RUNTIME_DIRS`
+    // below is all that ships. Optional: most plugins have no such script.
+    await runStageAssets(args.sourceRoot);
   }
 
   const targetDir = path.join(args.targetRoot, args.name);
diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts
index 1f52ebb6bf..36d97834ed 100644
--- a/apps/server/src/services/plugins/builtin-registry.ts
+++ b/apps/server/src/services/plugins/builtin-registry.ts
@@ -72,6 +72,12 @@ export const BUILTIN_PLUGINS = [
     defaultEnabled: true,
     category: "Interface",
   },
+  {
+    name: "monaco",
+    pluginId: "monaco",
+    defaultEnabled: true,
+    category: "Interface",
+  },
   {
     name: "pdf-preview",
     pluginId: "pdf-preview",
diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts
index 28e2cb10a5..0ee6b2c2f8 100644
--- a/apps/server/test/services/plugins/builtin-plugins.test.ts
+++ b/apps/server/test/services/plugins/builtin-plugins.test.ts
@@ -214,6 +214,7 @@ describe("builtin plugin reconciliation", () => {
       ["custom-instructions", "EditFile"],
       ["inline-vis", "AppWindow"],
       ["keep-awake", "Coffee"],
+      ["monaco", "Code"],
       ["pdf-preview", "FileText"],
       ["provider-acp", "./icons/acp.svg"],
       ["provider-claude-code", "./icons/claude-code.svg"],
diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts
index f841b4b229..85fe3d4304 100644
--- a/apps/server/test/services/plugins/official-plugins.test.ts
+++ b/apps/server/test/services/plugins/official-plugins.test.ts
@@ -97,6 +97,7 @@ describe("official plugin registry invariants", () => {
       "inline-vis": "Interface",
       "keep-awake": "Host access",
       memory: "Context & knowledge",
+      monaco: "Interface",
       "pdf-preview": "Interface",
       "provider-acp": "Agent interaction",
       "provider-claude-code": "Agent interaction",
diff --git a/packages/bb-app/scripts/smoke-tarball.mjs b/packages/bb-app/scripts/smoke-tarball.mjs
index 3e6ac3aefd..ec2ad761ef 100644
--- a/packages/bb-app/scripts/smoke-tarball.mjs
+++ b/packages/bb-app/scripts/smoke-tarball.mjs
@@ -33,6 +33,7 @@ const EXPECTED_RUNNING_BUILTIN_PLUGINS = [
   "custom-instructions",
   "inline-vis",
   "keep-awake",
+  "monaco",
   "pdf-preview",
   "provider-retry",
   "secrets",
diff --git a/plugins/monaco/README.md b/plugins/monaco/README.md
new file mode 100644
index 0000000000..ddf1a5405d
--- /dev/null
+++ b/plugins/monaco/README.md
@@ -0,0 +1,65 @@
+# bb-plugin-monaco
+
+Opens files in BB using [Monaco](https://microsoft.github.io/monaco-editor/),
+the editor from VS Code, instead of BB's read-only file preview.
+
+It applies everywhere BB opens a file: links clicked in chat, the secondary
+panel's file search, and `bb thread open`.
+
+## Features
+
+- **Edit and save.** <kbd>⌘S</kbd> writes the file. If it changed on disk
+  since you opened it — often because the agent edited it — the save stops and
+  offers Reload or Overwrite rather than clobbering the change.
+- **Find in file** with <kbd>⌘F</kbd>, plus Monaco's usual editing: multiple
+  cursors, block selection, bracket matching, code folding.
+- **Syntax highlighting** for ~86 common file types.
+- **File tree.** Toggle it from the file bar to browse the project, filter by
+  path, expand and collapse directories, and jump to another file. It opens
+  with the current file revealed. Right-click any row to copy its absolute
+  path, relative path, or filename.
+- **Follows your theme,** including light/dark switches and custom palettes.
+
+## Development
+
+Ships with BB as a builtin; there is nothing to install.
+
+```
+pnpm exec turbo run typecheck test --filter=bb-plugin-monaco
+```
+
+Monaco's AMD build is what the editor loads at runtime, and packaging copies
+only a builtin's `dist/`, so `scripts/stage-assets.mjs` copies
+`monaco-editor/min/vs` into `dist/vs` during the build
+(`apps/server/scripts/copy-builtin-plugins.ts` runs it). Running from source
+there is no `dist/`, and the server falls back to resolving `monaco-editor`
+from `node_modules`.
+
+## Which files it opens
+
+The plugin claims the extensions listed in `lib/languages.ts` — common code,
+config, and text formats. Binaries like `png` and `pdf` are left to BB's own
+preview, which renders them properly.
+
+To change any file type back, use **Settings → File openers**, which offers
+Automatic, BB's built-in preview, or Monaco per extension. Right-clicking a
+file link also offers a one-off "Open with…".
+
+## Roadmap
+
+- **Language intelligence.** There is no language server, so no
+  go-to-definition, find-references, or type checking. Monaco ships a
+  TypeScript checker, but it can only see the one open file, so every import
+  looks unresolved — it is switched off rather than showing errors that are
+  wrong.
+- **File operations.** The tree is read-only; renaming, creating, and
+  deleting files are not implemented yet.
+- **Hidden files and `node_modules`** never appear in the tree. BB's path
+  listing excludes them and offers no way to ask for them
+  ([#2093](https://github.com/get-bb/bb/issues/2093)).
+- **Opening a file from the tree reuses the current tab,** so the tab title
+  keeps naming the file it was opened with. A plugin cannot ask BB to open a
+  file or retitle its tab ([#2102](https://github.com/get-bb/bb/issues/2102)).
+- **No "open in editor" button** like BB's preview has; that capability is not
+  available to plugins.
+- **Thread-storage files on a remote machine** fail to open.
diff --git a/plugins/monaco/app.tsx b/plugins/monaco/app.tsx
new file mode 100644
index 0000000000..fdc7d4b1cc
--- /dev/null
+++ b/plugins/monaco/app.tsx
@@ -0,0 +1,535 @@
+// bb-plugin-monaco — frontend entry.
+//
+// Registers a `fileOpener`, which is BB's seam for replacing the built-in
+// file preview. Every file-open flow in the app funnels through one call site
+// (`useThreadFileTabs`'s `openTab`), so this single registration covers file
+// links clicked in chat, the secondary panel's "+" file search, and
+// `bb thread open` alike.
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+  definePluginApp,
+  useRpc,
+  type PluginFileOpenerProps,
+} from "@get-bb/plugin-sdk/app";
+import type * as MonacoNs from "monaco-editor";
+import type { rpcContract } from "./server.js";
+import { CLAIMED_EXTENSIONS, languageForPath } from "./lib/languages.js";
+import {
+  loadMonaco,
+  overflowWidgetsNode,
+  setOverflowWidgetsTheme,
+} from "./lib/monaco-loader.js";
+import { cn } from "@bb/shared-ui/lib/utils";
+import { FileToolbar, type SaveIndicator } from "./components/FileToolbar.js";
+import { FileTreePanel } from "./components/FileTreePanel.js";
+import type { FlatEntry } from "./lib/file-tree.js";
+
+type SaveState =
+  | { kind: "clean" }
+  | { kind: "dirty" }
+  | { kind: "saving" }
+  | { kind: "error"; message: string }
+  | { kind: "conflict" };
+
+/** Monaco's dark/light pair, following the app's `<html class="dark">`. */
+function useMonacoTheme(): "vs-dark" | "vs" {
+  const [isDark, setIsDark] = useState(
+    () => document.documentElement.classList.contains("dark"),
+  );
+  useEffect(() => {
+    const target = document.documentElement;
+    const observer = new MutationObserver(() => {
+      setIsDark(target.classList.contains("dark"));
+    });
+    observer.observe(target, { attributes: true, attributeFilter: ["class"] });
+    return () => observer.disconnect();
+  }, []);
+  return isDark ? "vs-dark" : "vs";
+}
+
+function MonacoFileOpener({
+  path,
+  source,
+  experimental_Original: Original,
+}: PluginFileOpenerProps) {
+  const rpc = useRpc<typeof rpcContract>();
+  const theme = useMonacoTheme();
+  const containerRef = useRef<HTMLDivElement | null>(null);
+  const editorRef = useRef<MonacoNs.editor.IStandaloneCodeEditor | null>(null);
+
+  // The file actually in the editor. It starts as the one BB opened the tab
+  // for and changes when the user picks another from the file tree, so every
+  // read and write below targets this rather than the prop. BB's tab title
+  // keeps naming the original file: a plugin cannot retitle its own tab.
+  const [activePath, setActivePath] = useState(path);
+  useEffect(() => setActivePath(path), [path]);
+
+  // The hash the file had when we last agreed with disk. It guards every
+  // save, and a save advances it — so it lives in a ref rather than state:
+  // the cmd+S handler is registered once and must see the current value.
+  const sha256Ref = useRef<string | null>(null);
+  const saveStateRef = useRef<SaveState>({ kind: "clean" });
+
+  const [saveState, setSaveStateValue] = useState<SaveState>({ kind: "clean" });
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const [pendingDiscard, setPendingDiscard] = useState(false);
+  const [isFilesOpen, setIsFilesOpen] = useState(false);
+  // A file picked from the tree while the buffer was dirty, held until the
+  // user says whether to discard.
+  const [pendingOpen, setPendingOpen] = useState<string | null>(null);
+  const [tree, setTree] = useState<{
+    entries: readonly FlatEntry[];
+    root: string;
+    truncated: boolean;
+    isLoading: boolean;
+    error: string | null;
+  }>({
+    entries: [],
+    root: "",
+    truncated: false,
+    isLoading: false,
+    error: null,
+  });
+  const [status, setStatus] = useState<
+    | { kind: "loading" }
+    | { kind: "ready" }
+    | { kind: "delegate"; reason: string }
+    | { kind: "error"; message: string }
+  >({ kind: "loading" });
+
+  const setSaveState = useCallback((next: SaveState) => {
+    saveStateRef.current = next;
+    setSaveStateValue(next);
+  }, []);
+
+  const save = useCallback(async () => {
+    const editor = editorRef.current;
+    if (!editor) return;
+    if (saveStateRef.current.kind === "saving") return;
+    setSaveState({ kind: "saving" });
+    try {
+      const result = await rpc.call("write", {
+        path: activePath,
+        source,
+        content: editor.getValue(),
+        expectedSha256: sha256Ref.current,
+      });
+      if (result.outcome === "conflict") {
+        // Someone else — very often the agent working in this thread — wrote
+        // the file after we read it. Never clobber: surface it and let the
+        // user choose.
+        setSaveState({ kind: "conflict" });
+        return;
+      }
+      sha256Ref.current = result.sha256;
+      setSaveState({ kind: "clean" });
+    } catch (error) {
+      setSaveState({
+        kind: "error",
+        message: error instanceof Error ? error.message : "Save failed",
+      });
+    }
+  }, [activePath, rpc, setSaveState, source]);
+
+  const saveRef = useRef(save);
+  saveRef.current = save;
+
+  /** Discard local edits and take what is on disk now. */
+  const reloadFromDisk = useCallback(async () => {
+    const editor = editorRef.current;
+    if (!editor) return;
+    setIsRefreshing(true);
+    try {
+      const file = await rpc.call("read", { path: activePath, source });
+      if (file.kind !== "text") return;
+      sha256Ref.current = file.sha256;
+      // `setValue` resets undo history, which is correct here: the buffer no
+      // longer descends from what the user was editing.
+      editor.setValue(file.content);
+      setSaveState({ kind: "clean" });
+    } catch (error) {
+      setSaveState({
+        kind: "error",
+        message: error instanceof Error ? error.message : "Reload failed",
+      });
+    } finally {
+      setIsRefreshing(false);
+    }
+  }, [activePath, rpc, setSaveState, source]);
+
+  /**
+   * Lists the project once, the first time the panel is opened. The listing
+   * is a snapshot; the reload button is the way to pick up files created
+   * since. Fetching lazily keeps a 5,000-entry request off the open path for
+   * everyone who never opens the tree.
+   */
+  // "Have we already asked?" is a ref, not state, on purpose. Deriving it
+  // from `tree` would put `tree.isLoading` in this effect's dependencies —
+  // and since the effect's own first act is to set that flag, React would
+  // tear the effect down mid-flight, the cleanup would mark the in-flight
+  // request cancelled, and the response would be dropped. The panel then sits
+  // on "Loading files…" forever.
+  const treeRequestedRef = useRef(false);
+  useEffect(() => {
+    if (!isFilesOpen || treeRequestedRef.current) return;
+    treeRequestedRef.current = true;
+    let cancelled = false;
+    setTree((current) => ({ ...current, isLoading: true, error: null }));
+    void rpc
+      .call("tree", { source })
+      .then((result) => {
+        if (cancelled) return;
+        setTree({
+          entries: result.entries,
+          root: result.root,
+          truncated: result.truncated,
+          isLoading: false,
+          error: null,
+        });
+      })
+      .catch((error: unknown) => {
+        if (cancelled) return;
+        // Let the next open retry rather than latching the failure forever.
+        treeRequestedRef.current = false;
+        setTree({
+          entries: [],
+          root: "",
+          truncated: false,
+          isLoading: false,
+          error:
+            error instanceof Error ? error.message : "Could not list files",
+        });
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [isFilesOpen, rpc, source]);
+
+  /** Switch the editor to another file, guarding unsaved work. */
+  const openFromTree = useCallback(
+    (next: string) => {
+      if (next === activePath) return;
+      if (saveStateRef.current.kind === "dirty") {
+        setPendingOpen(next);
+        return;
+      }
+      setActivePath(next);
+    },
+    [activePath],
+  );
+
+  /**
+   * Toolbar reload. With unsaved edits this asks first — reloading is the one
+   * control here that can destroy work the user has not committed to disk.
+   */
+  const requestRefresh = useCallback(() => {
+    if (saveStateRef.current.kind === "dirty") {
+      setPendingDiscard(true);
+      return;
+    }
+    void reloadFromDisk();
+  }, [reloadFromDisk]);
+
+  /** Take our buffer as the truth, dropping the hash guard for one write. */
+  const overwrite = useCallback(async () => {
+    sha256Ref.current = null;
+    const editor = editorRef.current;
+    if (!editor) return;
+    setSaveState({ kind: "saving" });
+    try {
+      const result = await rpc.call("write", {
+        path: activePath,
+        source,
+        content: editor.getValue(),
+        // An absent guard is an unconditional write; `null` would mean
+        // create-only, which is not what "overwrite" means here.
+        expectedSha256: null,
+      });
+      if (result.outcome === "conflict") {
+        setSaveState({ kind: "conflict" });
+        return;
+      }
+      sha256Ref.current = result.sha256;
+      setSaveState({ kind: "clean" });
+    } catch (error) {
+      setSaveState({
+        kind: "error",
+        message: error instanceof Error ? error.message : "Save failed",
+      });
+    }
+  }, [activePath, rpc, setSaveState, source]);
+
+  // Boot: fetch the asset URL and the file content in parallel, then create
+  // the editor. Re-runs when the tab is pointed at a different file.
+  useEffect(() => {
+    let disposed = false;
+    setStatus({ kind: "loading" });
+
+    void (async () => {
+      try {
+        const [{ baseUrl }, file] = await Promise.all([
+          rpc.call("assets"),
+          rpc.call("read", { path: activePath, source }),
+        ]);
+        if (disposed) return;
+        if (file.kind === "unsupported") {
+          setStatus({ kind: "delegate", reason: file.reason });
+          return;
+        }
+
+        const monaco = await loadMonaco(baseUrl);
+        if (disposed) return;
+        const container = containerRef.current;
+        if (!container) return;
+
+        sha256Ref.current = file.sha256;
+        const editor = monaco.editor.create(container, {
+          value: file.content,
+          language: languageForPath(activePath),
+          automaticLayout: true,
+          lineNumbers: "on",
+          // Read from the DOM rather than the hook so the editor is created
+          // in the right theme; re-theming on toggle is a separate effect.
+          theme: document.documentElement.classList.contains("dark")
+            ? "vs-dark"
+            : "vs",
+          minimap: { enabled: false },
+          scrollBeyondLastLine: false,
+          // Matches BB's own file preview, which renders its code table as
+          // `font-mono text-xs leading-5` — 12px on 20px, since the app
+          // leaves Tailwind's default `--text-xs` alone at desktop widths.
+          fontSize: 12,
+          lineHeight: 20,
+          // Read the app's mono stack rather than restating it, so a custom
+          // theme's font follows through to the editor.
+          fontFamily:
+            getComputedStyle(document.documentElement).getPropertyValue(
+              "--font-mono",
+            ) || undefined,
+          // Hovers, suggestions, and parameter hints render into a body-level
+          // node so BB's panel cannot clip them. Both options are required —
+          // see overflowWidgetsNode().
+          fixedOverflowWidgets: true,
+          overflowWidgetsDomNode: overflowWidgetsNode(),
+        });
+        editorRef.current = editor;
+        setStatus({ kind: "ready" });
+
+        editor.onDidChangeModelContent(() => {
+          if (saveStateRef.current.kind === "clean") {
+            setSaveState({ kind: "dirty" });
+          }
+        });
+        editor.addCommand(
+          monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
+          () => void saveRef.current(),
+        );
+      } catch (error) {
+        if (disposed) return;
+        setStatus({
+          kind: "error",
+          message:
+            error instanceof Error ? error.message : "Could not open this file",
+        });
+      }
+    })();
+
+    return () => {
+      disposed = true;
+      editorRef.current?.getModel()?.dispose();
+      editorRef.current?.dispose();
+      editorRef.current = null;
+    };
+  }, [activePath, rpc, setSaveState, source]);
+
+  useEffect(() => {
+    editorRef.current?.updateOptions({ theme });
+    // The overflow host lives outside the editor, so Monaco does not re-theme
+    // it for us.
+    setOverflowWidgetsTheme(theme);
+  }, [theme, status]);
+
+  // Binary and oversized files are ordinary things to click on, and this
+  // plugin claims broad extensions. Hand them back to BB's own preview, which
+  // renders them properly, rather than showing an editor that cannot.
+  if (status.kind === "delegate") return <Original />;
+
+  return (
+    <div className="flex min-h-0 flex-1 flex-col">
+      {isFilesOpen ? (
+        <FileTreePanel
+          activePath={activePath}
+          entries={tree.entries}
+          error={tree.error}
+          isLoading={tree.isLoading}
+          root={tree.root}
+          onClose={() => setIsFilesOpen(false)}
+          onOpenFile={openFromTree}
+          truncated={tree.truncated}
+        />
+      ) : null}
+      <FileToolbar
+        path={activePath}
+        indicator={indicatorFor(saveState, status)}
+        isRefreshing={isRefreshing}
+        onRefresh={requestRefresh}
+        isFilesOpen={isFilesOpen}
+        onToggleFiles={() => setIsFilesOpen((open) => !open)}
+      />
+      <Notice
+        onDiscardCancel={() => setPendingDiscard(false)}
+        onDiscardConfirm={() => {
+          setPendingDiscard(false);
+          void reloadFromDisk();
+        }}
+        onOpenCancel={() => setPendingOpen(null)}
+        onOpenConfirm={() => {
+          const next = pendingOpen;
+          setPendingOpen(null);
+          if (next !== null) setActivePath(next);
+        }}
+        onOverwrite={() => void overwrite()}
+        onReload={() => void reloadFromDisk()}
+        pendingDiscard={pendingDiscard}
+        pendingOpen={pendingOpen}
+        saveState={saveState}
+        status={status}
+      />
+      <div ref={containerRef} className="min-h-0 flex-1" />
+    </div>
+  );
+}
+
+/** Collapses the editor's internal states into the toolbar's one dot. */
+function indicatorFor(
+  saveState: SaveState,
+  status: { kind: string },
+): SaveIndicator {
+  if (status.kind === "error") return "error";
+  switch (saveState.kind) {
+    case "saving":
+      return "saving";
+    case "dirty":
+      return "dirty";
+    case "error":
+    case "conflict":
+      return "error";
+    default:
+      return "clean";
+  }
+}
+
+/**
+ * A thin row under the toolbar, shown only when there is something the user
+ * must decide or know. The dot carries routine state; this carries the rest,
+ * so nothing that needs a choice is reduced to a colored circle.
+ */
+function Notice({
+  onDiscardCancel,
+  onDiscardConfirm,
+  onOpenCancel,
+  onOpenConfirm,
+  onOverwrite,
+  onReload,
+  pendingDiscard,
+  pendingOpen,
+  saveState,
+  status,
+}: {
+  onDiscardCancel: () => void;
+  onDiscardConfirm: () => void;
+  onOpenCancel: () => void;
+  onOpenConfirm: () => void;
+  onOverwrite: () => void;
+  onReload: () => void;
+  pendingDiscard: boolean;
+  pendingOpen: string | null;
+  saveState: SaveState;
+  status: { kind: string; message?: string };
+}) {
+  if (status.kind === "error") {
+    return <NoticeRow tone="error">{status.message}</NoticeRow>;
+  }
+  if (saveState.kind === "conflict") {
+    return (
+      <NoticeRow tone="error">
+        This file changed on disk since you opened it.
+        <NoticeAction onClick={onReload}>Reload</NoticeAction>
+        <NoticeAction onClick={onOverwrite}>Overwrite</NoticeAction>
+      </NoticeRow>
+    );
+  }
+  if (pendingOpen !== null) {
+    return (
+      <NoticeRow tone="warning">
+        Open {pendingOpen.split("/").at(-1)} and discard your unsaved changes?
+        <NoticeAction onClick={onOpenConfirm}>Discard and open</NoticeAction>
+        <NoticeAction onClick={onOpenCancel}>Cancel</NoticeAction>
+      </NoticeRow>
+    );
+  }
+  // Reloading would throw away edits, so the toolbar's reload turns into a
+  // question rather than doing it.
+  if (pendingDiscard) {
+    return (
+      <NoticeRow tone="warning">
+        Reload from disk and discard your unsaved changes?
+        <NoticeAction onClick={onDiscardConfirm}>Discard</NoticeAction>
+        <NoticeAction onClick={onDiscardCancel}>Cancel</NoticeAction>
+      </NoticeRow>
+    );
+  }
+  if (saveState.kind === "error") {
+    return <NoticeRow tone="error">{saveState.message}</NoticeRow>;
+  }
+  return null;
+}
+
+function NoticeRow({
+  children,
+  tone,
+}: {
+  children: React.ReactNode;
+  tone: "error" | "warning";
+}) {
+  return (
+    <div
+      role="status"
+      className={cn(
+        "flex shrink-0 items-center gap-2 px-4 py-1.5 text-xs",
+        tone === "error"
+          ? "bg-destructive/10 text-destructive"
+          : "bg-surface-recessed text-foreground",
+      )}
+    >
+      {children}
+    </div>
+  );
+}
+
+function NoticeAction({
+  children,
+  onClick,
+}: {
+  children: React.ReactNode;
+  onClick: () => void;
+}) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className="cursor-pointer rounded-sm font-medium underline underline-offset-2 hover:opacity-80 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
+    >
+      {children}
+    </button>
+  );
+}
+
+export default definePluginApp((app) => {
+  app.slots.fileOpener({
+    id: "monaco",
+    title: "Monaco",
+    extensions: CLAIMED_EXTENSIONS,
+    component: MonacoFileOpener,
+  });
+});
diff --git a/plugins/monaco/components/ContextMenu.tsx b/plugins/monaco/components/ContextMenu.tsx
new file mode 100644
index 0000000000..b713d33c0f
--- /dev/null
+++ b/plugins/monaco/components/ContextMenu.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { cn } from "@bb/shared-ui/lib/utils";
+
+/**
+ * A small right-click menu.
+ *
+ * Hand-rolled rather than vendored from the BB registry: the registry's
+ * context menu pulls in an icon module and with it the whole hugeicons map,
+ * which is a lot of bundle for three copy actions. That trade would flip the
+ * moment this menu needs submenus, checkboxes, or typeahead — at which point
+ * `npx shadcn add @bb/context-menu` is the right move rather than growing
+ * this file.
+ *
+ * Portals to the body so the panel's `overflow-y-auto` cannot clip it, the
+ * same reason Monaco's hovers need their own host node.
+ */
+
+export interface ContextMenuItem {
+  label: string;
+  onSelect: () => void;
+}
+
+export interface ContextMenuState {
+  x: number;
+  y: number;
+  items: ContextMenuItem[];
+}
+
+const VIEWPORT_MARGIN_PX = 8;
+
+/**
+ * Chrome copied from BB's timeline selection menu (the "Reply in side chat"
+ * popover) so the two read as the same surface. Container and item classes
+ * are its `SELECTION_MENU_CONTENT_CLASS` and `SELECTION_ACTION_BUTTON_CLASS`,
+ * minus the radix `data-[state]` variants — this menu is not radix, so it
+ * animates in unconditionally on mount.
+ */
+const MENU_CLASS =
+  "fixed z-50 w-auto rounded-md border bg-popover p-0.5 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95";
+const ITEM_CLASS =
+  "flex w-full cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-left text-xs text-foreground transition-colors select-none hover:bg-surface-recessed focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none max-md:pointer-coarse:min-h-7 max-md:pointer-coarse:px-2 max-md:pointer-coarse:py-1";
+
+export function ContextMenu({
+  state,
+  onClose,
+}: {
+  state: ContextMenuState | null;
+  onClose: () => void;
+}) {
+  const menuRef = useRef<HTMLDivElement | null>(null);
+  const [position, setPosition] = useState({ x: 0, y: 0 });
+
+  // Measure before paint, so a menu opened near an edge never renders
+  // off-screen for a frame first.
+  useLayoutEffect(() => {
+    if (state === null) return;
+    // Width is measured rather than fixed: the menu sizes to its labels, like
+    // the selection menu it mirrors.
+    const menu = menuRef.current;
+    const width = menu?.offsetWidth ?? 0;
+    const height = menu?.offsetHeight ?? 0;
+    setPosition({
+      x: Math.max(
+        VIEWPORT_MARGIN_PX,
+        Math.min(state.x, window.innerWidth - width - VIEWPORT_MARGIN_PX),
+      ),
+      y: Math.max(
+        VIEWPORT_MARGIN_PX,
+        Math.min(state.y, window.innerHeight - height - VIEWPORT_MARGIN_PX),
+      ),
+    });
+  }, [state]);
+
+  useEffect(() => {
+    if (state === null) return;
+    menuRef.current?.querySelector("button")?.focus();
+    const onPointerDown = (event: PointerEvent) => {
+      if (!menuRef.current?.contains(event.target as Node)) onClose();
+    };
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") {
+        event.stopPropagation();
+        onClose();
+        return;
+      }
+      if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
+      event.preventDefault();
+      const menu = menuRef.current;
+      if (menu === null) return;
+      const buttons = Array.from(menu.querySelectorAll("button"));
+      if (buttons.length === 0) return;
+      const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
+      const delta = event.key === "ArrowDown" ? 1 : -1;
+      const next = (index + delta + buttons.length) % buttons.length;
+      buttons[next]?.focus();
+    };
+    // `true` so a scroll anywhere — including inside the tree — dismisses
+    // rather than leaving the menu floating over unrelated rows.
+    window.addEventListener("pointerdown", onPointerDown, true);
+    window.addEventListener("keydown", onKeyDown, true);
+    window.addEventListener("scroll", onClose, true);
+    window.addEventListener("resize", onClose);
+    return () => {
+      window.removeEventListener("pointerdown", onPointerDown, true);
+      window.removeEventListener("keydown", onKeyDown, true);
+      window.removeEventListener("scroll", onClose, true);
+      window.removeEventListener("resize", onClose);
+    };
+  }, [state, onClose]);
+
+  if (state === null) return null;
+
+  return createPortal(
+    <div
+      ref={menuRef}
+      role="menu"
+      style={{ left: position.x, top: position.y }}
+      className={MENU_CLASS}
+    >
+      {state.items.map((item) => (
+        <button
+          key={item.label}
+          type="button"
+          role="menuitem"
+          onClick={() => {
+            item.onSelect();
+            onClose();
+          }}
+          className={cn(ITEM_CLASS, "whitespace-nowrap")}
+        >
+          {item.label}
+        </button>
+      ))}
+    </div>,
+    document.body,
+  );
+}
diff --git a/plugins/monaco/components/FileToolbar.tsx b/plugins/monaco/components/FileToolbar.tsx
new file mode 100644
index 0000000000..8765f6b9d4
--- /dev/null
+++ b/plugins/monaco/components/FileToolbar.tsx
@@ -0,0 +1,288 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { cn } from "@bb/shared-ui/lib/utils";
+
+/**
+ * The bar above the editor. Mirrors BB's own file-preview header (`h-9`,
+ * `bg-surface-raised`, monospace `text-file-accent` path) so a plugin-opened
+ * file does not look like a different application from a BB-opened one.
+ */
+
+export type SaveIndicator = "clean" | "dirty" | "saving" | "error";
+
+export interface FileToolbarProps {
+  path: string;
+  indicator: SaveIndicator;
+  isRefreshing: boolean;
+  onRefresh: () => void;
+  isFilesOpen: boolean;
+  onToggleFiles: () => void;
+}
+
+export function FileToolbar({
+  path,
+  indicator,
+  isRefreshing,
+  onRefresh,
+  isFilesOpen,
+  onToggleFiles,
+}: FileToolbarProps) {
+  return (
+    <div className="flex h-9 shrink-0 items-center gap-2 bg-surface-raised px-4">
+      <div className="flex min-w-0 flex-1 items-center gap-1.5">
+        <FileGlyph
+          path={path}
+          className="size-3.5 shrink-0 text-subtle-foreground"
+        />
+        <CopyablePath path={path} />
+        <ToolbarButton
+          label={isRefreshing ? "Reloading file" : "Reload from disk"}
+          onClick={onRefresh}
+          disabled={isRefreshing}
+        >
+          <RotateIcon className={cn(isRefreshing && "animate-spin")} />
+        </ToolbarButton>
+      </div>
+      <SaveDot indicator={indicator} />
+      <ToolbarButton
+        label={isFilesOpen ? "Hide files" : "Show in files"}
+        onClick={onToggleFiles}
+        pressed={isFilesOpen}
+      >
+        <TreeIcon />
+      </ToolbarButton>
+    </div>
+  );
+}
+
+/**
+ * Present only while the file differs from disk, the way editors do it — a
+ * saved file is the resting state and needs no ornament. The slot keeps its
+ * width either way so the reload button does not shift when the dot appears.
+ */
+function SaveDot({ indicator }: { indicator: SaveIndicator }) {
+  if (indicator === "clean") {
+    return <span className="size-4 shrink-0" aria-hidden />;
+  }
+  const label =
+    indicator === "saving"
+      ? "Saving…"
+      : indicator === "error"
+        ? "Could not save — unsaved changes"
+        : "Unsaved changes";
+  return (
+    <span
+      className="flex size-4 shrink-0 items-center justify-center"
+      title={label}
+      role="status"
+      aria-label={label}
+    >
+      <span
+        className={cn(
+          "size-2 rounded-full transition-colors",
+          indicator === "saving" && "animate-pulse bg-foreground",
+          indicator === "dirty" && "bg-foreground",
+          indicator === "error" && "bg-destructive",
+        )}
+      />
+    </span>
+  );
+}
+
+/** Truncates from the start, so the file name stays visible in a narrow panel. */
+function CopyablePath({ path }: { path: string }) {
+  const [copied, setCopied] = useState(false);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(
+    () => () => {
+      if (timerRef.current !== null) clearTimeout(timerRef.current);
+    },
+    [],
+  );
+
+  const copy = useCallback(() => {
+    void navigator.clipboard
+      .writeText(path)
+      .then(() => {
+        setCopied(true);
+        if (timerRef.current !== null) clearTimeout(timerRef.current);
+        timerRef.current = setTimeout(() => setCopied(false), 1500);
+        toast.success("File path copied");
+      })
+      .catch(() => toast.error("Failed to copy file path"));
+  }, [path]);
+
+  return (
+    <button
+      type="button"
+      onClick={copy}
+      title={copied ? "Copied" : "Copy file path"}
+      aria-label="Copy file path"
+      className={cn(
+        "min-w-0 cursor-pointer truncate rounded-sm text-left font-mono text-sm",
+        "font-medium leading-5 text-file-accent underline-offset-2",
+        "hover:underline focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none",
+      )}
+      // Truncation from the left keeps the file name readable; `direction`
+      // does the work so the ellipsis lands on the directory prefix.
+      style={{ direction: "rtl", textAlign: "left" }}
+    >
+      {/* The bidi isolate stops RTL from reordering punctuation in the path. */}
+      <bdi>{path}</bdi>
+    </button>
+  );
+}
+
+function ToolbarButton({
+  label,
+  onClick,
+  disabled,
+  pressed,
+  children,
+}: {
+  label: string;
+  onClick: () => void;
+  disabled?: boolean;
+  pressed?: boolean;
+  children: React.ReactNode;
+}) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      disabled={disabled}
+      title={label}
+      aria-label={label}
+      {...(pressed === undefined ? {} : { "aria-pressed": pressed })}
+      className={cn(
+        "flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md",
+        "transition-colors hover:bg-state-hover hover:text-foreground",
+        "focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none",
+        "disabled:pointer-events-none disabled:opacity-50",
+        pressed ? "bg-state-hover text-foreground" : "text-muted-foreground",
+      )}
+    >
+      {children}
+    </button>
+  );
+}
+
+function TreeIcon() {
+  return (
+    <svg viewBox="0 0 24 24" fill="none" className="size-3.5" aria-hidden>
+      <path
+        d="M4 3v14a2 2 0 002 2h3M4 10h5M14 5h6M14 12h6M14 19h6"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
+
+/**
+ * Inline glyphs rather than an icon dependency: the plugin needs three shapes,
+ * and pulling in an icon package to get them would bundle a whole map.
+ */
+function FileGlyph({ path, className }: { path: string; className?: string }) {
+  const kind = glyphKindForPath(path);
+  if (kind === "code") {
+    return (
+      <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
+        <path
+          d="M8 6L2 12l6 6M16 6l6 6-6 6"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        />
+      </svg>
+    );
+  }
+  if (kind === "data") {
+    return (
+      <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
+        <path
+          d="M8 3H7a2 2 0 00-2 2v4a2 2 0 01-2 2 2 2 0 012 2v4a2 2 0 002 2h1M16 3h1a2 2 0 012 2v4a2 2 0 002 2 2 2 0 00-2 2v4a2 2 0 01-2 2h-1"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        />
+      </svg>
+    );
+  }
+  return (
+    <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
+      <path
+        d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+      <path
+        d="M14 2v6h6"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
+
+const DATA_EXTENSIONS = new Set([
+  "json",
+  "jsonc",
+  "yaml",
+  "yml",
+  "toml",
+  "ini",
+  "cfg",
+  "conf",
+  "xml",
+  "csv",
+  "tsv",
+]);
+
+const DOC_EXTENSIONS = new Set([
+  "md",
+  "mdx",
+  "markdown",
+  "txt",
+  "text",
+  "rst",
+  "adoc",
+  "log",
+]);
+
+function glyphKindForPath(path: string): "code" | "data" | "doc" {
+  const name = path.split("/").at(-1) ?? path;
+  const dotIndex = name.lastIndexOf(".");
+  const extension = dotIndex <= 0 ? "" : name.slice(dotIndex + 1).toLowerCase();
+  if (DATA_EXTENSIONS.has(extension)) return "data";
+  if (DOC_EXTENSIONS.has(extension)) return "doc";
+  return "code";
+}
+
+function RotateIcon({ className }: { className?: string }) {
+  return (
+    <svg
+      viewBox="0 0 24 24"
+      fill="none"
+      className={cn("size-3.5", className)}
+      aria-hidden
+    >
+      <path
+        d="M3 2v6h6M3.51 15a9 9 0 102.13-9.36L3 8"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
diff --git a/plugins/monaco/components/FileTreePanel.tsx b/plugins/monaco/components/FileTreePanel.tsx
new file mode 100644
index 0000000000..534300e3c0
--- /dev/null
+++ b/plugins/monaco/components/FileTreePanel.tsx
@@ -0,0 +1,306 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+  ancestorsOf,
+  buildTree,
+  filterTree,
+  type FlatEntry,
+  type TreeNode,
+} from "../lib/file-tree.js";
+import { toast } from "sonner";
+import { ContextMenu, type ContextMenuState } from "./ContextMenu.js";
+import { cn } from "@bb/shared-ui/lib/utils";
+
+export interface FileTreePanelProps {
+  entries: readonly FlatEntry[];
+  /** Absolute path the entries are relative to; "" until the listing lands. */
+  root: string;
+  /** True while the listing is in flight; the panel opens before it lands. */
+  isLoading: boolean;
+  error: string | null;
+  truncated: boolean;
+  /** The file currently in the editor, revealed and highlighted. */
+  activePath: string;
+  onOpenFile: (path: string) => void;
+  onClose: () => void;
+}
+
+const INDENT_PER_LEVEL_PX = 12;
+
+export function FileTreePanel({
+  entries,
+  root,
+  isLoading,
+  error,
+  truncated,
+  activePath,
+  onOpenFile,
+  onClose,
+}: FileTreePanelProps) {
+  const [query, setQuery] = useState("");
+  const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
+  const [menu, setMenu] = useState<ContextMenuState | null>(null);
+  const activeRowRef = useRef<HTMLButtonElement | null>(null);
+
+  const openMenu = (event: React.MouseEvent, node: TreeNode) => {
+    event.preventDefault();
+    setMenu({
+      x: event.clientX,
+      y: event.clientY,
+      items: [
+        {
+          label: "Copy absolute path",
+          onSelect: () =>
+            copy(
+              // The daemon may hand back a Windows root; joining with "/"
+              // there would produce a path nothing on that host accepts.
+              root === ""
+                ? node.path
+                : root.includes("\\")
+                  ? `${root}\\${node.path.replace(/\//g, "\\")}`
+                  : `${root}/${node.path}`,
+              "Absolute path copied",
+            ),
+        },
+        {
+          label: "Copy relative path",
+          onSelect: () => copy(node.path, "Relative path copied"),
+        },
+        {
+          label: "Copy filename",
+          onSelect: () => copy(node.name, "Filename copied"),
+        },
+      ],
+    });
+  };
+
+  const tree = useMemo(() => buildTree(entries), [entries]);
+  const filtered = useMemo(() => filterTree(tree, query), [tree, query]);
+
+  // Reveal the open file: every directory above it starts expanded. Re-runs
+  // when the editor moves to another file, so the tree follows along.
+  useEffect(() => {
+    setExpanded((current) => {
+      const next = new Set(current);
+      for (const ancestor of ancestorsOf(activePath)) next.add(ancestor);
+      return next;
+    });
+  }, [activePath]);
+
+  // Scroll the revealed file into view once the rows for it exist.
+  useEffect(() => {
+    activeRowRef.current?.scrollIntoView({ block: "nearest" });
+  }, [activePath, entries.length]);
+
+  const effectiveExpanded = useMemo(() => {
+    if (filtered.expand.size === 0) return expanded;
+    // While filtering, matches are shown regardless of what the user has
+    // collapsed; their own expansion state is preserved for when the query
+    // is cleared.
+    return new Set([...expanded, ...filtered.expand]);
+  }, [expanded, filtered.expand]);
+
+  const toggle = (path: string) => {
+    setExpanded((current) => {
+      const next = new Set(current);
+      if (next.has(path)) next.delete(path);
+      else next.add(path);
+      return next;
+    });
+  };
+
+  return (
+    // Sits above the toolbar, so the divider goes on the bottom edge to
+    // separate the tree from the file bar beneath it.
+    <div className="flex max-h-64 shrink-0 flex-col border-b border-border bg-surface-recessed">
+      <div className="flex shrink-0 items-center gap-1.5 px-3 py-1.5">
+        <input
+          type="text"
+          value={query}
+          onChange={(event) => setQuery(event.target.value)}
+          onKeyDown={(event) => {
+            // Escape clears a query first, and closes only once the box is
+            // empty — so it never discards a filter and the panel in one press.
+            if (event.key !== "Escape") return;
+            event.stopPropagation();
+            if (query !== "") setQuery("");
+            else onClose();
+          }}
+          placeholder="Filter files…"
+          aria-label="Filter files"
+          spellCheck={false}
+          className={cn(
+            "h-6 min-w-0 flex-1 rounded-sm bg-background px-2 text-sm text-foreground",
+            "placeholder:text-muted-foreground",
+            "focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none",
+          )}
+        />
+        <button
+          type="button"
+          onClick={onClose}
+          title="Hide files"
+          aria-label="Hide files"
+          className={cn(
+            "flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md",
+            "text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground",
+            "focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none",
+          )}
+        >
+          <svg viewBox="0 0 24 24" fill="none" className="size-3.5" aria-hidden>
+            <path
+              d="M18 6L6 18M6 6l12 12"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+      <div className="min-h-0 flex-1 overflow-y-auto pb-1">
+        {error !== null ? (
+          <Message tone="error">{error}</Message>
+        ) : isLoading ? (
+          <Message>Loading files…</Message>
+        ) : filtered.nodes.length === 0 ? (
+          <Message>
+            {query.trim() === "" ? "No files" : `No files match “${query}”`}
+          </Message>
+        ) : (
+          <Rows
+            activePath={activePath}
+            activeRowRef={activeRowRef}
+            expanded={effectiveExpanded}
+            level={0}
+            nodes={filtered.nodes}
+            onContextMenu={openMenu}
+            onOpenFile={onOpenFile}
+            onToggle={toggle}
+          />
+        )}
+        {truncated && error === null ? (
+          <Message>
+            Showing the first {entries.length.toLocaleString()} entries; this
+            project is larger.
+          </Message>
+        ) : null}
+      </div>
+      <ContextMenu state={menu} onClose={() => setMenu(null)} />
+    </div>
+  );
+}
+
+/** Clipboard write with the same toast treatment as the toolbar's path copy. */
+function copy(text: string, successMessage: string): void {
+  void navigator.clipboard
+    .writeText(text)
+    .then(() => toast.success(successMessage))
+    .catch(() => toast.error("Failed to copy"));
+}
+
+function Rows({
+  activePath,
+  activeRowRef,
+  expanded,
+  level,
+  nodes,
+  onContextMenu,
+  onOpenFile,
+  onToggle,
+}: {
+  activePath: string;
+  activeRowRef: React.RefObject<HTMLButtonElement | null>;
+  expanded: ReadonlySet<string>;
+  level: number;
+  nodes: readonly TreeNode[];
+  onContextMenu: (event: React.MouseEvent, node: TreeNode) => void;
+  onOpenFile: (path: string) => void;
+  onToggle: (path: string) => void;
+}) {
+  return (
+    <>
+      {nodes.map((node) => {
+        const isDirectory = node.kind === "directory";
+        const isOpen = isDirectory && expanded.has(node.path);
+        const isActive = !isDirectory && node.path === activePath;
+        return (
+          <div key={node.path}>
+            <button
+              type="button"
+              ref={isActive ? activeRowRef : undefined}
+              onClick={() =>
+                isDirectory ? onToggle(node.path) : onOpenFile(node.path)
+              }
+              onContextMenu={(event) => onContextMenu(event, node)}
+              title={node.path}
+              aria-expanded={isDirectory ? isOpen : undefined}
+              aria-current={isActive ? "true" : undefined}
+              style={{ paddingLeft: 8 + level * INDENT_PER_LEVEL_PX }}
+              className={cn(
+                "flex h-6 w-full cursor-pointer items-center gap-1 pr-2 text-left text-sm",
+                "hover:bg-state-hover",
+                isActive
+                  ? "bg-state-hover font-medium text-file-accent"
+                  : "text-foreground",
+              )}
+            >
+              <span className="flex size-3 shrink-0 items-center justify-center text-subtle-foreground">
+                {isDirectory ? <Chevron isOpen={isOpen} /> : null}
+              </span>
+              <span className="truncate">{node.name}</span>
+            </button>
+            {isDirectory && isOpen ? (
+              <Rows
+                activePath={activePath}
+                activeRowRef={activeRowRef}
+                expanded={expanded}
+                level={level + 1}
+                nodes={node.children}
+                onContextMenu={onContextMenu}
+                onOpenFile={onOpenFile}
+                onToggle={onToggle}
+              />
+            ) : null}
+          </div>
+        );
+      })}
+    </>
+  );
+}
+
+function Chevron({ isOpen }: { isOpen: boolean }) {
+  return (
+    <svg
+      viewBox="0 0 24 24"
+      fill="none"
+      className={cn("size-3 transition-transform", isOpen && "rotate-90")}
+      aria-hidden
+    >
+      <path
+        d="M9 6l6 6-6 6"
+        stroke="currentColor"
+        strokeWidth="2.5"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
+
+function Message({
+  children,
+  tone,
+}: {
+  children: React.ReactNode;
+  tone?: "error";
+}) {
+  return (
+    <p
+      className={cn(
+        "px-3 py-2 text-sm",
+        tone === "error" ? "text-destructive" : "text-muted-foreground",
+      )}
+    >
+      {children}
+    </p>
+  );
+}
diff --git a/plugins/monaco/lib/file-tree.test.ts b/plugins/monaco/lib/file-tree.test.ts
new file mode 100644
index 0000000000..c5b1ca5a5c
--- /dev/null
+++ b/plugins/monaco/lib/file-tree.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+import { ancestorsOf, buildTree, filterTree } from "./file-tree.js";
+
+describe("buildTree", () => {
+  it("nests flat paths and sorts directories before files", () => {
+    const tree = buildTree([
+      { path: "readme.md", kind: "file" },
+      { path: "src", kind: "directory" },
+      { path: "src/index.ts", kind: "file" },
+      { path: "src/lib", kind: "directory" },
+      { path: "src/lib/util.ts", kind: "file" },
+    ]);
+
+    expect(tree.map((node) => node.name)).toEqual(["src", "readme.md"]);
+    const src = tree[0]!;
+    expect(src.children.map((node) => node.name)).toEqual([
+      "lib",
+      "index.ts",
+    ]);
+    expect(src.children[0]!.children[0]!.path).toBe("src/lib/util.ts");
+  });
+
+  // A truncated listing can carry a file whose parent directory entry was cut,
+  // and dropping it would misrepresent the tree as smaller than it is.
+  it("synthesises directories that the listing omitted", () => {
+    const tree = buildTree([{ path: "a/b/c.ts", kind: "file" }]);
+
+    expect(tree).toHaveLength(1);
+    expect(tree[0]!.kind).toBe("directory");
+    expect(tree[0]!.children[0]!.path).toBe("a/b");
+    expect(tree[0]!.children[0]!.children[0]!.path).toBe("a/b/c.ts");
+  });
+
+  it("sorts case-insensitively", () => {
+    const tree = buildTree([
+      { path: "beta.ts", kind: "file" },
+      { path: "Alpha.ts", kind: "file" },
+    ]);
+
+    expect(tree.map((node) => node.name)).toEqual(["Alpha.ts", "beta.ts"]);
+  });
+});
+
+describe("ancestorsOf", () => {
+  it("lists each containing directory, nearest last", () => {
+    expect(ancestorsOf("a/b/c.ts")).toEqual(["a", "a/b"]);
+  });
+
+  it("has none for a root-level file", () => {
+    expect(ancestorsOf("readme.md")).toEqual([]);
+  });
+});
+
+describe("filterTree", () => {
+  const tree = buildTree([
+    { path: "src/index.ts", kind: "file" },
+    { path: "src/ui/button.tsx", kind: "file" },
+    { path: "docs/guide.md", kind: "file" },
+  ]);
+
+  it("keeps matches with the directories leading to them, and says which to open", () => {
+    const filtered = filterTree(tree, "button");
+
+    expect(filtered.matchCount).toBe(1);
+    expect(filtered.nodes.map((node) => node.name)).toEqual(["src"]);
+    // Both ancestors must expand or the match stays hidden behind a collapsed
+    // row, which is the whole point of filtering.
+    expect([...filtered.expand].sort()).toEqual(["src", "src/ui"]);
+  });
+
+  it("matches on the whole relative path, not just the file name", () => {
+    expect(filterTree(tree, "src/ui").matchCount).toBe(1);
+  });
+
+  it("is case-insensitive and returns nothing when nothing matches", () => {
+    expect(filterTree(tree, "BUTTON").matchCount).toBe(1);
+    expect(filterTree(tree, "nothing-here").nodes).toEqual([]);
+  });
+
+  it("passes the tree through untouched when the query is blank", () => {
+    const filtered = filterTree(tree, "   ");
+
+    expect(filtered.nodes).toHaveLength(2);
+    expect(filtered.expand.size).toBe(0);
+  });
+});
diff --git a/plugins/monaco/lib/file-tree.ts b/plugins/monaco/lib/file-tree.ts
new file mode 100644
index 0000000000..228f1c661e
--- /dev/null
+++ b/plugins/monaco/lib/file-tree.ts
@@ -0,0 +1,144 @@
+/**
+ * Turning the server's flat path list into something a tree view can render.
+ * Kept free of React so the nesting, filtering, and reveal rules can be read
+ * (and reasoned about) on their own.
+ */
+
+export type EntryKind = "file" | "directory";
+
+export interface FlatEntry {
+  path: string;
+  kind: EntryKind;
+}
+
+export interface TreeNode {
+  /** Root-relative, `/`-separated. Unique; used as the React key. */
+  path: string;
+  name: string;
+  kind: EntryKind;
+  /** Empty for files. Directories first, then case-insensitive by name. */
+  children: TreeNode[];
+}
+
+/**
+ * Nests flat entries. Intermediate directories are synthesised when missing —
+ * a truncated listing can contain `a/b/c.ts` with no entry for `a/b`, and
+ * dropping that file would misrepresent the tree as smaller than it is.
+ */
+export function buildTree(entries: readonly FlatEntry[]): TreeNode[] {
+  const root: TreeNode = {
+    path: "",
+    name: "",
+    kind: "directory",
+    children: [],
+  };
+  const byPath = new Map<string, TreeNode>([["", root]]);
+
+  const directoryAt = (path: string): TreeNode => {
+    const existing = byPath.get(path);
+    if (existing !== undefined) return existing;
+    const separator = path.lastIndexOf("/");
+    const parent = directoryAt(separator === -1 ? "" : path.slice(0, separator));
+    const node: TreeNode = {
+      path,
+      name: path.slice(separator + 1),
+      kind: "directory",
+      children: [],
+    };
+    byPath.set(path, node);
+    parent.children.push(node);
+    return node;
+  };
+
+  for (const entry of entries) {
+    const path = normalize(entry.path);
+    if (path === "") continue;
+    if (entry.kind === "directory") {
+      directoryAt(path);
+      continue;
+    }
+    if (byPath.has(path)) continue;
+    const separator = path.lastIndexOf("/");
+    const parent = directoryAt(separator === -1 ? "" : path.slice(0, separator));
+    const node: TreeNode = {
+      path,
+      name: path.slice(separator + 1),
+      kind: "file",
+      children: [],
+    };
+    byPath.set(path, node);
+    parent.children.push(node);
+  }
+
+  sortRecursively(root);
+  return root.children;
+}
+
+function normalize(path: string): string {
+  return path.replace(/^\.?\//, "").replace(/\/+$/, "");
+}
+
+function sortRecursively(node: TreeNode): void {
+  node.children.sort((left, right) => {
+    if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
+    return left.name.localeCompare(right.name, undefined, {
+      sensitivity: "base",
+    });
+  });
+  for (const child of node.children) sortRecursively(child);
+}
+
+/** Every directory containing `path`, nearest last: `a`, `a/b` for `a/b/c.ts`. */
+export function ancestorsOf(path: string): string[] {
+  const segments = normalize(path).split("/");
+  segments.pop();
+  const ancestors: string[] = [];
+  let current = "";
+  for (const segment of segments) {
+    current = current === "" ? segment : `${current}/${segment}`;
+    ancestors.push(current);
+  }
+  return ancestors;
+}
+
+export interface FilteredTree {
+  nodes: TreeNode[];
+  /** Directories to force open so every match is visible without clicking. */
+  expand: Set<string>;
+  matchCount: number;
+}
+
+/**
+ * Filters to files whose path contains `query`, keeping the directories that
+ * lead to them. Matching is on the whole relative path, not just the file
+ * name, so "components/ui" narrows by directory as readily as by file.
+ */
+export function filterTree(nodes: readonly TreeNode[], query: string): FilteredTree {
+  const needle = query.trim().toLowerCase();
+  if (needle === "") {
+    return { nodes: [...nodes], expand: new Set(), matchCount: 0 };
+  }
+
+  const expand = new Set<string>();
+  let matchCount = 0;
+
+  const visit = (node: TreeNode): TreeNode | null => {
+    if (node.kind === "file") {
+      if (!node.path.toLowerCase().includes(needle)) return null;
+      matchCount += 1;
+      return node;
+    }
+    const children = node.children
+      .map(visit)
+      .filter((child): child is TreeNode => child !== null);
+    if (children.length === 0) return null;
+    expand.add(node.path);
+    return { ...node, children };
+  };
+
+  return {
+    nodes: nodes.map(visit).filter((node): node is TreeNode => node !== null),
+    expand,
+    matchCount,
+  };
+}
diff --git a/plugins/monaco/lib/languages.ts b/plugins/monaco/lib/languages.ts
new file mode 100644
index 0000000000..9b462b462c
--- /dev/null
+++ b/plugins/monaco/lib/languages.ts
@@ -0,0 +1,139 @@
+/**
+ * The extensions this plugin claims as a file opener, and their Monaco
+ * language ids.
+ *
+ * Claiming an extension makes Monaco the *default* viewer for it the moment
+ * the plugin is installed: BB picks the first registration matching an
+ * extension whenever the user has no per-extension preference. Users opt back
+ * out one extension at a time under Settings → File openers, and that
+ * settings page renders one row per distinct claimed extension — so this list
+ * is deliberately "common text and code" rather than exhaustive.
+ *
+ * Two kinds of file can never reach us regardless of what is listed here:
+ * binaries we deliberately leave out (png, pdf, zip — BB's preview renders
+ * them properly), and files BB reads as having no extension at all, which
+ * includes dotfiles: its `getFileExtension` returns null when the last dot is
+ * at index 0 or absent, so `Makefile`, `LICENSE`, and `.gitignore` always use
+ * the built-in preview.
+ *
+ * Extensions must be lowercase alphanumerics with no dot — the SDK rejects
+ * the registration otherwise.
+ */
+const LANGUAGE_BY_EXTENSION: Record<string, string> = {
+  // Web
+  js: "javascript",
+  jsx: "javascript",
+  mjs: "javascript",
+  cjs: "javascript",
+  ts: "typescript",
+  tsx: "typescript",
+  mts: "typescript",
+  cts: "typescript",
+  html: "html",
+  htm: "html",
+  css: "css",
+  scss: "scss",
+  less: "less",
+  vue: "html",
+  svelte: "html",
+
+  // Data and config
+  json: "json",
+  jsonc: "json",
+  yaml: "yaml",
+  yml: "yaml",
+  toml: "ini",
+  ini: "ini",
+  cfg: "ini",
+  conf: "ini",
+  env: "shell",
+  xml: "xml",
+  csv: "plaintext",
+  tsv: "plaintext",
+
+  // Docs
+  md: "markdown",
+  mdx: "markdown",
+  markdown: "markdown",
+  txt: "plaintext",
+  text: "plaintext",
+  rst: "plaintext",
+  adoc: "plaintext",
+
+  // Systems
+  c: "c",
+  h: "c",
+  cc: "cpp",
+  cpp: "cpp",
+  cxx: "cpp",
+  hpp: "cpp",
+  hh: "cpp",
+  rs: "rust",
+  go: "go",
+  zig: "plaintext",
+  swift: "swift",
+  m: "objective-c",
+  mm: "objective-c",
+
+  // JVM / .NET
+  java: "java",
+  kt: "kotlin",
+  kts: "kotlin",
+  scala: "scala",
+  groovy: "plaintext",
+  cs: "csharp",
+  fs: "fsharp",
+
+  // Scripting
+  py: "python",
+  pyi: "python",
+  rb: "ruby",
+  php: "php",
+  pl: "perl",
+  lua: "lua",
+  r: "r",
+  sh: "shell",
+  bash: "shell",
+  zsh: "shell",
+  fish: "shell",
+  ps1: "powershell",
+  bat: "bat",
+  cmd: "bat",
+
+  // Query and schema
+  sql: "sql",
+  graphql: "graphql",
+  gql: "graphql",
+  proto: "plaintext",
+
+  // Infra
+  tf: "hcl",
+  tfvars: "hcl",
+  hcl: "hcl",
+  dockerfile: "dockerfile",
+
+  // Other
+  dart: "dart",
+  ex: "plaintext",
+  exs: "plaintext",
+  erl: "plaintext",
+  clj: "clojure",
+  hs: "plaintext",
+  jl: "julia",
+  patch: "plaintext",
+  diff: "plaintext",
+  log: "plaintext",
+};
+
+/** Every extension this plugin claims, for `app.slots.fileOpener`. */
+export const CLAIMED_EXTENSIONS: readonly string[] =
+  Object.keys(LANGUAGE_BY_EXTENSION);
+
+/** The Monaco language id for a path, defaulting to plaintext. */
+export function languageForPath(path: string): string {
+  const name = path.split("/").at(-1) ?? path;
+  const dotIndex = name.lastIndexOf(".");
+  if (dotIndex <= 0 || dotIndex === name.length - 1) return "plaintext";
+  const extension = name.slice(dotIndex + 1).toLowerCase();
+  return LANGUAGE_BY_EXTENSION[extension] ?? "plaintext";
+}
diff --git a/plugins/monaco/lib/monaco-loader.ts b/plugins/monaco/lib/monaco-loader.ts
new file mode 100644
index 0000000000..ccdd7165fb
--- /dev/null
+++ b/plugins/monaco/lib/monaco-loader.ts
@@ -0,0 +1,222 @@
+import type * as MonacoNs from "monaco-editor";
+
+/**
+ * Loads Monaco's prebuilt AMD bundle from a URL at runtime.
+ *
+ * Monaco is deliberately NOT bundled into app.js. `bb plugin build` runs one
+ * fixed esbuild config — single entry, single outfile, no code splitting and
+ * no loader map — which means the ESM build fails outright (its stylesheet
+ * pulls in codicon.ttf, and no loader is configured for `.ttf`), and even
+ * patched around it, a dynamic import cannot split, so all ~4.4 MB would
+ * parse at app boot for every user with the plugin enabled. Loading the AMD
+ * build from a URL keeps our bundle at a few KB, defers every byte of Monaco
+ * until a file tab actually opens, and gets the stylesheet, fonts, workers,
+ * and on-demand language definitions working exactly as Microsoft ships them.
+ *
+ * The URL comes from `bb.sdk.files.createPreview` over `monaco-editor/min/vs`
+ * (see server.ts) — same origin as the app, so workers are not cross-origin.
+ *
+ * Caveat worth knowing: Monaco's README marks the AMD build deprecated. It
+ * ships in 0.56 and works; if it is ever dropped, the replacement is to serve
+ * a self-built ESM bundle from the same preview URL, which changes this file
+ * and nothing else.
+ */
+
+type AmdRequire = {
+  (modules: string[], onLoad: () => void, onError: (error: unknown) => void): void;
+  config(options: { paths: Record<string, string> }): void;
+};
+
+type MonacoGlobals = {
+  require?: AmdRequire;
+  define?: unknown;
+  monaco?: typeof MonacoNs;
+  MonacoEnvironment?: unknown;
+};
+
+/**
+ * One load per app window, shared by every open editor tab. Keyed by nothing:
+ * the asset URL can change when a lease is re-issued, but the loader is
+ * already configured by then and Monaco is in memory, so re-booting it would
+ * be wasteful and would re-register its global `define`.
+ */
+let bootPromise: Promise<typeof MonacoNs> | null = null;
+
+export function loadMonaco(baseUrl: string): Promise<typeof MonacoNs> {
+  bootPromise ??= boot(baseUrl);
+  return bootPromise;
+}
+
+async function boot(baseUrl: string): Promise<typeof MonacoNs> {
+  // Monaco's loader installs `require`/`define`/`monaco` as page globals;
+  // TypeScript's view of globalThis knows nothing about them.
+  const globals = globalThis as unknown as MonacoGlobals;
+
+  await injectScript(`${baseUrl}/loader.js`);
+  const amdRequire = globals.require;
+  if (!amdRequire) {
+    throw new Error("Monaco's AMD loader did not install itself");
+  }
+  amdRequire.config({ paths: { vs: baseUrl } });
+
+  // Deliberately NOT setting MonacoEnvironment: Monaco 0.56's AMD build
+  // assigns `self.MonacoEnvironment` itself during `editor.main`, with a
+  // getWorker that builds blob workers from its own bundled worker assets.
+  // Anything set here is overwritten by it.
+  await new Promise<void>((resolve, reject) => {
+    amdRequire(
+      ["vs/editor/editor.main"],
+      () => resolve(),
+      (error) =>
+        reject(error instanceof Error ? error : new Error(String(error))),
+    );
+  });
+
+  const monaco = globals.monaco;
+  if (!monaco) {
+    throw new Error("Monaco loaded but did not expose its API");
+  }
+  configureDiagnostics(monaco);
+  return monaco;
+}
+
+/**
+ * Turns off type checking, leaving syntax checking on.
+ *
+ * `monaco-editor` bundles the whole TypeScript compiler (its `ts.worker` is
+ * ~7 MB) and `editor.main` wires it up by default, so opening a `.ts` file
+ * silently starts a type checker in a web worker. Nothing in BB asks for
+ * this and BB has no LSP — it is Monaco's own batteries.
+ *
+ * That checker has no file system. It sees exactly one file: the open model.
+ * Every import therefore fails to resolve, and the editor fills with
+ * "Cannot find module" on imports that are perfectly correct on disk. The
+ * diagnostics are not just noisy, they are wrong.
+ *
+ * Syntax diagnostics stay on: an unbalanced brace is a real error in a
+ * single file, and reporting it needs no project graph. If BB ever grows
+ * real language-server support, semantic checking belongs there — with the
+ * whole project behind it — not in this worker.
+ */
+interface DiagnosticsDefaults {
+  setDiagnosticsOptions(options: {
+    noSemanticValidation: boolean;
+    noSyntaxValidation: boolean;
+    noSuggestionDiagnostics: boolean;
+  }): void;
+}
+
+interface TypescriptNamespace {
+  typescriptDefaults?: unknown;
+  javascriptDefaults?: unknown;
+}
+
+function isDiagnosticsDefaults(value: unknown): value is DiagnosticsDefaults {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    typeof (value as { setDiagnosticsOptions?: unknown })
+      .setDiagnosticsOptions === "function"
+  );
+}
+
+/**
+ * Monaco 0.56 deprecated `languages.typescript` in favour of a top-level
+ * `typescript` namespace, but the AMD build still installs the working
+ * implementation on `languages` and types the deprecated path as an inert
+ * `{ deprecated: true }` stub. The declarations and the runtime disagree, so
+ * probe both and narrow what comes back.
+ */
+function typescriptNamespaceOf(monaco: typeof MonacoNs): TypescriptNamespace[] {
+  const candidates: unknown[] = [
+    (monaco as { typescript?: unknown }).typescript,
+    (monaco.languages as { typescript?: unknown } | undefined)?.typescript,
+  ];
+  return candidates.filter(
+    (candidate): candidate is TypescriptNamespace =>
+      typeof candidate === "object" && candidate !== null,
+  );
+}
+
+function configureDiagnostics(monaco: typeof MonacoNs): void {
+  let configured = 0;
+  for (const namespace of typescriptNamespaceOf(monaco)) {
+    for (const defaults of [
+      namespace.typescriptDefaults,
+      namespace.javascriptDefaults,
+    ]) {
+      if (!isDiagnosticsDefaults(defaults)) continue;
+      defaults.setDiagnosticsOptions({
+        noSemanticValidation: true,
+        noSyntaxValidation: false,
+        noSuggestionDiagnostics: true,
+      });
+      configured += 1;
+    }
+  }
+  if (configured === 0) {
+    // Not fatal, but the editor will fill with false "Cannot find module"
+    // errors, and the cause would otherwise be invisible.
+    console.warn(
+      "[monaco] could not disable semantic diagnostics: Monaco's typescript" +
+        " defaults were not found at either the current or deprecated path",
+    );
+  }
+}
+
+const OVERFLOW_NODE_ID = "bb-plugin-monaco-overflow-widgets";
+
+/**
+ * A body-level host for Monaco's "overflow widgets" — hovers, the suggest
+ * list, the parameter hints, the context menu.
+ *
+ * By default Monaco renders these inside the editor's own DOM, where BB's
+ * panel chrome clips them: a hover wider than the panel is cut off at its
+ * edge rather than overflowing across the conversation.
+ *
+ * `fixedOverflowWidgets: true` alone is not enough here. It switches the
+ * widgets to `position: fixed`, which normally escapes ancestor clipping —
+ * but one of the panel's ancestors is a Tailwind `@container`, and
+ * `container-type: inline-size` establishes a containing block for fixed
+ * descendants, so they stay trapped. Giving Monaco a node outside that
+ * subtree is what actually frees them.
+ *
+ * Shared by every open editor (Monaco supports that) and deliberately not
+ * torn down: it is one empty div, and removing it while another tab's editor
+ * still references it would break that editor's widgets.
+ */
+export function overflowWidgetsNode(): HTMLElement {
+  const existing = document.getElementById(OVERFLOW_NODE_ID);
+  if (existing !== null) return existing;
+  const node = document.createElement("div");
+  node.id = OVERFLOW_NODE_ID;
+  // Monaco's widget CSS is scoped under `.monaco-editor`, so the host node
+  // has to carry that class or the hovers render unstyled.
+  node.className = "monaco-editor";
+  node.style.position = "absolute";
+  node.style.top = "0";
+  node.style.left = "0";
+  // Above BB's panel chrome. Kept below the 50+ band that dialogs and the
+  // app header occupy, and since radix portals mount later in the body they
+  // still stack over this.
+  node.style.zIndex = "40";
+  document.body.appendChild(node);
+  return node;
+}
+
+/** Keeps the overflow host on the same Monaco theme as the editors. */
+export function setOverflowWidgetsTheme(theme: "vs" | "vs-dark"): void {
+  const node = document.getElementById(OVERFLOW_NODE_ID);
+  if (node !== null) node.className = `monaco-editor ${theme}`;
+}
+
+function injectScript(src: string): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const script = document.createElement("script");
+    script.src = src;
+    script.async = true;
+    script.onload = () => resolve();
+    script.onerror = () => reject(new Error(`Failed to load ${src}`));
+    document.head.appendChild(script);
+  });
+}
diff --git a/plugins/monaco/package.json b/plugins/monaco/package.json
new file mode 100644
index 0000000000..7f92a494aa
--- /dev/null
+++ b/plugins/monaco/package.json
@@ -0,0 +1,64 @@
+{
+  "name": "bb-plugin-monaco",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Edit files in BB with the Monaco editor instead of the read-only preview.",
+  "license": "MIT",
+  "homepage": "https://github.com/get-bb/bb#readme",
+  "bugs": {
+    "url": "https://github.com/get-bb/bb/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/get-bb/bb.git",
+    "directory": "plugins/monaco"
+  },
+  "files": [
+    "dist",
+    "server.ts",
+    "app.tsx",
+    "components",
+    "lib",
+    "README.md"
+  ],
+  "engines": {
+    "bb": ">=0.0",
+    "bbPluginSdk": ">=0.4.9"
+  },
+  "bb": {
+    "name": "Monaco",
+    "description": "Edit files in BB with the Monaco editor instead of the read-only preview.",
+    "branding": {
+      "icon": "Code"
+    },
+    "server": "./server.ts",
+    "app": "./app.tsx"
+  },
+  "keywords": [
+    "bb-plugin"
+  ],
+  "scripts": {
+    "test": "vitest run --config vitest.config.ts",
+    "typecheck": "tsc --noEmit -p tsconfig.json"
+  },
+  "dependencies": {
+    "@bb/shared-ui": "workspace:*",
+    "monaco-editor": "^0.56.0",
+    "zod": "^4.3.6"
+  },
+  "devDependencies": {
+    "@get-bb/plugin-sdk": "workspace:*",
+    "@testing-library/react": "^16.3.2",
+    "@types/node": "^22.0.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "jsdom": "^29.0.1",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "sonner": "^1.7.4",
+    "typescript": "npm:@typescript/typescript6@^6.0.2",
+    "typescript-7": "npm:typescript@^7.0.2",
+    "vitest": "^4.1.1"
+  }
+}
diff --git a/plugins/monaco/scripts/stage-assets.mjs b/plugins/monaco/scripts/stage-assets.mjs
new file mode 100644
index 0000000000..a4629f44b9
--- /dev/null
+++ b/plugins/monaco/scripts/stage-assets.mjs
@@ -0,0 +1,35 @@
+/**
+ * Copies Monaco's prebuilt AMD bundle into `dist/vs`.
+ *
+ * Packaging ships only a builtin plugin's `dist/` and `skills/` directories
+ * (`apps/server/scripts/copy-builtin-plugins.ts`), so anything the plugin
+ * needs on disk at runtime has to be inside `dist/`. The server serves this
+ * directory over a `files.createPreview` URL and the frontend loads Monaco
+ * from it; without the copy, a released BB has no Monaco to serve.
+ *
+ * Run after `build-official-plugins.mjs`, which clears `dist/` first.
+ */
+import { cp, mkdir, stat } from "node:fs/promises";
+import { createRequire } from "node:module";
+import path from "node:path";
+
+const pluginRoot = path.resolve(import.meta.dirname, "..");
+const require = createRequire(path.join(pluginRoot, "package.json"));
+
+// `exports` rewrites every subpath to ./esm/vs/*, so resolve the package root
+// (whose `require` condition is min/vs/index.js) and take its directory.
+const sourceDir = path.dirname(require.resolve("monaco-editor"));
+const targetDir = path.join(pluginRoot, "dist", "vs");
+
+const loader = path.join(sourceDir, "loader.js");
+try {
+  await stat(loader);
+} catch {
+  throw new Error(
+    `monaco-editor's AMD build is missing (${loader}); run npm/pnpm install first`,
+  );
+}
+
+await mkdir(path.dirname(targetDir), { recursive: true });
+await cp(sourceDir, targetDir, { recursive: true });
+console.log(`monaco: staged ${sourceDir} -> ${targetDir}`);
diff --git a/plugins/monaco/server.ts b/plugins/monaco/server.ts
new file mode 100644
index 0000000000..da530629dc
--- /dev/null
+++ b/plugins/monaco/server.ts
@@ -0,0 +1,322 @@
+// bb-plugin-monaco — backend entry.
+//
+// Three jobs, all in service of the `fileOpener` slot in app.tsx:
+//   1. `assets`  — hand the frontend a URL it can load Monaco's AMD build from.
+//   2. `read`    — read the opened file off whichever host owns it.
+//   3. `write`   — save it back, guarded by a content hash.
+//
+// Everything file-shaped goes through `bb.sdk.files`, never `node:fs`: the
+// file being edited may live on an enrolled remote machine, and `rootPath`
+// confinement plus the compare-and-swap guard are the reason to use it even
+// when it does not.
+import path from "node:path";
+import { existsSync } from "node:fs";
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk";
+import { z } from "zod";
+
+/** Files above this are refused: Monaco bogs down and the tab is unusable. */
+const MAX_EDITABLE_BYTES = 8 * 1024 * 1024;
+
+/**
+ * Ceiling on the file-tree listing — also the daemon's own maximum, which
+ * rejects anything larger with "Too big: expected number to be <=10000".
+ * A repository bigger than this lists partially and says so.
+ */
+const MAX_TREE_ENTRIES = 10_000;
+
+/**
+ * Preview lease lifetime. One hour is the server's maximum — it rejects
+ * anything larger with "Too big: expected number to be <=3600000" — so the
+ * lease is re-issued rather than held.
+ */
+const ASSET_LEASE_TTL_MS = 60 * 60 * 1000;
+
+/**
+ * Re-issue the lease when it has less than this left. Comfortably longer than
+ * a page load, so a tab opening near expiry never races it.
+ */
+const ASSET_LEASE_REFRESH_MARGIN_MS = 5 * 60 * 1000;
+
+/**
+ * `PluginFileOpenerSource` as it arrives over the wire. BB owns this shape;
+ * we re-validate it because RPC input is a boundary like any other.
+ */
+const sourceSchema = z
+  .object({
+    kind: z.enum(["workspace", "host", "thread-storage"]),
+    threadId: z.string().nullable(),
+    environmentId: z.string().nullable(),
+    projectId: z.string().nullable(),
+    /** Set for a project-backed workspace file opened on a non-primary host. */
+    experimental_hostId: z.string().optional(),
+  })
+  .strict();
+
+const fileSchema = z
+  .object({ path: z.string().min(1), source: sourceSchema })
+  .strict();
+
+export const rpcContract = defineRpcContract({
+  assets: {
+    input: z.null(),
+    output: z.object({ baseUrl: z.string(), expiresAtMs: z.number() }),
+  },
+  read: {
+    input: fileSchema,
+    output: z.discriminatedUnion("kind", [
+      z.object({
+        kind: z.literal("text"),
+        content: z.string(),
+        sha256: z.string(),
+      }),
+      // Not an error: binary and oversized files are ordinary things to click
+      // on. The frontend renders BB's own preview for these instead.
+      z.object({ kind: z.literal("unsupported"), reason: z.string() }),
+    ]),
+  },
+  tree: {
+    input: z.object({ source: sourceSchema }).strict(),
+    output: z.object({
+      /** Absolute root the entries are relative to, for "copy absolute path". */
+      root: z.string(),
+      entries: z.array(
+        z.object({
+          path: z.string(),
+          kind: z.enum(["file", "directory"]),
+        }),
+      ),
+      // The daemon caps its own listing; surfacing the flag lets the UI say
+      // "showing the first N" instead of quietly presenting a partial tree
+      // as if it were the whole project.
+      truncated: z.boolean(),
+    }),
+  },
+  write: {
+    input: fileSchema.extend({
+      content: z.string(),
+      // The hash `read` returned. Null means "create only" — we never send it
+      // today, but the SDK distinguishes it from an absent guard, so the
+      // contract keeps the distinction rather than collapsing it.
+      expectedSha256: z.string().nullable(),
+    }),
+    output: z.discriminatedUnion("outcome", [
+      z.object({ outcome: z.literal("written"), sha256: z.string() }),
+      z.object({
+        outcome: z.literal("conflict"),
+        currentSha256: z.string().nullable(),
+      }),
+    ]),
+  },
+});
+
+/**
+ * Monaco's prebuilt AMD bundle directory.
+ *
+ * Two layouts, because a builtin plugin ships differently from how it is
+ * developed. Packaging copies only `dist/` and `skills/` (see
+ * `apps/server/scripts/copy-builtin-plugins.ts`), so `stage-monaco.mjs` puts
+ * a copy of `min/vs` at `dist/vs` at build time and that is what a released
+ * BB serves. Running from source there is no `dist/`, so fall back to
+ * node_modules.
+ *
+ * The node_modules lookup resolves the package root rather than the file:
+ * monaco's `exports` map rewrites every subpath to `./esm/vs/*`, so
+ * `require.resolve("monaco-editor/min/vs/loader.js")` fails, while resolving
+ * the root under the `require` condition lands on `min/vs/index.js`.
+ */
+function resolveMonacoVsDir(): string {
+  const staged = path.join(path.dirname(fileURLToPath(import.meta.url)), "vs");
+  if (existsSync(path.join(staged, "loader.js"))) return staged;
+  const require = createRequire(import.meta.url);
+  return path.dirname(require.resolve("monaco-editor"));
+}
+
+export default async function plugin(bb: BbPluginApi) {
+  const vsDir = resolveMonacoVsDir();
+
+  let assetLease: { baseUrl: string; expiresAtMs: number } | null = null;
+
+  /**
+   * A preview URL over Monaco's asset directory, refreshed before it lapses.
+   * `createPreview` serves any file beneath the root from BB's own origin,
+   * which is what lets the AMD loader pull `editor.main.js`, the stylesheet,
+   * and each language definition on demand.
+   */
+  async function assets() {
+    const now = Date.now();
+    if (
+      assetLease === null ||
+      assetLease.expiresAtMs - now < ASSET_LEASE_REFRESH_MARGIN_MS
+    ) {
+      // No hostId: Monaco lives in this plugin's node_modules, on the server.
+      assetLease = await bb.sdk.files.createPreview({
+        rootPath: vsDir,
+        ttlMs: ASSET_LEASE_TTL_MS,
+      });
+    }
+    return assetLease;
+  }
+
+  /**
+   * The thread-storage root, mirroring the server's own resolution: the
+   * `BB_THREAD_STORAGE` override if set, else `<dataDir>/thread-storage`.
+   * Reading `process.env` is legitimate here — plugins run in-process inside
+   * the server, so this is the same environment the server resolved from.
+   */
+  async function threadStorageRoot(): Promise<string> {
+    const override = process.env.BB_THREAD_STORAGE;
+    if (override && override.trim().length > 0) return path.resolve(override);
+    const { dataDir } = await bb.sdk.system.config();
+    return path.join(dataDir, "thread-storage");
+  }
+
+  /**
+   * Where a file the user clicked actually lives. `workspace` paths are
+   * worktree-relative and need the environment to become absolute; `host`
+   * paths are already absolute; thread-storage paths are relative to the
+   * thread's own storage directory. All three are confined to a root, so a
+   * traversal in the path cannot escape the worktree, the file's directory,
+   * or the thread's storage.
+   *
+   * BB's public API is read-only over thread storage, so we resolve it to a
+   * plain filesystem path instead and get editing for free. Known limitation:
+   * `dataDir` is the *server's*, so a thread whose environment lives on an
+   * enrolled remote machine resolves to a path that does not exist there and
+   * fails to open rather than silently touching the wrong host's disk.
+   */
+  async function resolveTarget(
+    source: z.infer<typeof sourceSchema>,
+    filePath: string,
+  ): Promise<{ path: string; rootPath: string; hostId?: string }> {
+    if (source.kind === "thread-storage") {
+      if (source.threadId === null) {
+        throw new Error("This thread-storage file has no thread");
+      }
+      const rootPath = path.join(await threadStorageRoot(), source.threadId);
+      return { path: path.join(rootPath, filePath), rootPath };
+    }
+    // A workspace file opened from a project surface has no environment: it
+    // lives directly in one of the project's source checkouts. `hostId` is
+    // explicit there, because a project's sources can span hosts.
+    if (source.environmentId === null && source.kind === "workspace") {
+      if (source.projectId === null) {
+        throw new Error("This file has no environment or project");
+      }
+      const project = await bb.sdk.projects.get({
+        projectId: source.projectId,
+      });
+      const sources = project.sources;
+      const checkout =
+        source.experimental_hostId === undefined
+          ? (sources.find((entry) => entry.isDefault) ?? sources[0])
+          : sources.find(
+              (entry) => entry.hostId === source.experimental_hostId,
+            );
+      if (checkout === undefined) {
+        throw new Error("This project has no matching source checkout");
+      }
+      return {
+        path: path.join(checkout.path, filePath),
+        rootPath: checkout.path,
+        hostId: checkout.hostId,
+      };
+    }
+    if (source.environmentId === null) {
+      throw new Error("This file has no environment to resolve it against");
+    }
+    const environment = await bb.sdk.environments.get({
+      environmentId: source.environmentId,
+    });
+
+    if (source.kind === "host") {
+      // Absolute already; confine to its own directory so the path cannot
+      // walk somewhere else on the host.
+      const api = path.win32.isAbsolute(filePath) ? path.win32 : path.posix;
+      return {
+        path: filePath,
+        rootPath: api.dirname(filePath),
+        ...(environment.hostId ? { hostId: environment.hostId } : {}),
+      };
+    }
+
+    if (!environment.path) {
+      throw new Error("This environment has no workspace path");
+    }
+    return {
+      path: path.join(environment.path, filePath),
+      rootPath: environment.path,
+      ...(environment.hostId ? { hostId: environment.hostId } : {}),
+    };
+  }
+
+  bb.rpc.register(rpcContract, {
+    assets: () => assets(),
+
+    async read({ path: filePath, source }) {
+      const target = await resolveTarget(source, filePath);
+      const file = await bb.sdk.files.read(target);
+
+      // The daemon returns base64 when the bytes are not valid UTF-8. Handing
+      // that to Monaco would render mojibake and, worse, saving it would
+      // corrupt the file.
+      if (file.contentEncoding !== "utf8") {
+        return { kind: "unsupported" as const, reason: "This file is not text" };
+      }
+      if (file.sizeBytes > MAX_EDITABLE_BYTES) {
+        return {
+          kind: "unsupported" as const,
+          reason: `This file is too large to edit (${Math.round(file.sizeBytes / 1024 / 1024)} MB)`,
+        };
+      }
+      return {
+        kind: "text" as const,
+        content: file.content,
+        sha256: file.sha256,
+      };
+    },
+
+    /**
+     * The file tree the "Show in files" panel renders, as a flat list of
+     * root-relative paths; the frontend nests them.
+     *
+     * Rooted at the same place `read`/`write` confine to — the worktree for a
+     * workspace file, the thread's storage directory, the file's own
+     * directory for a bare host path (there is no project to speak of there).
+     */
+    async tree({ source }) {
+      const target = await resolveTarget(source, ".");
+      const result = await bb.sdk.files.listPaths({
+        path: target.rootPath,
+        includeFiles: true,
+        includeDirectories: true,
+        limit: MAX_TREE_ENTRIES,
+        ...(target.hostId !== undefined ? { hostId: target.hostId } : {}),
+      });
+      return {
+        root: target.rootPath,
+        entries: result.paths.map((entry) => ({
+          path: entry.path,
+          kind: entry.kind,
+        })),
+        truncated: result.truncated,
+      };
+    },
+
+    async write({ path: filePath, source, content, expectedSha256 }) {
+      const target = await resolveTarget(source, filePath);
+      const result = await bb.sdk.files.write({
+        ...target,
+        content,
+        contentEncoding: "utf8",
+        expectedSha256,
+      });
+      return result.outcome === "written"
+        ? { outcome: "written" as const, sha256: result.sha256 }
+        : { outcome: "conflict" as const, currentSha256: result.currentSha256 };
+    },
+  });
+
+  bb.log.info(`serving Monaco from ${vsDir}`);
+}
diff --git a/plugins/monaco/tsconfig.json b/plugins/monaco/tsconfig.json
new file mode 100644
index 0000000000..09f569141b
--- /dev/null
+++ b/plugins/monaco/tsconfig.json
@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "strict": true,
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "jsx": "react-jsx",
+    "lib": [
+      "ES2022",
+      "DOM"
+    ],
+    "noEmit": true,
+    "skipLibCheck": true,
+    "types": [
+      "node"
+    ]
+  },
+  "include": [
+    "server.ts",
+    "app.tsx",
+    "components",
+    "lib",
+    "vitest.config.ts"
+  ]
+}
diff --git a/plugins/monaco/vitest.config.ts b/plugins/monaco/vitest.config.ts
new file mode 100644
index 0000000000..e7108d919b
--- /dev/null
+++ b/plugins/monaco/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineWorkspaceTestConfig } from "../../vitest.shared.js";
+
+export default defineWorkspaceTestConfig({
+  test: {
+    silent: "passed-only",
+    name: "bb-plugin-monaco",
+    include: ["**/*.test.{ts,tsx}"],
+    exclude: ["node_modules/**"],
+  },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 386e416b34..d3e64da26c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1677,7 +1677,7 @@ importers:
         version: typescript@7.0.2
       vitest:
         specifier: ^4.1.1
-        version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+        version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
 
   packages/client-core:
     dependencies:
@@ -3323,6 +3323,55 @@ importers:
         specifier: ^4.1.1
         version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
 
+  plugins/monaco:
+    dependencies:
+      '@bb/shared-ui':
+        specifier: workspace:*
+        version: link:../../packages/shared-ui
+      monaco-editor:
+        specifier: ^0.56.0
+        version: 0.56.0
+      zod:
+        specifier: 4.3.6
+        version: 4.3.6
+    devDependencies:
+      '@get-bb/plugin-sdk':
+        specifier: workspace:*
+        version: link:../../packages/plugin-sdk
+      '@testing-library/react':
+        specifier: ^16.3.2
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.10
+      '@types/react':
+        specifier: ^19.0.0
+        version: 19.2.13
+      '@types/react-dom':
+        specifier: ^19.0.0
+        version: 19.2.3(@types/react@19.2.13)
+      jsdom:
+        specifier: ^29.0.1
+        version: 29.0.1(@noble/hashes@2.0.1)
+      react:
+        specifier: ^19.0.0
+        version: 19.2.4
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.2.4(react@19.2.4)
+      sonner:
+        specifier: ^1.7.4
+        version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      typescript:
+        specifier: npm:@typescript/typescript6@^6.0.2
+        version: '@typescript/typescript6@6.0.2'
+      typescript-7:
+        specifier: npm:typescript@^7.0.2
+        version: typescript@7.0.2
+      vitest:
+        specifier: ^4.1.1
+        version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+
   plugins/pdf-preview:
     devDependencies:
       '@get-bb/plugin-sdk':
@@ -4822,11 +4871,11 @@ packages:
 
   '@esbuild-kit/core-utils@3.3.2':
     resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
-    deprecated: 'Merged into tsx: https://tsx.is'
+    deprecated: 'Merged into tsx: https://tsx.hirok.io'
 
   '@esbuild-kit/esm-loader@2.6.5':
     resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
-    deprecated: 'Merged into tsx: https://tsx.is'
+    deprecated: 'Merged into tsx: https://tsx.hirok.io'
 
   '@esbuild/aix-ppc64@0.19.12':
     resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
@@ -5472,7 +5521,7 @@ packages:
 
   '@expo/bunyan@4.0.1':
     resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
-    engines: {'0': node >=0.10.0}
+    engines: {node: '>=0.10.0'}
 
   '@expo/cli@57.0.16':
     resolution: {integrity: sha512-+HyMY2nAS6QBJb0nSeMz92p11bZ1AtWSRnhWnklznN07IRoQirisnKq2vr18Ayjb/fnb5F/um+n6FRhF0fZm1Q==}
@@ -10241,6 +10290,9 @@ packages:
   domino@2.1.8:
     resolution: {integrity: sha512-qLkTcPRkqvifAB0bweVznkWgAu2hwin8An+HbLy0bH1ZtMyJzwXiHKMhynwR1Wq4PgBvEyg2Y749j4JJnlfDKg==}
 
+  dompurify@3.4.8:
+    resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==}
+
   dompurify@3.4.9:
     resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==}
 
@@ -10627,6 +10679,7 @@ packages:
   eslint@9.39.3:
     resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
     hasBin: true
     peerDependencies:
       jiti: '*'
@@ -12332,6 +12385,11 @@ packages:
   markdown-table@3.0.4:
     resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
 
+  marked@14.0.0:
+    resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
+    engines: {node: '>= 18'}
+    hasBin: true
+
   marked@16.4.2:
     resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
     engines: {node: '>= 20'}
@@ -12783,6 +12841,9 @@ packages:
   moment@2.30.1:
     resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
 
+  monaco-editor@0.56.0:
+    resolution: {integrity: sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==}
+
   ms@2.0.0:
     resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
 
@@ -14579,10 +14640,6 @@ packages:
     resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
     engines: {node: '>=14.0.0'}
 
-  tinyrainbow@3.0.3:
-    resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
-    engines: {node: '>=14.0.0'}
-
   tinyrainbow@3.1.1:
     resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
     engines: {node: '>=14.0.0'}
@@ -19336,9 +19393,7 @@ snapshots:
       metro-runtime: 0.84.5
     transitivePeerDependencies:
       - '@babel/core'
-      - bufferutil
       - supports-color
-      - utf-8-validate
 
   '@react-native/normalize-colors@0.79.6': {}
 
@@ -21210,7 +21265,7 @@ snapshots:
     dependencies:
       '@vitest/pretty-format': 4.1.1
       convert-source-map: 2.0.0
-      tinyrainbow: 3.0.3
+      tinyrainbow: 3.1.1
 
   '@vue/compiler-core@3.5.39':
     dependencies:
@@ -22641,6 +22696,10 @@ snapshots:
 
   domino@2.1.8: {}
 
+  dompurify@3.4.8:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+
   dompurify@3.4.9:
     optionalDependencies:
       '@types/trusted-types': 2.0.7
@@ -23708,10 +23767,6 @@ snapshots:
     dependencies:
       pend: 1.2.0
 
-  fdir@6.5.0(picomatch@4.0.4):
-    optionalDependencies:
-      picomatch: 4.0.4
-
   fdir@6.5.0(picomatch@4.0.5):
     optionalDependencies:
       picomatch: 4.0.5
@@ -25065,6 +25120,8 @@ snapshots:
 
   markdown-table@3.0.4: {}
 
+  marked@14.0.0: {}
+
   marked@16.4.2: {}
 
   marked@18.0.5: {}
@@ -26040,6 +26097,11 @@ snapshots:
   moment@2.30.1:
     optional: true
 
+  monaco-editor@0.56.0:
+    dependencies:
+      dompurify: 3.4.8
+      marked: 14.0.0
+
   ms@2.0.0: {}
 
   ms@2.1.3: {}
@@ -28111,8 +28173,8 @@ snapshots:
 
   tinyglobby@0.2.16:
     dependencies:
-      fdir: 6.5.0(picomatch@4.0.4)
-      picomatch: 4.0.4
+      fdir: 6.5.0(picomatch@4.0.5)
+      picomatch: 4.0.5
 
   tinyglobby@0.2.17:
     dependencies:
@@ -28123,8 +28185,6 @@ snapshots:
 
   tinyrainbow@2.0.0: {}
 
-  tinyrainbow@3.0.3: {}
-
   tinyrainbow@3.1.1: {}
 
   tinyspy@4.0.4: {}
@@ -28598,11 +28658,11 @@ snapshots:
   vite@6.4.1(@types/node@22.19.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0):
     dependencies:
       esbuild: 0.25.12
-      fdir: 6.5.0(picomatch@4.0.4)
-      picomatch: 4.0.4
+      fdir: 6.5.0(picomatch@4.0.5)
+      picomatch: 4.0.5
       postcss: 8.5.26
       rollup: 4.62.2
-      tinyglobby: 0.2.16
+      tinyglobby: 0.2.17
     optionalDependencies:
       '@types/node': 22.19.10
       fsevents: 2.3.3
@@ -28615,11 +28675,11 @@ snapshots:
   vite@6.4.1(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0):
     dependencies:
       esbuild: 0.25.12
-      fdir: 6.5.0(picomatch@4.0.4)
-      picomatch: 4.0.4
+      fdir: 6.5.0(picomatch@4.0.5)
+      picomatch: 4.0.5
       postcss: 8.5.26
       rollup: 4.62.2
-      tinyglobby: 0.2.16
+      tinyglobby: 0.2.17
     optionalDependencies:
       '@types/node': 24.12.4
       fsevents: 2.3.3
@@ -28788,6 +28848,35 @@ snapshots:
       - tsx
       - yaml
 
+  vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)):
+    dependencies:
+      '@vitest/expect': 4.1.1
+      '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+      '@vitest/pretty-format': 4.1.1
+      '@vitest/runner': 4.1.1
+      '@vitest/snapshot': 4.1.1
+      '@vitest/spy': 4.1.1
+      '@vitest/utils': 4.1.1
+      es-module-lexer: 2.0.0
+      expect-type: 1.3.0
+      magic-string: 0.30.21
+      obug: 2.1.1
+      pathe: 2.0.3
+      picomatch: 4.0.5
+      std-env: 4.0.0
+      tinybench: 2.9.0
+      tinyexec: 1.0.2
+      tinyglobby: 0.2.17
+      tinyrainbow: 3.1.1
+      vite: 8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@opentelemetry/api': 1.9.0
+      '@types/node': 22.19.10
+      jsdom: 29.0.1(@noble/hashes@2.0.1)
+    transitivePeerDependencies:
+      - msw
+
   vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.19.12)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)):
     dependencies:
       '@vitest/expect': 4.1.1
diff --git a/turbo.json b/turbo.json
index 8a8bdee3aa..3f88fd1af8 100644
--- a/turbo.json
+++ b/turbo.json
@@ -820,6 +820,12 @@
         "topo"
       ]
     },
+    "bb-plugin-monaco#typecheck": {
+      "dependsOn": [
+        "@get-bb/plugin-sdk#build:types",
+        "topo"
+      ]
+    },
     "bb-plugin-pdf-preview#typecheck": {
       "dependsOn": [
         "@get-bb/plugin-sdk#build:types",
