Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.0]

### Changed

- **BREAKING: Ctrl-C now stops the server you started, like `npm run dev`.** This
reverses 0.4.0's "Ctrl-C always detaches." The behaviour is *hybrid*, keyed on whether
your terminal started the server:
- Bare `perchd` / `perchd <branch>` / `perchd switch` **start** the server, so Ctrl-C
stops it — and so does closing the terminal window (SIGHUP). No orphans.
- `perchd attach` **attaches** to a server that's meant to persist, so Ctrl-C there only
detaches and leaves it running.
The attach banner states which mode you're in (`^C stops` vs `^C detaches`).
`perchd stop` still terminates the active server from anywhere.
- Internally, `runViewport` now reports `{ interrupted, signal }` and never signals the
server itself; the stop-vs-detach policy lives in `attachViewport` (graceful `stopGroup`
on SIGINT, best-effort group `SIGTERM` on SIGHUP since the terminal is already gone).

- **Bare `perchd` shows the worktree picker again, with the current worktree
pre-selected.** In 0.4.0, bare `perchd` inside a worktree silently ran *that* worktree
and never showed the menu — which hid perchd's whole "switch without `cd`-ing" point.
Now it always shows the picker with cwd's worktree floated to the top and pre-selected:
press Enter to run where you stand (the drop-in), or arrow to another worktree to
switch. Naming a target (`perchd <branch>`) still skips the menu. A non-interactive
bare `perchd` (piped / CI) now fails with a clear "name a target" message instead of a
raw TTY crash.

### Added

- **`perchd -v` / `perchd --version`** — previously threw `Unknown option`. Prints the
installed version (read from `package.json`, so it's correct under both the published
binary and `pnpm dev`).

## [0.4.0]

### Changed
Expand Down
31 changes: 24 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,41 @@ Flow for a switch: `cli.ts` → `core/context.ts` (git worktrees + common dir)
in `test/fixtures/` (`mini-server`, and `multiproc-server` which mimics vite→esbuild
for process-group teardown).

## The viewport model (0.4.0)
## The viewport model (0.4.0), with hybrid Ctrl-C (0.5.0)

**One server, detachable viewport** — tmux-for-dev-servers. Every server runs detached
in the background and writes to a log file. "Foreground" is not a kind of server; it is
a **viewport**: `tail -f` on that log plus a poller watching the state file
(`core/viewport.ts`).

