Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.
- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
plan gates fall back to today's name-only bar.
Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
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).
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.
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).
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.
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.
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.
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
- 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.
- src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort
- config: useTmux from USE_TMUX env (1/0/auto→detect tmux on PATH)
- session: when useTmux, spawn 'tmux new-session -A -s web_<id> <shell>' (the
node-pty proc is a tmux CLIENT); createSession takes an optional id for
re-attach; Session.tmuxName; kill() runs tmux kill-session
- manager: handleAttach re-attaches to a surviving 'web_<id>' tmux session after
a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so
the shell keeps running
- tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs
useTmux:false; integration 'H1' (real tmux): set var → restart server →
re-attach → var survived. 208 tests green.
- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
- All ARCHITECTURE §3 interfaces as pure types; dependency-free (no ws/node/DOM)
so both backend and frontend can import it
- Refinements vs §3 (documented): EnvLike (not NodeJS.ProcessEnv), WebSocketLike +
WS_OPEN (not ws.WebSocket), added Config.wsPath (invariant 8)
- ARCHITECTURE §3.1/§3.4 reconciled to match (anti-drift)
- tsc --noEmit passes
T2 of docs/PLAN.md