copilot-runway
v0.2.1
Published
A local web dashboard for visualizing and orchestrating multiple GitHub Copilot CLI sessions across multiple projects
Maintainers
Readme
Copilot Runway
A local web dashboard for visualizing and orchestrating GitHub Copilot CLI sessions across all your projects.
If you work across multiple repos with several Copilot CLI sessions running at once, Runway gives you a single control tower to see what's happening, read conversation history, and send prompts without switching terminals.

Features
- Project sidebar: auto-discovers projects from the Copilot CLI data store; add custom folders on the fly
- Session list: view all sessions for a project, filter by active/inactive, sorted by most recent activity
- Live status: detects active sessions via PID lock files with process verification
- Conversation viewer: read full session history with Markdown rendering (GFM, code blocks, tables) and syntax highlighting
- Send prompts: send one-shot prompts to new or existing sessions, streamed back via SSE
- Resizable detail panel: drag to resize, double-click to collapse, auto-expands on session select
- Agent selection: choose custom agents for new or existing sessions; Runway remembers the last-used agent per session
- Localhost only: binds to
127.0.0.1with CORS protection
Prerequisites
- Node.js 18+
- GitHub Copilot CLI installed and on your
PATH(copilot --versionshould work)
Install
Run it instantly with npx (no install required):
npx copilot-runwayOr install globally for a permanent command:
npm install -g copilot-runway
copilot-runwayThe server starts and opens your browser automatically:
Copilot Runway running at http://127.0.0.1:3847From source
git clone https://github.com/jamesmcroft/copilot-runway.git
cd copilot-runway
npm install
npm startDevelopment mode
npm run devUses node --watch to auto-restart the server on file changes.
How it works
Runway reads from the Copilot CLI's local data stores (read-only) to build the dashboard:
| Source | What it provides |
| ------------------------------------------------ | -------------------------------------------------------------- |
| ~/.copilot/session-store.db | Session history, conversation turns, checkpoints, file changes |
| ~/.copilot/data.db | Project list (repos you've used with the CLI) |
| ~/.copilot/session-state/<id>/workspace.yaml | Session name, working directory, branch, git root |
| ~/.copilot/session-state/<id>/inuse.<pid>.lock | Active session detection (PID verified against OS) |
Custom projects and app settings are stored in ~/.runway/ to keep Runway's data separate from the CLI's databases:
| File | Purpose |
| --------------------------------- | ----------------------------------------------------------------- |
| ~/.runway/projects.json | Custom project folders added through the dashboard |
| ~/.runway/session-agents.json | Last-used agent per session (auto-populated when sending prompts) |
| ~/.runway/settings.json | Global Runway settings (worktrees root, default agent, launchers) |
| ~/.runway/project-settings.json | Per-project setting overrides keyed by absolute project path |
When you send a prompt, Runway spawns a copilot process with -p "your prompt" --output-format json and streams the JSONL output back to the browser via Server-Sent Events. You can optionally select a custom agent (via the CLI's --agent flag), and Runway remembers your choice per session.
Settings
Open the settings page from the gear icon in the top bar, or with the Ctrl+, (Cmd+, on macOS) shortcut. The page is also reachable directly at /settings.
On-disk layout (under ~/.runway/):
settings.jsonholds the global document:{ "schema_version": 1, "values": { ... } }. Read on demand, cached in memory, rewritten atomically (tempfile + rename) on every save.project-settings.jsonholds per-project overrides as a single map keyed by the project's absolute path:{ "schema_version": 1, "projects": { "/abs/path": { ... } } }.launchers.jsonis the legacy editor-binary file from earlier releases. On first boot after upgrading, Runway folds its contents intosettings.jsonunderlaunchers.vscodeand logs a deprecation warning. The old file is left in place for one release; a future version will remove it.
Resolution order (highest precedence last):
- Hardcoded default (declared in
lib/runway/settings-schema.js). - Global value from
settings.json. - Per-project override from
project-settings.json(only for keys marked overridable;worktrees.rootis global only).
Visual preferences (theme mode, palette family, detail-panel width) stay in browser localStorage so they apply without a server round-trip and never travel between machines. Theme mode (system / light / dark) is independent from palette; eight palettes ship today: default, solarized, monokai-inspired, high-contrast, material, tokyo-night, catppuccin, rose-pine. Each palette CSS file declares both light and dark variants so flipping theme mode keeps the active palette.
Reset to defaults: delete the relevant file under ~/.runway/ and reload. Runway will recreate it with schema defaults on the next save. For a partial reset, edit the JSON directly (Runway tolerates unknown keys and falls back to defaults if it cannot parse the file, logging a warning rather than crashing).
Removing a project
The settings page (per-project section) has a Remove project button that hard-purges the project from every Runway-owned state file under ~/.runway/. The confirmation modal includes:
- A typed-name input. The destructive Remove button stays disabled until you type the project's display name (the last path segment) verbatim. Standard guard for irreversible actions.
- An Also delete worktree directories on disk checkbox, default checked. When checked, every worktree directory bound to this project is removed through
git worktree remove --forceso git's worktree metadata stays consistent. When unchecked, the worktree bindings are still cleared from Runway state but the on-disk directories stay for manual recovery. - A summary line listing what will be removed, for example "Will remove 1 project entry, 2 setting override groups, 3 pins, 2 worktree bindings."
The sweep is implemented as a dynamic registry over per-project state files (projects.json, project-settings.json, pins.json, session-agents.json, worktree-bindings.json). New per-project stores can join the sweep without touching the route layer. The global launchers.json file is not project-scoped and is left untouched. Every per-file write is atomic (tempfile plus rename), and a missing or malformed individual store is treated as "nothing to remove" rather than a fatal error so a partially corrupt state directory still purges cleanly.
Active sessions block removal. If any live Copilot CLI session is attached to the project (an alive PID under ~/.copilot/session-state/<id>/inuse.<pid>.lock), the server returns HTTP 409 with a body of the form { "error": "active_sessions", "sessionIds": [...] } and performs no state mutations. The modal lists the blocking session ids and prompts you to stop them first; Runway never force-kills sessions on your behalf.
Idempotent re-delete returns 404. Once a project's Runway state is gone there is nothing to remove on the second attempt, so the server replies 404 Not Found. The UI translates a 404 into a "Project is already removed" toast. The 404 response shape is intentional: it is also returned for any other unknown project key so callers can rely on a single not-found path.
What is preserved. Historical session rows in the CLI's ~/.copilot/session-store.db are never touched. Session detail pages still load after the project is removed; their project label degrades to the raw cwd path. The user's repository on disk is never touched under any code path.
Live updates
The dashboard subscribes to /api/events (Server-Sent Events) for push-based updates so the UI reflects CLI activity within milliseconds instead of waiting for a periodic poll. The server watches two surfaces:
~/.copilot/session-state/(recursivefs.watch) for session directory andinuse.<pid>.lockchanges~/.copilot/session-store.dband its-walsidecar (hybridfs.watchplus periodicfs.statmtime heartbeat) for write activity, with a 250 ms debounce so the burst of WAL commits from a single CLI turn produces one event
The event channel publishes the following types:
| Event | Trigger | Data |
| ------------------ | -------------------------------------------------------------------- | ------------------------------------- |
| ready | Sent immediately on each successful connection | { at } |
| session.created | New directory under ~/.copilot/session-state/ | { sessionId, at } |
| session.active | inuse.<pid>.lock appears and the PID is alive | { sessionId, pid, at } |
| session.inactive | inuse.<pid>.lock disappears or the PID fails a liveness probe | { sessionId, at } |
| session.ended | Session directory removed | { sessionId, at } |
| db.activity | Debounced write activity on session-store.db or -wal | { at } |
| state.snapshot.end | Marks the end of the initial state snapshot on a new connection | { count, at } |
On every new connection the server sends ready, then one session.active or session.inactive frame per known session under ~/.copilot/session-state/ (classified by the same PID liveness check used for live emissions), then state.snapshot.end with the total snapshot count, and only after that the live delta stream. Snapshot frames are sent only to the new client's stream, never broadcast. Subscribers can therefore build full session state from the SSE stream alone, without a parallel REST call. Any events fired by the watcher during the snapshot read are captured in a per-connection buffer and drained immediately after state.snapshot.end, so no state change can be lost in the gap.
Frames are encoded as event: <type>\ndata: <json>\n\n and a comment heartbeat (: heartbeat\n\n) is sent every 25 seconds to defeat idle-proxy timeouts. EventSource clients ignore comment lines.
If the SSE connection drops, the dashboard reconnects with exponential backoff (1s, 2s, 4s, 8s, 16s, capped at 30s). After three consecutive failures, or if the browser does not support EventSource, the client falls back to a 30 second polling loop. The fallback is cancelled automatically the next time SSE reconnects and emits ready.
The session detail panel updates live off the same channel. When the CLI commits a new turn or checkpoint, a coalesced db.activity event triggers a re-fetch of the selected session and the conversation re-renders within roughly two seconds, with no manual reload. Refreshes are de-duplicated so at most one request is in flight at a time, and a stale-result guard discards any response that arrives after you have switched to a different session. Scroll position is preserved across re-renders: if you were scrolled to the bottom, the panel follows the conversation to the new bottom; if you had scrolled up to read earlier history, your position is kept exactly where it was.
Syntax highlighting
Fenced code blocks in the conversation view are highlighted with Prism (MIT). The eagerly loaded language set covers javascript, typescript, jsx, tsx, python, bash, shell-session, powershell, json, yaml, markdown, css, markup (HTML), sql, go, rust, csharp, java, and diff. Unknown or missing fence languages fall through to plain text, and blocks larger than 100,000 characters bypass the tokenizer so a pasted log dump cannot stall the renderer. Token colors are mapped to the existing theme variables so the same stylesheet works in both light and dark mode. Highlighting is render-time only via Prism.highlight(string, grammar, language), never Prism.highlightElement, so user content cannot escape the tokenizer.
Concurrency safety
Runway only ever reads the CLI's SQLite files via better-sqlite3 in read-only mode (WAL-safe) and subscribes to filesystem change notifications. It never locks or writes the CLI's files, so there is no interference with the CLI as the active writer. fs.watch events from SQLite WAL commits are debounced (250 ms) to avoid event storms.
Worktrees
Each session can opt in to a dedicated git worktree so parallel sessions on the same project never step on each other's files. The feature is fully opt in: new sessions get no worktree by default, and you bind one explicitly from the session detail panel.
How binding works
- Open a session and click Bind worktree in the Worktree section of the detail panel.
- Runway creates a new branch
runway/<session-id-short>(the first eight alphanumeric characters of the session id) off the project's currentHEAD. - Runway runs
git worktree addto materialize the branch under<worktrees.root>/<sanitized-project-name>/<session-id-short>on disk and persists the path-to-session binding in~/.runway/worktree-bindings.json.
The sanitized project name is the project folder basename, lowercased, with non-alphanumeric characters folded to single hyphens, trimmed, and capped at 64 characters. The slug is purely a directory naming convenience; the canonical project key remains the absolute path.
Where worktrees live
The root directory is controlled by the worktrees.root global setting (see /settings). The default is ~/.runway/worktrees and a leading ~ is expanded to your home directory. The setting is global only: there is no per-project override, so every project's worktrees are siblings under a single tree.
Opening a worktree
Once bound, the panel offers Open worktree in VS Code which launches your editor at the worktree path via the vscode://file/... URL handler. The same handler is used by Code, Code-Insiders, Cursor, and Codium, so the action respects whatever editor binary your OS has configured.
Manual cleanup
Removal is always manual. The Remove worktree button opens a confirmation modal that shows the path and a clean or dirty badge based on git status --porcelain. The modal exposes two opt in checkboxes:
- Force removal: passes
--forcetogit worktree remove. Required when the worktree has uncommitted changes; the server rejects a non-force delete on a dirty tree with HTTP 409. - Also delete the branch: deletes the local
runway/<id>branch after the worktree is gone. Disabled when the branch has commits beyond the branch point (Runway checks reachability withgit merge-base --is-ancestor), so you can never lose work in flight by accident.
There is no automatic cleanup on session end or server restart. Bindings persist across restarts and only the explicit Remove action drops them.
Concurrency
A worktree path can be bound to at most one session at a time. If a second session resolves to the same path (for example, two session ids share the same eight-character prefix on the same project), the server returns HTTP 409 with the bound session id. The UI shows a "Focus the bound session" dialog whose button navigates straight to that session.
Diffing two worktrees
The compare-worktrees UI is out of scope for this iteration. Use git diff from either worktree directly for now.
Project structure
copilot-runway/
├── server.js # Express app wiring: middleware, static assets, route mounting, listen
├── lib/
│ ├── paths.js # Shared filesystem path constants; ensures ~/.runway exists
│ ├── cli/
│ │ ├── spawn.js # `copilot` CLI spawn helper + running-process registry
│ │ └── agents.js # List available custom agents (cached) via the CLI
│ ├── store/
│ │ ├── db.js # Read-only openers for ~/.copilot/session-store.db and data.db
│ │ └── sessions.js # workspace.yaml reader, lock-file/PID status, find-new-session-id
│ ├── runway/
│ │ ├── projects.js # Load/save ~/.runway/projects.json (custom folders)
│ │ ├── session-agents.js # Load/save ~/.runway/session-agents.json (per-session agent)
│ │ ├── settings.js # Read/write/cache layer for settings.json and project-settings.json
│ │ ├── settings-schema.js # Descriptor table + defaults (single source of truth)
│ │ └── settings-migrations.js # Forward-only migration runner keyed by schema_version
│ ├── watchers/
│ │ ├── lifecycle.js # fs.watch on ~/.copilot/session-state/, emits session.* events
│ │ └── db.js # Hybrid fs.watch + mtime heartbeat on session-store.db, emits db.activity
│ └── routes/
│ ├── projects.js # GET /api/projects, POST /api/projects/add
│ ├── sessions.js # GET /api/sessions, /sessions/active, /sessions/:id
│ ├── send.js # POST /api/sessions/send (SSE stream of CLI events)
│ ├── agents.js # GET /api/agents
│ ├── stats.js # GET /api/stats
│ ├── settings.js # GET/PATCH/PUT /api/settings and /api/settings/projects/:projectKey
│ └── events.js # GET /api/events (SSE stream of lifecycle and DB events)
├── bin/
│ └── copilot-runway.js # CLI entry point (npx / global install)
├── public/
│ ├── index.html # Single-page app shell
│ ├── settings.html # Standalone settings page (loaded at /settings)
│ ├── settings.css # Layout for the settings page
│ ├── settings.js # Schema-driven settings form (UMD)
│ ├── palettes.js # Palette manifest + apply helper (UMD)
│ ├── palettes/ # Per-palette CSS files, loaded on demand
│ ├── styles.css # Light and dark themed styles
│ ├── app.js # Frontend (rendering, themes, resize, markdown, API calls)
│ ├── logo.svg # App logo
│ └── favicon.svg # Browser tab icon
├── .github/
│ ├── workflows/ci.yml # CI + npm publish pipeline
│ ├── dependabot.yml
│ └── ISSUE_TEMPLATE/
├── package.json
├── LICENSE
└── README.mdModule boundaries
The backend is split along three concerns so the upcoming WebSocket bridge has a clean place to live:
lib/cli/owns every interaction with thecopilotbinary (spawn, agent discovery, the running-process registry).lib/store/owns read-only access to the Copilot CLI's own state under~/.copilot/(the two SQLite databases and per-sessionworkspace.yaml+inuse.<pid>.lockfiles).lib/runway/owns Runway's own config files under~/.runway/(custom projects, per-session agent selection).lib/routes/contains one Express router per resource. Routers depend on the three layers above; they never reach into the filesystem or spawn processes directly.server.jsis wiring only: it sets up middleware (JSON body, CORS, static assets), mounts the routers under/api/..., and starts the listener.
API reference
All endpoints are localhost-only with CORS origin protection.
| Method | Path | Description |
| ------ | ------------------------------------------------- | ------------------------------------------------------------------------------- |
| GET | /api/projects | List all projects (CLI + custom), sorted by recent activity |
| POST | /api/projects/add | Add a custom project folder ({ folderPath, name? }) |
| GET | /api/projects/:projectKey/summary | Counts of Runway-owned state for the project ({ projects, projectSettings, pins, sessionAgents, worktreeBindings }) and the list of active session ids attached to it |
| DELETE | /api/projects/:projectKey?removeWorktrees=true\|false | Hard-purge the project. 204 on success, 404 on unknown key (and idempotent re-delete), 409 with { error: "active_sessions", sessionIds } when live sessions are attached, 400 on a malformed key |
| GET | /api/sessions?cwd=...&limit=50&active_only=true | List sessions, optionally filtered by directory |
| GET | /api/sessions/active | List all active sessions across all projects |
| GET | /api/sessions/:id | Session detail with paginated chronology (turns + inline file events) and checkpoints |
| POST | /api/sessions/send | Send a prompt (SSE stream). Body: { prompt, sessionId?, cwd?, name?, agent? } |
| GET | /api/agents | List available custom agents (cached 5 min) |
| GET | /api/stats | Dashboard stats (total sessions, active count, recent activity) |
| GET | /api/events | SSE stream of session lifecycle and DB activity events |
| GET | /api/sessions/:id/worktree | Worktree binding state for a session ({ bound, worktreePath?, branchName?, dirty?, canDeleteBranch? }) |
| POST | /api/sessions/:id/worktree | Bind a fresh worktree to the session. Returns 201 on create, 200 when already bound, 409 with boundSessionId on concurrency |
| DELETE | /api/sessions/:id/worktree | Remove the bound worktree. Body: { force?, deleteBranch? }. 409 on dirty without force |
| GET | /api/projects/:projectKey/worktrees | List worktrees for a project, enriched with the bound session id where applicable |
Configuration
| Environment variable | Default | Description |
| -------------------- | ------- | ------------------------------------------- |
| PORT | 3847 | Server port (change in server.js for now) |
Theme preference and panel width are stored in the browser's localStorage.
Contributing
Contributions are welcome. To get started:
- Fork the repo and clone your fork
npm installnpm run devto start with auto-reload- Make your changes and test locally
- Open a PR with a clear description of what you changed and why
