Developer Documentation
How the project is put together — start here before changing anything.
Overview
Meowtrix is deliberately small and dependency-light. The frontend is
plain ES scripts served directly from public/ —
no bundler, no modules, no build step. Files share state through the global
scope and are loaded with ordered <script> tags. The backend
is a single server.js with two responsibilities.
There is no linter or build step. A Playwright E2E test suite lives in tests/ and can be run locally or inside Docker — see Docker-based testing. After editing frontend code, reload the page (or use ./start.sh for auto-reload).
The canonical architecture notes live in CLAUDE.md at the repo root. This page mirrors them in a browsable form.
Repository layout
meowtrix/
├── server.js # backend (HTTP + WebSocket + PTYs + proxy + git/file APIs)
├── public/ # the frontend, served as-is
│ ├── index.html # script load order lives here
│ ├── ws.js # the single WebSocket + PTY callback map
│ ├── pane.js # pane/tab model, DnD, broadcast
│ ├── editor.js # Monaco text editor, file tree & Git client
│ ├── codesign.js # CoDesign tab: spec frame, comment pins, sign-off
│ ├── codesign-inspector.js # injected into specs: inspect/annotate over postMessage
│ ├── mtx.js # mtx agent bridge: tab placement, layout, notify
│ ├── layout.js # splitting & draggable dividers
│ ├── app.js # session bootstrap, serialize/restore, shortcuts
│ ├── mobile.js # on-screen key bar, sticky modifiers
│ ├── settings.js # settings panel + live application
│ ├── palette.js # ⌘K command palette (fuzzy action launcher)
│ ├── schedule.js # scheduled Enter overlay and cancellation
│ ├── update.js # self-update banner and API client
│ ├── combo.js # keystroke combo streak FX and canvas rendering
│ ├── pet.js # Chrome Gemini Nano on-device LLM chat companion
│ └── style.css # themes & layout
├── website/ # this project site (landing + docs + dev docs)
├── start.sh # dev: nodemon + hot reload
├── demo.sh # serverless static demo runner
└── install.sh # installer + service setup
Dev commands
npm install # install deps (node-pty compiles natively)
npm start # run the server on PORT (default 3000)
./start.sh # dev mode: nodemon + browser hot-reload (HOTRELOAD=1)
./demo.sh # serve public/ statically in serverless demo mode
npm run test:e2e # run Playwright E2E tests against a locally running server
npm run screenshots # capture landing-page showcase screenshots
Docker-based testing
Because node-pty compiles a native addon, binaries built on macOS are incompatible with Linux Docker images. Two workflows exist to work around this.
Full E2E suite via Docker Compose
The repo ships a docker-compose.test.yml that spins up two services:
- meowtrix — builds the app image from
Dockerfile, binds the container port to host port9234, and exposes a health-check endpoint so Playwright waits until the server is ready. - playwright — uses the official
mcr.microsoft.com/playwrightimage, installs dev-only deps (including Playwright itself), and runsnpx playwright testwithBASE_URL=http://meowtrix:9123.
# Run the full test suite in a clean Linux environment
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
# Tear down volumes afterwards (optional)
docker compose -f docker-compose.test.yml down -v
The node_modules directory inside the container is intentionally isolated from the host via an anonymous volume — this prevents macOS-compiled native binaries from polluting the Linux container.
Interactive dev container via docker-run.sh
For live development against a containerised server (e.g. to test on a different OS or to expose the app on a LAN), use docker-run.sh:
# Build the image and start Meowtrix on a random free host port
./docker-run.sh
# Optionally pass a specific workspace directory to mount
./docker-run.sh /path/to/your/project
The script auto-detects a free host port, mounts the repo as /app inside the container, and binds your chosen workspace directory to /workspace. Settings are persisted to the meowtrix-dev-settings named Docker volume across restarts. Hot-reload (HOTRELOAD=1) is enabled, so editing files in the mounted /app directory triggers a live server restart.
Debugging hints
Server-side (Node.js)
- Verbose logging: Set
DEBUG=1before starting the server (DEBUG=1 npm start) to enable additional console output for PTY lifecycle events and WebSocket messages where those log branches exist. - Inspect mode: Run
node --inspect server.jsand openchrome://inspectin Chrome to attach the V8 debugger — useful for stepping through WebSocket handlers and REST endpoints. - PTY output buffer: Each PTY keeps a rolling ~200 KB ring buffer in
ptys.get(id).buf. You can dump it from a Node REPL or a breakpoint to understand what a client would see on reconnect. - git API endpoints: Hit them directly with
curlto rule out frontend bugs, e.g.curl http://localhost:9123/api/git/status?root=/path/to/repo.
PTY replay issues
If the terminal looks garbled after a refresh, the likely cause is a width mismatch between the PTY's recorded grid size and the current pane size. The server sends pty:created {cols, rows} (the PTY's generation size) before the buffer on reconnect; the client snaps xterm to that size, writes the buffer, then re-fits. Check the onPtyRestore / onReplayDone path in pane.js.
Frontend (browser DevTools)
- Script load order: All frontend files are globals — if a function is undefined at call-time, the most likely cause is a missing or out-of-order
<script>tag inindex.html. Check the Network panel to confirm all scripts loaded. - WebSocket traffic: Open DevTools → Network → WS, select the socket connection, and watch the Frames tab. Every PTY input, schedule update, and session broadcast is visible there in real time.
- Monaco editor: Monaco is lazy-loaded from CDN by
ensureMonaco(). If the editor tab is blank, check the Console for AMD loader errors and confirm the CDN URLs are reachable from your network. - Session/workspace state: The active workspace JSON is written to
~/.meowtrix/session.jsonon the host. Inspect or delete it to reset the layout without restarting the server.
Single server, two jobs
Everything backend is in server.js:
- HTTP — serves
public/, the settings/session REST API, and the embedding proxy. - WebSocket — PTY lifecycle (
pty:create/input/resize/destroy) plus the active-session coordination messages.
PTY lifecycle & persistence
PTYs outlive WebSocket connections. The ptys Map keys each PTY by id and keeps a rolling ~200 KB output buffer. On reconnect, a pty:create for an existing id replays that buffer and reattaches the live stream instead of spawning a fresh shell — this is what makes refreshes and network blips non-destructive.
Closing a browser tab or dropping the WebSocket only detaches listeners; the shell keeps running. PTY state is in-memory only, so restarting the server kills every shell.
Single-session coordination
Only one client is the active session; every other connected client (across browsers and devices) sees the inactive overlay. The flow lives in server.js + public/app.js:
- The newest client to send
session:claimwins. - Others receive a
session:statebroadcast and drop to the overlay until they press Move session here. - When the active socket drops, the server hands off to the most-recently-connected remaining client.
This is intentionally not done via BroadcastChannel/localStorage — server coordination is what makes it work cross-device, not just across tabs in one browser.
Workspace state
The layout is serialized to the server, not the PTYs. captureWorkspaceState / restoreWorkspaceState (public/app.js) persist the pane/split/tab tree — including flex ratios and PTY ids — to ~/.meowtrix/session.json via /api/session.
Restoring rebuilds the DOM and reconnects each terminal to its still-alive PTY by id. Only the active session may write (an inactive tab holds a stale layout); saves are debounced and a beforeunload beacon flushes the final state.
Embedding proxy
The /proxy route fetches pages server-side, strips X-Frame-Options / CSP frame directives, and rewrites href/src/action URLs to route back through the proxy — letting otherwise un-embeddable sites render in a browser pane.
Two route shapes exist for a reason:
/proxy?url=— initial loads./proxy/<encoded>— target in the path, so GET-form submissions don't wipe the target when the browser replaces the query string with form fields.
Cookies. Proxied pages all share the Meowtrix origin, so the browser can't hold their cookies: the proxy strips Set-Cookie and keeps them in a server-side jar (proxy-cookies.js) that follows RFC 6265's Domain/Path/Secure/expiry rules and persists to ~/.meowtrix/proxy-cookies.json. Cookies are stored before a redirect is followed (sign-in POSTs answer 302 + Set-Cookie), request bodies are piped through untouched (the JSON body parser skips /proxy), and an injected document.cookie accessor reads a snapshot of the page's non-HttpOnly cookies and forwards writes to /api/proxy/cookie.
WebSockets. An injected shim rewrites new WebSocket(url) — including URLs built from location.host, which behind the proxy is Meowtrix's — to /proxy-ws/<ws|wss>/<host>/<path>. The server's upgrade router (server.js) hands those to proxyWsUpgrade, which replays the handshake upstream with Host/Origin/Cookie rewritten and pipes the two sockets together.
Host browser
A 'hostbrowser' tab drives a page in a real Chromium on the host (hostbrowser-service.js, via playwright-core + raw CDP) with a persistent profile in ~/.meowtrix/host-browser-profile. Each tab has its own WebSocket, /hostbrowser/<tabId>, so frames never queue behind terminal traffic:
- Pixels:
Page.startScreencastJPEG frames go to the tab as binary messages and are painted on a canvas (public/hostbrowser.js). The client acks each frame; the server holds back CDP's frame ack while two frames are unacknowledged, so a slow link lowers the frame rate instead of building a backlog. - Size: the page's viewport is the canvas's CSS size at the client's devicePixelRatio (
Emulation.setDeviceMetricsOverride), so frames map 1:1. Touch clients get touch emulation and real touch events, so the page scrolls and pinches natively. - Input: mouse/wheel/touch become
Input.dispatch*calls; keys go through Playwright's keyboard (key codes, macOS editing commands). A hidden textarea catches IME composition, soft-keyboard input and paste (sent asinsertText). ⌘/Ctrl is swapped when client and host platforms differ. - Lifecycle: pages are keyed by tab id and outlive their sockets, like PTYs — the newest socket becomes the viewer. Popups (
context.on('page')) get their own ids and ahostbrowser:popupbroadcast asks the active session to open a tab for them. Dialogs, file choosers and downloads are relayed to the viewer. Chromium is launched lazily and closed after 10 idle minutes. - Agent surface:
/api/hostbrowser/tabs(+/:id/screenshot,/:id/text,/:id/navigate) backsmtx browserand thehost_browser_*MCP tools.
Native browser tabs (desktop app)
In the Tauri app a 'browser' tab renders with a native webview (src-tauri/src/native_browser.rs + public/nativebrowser.js) instead of the proxy. The engine is a per-client choice — the saved tab is still 'browser', so the same layout uses the proxy in a plain browser.
- Each view is a borderless child window of the main window, not a second webview inside it: Tauri only treats a window as a
WebviewWindowwhile it holds one webview, soget_webview_window("main")(tray, overlay, updater) would break. On macOS the view is attached withaddChildWindowand ordered in without taking key focus. - The page owns placement: every frame it measures the placeholder and sends its rect plus the viewport size; Rust maps it as a fraction of the main window's content area. Native views draw over the DOM, so the page hides a view whenever hit-testing finds DOM UI (palette, menus, overlays) covering its placeholder.
- Events (load, title, SPA URL changes, new-window, downloads) come back by
evalingonNativeBrowserEventin the main page. App shortcuts pressed inside a native page (⌘K, ⌘L, ⌘W, workspace switching) are forwarded by navigating tomeowtrix-native://key/…, whichon_navigationcancels and relays — pages in native tabs get no IPC. - The main window loads the Node server, a remote origin to Tauri, so the
nb_*commands are granted incapabilities/remote-window.jsonvia the app manifest inbuild.rs. The server self-updates independently of the app, so the page probesnb_supportedand falls back to the proxy on older app builds.
Google, OAuth popups, and heavily bot-protected sites still won't work through it — that's what the host browser is for.
File System & Git APIs
The code editor is powered by client-side logic in public/editor.js communicating with Node-side APIs in server.js:
- Directory listing & Reads/Writes: Shipped over simple JSON endpoints (
/api/fs/list,/api/fs/read,/api/fs/write). Because the user already has full terminal/shell access, file reads and writes run with the same host permissions as the server process. - Git Operations: Backed by
/api/git/*endpoints in the server that safely shell out using Node'sexecFile(avoiding the shell injection risks ofexec). Endpoints includestatus(which runsgit status -zand parses branch tracking, staged, and unstaged modifications),filediff(which yields HEAD, index, or worktree buffers), and POST actions forstage,unstage,discard,commit,push, andpull. - AI Commit Message Generator: If a staged diff exists, the UI contacts the AI commit endpoint (using locally configured environment keys or fallback models) to generate a detailed commit description from the staged git diff.
CoDesign review loop
codesign-service.js (mounted from server.js) serves a living HTML spec at <project>/.codesign/spec.html and records the human's review. The agent side is just the shell CLI bin/mtx, with no MCP server.
- State on disk:
.codesign/review.jsonholds the revision counter, a hash of the spec, the comments and the latest decision.syncRevision()runs on every access and from anfs.watchFilepoll: a content change bumps the revision, clears the decision, and broadcastscodesign:update {dir}over the WebSocket. - Agent API:
mtx reviewlong-pollsGET /api/codesign/wait(≤50s per request, looped up to--timeout). The server replies right away if a decision exists for the current revision (an approval every time; a change request only until it has been delivered once). TheX-Codesign-Eventheader carriesapproved|changes|timeout|missing, andformat=textgives an agent-readable summary.mtx spec/reviewcallPOST /api/codesign/open, which broadcastscodesign:open; the active session opens a tab beside the calling agent's pane using the sharedmtxplacement policy (see mtx agent bridge). Agents runmtxin tool subprocesses whose output never reaches xterm, so an OSC sequence wouldn't work here. - Sandbox: the spec is served from
/codesign-spec/<base64url dir>/…withContent-Security-Policy: sandbox(noallow-same-origin), and the tab's iframe carries the same flags. The WebSocket rejectsOrigin: null. The tab never reads the iframe DOM:public/codesign-inspector.jsis inlined into the HTML and exchangesmode / pins / scale / focus / scrollTocommands andready / pick / pinClick / scrollevents withpublic/codesign.jsviapostMessage. - Pins are anchored to a CSS selector plus a fractional offset inside the element, so they follow reflow across device frames. Raw document coordinates are the fallback.
mtx agent bridge
mtx-service.js (mounted from server.js) exposes /api/mtx/open, /open-file, /layout and /notify for host processes: the mtx open|edit|layout|notify commands and the stdio MCP server bin/mtx-mcp.js (mtx mcp). The MCP server is a dependency-free adapter that turns each tool call into one HTTP call.
- RPC to the active session: the layout lives in the browser, so each endpoint sends
mtx:rpc {reqId, method, params}to the active session's socket and waits up to 8s formtx:rpc:result. Only that socket may answer. With no active session the endpoint returns 409no-active-session. - Caller identity: every PTY gets
MEOWTRIX_PTY_ID(plusMEOWTRIX_NODE, the server's node binary, formtx mcp). Callers pass it asptyId, so the client knows which pane is the agent's. CoDesign'scodesign:opencarries it too. - Placement is client policy (
mtxPlacePaneinpublic/mtx.js). Agents passauto,split, or a pane id fromlayout.autopicks a pane other than the caller's, preferring panes that show pages, then larger panes, and avoiding the focused pane. It splits the caller along its longer axis only when no other pane exists. Opening goes throughmtxPreservingFocus, so the user's active pane and terminal focus survive. - Reuse:
openretargets a browser tab with the exact URL, or one on the same origin if the host is loopback/private (a dev server). Public sites never get navigated away.open-filereuses the editor tab whose root most specifically contains the file; otherwise the server picks the enclosing git repo as the new editor's root. - Safety: URLs are normalized server-side and restricted to
http(s). There is no new trust boundary: anything that can reach the API already has a shell.
Schedules Timer
The scheduled Enter key press mechanism runs entirely server-side in server.js (the schedules Map, keyed by PTY ID). Living on the server allows timers to survive temporary browser disconnects or client reloads.
- Creation: Client triggers timer setup via a WebSocket message
schedule:create {ptyId, fireAt}. The server sets asetTimeoutfor that exact millisecond. - Execution: When the timeout fires, the server writes a carriage return (
\r) directly to the target PTY stream and deletes the schedule, broadcasting the updated schedules state back to all clients. - Stateless Rendering: The frontend
public/schedule.jsregisters forschedule:statebroadcasts. It is a pure visual overlay layer that renders countdown clocks and blurs terminal input while a schedule is active, but stores no timer state itself.
Supervisor-Driven Self-Updates
Self-updates are designed to run git-based updates seamlessly while protecting the running terminal processes:
- Hourly Check: The server runs a background check against upstream git coordinates. When updates are found, it broadcasts
update:stateto all sockets. - Clean Application: A POST request to
/api/update/applytriggers a git pull. If dependencies have changed, a local install runs. The server then callsprocess.exit(0). - Supervisor: If installed as a launchd/systemd service (defining
MEOWTRIX_SUPERVISED=1), the process manager automatically detects the clean exit and restarts the process using the fresh codebase. If run manually, the server exits and asks the user to restart it.
Frontend module map
All files are global-scope and load order matters. In index.html: demo.js → ws.js → pane.js → editor.js → codesign.js → hostbrowser.js → nativebrowser.js → layout.js → app.js → mobile.js → settings.js → palette.js → schedule.js → update.js → mtx.js → combo.js → pet.js. Cross-file calls are plain function references (e.g. wsSend, getAllPanes, applyStickyMods), not imports.
| File | Responsibility |
|---|---|
demo.js | Sets window.DEMO_MODE and, when on, provides the serverless backend (JS REPL + localStorage API shim). Loaded first so the flag exists before ws.js runs. |
ws.js | The single WebSocket, 1s auto-reconnect, and the ptyCallbacks map (ptyId → xterm Terminal). Queues pty:create calls made before the socket opens. |
pane.js | Pane/tab model and registry, tab creation for terminals, browsers, and editor tabs, drag-and-drop tab movements. |
editor.js | Code editor integration (Monaco, file sidebar, Markdown/HTML Preview, Git Source Control integration and AI commit messages). |
hostbrowser.js | Host Browser tab: canvas renderer for the streamed Chromium page, input/IME/clipboard forwarding, popups, dialogs and file choosers. |
nativebrowser.js | Desktop app: native-webview browser tabs — the nb_supported probe, per-frame placement and occlusion hiding, native events and forwarded shortcuts. |
layout.js | Splitting panes and the draggable dividers. Splits are flat; sizes are unitless flex-grow ratios so they survive resizes. |
app.js | Session bootstrap/claim/handoff, workspace serialize/restore, keyboard shortcuts, double-click → autocomplete. |
mobile.js | On-screen key bar. Ctrl/Alt/Cmd are sticky modifiers applied at the PTY-input layer (applyStickyMods), not via synthetic key events. |
settings.js | Settings panel and live application of font/scrollback/theme, and checking LLM availability for the pet. |
palette.js | Fuzzy command palette launcher for quick shortcuts and workflow commands. |
schedule.js | UI controller for setting, countdown formatting, and cancelling scheduled Enter timers. |
update.js | Stateless banner and status text rendering for the server self-update check. |
combo.js | Keystroke combo counter, decays, streak metrics, canvas particle bursts and edge glows. |
pet.js | Desktop pet assistant (Mochi) using Chrome Gemini Nano local Prompt API for chatbot functionality. |
Persisted state (host machine)
| File | Contents |
|---|---|
~/.meowtrix/settings.json | Theme, terminal font/size/scrollback, shell, browser homepage. Server merges over DEFAULT_SETTINGS. |
~/.meowtrix/session.json | Serialized workspace layout (panes, splits, tabs, PTY ids). |
~/.meowtrix/proxy-cookies.json | The embedding proxy's cookie jar (mode 0600). |
~/.meowtrix/host-browser-profile/ | The Host Browser's Chromium profile (cookies, storage, history). |
PTY state itself is in-memory only — restarting the server kills all shells.
Serverless demo
Because the frontend is bundler-free static files, it can run with no backend at all. public/demo.js activates when the URL has ?demo and stands in for the server:
- Terminals → JS REPL.
demoBackendspeaks the exact samepty:*/session:*message shapesws.jsexpects; each "PTY" is an in-browser JavaScript REPL with its own line editor, history, andeval()-based evaluation. - REST → localStorage. A
fetchshim intercepts/api/settingsand/api/sessionand backs them withlocalStorage, soapp.jsandsettings.jsare untouched. - Browser panes load URLs directly (no proxy), so only embeddable sites render — with an on-screen hint to that effect.
The seams are narrow: ws.js branches on window.DEMO_MODE for the socket, and fetch is shimmed for the API. Everything else — pane.js, layout.js, app.js, settings.js, mobile.js — is shared verbatim between the real and demo paths. Run it locally with ./demo.sh.
Distribution
install.sh clones/updates into ~/.meowtrix/app, installs production dependencies, and writes a meowtrix launcher to ~/.local/bin. With --service it installs a launchd agent (macOS) or systemd user service (Linux) that auto-starts on login and restarts on crash. The README's install commands curl this script from GitHub.
Project site & deploy
This site lives in website/ (landing page + these docs) of projects/meowtrix in the HEJI monorepo and is deployed with the HEJI apps site by .github/workflows/pages.yml: projects/meowtrix/docs/pages.json publishes website/ at /hejitech-apps/meowtrix/site/ and the app's public/ at site/demo/ (visited as demo/?demo).
Pages source is set to GitHub Actions (not "deploy from a branch"). All links in the site are relative so it works under the /meowtrix/ project-pages base path.