Add a `desktop/` Electron app that embeds the existing Node server + node-pty
(all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend
unchanged; other LAN devices can still connect. The server needs zero changes —
startServer(cfg)/loadConfig already support programmatic embedding.
- Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy,
notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov)
- Electron glue: main/window/tray/menu/preload/embedded-server/logger
(hardened: contextIsolation, sandbox, deny foreign-origin navigation)
- Native value: OS notifications driven by /live-sessions status, tray, deep links
- Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules
on-disk under Resources so the server resolves its deps from /Applications;
node-pty rebuilt for the Electron ABI
- Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated
Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97);
.dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.
Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.
New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.
Plan: docs/PLAN_VOICE_COMMANDS.md.
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.
Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
fall to "Other"), degrade-to-flat when repos share no prefix, running projects
duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
the legacy per-device proj-favs.
Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
PREFS_STORE_PATH config.
Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
Give the two tab-bar line-icon buttons the same styled data-tip hover tooltip as
the toolbar icons, so their meaning is clear on hover (native title was slow/ugly):
- timeline → "Activity timeline"
- push toggle → "Notifications off — tap to turn on" / "…on — tap to turn off"
Left-anchored (they sit on the left of the tab bar). 158 push+tabs tests green.
The icons were swapped to lucide line icons, but the buttons kept a boxed/light
background that clashed with the minimal toolbar icons. CSS-only fix: render
.tab-timeline + .push-toggle-btn as transparent, monochrome 18px line-icon
buttons (text-dim, accent on hover/active) — identical to .toolbtn — overriding
the earlier boxed default. Markup unchanged (tests stay green: 158 push+tabs).
Swap the two v0.7 emoji buttons for lucide line icons (stroke=currentColor) so
they take the Amber theme colour like the toolbar icons:
- timeline toggle (tabs.ts) → scroll-text icon
- push toggle + disabled bell (push.ts) → bell icon, keeping the On/Off label
- icons.ts: ICON_TIMELINE, ICON_BELL; style.css sizes the inline svgs (16px)
Frontend-only. web tsc + build:web clean, 102 push tests green, bundle emoji-free.
The detail page now reads <repo>/CLAUDE.md and shows it in a scrollable
monospace box, with one smart button: "↻ Update" when it exists / "✨ Generate"
when it doesn't. The button opens an interactive Claude session in the repo
running /init (claude "/init") so you review what it writes — reuses the project
launcher, no new backend orchestration, stays a byte-shuttle.
- ProjectDetail += hasClaudeMd / claudeMd; buildProjectDetail reads CLAUDE.md
(truncated 64KB, best-effort, in the existing Promise.all)
- renderProjectDetail: CLAUDE.md section (content box or empty state) + button
- style.css: .proj-section-row / .proj-claudemd-btn / .proj-claudemd
Tests +5 (458): backend read present/absent; frontend content+Update,
empty+Generate, button opens `claude "/init"`. Verified: tsc clean, full suite
green, build OK, curl returns the content, in-browser shows the box + button.
The glossy emoji (🔍⚙▦🕘⌨🔗📱) clashed with the Amber dark theme. Swap them
for clean lucide line icons (MIT, stroke=currentColor) so they take the theme
colour — muted by default, Amber on hover — and add a styled CSS hover tooltip
(data-tip, right-anchored so it never clips the edge) showing what each does.
- public/icons.ts: 7 line icons (search/settings/dashboard/history/keyboard/
share/device), script-generated from lucide
- 7 toolbar modules: innerHTML = ICON_* + data-tip + aria-label
- style.css .toolbtn: 18px svg, --text-dim → --accent on hover, [data-tip] tooltip
Frontend-only. web typecheck + build clean; bundle has 0 toolbar emoji; verified
in-browser: line icons in theme colour, amber + tooltip on hover.
The big solid-amber "+ New session" button clashed with the refined segmented
control. Replace it with a dashed "+" tile that leads the session grid (same
size as the session cards, re-prepended each refresh), so it reads as a natural
"create" cell. Header is now just the title + Sessions/Projects toggle; the
redundant empty-state message is gone (the tile is the CTA).
Frontend-only (launcher.ts + style.css). Verified: web typecheck + build clean,
preview-grid tests green, in-browser the grid leads with the New-session tile
followed by the session cards (Open/Kill).
The Sessions chooser already shows every running session with a live thumbnail
and Open + New session, so fold delete into it and drop the separate page:
- add a Kill ✕ button to each session card (DELETE /live-sessions/:id + refresh),
reusing makePreviewCard's extraActions and the existing .mg-kill style
- remove public/manage.html + public/manage.ts and the esbuild manage entry
- remove the 🗂 toolbar button (main.ts) and the 🗂 Manage header link (launcher.ts)
Backend DELETE/preview routes stay (used by the launcher kill, thumbnails, and
the project pages). Frontend + build-config only.
Verified: full suite 453 green, web typecheck clean, build:web emits only main
(no manage.* artifacts), and in-browser the Sessions cards have Open + Kill (2→1
on kill), no Manage entry, and GET /manage.html → 404.
The detail view could kill sessions, but the grid card's session rows were
enter-only. Add a ✕ kill button to each card session row (stopPropagation so it
doesn't also enter the session); makeSessionRow gains an optional onKill,
makeProjectCard an optional onKillSession, and mountProjects wires it to
DELETE /live-sessions/:id followed by a refresh.
Frontend-only. projects-panel tests +1 (51): ✕ present only with the callback,
click kills the right id without entering. Verified in-browser: a card with 2
sessions dropped to 1 after clicking ✕.
Clicking a project's name opens an in-app detail view (back button returns to
the grid) with:
- header: name, path, branch, dirty
- Branch / Worktrees: each `git worktree list` entry (branch or detached@head,
main/current/locked/prunable tags, path) — a single-worktree repo shows its
branch; a multi-worktree repo shows them all
- Active sessions (N): detailed rows (status, devices watching, started-ago)
with Open + Kill
- the Claude/Codex/VS Code launcher row
Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via
execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions)
in projects.ts (validates absolute existing dir, reuses branch/dirty/session
helpers, uncached — detail is on-demand and wants fresh state); read-only
GET /projects/detail?path= (no Origin guard, like /projects; 400 missing /
404 invalid). Types: WorktreeInfo, ProjectDetail.
Frontend: detail view + navigation in projects.ts (grid<->detail), project
name becomes a link, Kill via DELETE /live-sessions/:id.
Tests +21 (452 total): worktree parser, buildProjectDetail (validation,
non-git, session merge, real git-init worktree), renderProjectDetail.
worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a
Claude session, then the detail showed its branch+worktree and the live
session with Open/Kill.
Replace each card's "+ start claude here" with a row of three brand-logo
launcher buttons (logo + caption, touch-friendly, tooltips):
- Claude (coral burst) → new terminal tab running `claude`
- Codex (OpenAI mark) → new terminal tab running `codex`
- VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST
VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default
`code`) on the host via execFile (no shell); path validated as an existing
absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added.
Active-session highlight (user request): when a project has a running Claude
session (Claude-hook status != unknown — plain shells/codex stay unknown), the
Claude logo gets a coral ring + tint, with a count badge when more than one.
New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts.
onOpenProject gains an optional cmd; makeProjectCard exported for tests.
Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher
row (3 buttons, claude/codex commands), and the active-Claude highlight + badge.
Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3
logos render with brand colours, and POST /open-in-editor actually launched
VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
The toggle sat on its own dedicated row, leaving a large empty band above the
project grid. On desktop (>=769px) float it top-right on the SAME row as the
filter box / launcher header: panels fill from the top (inset 0), launcher-head
reserves right padding for the pill. Mobile keeps the centred stacked toggle to
avoid overlapping the full-width filter on narrow screens. Also reclaim the
hidden key-bar's space on the home view (body.home-open #term { bottom: 0 }).
CSS-only (style.css is served directly). Verified in-browser: toggle and filter
share a row on Projects, no overlap with New/Manage on Sessions.
- Segmented control: replace the two free-standing bordered boxes with a
single rounded track holding two pill buttons (iOS-style), active segment
filled Amber; centred via fit-content + margin auto. Panel top-offset 44→68px.
- Hide the bottom key-bar on the home chooser (it only targets an active
terminal): updateHomeView toggles body.home-open; CSS hides #keybar.
Frontend-only. 415 tests green; both typechecks clean; verified in-browser
(toggle pill renders, keybar hidden on home, reappears when a tab opens).
Once a project/session tab was open, the home chooser (Sessions/Projects
segmented control) was hidden and only reachable by closing every tab — no
way back to the Projects view to open another project.
Add a ⌂ Home button to the tab bar that overlays the chooser on top of the
open tabs (homeForced flag; updateHomeView shows home when no tab OR forced,
hiding the terminal panes). Clicking ⌂ again, clicking a tab (activate resets
homeForced), or opening another session/project dismisses the overlay.
Closing the last tab resets the flag. The button highlights while active.
Tests: +3 (tabs.test.ts). Full suite 415 green; both typechecks clean;
verified in-browser (open tab → ⌂ → Projects panel reachable → ⌂ → terminal).
Prior-session prep for the v0.6 Project Manager, checkpointed on the
v0.6-projects branch as a clean base for the parallel builders:
- P1 (src/types.ts): ProjectInfo + ProjectSessionRef contracts; Config
gains projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck.
- P3 (src/config.ts): parseBool + parseProjectRoots helpers; parse the
4 PROJECT_* env vars with defaults ([homeDir]/4/10000/true).
- UI: public/style.css indigo -> Amber (#e3a64a) accent theme.
- Docs: FEATURE_PROJECT_MANAGER.md (1:N session design), THEME.md,
docs/mockups/ (final-amber.png is the locked visual).
Typecheck (backend + web) green. No behavior change to runtime code yet;
P2/P4/P5/P6 implement the feature on top of this.
Opening the app no longer auto-creates a blank tab or auto-restores/discovers
sessions as tabs. Instead it lands on a launcher (session chooser): a grid of the
host's running sessions as live preview thumbnails — pick one to open (replays its
full scrollback), or '+ New session'. Closing the last tab returns to the chooser.
- public/launcher.ts: mountLauncher() renders the chooser into the terminal area
(live xterm thumbnails via /live-sessions + /preview, 5s refresh), New session +
Manage link
- tabs.ts: drop restore()/syncLiveSessions() auto-tab behavior; constructor lands
on the launcher; rebuild() toggles launcher when tab count is 0; closeTab no
longer spawns a blank tab
- style.css: .launcher chrome (reuses the .mg-* card/grid/thumbnail styles)
Verified: fresh load → 0 tabs, chooser with thumbnails; Open → tab + chooser
hides; close last tab → chooser returns. 228 tests green, tsc + build clean.
The flat session list didn't tell you what each session was doing. Rebuild
/manage.html as a full-page grid where each card renders a LIVE preview
thumbnail — a read-only xterm showing the session's actual current screen,
scaled down like a screenshot — so you can recognize each at a glance.
- RingBuffer.tail(maxBytes): newest scrollback on chunk boundaries (no ANSI/UTF-8
split); GET /live-sessions/:id/preview returns {cols,rows,data} (no attach, so
it doesn't inflate watcher counts or keep sessions alive)
- manage.ts: card grid; per card a scaled read-only Terminal renders CLEAR+tail,
auto-refresh every 4s, click thumbnail/Open to join, Kill / bulk kill
- bundles xterm into manage.js (+manage.css linked in manage.html)
- ring-buffer tail tests
Verified: 3 sessions rendered as recognizable thumbnails (top / git log / ls),
colored, with status/age; click opens. 228 tests green, tsc clean.
A shared PTY can only be one size; min-sizing clamped the shared session to the
SMALLEST viewer, so a wide desktop got letterboxed when an iPad was also attached
(iPad looked full-screen, desktop did not).
Switch to latest-writer-wins: the device that most recently fit/focused drives the
PTY size, so whichever device you're actively using is full-screen.
- session.ts: setClientDims resizes the PTY directly (drop min across clients);
attach/detach/blur never resize (the active device keeps its size)
- frontend: TerminalSession.refit() force-resends dims; window 'focus' /
visibilitychange re-assert the active tab's size, so switching devices reclaims
full-screen
- tests updated for latest-wins (incl. join/hidden/blur don't resize)
Verified (two ws clients): 200x50 → iPad 100x40 → desktop refit 200x50 → iPad blur
keeps 200x50. 225 tests green.
The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.
Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur
Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry
Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
- tabs.ts: on open, syncLiveSessions() fetches /live-sessions and adds a tab for
each host session not already open → any device sees & mirrors everything
running on the host. Empty storage + no live sessions → one fresh tab.
activeSessionId() + openSession(id) helpers.
- share.ts: 🔗 toolbar button → QR + <origin>/?join=<id> for the active session.
- main.ts: ?join=<id> on load opens/focuses that shared session, then strips the
param. Mount share button (toolbar: 🔍 ⚙ ▦ 🕘 ⌨ 🔗📱).
- style.css: #sharemodal reuses the QR card/backdrop.
Verified in-browser: fresh device auto-discovered the host session and replayed
its scrollback; two concurrent clients (browser + ws) both received live output
(clientCount=2); share modal shows the join URL/QR.
Relax the one-WS-per-session invariant (#5) so multiple devices mirror the
same live terminal (everyone sees output, anyone can type — tmux-style).
- types.ts: Session.attachedWs → clients Set + clientDims Map; add cwd;
SessionManager.list() + LiveSessionInfo
- session.ts: broadcast() output/exit to all clients; attachWs JOINS (no kick,
takes dims); detachWs removes one client, stamps detachedAt only when the last
leaves; setClientDims resizes PTY to the MIN cols/rows across clients
- manager.ts: join instead of kick; broadcast status; onExit drops only when a
client was attached; list() for discovery
- server.ts: GET /live-sessions; resize → per-client setClientDims; close →
detach one client; permission gate uses clients.size
- tests: rewrote session/manager tests for multi-client (join/broadcast/min-dims/
last-detach) + list(); 222 pass, tsc clean
keybar:
- add Ctrl+R (搜历史/\x12), Ctrl+L (重绘/\x0c), Ctrl+D (退出/\x04)
- each key now shows a short function caption under the glyph (self-documenting
on touch where tooltips don't appear); 17 keys total
- test/keybar.test.ts updated to 17 keys
cheat-sheet (new public/shortcuts.ts):
- ⌨ toolbar button opens a grouped reference of common Claude Code shortcuts
(general / editing / multiline / macOS / prefixes), each labeled with what it
does; keys also on the bottom bar are tagged 键栏
- Esc / click-outside to close; styled card with sticky header + accent tags
216 tests green; tsc + build clean.
- O2: /sessions reads ~/.claude/projects JSONL; 🕘 panel resumes a past session
in a new tab (claude --resume <id>) in its project dir
- chore: *.css module decl removes the last @ts-ignore
- ui: drop in-terminal Connecting/Connected lines (tab dot shows it)
216 tests green.
- public/css.d.ts: declare module '*.css' so tsc resolves CSS side-effect
imports; removed the @ts-ignore in main.ts
- PROGRESS_LOG F1-F9 table ticked to match what's actually verified (F4/F7 still
need a physical device)
- src/http/history.ts: listSessions() reads ~/.claude/projects/*/*.jsonl
(mtime-sorted, top 50), parseSessionMeta() extracts cwd + first user prompt
(pure, 4 unit tests); GET /sessions
- terminal-session: opts.initialInput (typed ~700ms after the shell is ready)
- tabs.newTabForResume(cwd,id): new tab in the project dir running
'claude --resume <id>'
- public/history.ts: 🕘 panel listing sessions (project · time · preview) with
Resume buttons
- Verified: /sessions returns 44 real sessions; panel lists project/time/preview.
216 tests green.
The tab connection dot (green/amber/red) already shows connection state, so the
'[terminal] Connecting…/Connected' and 'Disconnected — reconnecting' lines just
cluttered the scrollback. Rely on the dot; keep only the actionable exit message.
- title-util.cwdFromOsc7: parse cwd from OSC 7 (file:// URL); 4 unit tests
- terminal-session: register OSC 7 handler → cwd getter; opts.cwd → sent in the
attach frame on a fresh session
- protocol/types: ClientMessage attach.cwd (absolute path); validated
- session/manager/server: thread cwd → createSession spawn cwd (defaults homeDir)
- tabs: '+' (newTab) opens in the active tab's reported cwd, falls back to home
- Verified: server attach.cwd spawns there; cd /private/tmp → + → new tab pwd is
/private/tmp. 212 tests green.
- M3: settings.ts (⚙ panel: dark/light/solarized theme + font size A-/A+),
persisted to localStorage; terminal-session.applyTheme; TabApp.applySettings
applies to all + new tabs
- M7: dashboard.ts (▦ overlay listing every tab with connection dot + folder +
Claude status; click a row to focus; live re-render while open);
TabApp.snapshot()/focusTab()
- Browser-verified: light theme renders + persists; dashboard lists tabs and
focuses on click. 208 tests green, typechecks + build ok.