- Bare `perchd` is the `npm run dev` drop-in: switch to cwd's worktree, then attach.
Outside any worktree it shows the picker (`containingWorktree` decides).
- Bare `perchd` shows the worktree **picker with cwd's worktree pre-selected**
(`bareTarget` → `pick`, `pickerChoices` floats the pre-select to the front and clack
gets `initialValue`): Enter re-runs where you stand (the `npm run dev` drop-in),
arrowing to another is the switch — surfacing the other worktrees is the point (no
`cd`). Naming a target (`perchd <branch>`) skips the menu. The picker needs a TTY; a
non-interactive bare `perchd` fails with a clear "name a target" message, not a raw
`uv_tty_init` crash.
- `-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.
Ending a viewport must never signal the server — that invariant *is* the product.
- **Hybrid Ctrl-C (`attachViewport`'s `stopOnInterrupt`):** a viewport that *started*
the server (bare `perchd` / `switch`) **stops** it on Ctrl-C, and on window-close
(SIGHUP) too — that is the `npm run dev` drop-in promise. A viewport that *attached*
to an already-running server (`perchd attach`) only **detaches** on Ctrl-C; killing
something you attached to peek at would be a footgun. **The banner must state which,
per mode** ("^C stops" vs "^C detaches") — that is what keeps the overload legible
rather than surprising. `perchd stop` always terminates regardless.
- **`runViewport` never signals the server itself.** It reports `{interrupted, signal}`;
the stop-vs-detach *policy* lives in `attachViewport` (graceful `stopGroup` on SIGINT;
best-effort synchronous `SIGTERM` to the group on SIGHUP, since the terminal is gone
and there is no time to wait on an escalating teardown). Keeping the signal-to-reason
mapping pure is what makes it unit-testable.
- `cli.ts` is the **only** place attachment is decided. `runSwitch` never attaches;
keeping it that way avoids a `switch ↔ attach` import cycle.
- `perchd dev` is a deprecated alias for bare `perchd`.

A viewport ends for exactly four reasons — `detached`, `perch-moved` (another terminal
switched), `stopped`, `server-exited` (clears the record if the pid is still ours).
A viewport ends for exactly five reasons — `interrupted` (Ctrl-C/SIGHUP; the signal is
carried so the policy can pick stop vs. detach), `detached` (the tail process died on
its own), `perch-moved` (another terminal switched), `stopped`, `server-exited` (clears
the record if the pid is still ours).

**The `graceMs` window is load-bearing, don't remove it.** `runSwitch` stops the old
server and clears the state record *before* starting the new one, so for a few hundred
Expand Down
17 changes: 10 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ processes. That keeps them trivially testable.
## The core model

perchd keeps **one** dev server alive, always running in the background. A terminal
"in the foreground" is just a **detachable viewport** onto that server's log stream —
so `Ctrl-C` detaches the viewport and leaves the server running; only `perchd stop`
terminates it. The foreground/background choice is one explicit flag (`-d`) over one
lifecycle, never two separate commands.

Keep that shape when adding features. If a change makes "am I attached?" and "is the
server alive?" the same question again, it's the wrong change.
"in the foreground" is just a **viewport** onto that server's log stream. The
foreground/background choice is one explicit flag (`-d`) over one lifecycle, never two
separate commands.

Ctrl-C is *hybrid* (`attachViewport`'s `stopOnInterrupt`): if this viewport **started**
the server (bare `perchd` / `switch`), Ctrl-C — and closing the window — stop it, the
`npm run dev` drop-in promise. If it **attached** to an already-running server
(`perchd attach`), Ctrl-C only detaches. The banner states which, per mode. Keep that
distinction crisp when adding features: whether Ctrl-C stops depends on *who started the
server*, not on how it looks.

## Scope

Expand Down
94 changes: 48 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<p align="center">
<strong>perchd is <code>npm run dev</code>, pointed at any worktree —<br>
and you can change which one without killing anything.</strong>
switch which branch is live without the port-collision dance.</strong>
</p>

<p align="center">
Expand All @@ -48,49 +48,43 @@ branch you're looking at. No `cd`. No remembering the command. No port roulette.

## You already know the command

You don't have to learn perchd to use perchd. Your dev command is `npm run dev` (or
`pnpm dev`, or `make dev`). Trade it for **`perchd`** and you get the same thing —
foreground, logs streaming — except it runs **any worktree**, always on the **same
port**:
You don't have to learn perchd to use perchd. Run **`perchd`** and you get a menu of
your worktrees with the one you're standing in **pre-selected** — press Enter and it's
your `npm run dev`: foreground, logs streaming, on the native port. Arrow to a different
worktree and that's the switch. Or name one directly and skip the menu:

```sh
perchd # the worktree you're standing in — your `npm run dev`, basically
perchd feature/auth # a different version, same terminal, same URL
perchd # menu of worktrees, current one pre-selected → Enter runs it
perchd feature/auth # skip the menu — run that worktree by name
perchd main # the main tree, without leaving your branch
```

Same muscle memory. Swap one word, and now every branch your agents touched is one
command away.
Same muscle memory, all the way down to Ctrl-C: it stops the server, exactly like
`npm run dev`. The menu is the point, not overhead — it's how you switch which branch
you're previewing without ever `cd`-ing between worktrees.

<sub>(Each of those attaches to your terminal, so you'd <code>^C</code> to detach before
running the next — which, as the next section explains, doesn't stop anything.)</sub>
## The switcher underneath

## Detach and switch. Attach and watch.

Here's the part that isn't `npm run dev`.

The server **always lives in the background**. What you call "foreground" is just a
*viewport* — your terminal, attached to the log stream. So:
That one live server actually runs in the **background**, and your terminal is just a
*viewport* onto its log stream — so you can move it, leave it running, or watch it from
somewhere else:

```sh
perchd # attach: live logs in your terminal, like npm run dev
^C # detach: you stop watching. the server KEEPS RUNNING.
perchd fix/payments # move the perch to another branch
perchd stop # actually terminate it
perchd # pick a worktree → attach, live logs, like npm run dev
^C # → stops it, just like npm run dev
perchd feature/auth -d # switch to another branch, but DON'T attach — keep your prompt
perchd attach # come watch that one; here ^C only detaches, it keeps running
perchd stop # terminate whatever's active, from anywhere
```

> **Ctrl-C detaches. It does not kill.**
> That one rule is what makes switching safe — and it's the opposite reflex from
> `npm run dev`. To really stop the server, say `perchd stop`.

And when you'd rather not tie up a prompt at all, `-d` switches without attaching:

```sh
perchd feature/auth -d # switch, print the URL, hand your prompt back
perchd attach # ...come watch it whenever you like
```
> **Ctrl-C stops the server you started — like `npm run dev`.**
> The one exception is `perchd attach`: you're peeking at a server that's meant to
> persist, so there Ctrl-C only *detaches* and leaves it running (the banner tells you
> which mode you're in). Either way, `perchd stop` is the definitive kill.

One server. One port. `-d` is the only knob, and it means the same thing everywhere.
So there are two ways to keep a server alive while you move on: start it detached with
`-d`, or `attach` to it and Ctrl-C back out. Closing the terminal window on a server you
started stops it too — no orphans.

## Before / after

Expand Down Expand Up @@ -155,9 +149,9 @@ otherwise a clean built-in prompt takes over.

```sh
cd any/worktree/of/your/repo
perchd # run this worktree, attached (your `npm run dev`)
^C # detach — it keeps running
perchd feature/auth -d # switch to another branch, don't attach
perchd # menu (current worktree pre-selected) → Enter runs it
^C # stop it — just like npm run dev
perchd feature/auth -d # switch to another branch by name, keep it in the background
perchd status # table: worktree, runner, port, which is ACTIVE
perchd stop # stop the active server
```
Expand All @@ -166,7 +160,8 @@ perchd stop # stop the active server

| Command | What it does |
| --- | --- |
| `perchd [branch\|path]` | Switch the active dev server **and attach** (drop-in for `npm run dev`). Interactive picker when there's no obvious target. |
| `perchd` | Menu of worktrees, current one pre-selected — Enter runs it (drop-in), or pick another to switch. Then attaches. |
| `perchd <branch\|path>` | Skip the menu: switch the active dev server to that worktree **and attach**. |
| `perchd … -d` / `--detach` | Switch but stay in the background — print the URL and return the prompt. |
| `perchd switch [branch\|path]` | Explicit synonym for `perchd [branch\|path]`. Note it now **attaches** by default; add `-d` for the old background behavior. |
| `perchd attach [branch]` | Attach to the active server (or switch to `branch`, then attach). |
Expand Down Expand Up @@ -218,12 +213,14 @@ hint and will be removed in a future major.
| `perchd dev feature/auth` | `perchd feature/auth` |
| `perchd` (background switch) | `perchd -d` |
| `perchd switch feature/auth` (background) | `perchd feature/auth -d` |
| Ctrl-C **stopped** the server | Ctrl-C **detaches**; use `perchd stop` |
| Ctrl-C stopped the server | Ctrl-C still stops it — same as before |

`perchd switch` still exists as an explicit synonym, but like every switch it now
**attaches** by default — pass `-d` to get the old background behavior.

The last row is the one to internalize: Ctrl-C no longer kills the dev server.
> If you used `0.4.0` specifically: that release briefly made Ctrl-C *detach* instead of
> stop. `0.5.0` reverts to the intuitive `npm run dev` behavior — Ctrl-C stops the server
> you started. Only `perchd attach` detaches on Ctrl-C now.

## Supported frameworks

Expand Down Expand Up @@ -281,14 +278,19 @@ Then `perchd cd feature/auth` drops you right where your agent has been working.
## FAQ

**Do I have to learn a new tool?**
No. `perchd` *is* your `npm run dev` — same foreground, same logs — it just aims at any
worktree (or the main tree) on the same port. Swap one word today; discover the
switching whenever you feel like it.

**Why doesn't Ctrl-C stop the server?**
Because the server isn't *yours* — it's the perch, and your terminal is just looking at
it. Detaching lets you close the terminal, switch branches, or attach from somewhere
else without a restart. `perchd stop` is the kill switch.
Barely. Run `perchd`, press Enter on the pre-selected current worktree, and it's your
`npm run dev` — same foreground, same logs, same port. The only new thing is that the
menu lets you arrow to another worktree instead of `cd`-ing there first.

**Does Ctrl-C stop the server, like `npm run dev`?**
Yes — when you started it (`perchd` / `perchd <branch>`), Ctrl-C stops it, and so does
closing the terminal. The one exception is `perchd attach`: there you're looking at a
server that's meant to keep running, so Ctrl-C just detaches and leaves it alive. The
banner tells you which mode you're in, and `perchd stop` always terminates.

**How do I keep a server running while I move on?**
Two ways: start it detached with `perchd <branch> -d` (never attaches), or `perchd attach`
to a running one and Ctrl-C back out.

**Can't it just run all eight servers at once?**
No. You have two eyes. It runs the one you're looking at. That's not a limitation —
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "perchd",
"version": "0.4.0",
"version": "0.5.0",
"description": "npm run dev, pointed at any git worktree — one dev server, one port, switch which branch it points at",
"type": "module",
"bin": {
Expand Down
28 changes: 22 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { cac } from "cac";
import pc from "picocolors";
import { loadContext } from "./core/context.js";
Expand All @@ -18,6 +19,13 @@ import { runDoctor } from "./commands/doctor.js";
import { runConfig } from "./commands/config.js";
import { runWatch } from "./commands/watch.js";

// Read our own version from package.json. `../package.json` resolves the same
// from the entry whether it runs as src/cli.ts (tsx) or the bundled dist/cli.js
// (published tarball ships package.json at the root, one level above dist/).
const version: string = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
).version;

const cli = cac("perchd");
const cwd = process.cwd();
let passthrough: string[] = [];
Expand All @@ -37,15 +45,17 @@ function fail(e: unknown): never {
}

/**
* Bare `perchd`: the worktree you're standing in is the obvious target.
* Only when cwd is outside every worktree do we ask.
* Bare `perchd`: show the worktree menu, with the one you're standing in
* pre-selected. Enter re-runs where you stand (the `npm run dev` drop-in);
* arrowing to another worktree is the switch — perchd's whole point is not
* having to `cd` between them. The cwd worktree, else the active one, is the
* default landing.
*/
async function bareTarget(): Promise<string | null> {
const ctx = await loadContext(cwd);
const here = containingWorktree(ctx.worktrees, cwd);
if (here) return here.path;
const active = readState(ctx.commonDir).active;
return pick(ctx.worktrees, active?.worktreePath ?? null);
const preselect = containingWorktree(ctx.worktrees, cwd)?.path ?? active?.worktreePath ?? null;
return pick(ctx.worktrees, active?.worktreePath ?? null, preselect);
}

/**
Expand All @@ -68,7 +78,12 @@ async function switchThenMaybeAttach(target: string | undefined, flags: any): Pr
});
if (!active || flags.detach) return;
const ctx = await loadContext(cwd);
process.exit(await attachViewport(active, ctx.commonDir, { fromStart: true }));
// We started the server → Ctrl-C stops it, like `npm run dev`.
process.exit(await attachViewport(active, ctx.commonDir, {
fromStart: true,
stopOnInterrupt: true,
stopTimeoutMs: ctx.config.stop_timeout * 1000,
}));
}

type Cmd = ReturnType<typeof cli.command>;
Expand Down Expand Up @@ -135,6 +150,7 @@ cli.command("config", "print resolved config + detected runner per worktree")
cli.command("watch", "watch for worktree deletion and auto-stop the active server")
.action(async () => { try { await runWatch(cwd); } catch (e) { fail(e); } });

cli.version(version); // adds `-v, --version`
cli.help();

// Split argv at the first standalone `--`: everything after is verbatim
Expand Down
7 changes: 5 additions & 2 deletions src/commands/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import { runSwitch } from "./switch.js";
export async function runAttach(cwd: string, target?: string): Promise<number> {
const ctx = await loadContext(cwd);

// `attach` never stops on Ctrl-C: you're peeking at a server meant to persist.
const stopTimeoutMs = ctx.config.stop_timeout * 1000;

if (target) {
const active = await runSwitch({ target, quiet: true, nowIso: new Date().toISOString(), cwd });
if (!active) return 1;
// Freshly started: replay the log from the top so the startup banner shows.
return attachViewport(active, ctx.commonDir, { fromStart: true });
return attachViewport(active, ctx.commonDir, { fromStart: true, stopOnInterrupt: false, stopTimeoutMs });
}

const active = readState(ctx.commonDir).active;
Expand All @@ -30,5 +33,5 @@ export async function runAttach(cwd: string, target?: string): Promise<number> {
return 1;
}
// Re-attaching to a server that has been up a while: show recent lines only.
return attachViewport(active, ctx.commonDir, { fromStart: false });
return attachViewport(active, ctx.commonDir, { fromStart: false, stopOnInterrupt: false, stopTimeoutMs });
}
Loading
Loading