Skip to content

feat: drop-in-first redesign — one server, detachable viewport (0.4.0)#1

Merged
irwansetiawan merged 13 commits into
mainfrom
worktree-drop-in-first-redesign
Jul 9, 2026
Merged

feat: drop-in-first redesign — one server, detachable viewport (0.4.0)#1
irwansetiawan merged 13 commits into
mainfrom
worktree-drop-in-first-redesign

Conversation

@irwansetiawan

@irwansetiawan irwansetiawan commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

perchd becomes one server with a detachable viewport (tmux-for-dev-servers). The server always runs in the background; "foreground" is just whether a terminal is attached to its log stream.

Detach and switch. Attach and watch.

  • Bare perchd is now the npm run dev drop-in — switch to the worktree you're in, then attach. Outside a worktree it shows the picker.
  • -d / --detach flips any switch to background. One uniform, explicit axis.
  • Ctrl-C detaches; it does not kill. perchd stop is the only way to terminate. This is the linchpin: it's what makes switching safe.
  • perchd attach [branch] — new. Attach to the running server from any terminal.
  • perchd dev — deprecated alias for bare perchd, prints a hint. perchd switch retained, but now attaches by default.

Docs for this landed first (0a94a1d, 74f9278); the implementation now backs them, so the branch is coherent and safe to merge as a unit.

Notable implementation decisions

  • cli.ts is the only place attachment is decided. runSwitch never attaches — that keeps switch.ts free of viewport concerns and avoids a switch ↔ attach import cycle.
  • startForeground is deleted. Its multi-process group-teardown test coverage was preserved, retargeted at startServer.
  • FORCE_COLOR injection (src/core/env.ts). A server that survives detach must write to a file, and dev servers strip colour when stdout isn't a TTY (verified: picocolors → isColorSupported=false to a file, true with FORCE_COLOR=1). Without this the drop-in would look plainer than the npm run dev it replaces. Full fidelity (in-place spinners) would need a pty — rejected, native dependency.
  • ActiveServer.foreground retired. readState strips the legacy key; 0.3.x records have logPath: "" and can't be attached to (perchd restart fixes them).
  • A viewport ends for exactly four reasons: detached, perch-moved, stopped, server-exited (which clears the record if the pid is still ours).

Verification

  • pnpm typecheck clean · pnpm test 114/114 passing (was 95) · pnpm build clean (40K).
  • New coverage: env, viewport (7), attach (6), attach-flow integration (3), passthrough-on-switch, legacy-state migration.
  • Exercised end-to-end against a real repo, not just tests:
    • perchd -d → server backgrounded, status shows ACTIVE, HTTP 200.
    • perchd attach → SIGINT → ▸ detached, exit 0, server still serving.
    • bare perchd → attaches, SIGINT → detaches, server survives; perchd stop then frees the port.

Breaking changes (0.4.0)

Before (≤ 0.3.x) Now
perchd dev perchd
perchd (background switch) perchd -d
perchd switch X (background) perchd X -d
Ctrl-C stopped the server Ctrl-C detaches; perchd stop
status labelled (fg) no such label — no server is foreground

A real bug caught during verification (and fixed)

Driving this with two actual terminals — rather than trusting the test harness — surfaced that guardrail #3 (▸ perch moved to <branch>) almost never fired. runSwitch stops the old server and clears the state record before writing the new one, so the viewport's poller saw "our record, dead pid" and settled on server-exited — printing ▸ main exited. and exiting 1 on every ordinary switch.

The integration test missed it because it faked the transition atomically: new record written with no null gap and the old server still alive. It passed against broken code.

Fixed with a graceMs window (default 2.5s): when our server looks gone, keep polling; a record with a different pid appearing during the window wins → perch-moved. The integration test now drives a real runSwitch through the real gap. Confirmed by hand: the two-terminal repro now prints ▸ perch moved to main. and exits 0.

…t" model

Bare `perchd` becomes the `npm run dev` drop-in; `perchd dev` is demoted to a
deprecated alias. The foreground/background split collapses into one explicit
`-d`/`--detach` flag over one lifecycle: the server always lives in the
background and a terminal is just a detachable viewport onto its log stream.
Ctrl-C therefore detaches rather than kills; `perchd stop` terminates.

Docs only — the implementation has NOT landed yet. Do not merge or publish
ahead of the code.

- README: new hero, "Detach and switch. Attach and watch." section, Ctrl-C
  callout, updated command table, migration table for `perchd dev`/`switch`.
- CHANGELOG: [Unreleased] describing the 0.4.0 target, flagged as unshipped.
- CONTRIBUTING: document the core model and its invariant.
- CLAUDE.md: new — architecture, invariants, non-obvious constraints.
- package.json: description/keywords match the drop-in positioning.
A server that survives detach must write to a file, and dev servers strip colour
when stdout isn't a TTY (verified with picocolors: isColorSupported=false to a
file, true with FORCE_COLOR=1). Today's `perchd dev` inherits the real TTY, so
colour works now — a naive "background + tail the logfile" would make the drop-in
look plainer than the `npm run dev` it replaces, undercutting the familiarity bet
drop-in-first rests on.

- README: replace the keypress note with two honest caveats (no keypress UI; no
  faithful progress re-rendering), and clarify that the three example commands
  each attach, so you'd ^C between them.
- CLAUDE.md: flag the TTY-fidelity decision as open, blocking implementation.
The multi-process group-teardown assertion from the startForeground test is
preserved, retargeted at startServer.
cli.ts is now the only place attachment is decided, keeping runSwitch free of
viewport concerns. Adds --detach, perchd attach, the deprecated dev alias, and
the cwd-worktree-vs-picker rule for bare perchd.
@irwansetiawan irwansetiawan changed the title docs: reposition around drop-in-first "one server, detachable viewport" model feat: drop-in-first redesign — one server, detachable viewport (0.4.0) Jul 9, 2026
…a crash

runSwitch stops the old server and clears the state record BEFORE writing the
new one. The poller settled on first sight, so an ordinary cross-terminal switch
made an attached viewport print '<branch> exited' and exit 1 — guardrail #3 of
the design (clean 'perch moved to <branch>') almost never fired.

Adds a graceMs window (default 2.5s): when our server looks gone, keep polling;
a record with a different pid appearing during the window wins and yields
perch-moved. Verified with two real terminals, not just the harness.

The old integration test faked the transition atomically (no null gap, old pid
still alive) and passed against the broken code; it now drives a real runSwitch.
The viewport returns the moment the new state record lands, while runSwitch is
still in its readiness wait — so asserting on a variable assigned from .then()
read null. Deterministic failure on all four CI jobs; passed locally by luck of
timing. Hold the promise and await it.

Also documents the graceMs / stop_timeout limitation in CLAUDE.md.
@irwansetiawan
irwansetiawan marked this pull request as ready for review July 9, 2026 14:56
@irwansetiawan
irwansetiawan merged commit 8b77e95 into main Jul 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant