Run nodeterm's canvas in a browser, backed by a headless Node server on your own
machine or box. The server serves the same built renderer the desktop app uses
and speaks a WebSocket-RPC protocol to it; a browser-side window.nodeTerminal
shim (src/renderer/bridge/) stands in for the Electron preload, so the React UI is
unchanged. It boots the same Electron-free core services (src/core/) through a
ServerPlatform, so terminals get the same tmux session continuity as the desktop app.
Phase 2 scope: terminals only. This is a real, usable terminal canvas over the network, but it is deliberately narrow — see Limitations.
npm run server:devserver:dev runs npm run build (renderer + core) then npm run server:build
(bundles src/server/main.ts → out/server/main.cjs via esbuild) and finally
node out/server/main.cjs. On a repeat run where the renderer is already built you
can skip straight to npm run server:start.
With no password configured yet, the server prints a one-time setup URL to stdout:
Setup: http://127.0.0.1:8443/setup?token=<32-hex>
nodeterm-server listening on http 127.0.0.1:8443
Open that URL, choose a password (min 8 chars), and you're signed in. The setup token is single-use and lives only in memory (never written to disk), so it's regenerated if the process restarts before setup completes.
Seed the password out-of-band with an env var — useful for containers / CI where nobody is watching stdout:
NODETERM_SERVER_PASSWORD='choose-a-strong-one' npm run server:startWhen NODETERM_SERVER_PASSWORD is set and no password is configured yet, the
server writes the scrypt hash on boot and skips the setup URL entirely — go straight
to /login. It is ignored once a password already exists (it never overwrites).
npm run build # electron-vite build → out/renderer, out/core
npm run server:build # esbuild → out/server/main.cjs
npm run server:start # node out/server/main.cjsPrecedence: CLI flag > environment variable > default.
| Flag | Env var | Default | Meaning |
|---|---|---|---|
--port <n> |
NODETERM_PORT |
8443 |
TCP port to listen on. |
--host <h> |
NODETERM_HOST |
127.0.0.1 |
Interface to bind. |
--data-dir <path> |
NODETERM_DATA_DIR |
~/.nodeterm-server |
Where auth, sessions, workspace, settings, and scrollback live. |
--renderer-dir <path> |
NODETERM_RENDERER_DIR |
out/renderer (resolved from cwd) |
Directory of the built renderer (index.html + hashed assets). |
--insecure-http |
— | off | Acknowledge serving plain HTTP directly on a non-loopback interface (see below). |
| — | NODETERM_SERVER_PASSWORD |
— | Seed the password headlessly on first boot (see above). |
--trust-proxy-header <name> |
NODETERM_TRUST_PROXY_HEADER |
— (off) | Reverse-proxy SSO trust: identity header asserted by your proxy (see Reverse-proxy SSO). |
--trust-proxy-nets <list> |
NODETERM_TRUST_PROXY_NETS |
127.0.0.0/8, ::1/128 |
Comma-separated IPs/CIDRs (IPv4+IPv6) whose requests may use the trust header. Only meaningful with the header set. |
Binding a non-loopback host (anything other than 127.0.0.1 / localhost /
::1) without --insecure-http is refused at startup — plain HTTP on a public
interface would leak the session cookie. The intended deployment is loopback-bound
behind a TLS-terminating reverse proxy (see TLS).
The Server Edition doubles as a headless notification host: a background process on any Linux box that gives a phone SSHing into it full push / Live-Activity coverage — without serving the browser UI and without opening a single port.
curl -fsSL https://raw.githubusercontent.com/eneskirca/nodeterm/main/scripts/install-server.sh | bashThe installer (scripts/install-server.sh) is idempotent — re-run it any time to update. It:
- needs only
git,curlandtaron the host — Node.js is no longer a prerequisite: if a system Node ≥ 20 (with npm) is present it's used as-is, otherwise the installer downloads a pinned Node LTS from nodejs.org into~/.nodeterm-server-app/runtime/nodeand uses it for the build and the systemd service (nothing is installed system-wide; Alpine/musl hosts still need a distronodejs). It also warns (with the apt/dnf one-liner) if the C toolchain (make/gcc/python3) node-pty's native build needs is missing; - clones (or
git pulls) the repo into~/.nodeterm-server-app; - installs deps (
npm ci --ignore-scripts, thennpm rebuild node-ptyagainst Node's ABI — the same trap the Docker build documents) and builds the renderer + server bundle; - installs a systemd service (
NODETERM_HEADLESS=1,Restart=on-failure, journald logs): a system unit at/etc/systemd/system/nodeterm-server.servicewhen run as root, or a per-user unit +loginctl enable-lingerotherwise — then enables and (re)starts it; - writes
<data-dir>/install-meta.json({version, commit, installedAt}, where<data-dir>defaults to~/.nodeterm-server). The server reads it at boot and surfaces it to a paired phone through the agent-status mirror's top-levelserverblock, so a connection can show which version this install is on — the answer to "how does an installed Server Edition learn about updates?"; - installs a daily auto-update timer (unless you opt out — see below).
Follow the logs with journalctl -u nodeterm-server -f (add --user for a non-root install).
The installer also installs a nodeterm-server-update.service (a oneshot) plus a
nodeterm-server-update.timer (OnCalendar=daily, RandomizedDelaySec=3600, Persistent=true),
with the same root/user split as the main service (a system unit under
/etc/systemd/system/, or a per-user unit under ~/.config/systemd/user/). When it fires, the
service:
git pull --ff-onlyin~/.nodeterm-server-app— updating the checkout including this updater script, so the updater logic keeps itself current;- re-execs the freshly-pulled
scripts/install-server.sh, which rebuilds and restarts the service. The installer is idempotent, so an already-current pull just no-ops through a harmless rebuild.
Opt out: run the installer with NODETERM_NO_AUTOUPDATE=1 — it skips the timer, and (so a
re-run can turn auto-update off) removes an existing timer/service. The installer prints the
timer status (systemctl list-timers) in its summary so you can confirm the next run.
Check or trigger it manually:
systemctl list-timers nodeterm-server-update.timer # add --user for a non-root install
systemctl start nodeterm-server-update.service # run an update nowAn install covers only the Unix user it runs as — its systemd unit, ~/.nodeterm-server-app
checkout, and ~/.nodeterm-server data dir are that user's. A box with several server users
(root, customerservice, …) needs one install per user; the phone offers this per
connection (it installs under whichever user its SSH session logs in as). Non-root installs rely on
loginctl enable-linger <user> (the installer runs it) so the user's systemd manager — and thus
both the service and the auto-update timer — keep running without an active login session.
Set NODETERM_HEADLESS=1 (also accepts true, case-insensitive) to boot in headless mode.
Everything boots exactly as usual — the loopback hook server, the agent-status mirror, the
usage poll, the granted push senders (push-notify + Live-Activity), and the pending-approvals
sweep — except the public HTTP/WS listener, which is never bound. There is no renderer
serving and no auth surface. NODETERM_HOST / NODETERM_PORT are ignored (nothing binds), and
platform.broadcast is a no-op while no browser UI is attached.
The whole point of this mode is that it adds no network attack surface:
- No public listener at all. The HTTP/WS server is never created, so there is no port to expose, no login page to brute-force, and no session cookie in flight.
- The hook server stays loopback-only. Agent CLIs POST their lifecycle hooks to
127.0.0.1(a per-session bearer token, fail-open) — it never leaves the host. - Push is outbound-only. Notifications reach the phone over outbound HTTPS, authorized
by a signed, device-scoped grant the phone itself drops at
~/.nodeterm/push-grants/<deviceId>.grantwhen it reaches the host over SSH. No inbound connection, no standing relay identity — a grant a phone never dropped simply means no push.
The phone's own SSH session into the host is the trust boundary; nodeterm adds nothing listening beside it.
tmux sessions deliberately outlive their clients (that is the continuity contract), so a host that
serves many SSH projects slowly accumulates detached agent sessions nobody will ever come back
to — a field report counted 95 sessions holding 34 GB of idle claude processes. Every
nodeterm-server (headless or serving) runs a session budget (src/core/session-budget.ts, the
tmux counterpart of the renderer's WebGL budget): every 10 minutes it sweeps the local
node-terminal and SSH-remote nodeterm-rmt sockets and reaps the least-recently-active
detached nt-* sessions when
- host available memory drops below a watermark (default 10% of RAM, floor 1 GB) — the primary, pressure-driven trigger; or
- the detached session count exceeds a cap (default 48) — the accumulation backstop.
Attached sessions are never touched, a grace window (default 6 h since last activity) protects
recent work, kills are re-verified against a fresh listing, and each sweep reaps at most a small
batch (default 8) so convergence is gradual. A reaped session is indistinguishable from a reboot
to its node: on next open it cold-restores (scrollback snapshot + claude --resume). Tuning /
kill switch via env: NODETERM_SESSION_MIN_AVAILABLE_MB, NODETERM_SESSION_MAX_DETACHED,
NODETERM_SESSION_GRACE_HOURS, NODETERM_SESSION_REAP_BATCH, NODETERM_SESSION_REAP_DISABLED=1.
Single-user auth. There is one password; sessions are per-browser.
- Password hashing: scrypt (
N=16384, r=8, p=1, 32-byte key, per-password random salt), stored asauth.json(mode0600) in the data dir. Login comparison is constant-time (crypto.timingSafeEqual). The app never stores the plaintext. - Session cookie: on successful login the server sets
nt_session=<random>withHttpOnly; SameSite=Strict; Path=/(andSecurewhen served over HTTPS). Sessions are persisted (sessions.json, mode0600) with a 30-day TTL and swept lazily.revokeAll()exists to drop every session but is only wired programmatically in Phase 2 (no logout-everywhere UI yet;/auth/logoutclears the current cookie). - Origin check on WS upgrade: the WebSocket endpoint requires a valid session
cookie and, when the browser sends an
Originheader, that its host matches the requestHost. A malformed Origin is rejected (never throws). This blocks cross-site WebSocket hijacking. (Non-browser clients without an Origin still must present a valid cookie.) - Login rate limit / lockout: 5 failed password attempts trip a 60-second lockout
(further attempts get
429 too_many_attempts); a success resets the counter. - Auth gate: every route except the login/setup pages and their POST handlers
requires a valid session — HTML navigations redirect to
/login, API/WS get401.
Deployments that front the server with an SSO reverse proxy (Cloudflare Access,
Tailscale, oauth2-proxy, Authelia…) already authenticate every request before it
reaches nodeterm, and the proxy asserts the identity in a request header. Setting
NODETERM_TRUST_PROXY_HEADER makes such requests count as authenticated — no
password, setup token, or session cookie needed:
# Cloudflare Access
NODETERM_TRUST_PROXY_HEADER=Cf-Access-Authenticated-User-Email node out/server/main.cjs
# Tailscale (tailscale serve / funnel with identity headers)
NODETERM_TRUST_PROXY_HEADER=Tailscale-User-Login node out/server/main.cjs
# oauth2-proxy / Authelia / most others
NODETERM_TRUST_PROXY_HEADER=X-Forwarded-User node out/server/main.cjsA request is trusted only when both hold:
- its TCP peer address is inside
NODETERM_TRUST_PROXY_NETS(default: loopback only — the same-host proxy deployment). If your proxy reaches the container over a private network, list it explicitly, e.g.NODETERM_TRUST_PROXY_NETS=127.0.0.1,10.0.0.0/8; - it carries the configured header with a non-empty value.
The header content is not validated (no JWT verification) — the trusted-network boundary is the trust statement. That makes two things non-negotiable: only the proxy may be reachable from the trusted networks, and the proxy must strip/overwrite the header on incoming client requests (Cloudflare Access, Tailscale and oauth2-proxy all do). A typo'd nets entry fails the boot rather than silently changing who is trusted; setting nets without a header is likewise refused.
Everything else stays intact: password/cookie auth keeps working beside it (e.g.
for loopback ops access), the WS upgrade still enforces the same-host Origin check,
and a proxy-authed visit to /login or /setup just redirects home. The boot log
prints a ⚠️ Proxy header trust ENABLED line naming the header and networks —
verify it matches what you meant to trust.
The server speaks plain HTTP by design. For anything beyond localhost, run it
bound to loopback and put a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare
Tunnel, a VPN, etc.) in front. The Secure cookie flag is set automatically when the
proxy forwards HTTPS (detected via X-Forwarded-Proto / the request being TLS).
--insecure-http only exists as an explicit, eyes-open escape hatch for trusted
private networks — prefer the proxy.
The repo ships a multi-stage Dockerfile (root) that builds the renderer + server bundle and
produces a slim runtime image with tmux, git and curl (the managed hook scripts POST
through curl). Any Dockerfile-based PaaS (Dokploy, Coolify, plain compose) can deploy it:
docker build -t nodeterm-server .
docker run -d -p 8443:8443 \
-e NODETERM_SERVER_PASSWORD='choose-a-strong-one' \
-v nodeterm-data:/data \
nodeterm-serverOn Dokploy specifically: create an app from this repo (Dockerfile build), attach a volume at
/data, set NODETERM_SERVER_PASSWORD, and point a domain at container port 8443 — Traefik
terminates TLS and forwards X-Forwarded-Proto, so the session cookie gets its Secure flag
and the clipboard runs in a secure context.
Things the image decides for you (see the Dockerfile comments for the full why):
- node-pty is compiled against Node's ABI, not Electron's. The repo's
postinstallrunselectron-rebuild, which targets Electron — every install in the image uses--ignore-scriptsplus an explicitnpm rebuild node-pty. Don't "simplify" that away. --insecure-httpis passed because the container must bind0.0.0.0for the proxy to reach it, and TLS lives in the proxy. Never publish the port directly on a public interface.- A container restart/redeploy kills the tmux server (it lives inside the container). The
cold-restore path bridges it — scrollback replays from the
/datasnapshot and resumable agents relaunch with--resume— but running processes die with each deploy. This is the one behavioral difference from a long-lived host install, where only a machine reboot does that. /datamust be a volume — auth, sessions, workspace and scrollback snapshots live there; without it every restart forgets the password and the canvas.
Agent CLIs (claude etc.) are not baked into the image — install them into the running
container (or extend the image) and authenticate inside a terminal node; their config lives
under the container user's home, so consider a volume there too if you rely on them.
The inline login/setup pages carry a strict CSP. The built index.html ships with a
default-src 'self' marker that the server rewrites at serve time to
default-src 'self'; connect-src 'self' ws: wss:; so the browser can open the
WebSocket. If that marker is ever missing, the server logs a loud warning (the WS
would otherwise be blocked) — rebuild the renderer or update the rewrite.
Voice-to-text input works in the browser via the same core speech service the desktop app uses. Dictate with ⌘⌥D (or the dock mic button); transcribed text goes into the overlay's editable field and is sent only via explicit Send/Insert into the node that was selected when the overlay opened — nothing auto-submits. Browser constraints: getUserMedia requires a secure context — HTTPS or localhost; the microphone permission prompt is the browser's own. Model storage: Downloaded models (tiny free tier; larger Pro models) are stored on the server's data dir under speech-models/, persisted across sessions and server restarts.
Two intentional departures from docs/superpowers/specs/2026-07-10-server-edition-design.md:
node:http+ws, not Fastify. The HTTP/WS surface is tiny (a handful of routes + one WS endpoint); the built-inhttpmodule pluswskeeps the dependency footprint minimal and avoids a framework for no gain.- scrypt, not argon2. scrypt is in Node's standard library (
crypto), so there's no native dependency to build/ship. Parameters follow the OWASP baseline.
- Terminal-only. Terminal nodes work (spawn, I/O, resize, tmux continuity). The
git panel, source control, Monaco editor/diff nodes, SDK chat node, agent-status
badges/hooks, and the folder picker are not wired into the server bridge yet —
their
window.nodeTerminalmethods are stubbed. Deferred to Phase 3. - Reconnect = full-page reload. When the WebSocket drops, the bridge shows an overlay and the recovery path is to reload the page; on reload each terminal warm-reattaches to its still-running tmux session and tmux redraws. (The spec's lighter "thin reconnect strip" is recorded as a v1 tradeoff.)
- Initial-connect failure showed a blank screen (resolved in Phase 3c). If the server was unreachable at the very first page load (as opposed to a mid-session drop), the reconnect overlay did not appear — you got a blank page. The bridge now shows the reconnect overlay on a failed first connect instead of booting the app, and reloads on reopen. See Phase 3c.
- No backpressure / flow-control auto-trigger. The
pty.setFlowplumbing exists end-to-end, but nothing automatically pauses a flooding PTY based on WebSocketbufferedAmountyet. Deferred to Phase 3. - Single user. One password, no accounts/roles.
Phase 3a widens the browser surface from terminals-only to the file-and-git
workflow. The server now registers the core fs, git, and commit-message
handlers and the browser bridge exposes real fs / git / files / context
APIs, so several node kinds and panels that were stubbed in Phase 2 now work in the
browser:
- Editor & diff nodes — Monaco editor nodes read/write files over
fs:read/fs:write(⌘S saves), and diff nodes rendergit:show-file+fs:read— both now function unchanged in the browser. - Source Control panel — stage / unstage / discard, diff, branch switch/create,
commit + push, and the ✦ AI commit message (BYO local agent CLI on the staged
diff) all run against the server's
gitservice. - Explorer — the file tree lists the project
cwdviafs:list.
The following affordances change shape in the browser (no native OS is reachable):
- Folder / file picker — there is no native dialog. "Open folder…" and file
pickers use an in-app server-directory browser (built on
fs:list) that lets you navigate and pick a path on the server's filesystem. shell.openExternal— opens the URL in a new browser tab rather than a desktop-side default browser.- "Reveal in Finder" / "open with default app" — inert in the browser (there is no desktop file manager to reveal into); these actions are hidden or no-op rather than erroring.
- Clipboard (copy) — the browser's
navigator.clipboardonly exists in a secure context (https orlocalhost). Over plain http on a LAN it isundefined, so the bridge falls back to a hidden-textareadocument.execCommand('copy'). That fallback only works inside a user gesture: the copy shortcut and the click-driven copy buttons are fine, but anOSC 52write driven by terminal output (vim "+y,gh,yazi) is not — it fails and raises a banner ("the browser blocks clipboard access over plain http"). Copy never fails silently, but if you want it to work properly, serve over https (or localhost) — that is one more reason for the TLS proxy above. Note also that Ctrl+Shift+C (advertised as copy on Linux/Windows) additionally opens Chromium's element inspector and a page cannot suppress that; Ctrl+Insert is the browser-safe copy chord.
The backpressure / flow-control gap noted in the Phase 2 limitations is now
closed: a flooding PTY is automatically paused based on the WebSocket
bufferedAmount and resumed when it drains, so the WS is protected.
Loose coordination (follow-up). The server-side WS backpressure and the renderer's terminal (xterm) flow control coordinate only loosely over a shared pause actuator (
ptyManager.setFlow) — full two-master coordination is a follow-up. Because either side can resume the pty independently, the server re-asserts its pause on every send while the socket buffer stays above the high-water mark (rather than latching on a single rising edge), so a renderer-side resume cannot silently latch the server's protection off.
Still deferred to Phase 3b: the SDK chat node and the agent-status badges/hooks are not yet wired into the server bridge.
Phase 3b brings the agent-observability surface — the same code path the desktop app
uses — to the browser. The server boots the loopback hook server
(hookServer.start(), on its own 127.0.0.1 port) and installs the managed hook
scripts, so every agent CLI the server spawns POSTs its lifecycle hooks back to the
host. wireAgentStatus installs the hook server's normalized + raw listeners and
routes each event over platform.broadcast, so the existing renderer badge / subagent
/ context code now works unchanged in the browser:
- Agent-status badges — an agent's own hooks (not output parsing) drive the shared
4-state model; the normalized event is broadcast over
agent:statusand the header shows the pulsing RUNNING (working) / NEEDS YOU (waiting/blocked) badge, plus the unread dot and completion notification (as a Web Notification — see Phase 2). - Subagent visualization — subagent start/end drive ephemeral SubagentNode cards
over
agent:subagent-activity; the main-process subagent tail streams each card's live transcript to the browser. - Context-window meter — the per-node context tail streams
context:updateso the meter fills as the session's transcript grows (this channel was already real in 3a; 3b feeds it from the hook pipeline).
Because the hook POST carries a transcript_path, a forged POST is defended by jailing
that path to the system-default ~/.claude/projects or a managed account dir
(isSafeLocalTranscriptPath) before any tail reads it.
Context Link works in the Server Edition. The feature itself lives entirely in core
(src/core/context-link.ts) and writes everything under dataDir; what it needs from a
shell is the link map — who is linked to whom.
The desktop gets that from its renderer, because React Flow holds the live canvas. That
does not fit here: agents and their tmux sessions keep running with the browser closed,
and a headless deployment may never have one attached. So the server derives the same map
from the persisted bridges[] of every canvas (workspaceStore.persistedCanvases() →
buildBackgroundLinkMaps, in src/server/context-link.ts). A workspace save re-derives
immediately; a 15 s sweep also catches transcript paths, which arrive from the hooks
rather than from any canvas change, and skips the write when nothing moved.
Consequences worth knowing:
- Boot reads the workspace once (
load({ sideline: false })) so links are live before the first browser connects. Read-only on purpose — sidelining a conflictedproject.jsonstays a renderer/probe decision. - Boot also writes into agent configuration directories when managed hook installation
is on: the
get-linked-contextClaude skill, plus marker-delimited instruction blocks in~/.codex/AGENTS.md,~/.gemini/GEMINI.mdand opencode'sAGENTS.md. The blocks are idempotent and preserve surrounding content. SetinstallHooks: falseto skip every one of those writes — the Context Link read handler still works, agents simply have to be told about the shim some other way. - Local-only, deliberately. No remote deps are injected (
initContextLink(pty, {})): the server runs ON the host whose transcripts and tmux it reads, and SSH projects are a desktop-only concept. contextLink.info()is still unsupported in the browser bridge, so the canvas note that announces a new link cannot quote the shim path. The boot-installed instructions cover discovery independently.
Deliberate scope skips (Phase 3b):
- The SDK chat node is still deferred — it is not wired into the server bridge.
- Canvas-control (
agent:control, the Claude-onlynodetermCLI verbs) is not wired over the server. - The
ptyDestroytail-teardown — resolved in Phase 3c. Phase 3b left this skipped (agent tails self-cleared only onSessionEnd, so a node closed without one left an idle file-tail); the server now untracks agent tails on node close, at desktop parity. See Phase 3c. - SSH-remote agent tails are N/A — the server has no SSH-project manager, so the SSH branch of the desktop hook-wiring block is dropped (the raw listener falls straight through to the local logic).
Phase 3c closes two follow-ups left open by Phase 3b, bringing the browser bridge to desktop parity on agent-tail cleanup and first-connect behavior:
ptyDestroytail-teardown (desktop parity). When a node is closed the server now untracks its agent tails (the per-node context/subagent session state plus the context tail and subagent tail) onptyDestroy, exactly as the desktop app does — no more idle file-tail lingering for a node closed without aSessionEnd(e.g. the × button). This was enabled by makingplatform.onmulti-listener (matching Electron'sipcMain.on): the new teardown listener runs alongsidePtyManager's ownptyDestroydestroy handler instead of clobbering it.- Clean boot on a failed initial connect. A failed first WebSocket connect no longer boots the app into a broken/blank state — the bridge shows the standard reconnect overlay and the app reloads on reopen, so first-load failure now behaves like a mid-session drop.
Still deferred (unchanged from Phase 3b): the SDK chat node, canvas-control
(agent:control / the nodeterm CLI verbs), full two-master flow-control coordination
(the server still re-asserts its WS backpressure pause on each send rather than co-managing
a single actuator with the renderer), and the web folder picker's hardcoded start
directory.
Run against a real browser (this is the human-verified path; the automated harness
only exercises the HTTP/auth surface). With npm run server:dev running:
- Setup / login — open
http://127.0.0.1:8443. On first run you're redirected to/setup(or use the printed setup URL); choose a password. On later runs you land on/login; sign in. - Add a terminal — click the dock
+and add a terminal node. It should spawn a shell and show a prompt. - Terminal I/O — type
echo hiand confirmhiprints. - Resize — drag the node's resize handle; the terminal should re-fit (cols/rows update) without garbling.
- Refresh the page — reload. The terminal must warm-reattach to its tmux session and redraw its current contents (running processes survive).
- Restart the server — stop
node out/server/main.cjs, start it again, reload the page. Same warm-reattach: the tmux server outlived the app, so state is intact. - Lockout — sign out (or open a fresh session), enter the wrong password 5 times; the 6th attempt should be rejected with a "too many attempts" lockout for ~60s.
- Open a folder (Phase 3a) — use "Open folder…"; the in-app server-directory
picker appears (no native dialog). Navigate to a git repo on the server and pick
it; a project opens on its
cwd. - Edit & save a file (Phase 3a) — open a file (Explorer or picker) into an editor node, make an edit, press ⌘S; the dirty dot clears and the change lands on disk.
- Source Control (Phase 3a) — open the Source Control panel; your edit shows as a change. Click it to see the diff, stage it (+), type a message, and commit; the file leaves the change list and the commit appears in recent commits.
- Agent status (Phase 3b) — open a Claude terminal node, run a prompt; the header should show the pulsing RUNNING badge while it works and clear (with an unread dot if the tab is unfocused) when it's done.
- Subagent card (Phase 3b) — from a Claude node, run a prompt that dispatches a
subagent (e.g. a
/task); an ephemeral subagent card appears connected to the parent node; click it to watch its live transcript stream. - Context meter (Phase 3b) — as a Claude session accumulates transcript, the node's context-window meter should fill.