The low-severity leftovers from the panel audit, plus the tooling trap that made
reviewing this work harder than it needed to be.
.gitattributes (new) — `public/projects.ts diff`
That file embeds literal NUL bytes on purpose: the namespace sentinels
'\0active' / '\0other' are prefixed so they cannot collide with a real
`First.Second` key. git therefore classified it as binary and printed
"Binary files differ" for every diff, and `git diff --stat` reported
"Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively
unreviewable. The `diff` attribute makes git diff it as text without setting
`text`, so no line endings are normalised and the bytes on disk are untouched.
(Same trap in the shell: grep prints only "Binary file ... matches", which
reads as a no-match. The comment in the file says to use grep -a.)
Detached HEAD now shows its short sha
The Head cell held the "detached HEAD" chip and nothing else: it said what was
wrong and nothing about where you were. SyncState gains an optional `head`,
populated from the .git/HEAD text readSyncState ALREADY reads for the detached
test — so this costs no extra I/O and no extra git call. Guarded by a hex regex
and rendered as textContent.
One geometry for the ghost button role
The design defines two button roles. The outline one had drifted to three
different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at
12.5px/7px 15px/7px) and both used full-strength --text instead of the muted
label. All four outline buttons are now declared once, together, at the
design's geometry; the duplicate .proj-diff-toggle block further down the file
is gone, since a second block at equal specificity is exactly how the role
drifted in the first place.
Header branch chip joins the chip language
.proj-branch was the only chip with no border and no mono face, and 1px tighter
all round, so the detail header's branch did not match the branch chips in the
worktree list right below it.
"Changes" heading over the View Diff toggle
Every other block on the page is introduced by a section heading. Without one
the toggle hung 4px off the bottom of the sync band and read as part of it.
Tests: 2167 unit (4 new — detached sha present/absent on both the server and the
panel) and 27 integration, twice.
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.
One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:
- `ahead` needs only local refs and is never flagged.
- `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
- Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
"I checked, ignore this"; getting it wrong is lying to the user.
- No upstream (the normal state of a fresh worktree branch) leaves ahead and
behind undefined — it renders an explicit `no upstream`, never the green
path. That fall-through is the easiest bug to ship here.
What landed:
G1 SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
and .dirtyCount. All additive and optional — the Android and iOS clients
decode these shapes. The ahead/behind helper already existed for the list
view; buildProjectDetail had simply never called it.
Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
directly, so it returned nothing inside a LINKED worktree, where .git is a
file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
the shared common dir (FETCH_HEAD).
G2 POST /projects/git/fetch. Same discipline as push: the remote is derived
server-side and no remote or refspec is ever read from the body, so a
client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
working tree, no index, no merge; it is not a pull. Own rate-limit bucket
so refreshes cannot eat the budget a real push needs. On failure
lastFetchMs is left alone, so the UI keeps saying "stale" instead of
pretending it refreshed.
G3 makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
dirty count, Fetch button (disabled on a detached HEAD).
G4 The commit list marks unpushed commits and draws the upstream boundary
once, after the last of them. Marking is server-side from `rev-list`,
deliberately NOT "the first N rows": `git log` is date-ordered, so merging
an older branch interleaves unpushed commits BELOW pushed ones, and that
shortcut fails in the dangerous direction — calling an unpushed commit
pushed. A regression test builds exactly that backdated-merge shape.
G5 The worktree section is always "Worktrees (n)" (it used to rename itself
to "Branch" at n=1) and the current row carries its own state chips.
G6 Cost control. The plan called for a .git-mtime cache; that was dropped
during implementation because a fingerprint over HEAD/index/reflog does
NOT move when push updates a remote-tracking ref — the cached `ahead`
would still claim "9 to push" right after a successful push, which is the
exact lie the feature exists to prevent. Replaced with three measures that
cannot go stale: in-flight coalescing (N devices watching one repo cost
one probe, entry dropped as it settles, nothing cached across time),
skipping the re-render when the payload is byte-identical (this also stops
the 5 s re-mount of the commit log, two more git spawns per tick), and
pausing the timer while the document is hidden.
G7 Per-worktree state via GET /projects/worktree/state, kept narrower than
/projects/detail so N rows do not pay for worktree listing and CLAUDE.md
reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
carried no cwd, so sessions could not be attributed to a worktree. Added
it, plus countSessionsByWorktree, which matches DEEPEST-first because
.claude/worktrees/<name> lives INSIDE the main checkout and prefix
matching would count every worktree session against the parent repo too.
Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.
Verified: tsc and build clean; 46 new tests.
An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.
- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
central authGate over all remote HTTP require the cookie. Open: /login, /auth.
Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).
Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:
- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
(1) target realpath must match an entry git itself reports in `git worktree list`
for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
containment on request + each list entry, operating on git's canonical path (+ --);
(4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
+ a prune button; force needs an explicit second confirm (no single-click data loss).
Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.
- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
(default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
chip mounted in the project detail header (git repos only).
Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.
- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
attacker-supplied base never reaches a shell or acts as a git option:
(1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
<base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
(3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
disables Working/Staged tabs in base mode. public/projects.ts derives bases from
the worktree branches ∪ current branch.
Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
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