Skip to content

Version Packages - #14

Merged
quantizor merged 1 commit into
mainfrom
changeset-release/main
Aug 10, 2026
Merged

Version Packages#14
quantizor merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

devctl@1.4.0

Minor Changes

  • #12 f5831ac - devctl config init writes a devservers.json from the servers the daemon already knows, which is the way back from losing one. The file is routinely gitignored per machine and nothing could regenerate it, so a lost or destroyed config meant retyping every server by hand. What it writes is deliberately portable: an effective or rebound port, a worktree-derived host, a materialized url, an absolute icon path, and the port and host variables devctl injects are all dropped, so a file recovered inside a linked worktree is still correct on someone else's machine. --dry-run shows the content without touching disk, and an existing file is refused unless --force. devctl register --write appends one server to the file, leaving every other entry alone.

    devctl config check now reports the host a start would actually use when it differs from the declared one. A linked worktree gets an ephemeral host, so any origin the app itself pins (an auth callback URL, a CORS allow list, an API key referrer restriction) was already wrong with nothing saying so until the app broke.

    devctl stop on a server that declares a resource points at devctl lock, which gets exclusive access without a bounce, and ServerStatus carries the server's locks so the same fact reaches --json consumers. The session context block names devctl lock as the way to exclusive access, and its invitation to report devctl friction now says to describe the problem generically rather than naming the project, since that backlog lives outside it.

  • #18 0069434 - devctl installs from Homebrew: brew install --cask quantizor/tap/devctl. brew upgrade keeps it current, and when a newer version ships the menu bar popover shows a quiet notice with a one-click Upgrade button that runs the upgrade in Terminal (a Homebrew install) or links to the release notes (a direct download). A direct DMG download still works exactly as before.

    A new Settings window, opened from the gear at the bottom of the popover, is the way back to anything you skipped at first run: install or remove the Claude Code and Cursor session hooks per harness, toggle Start at login, and turn the update check on or off. devctl still only edits a harness's settings when you click; it never changes them on its own.

    Removing devctl is now a single command. devctl uninstall unregisters the background agent, removes the agent hooks, and removes the CLI, keeping your data unless you pass --purge; running servers keep going. The Settings window offers the same as a button. devctl doctor reports a harness whose hook is missing or points at a path that no longer exists, and names the command to fix it.

  • #13 5f24f46 - devctl lock can now tell you when a command changed the state it was guarding while a server still held that state open. A locks entry may name where the resource lives on disk ({"name": "d1", "path": ".wrangler/state/v3/d1"}, alongside the plain "d1" form, which keeps working unchanged). With a path declared, lock fingerprints that state before and after the command. Under --no-pause with a declaring server still running, a change is a resource-mutated failure naming the servers to stop and the command to re-run, because the running server holds the old state open and can write its cached pages back over what the command wrote. Under the default paused mode the same change is just a note.

    This closes a silent data loss: a migration run under --no-pause that wiped and rebuilt a local database reported success while the seeded rows were gone, and nothing in the output distinguished that from a clean run. The check is deliberately modest about its limits, and the contract states them: it flags the risk window rather than the damage, it cannot see state outside the declared path or divergence that never reaches disk, and above 8 MiB it samples a file rather than hashing it whole.

  • #13 5f24f46 - devctl lock no longer passes its own options to the guarded command. devctl lock d1 --timeout 300 -- cmd ran env --timeout 300 -- cmd and died with env: illegal option -- t, because the resource name ended option parsing and everything after it was captured as the command. The command is now taken from after -- verbatim, so a nested --, a dash option, and an empty string all survive, while a missing terminator or an unknown option is rejected instead of quietly passed through.

    A contended devctl lock says who holds the resource instead of blocking silently for up to five minutes. It names the holder's pid, how long that run has been going, and which servers it paused or left running, then repeats a still-waiting line while it waits. Silence there reads as a hung gate, and the reflex it invites is killing the run that holds the lock, which is the one making progress. --acquire-timeout 0 now makes exactly one attempt and fails immediately, which it could not do before: the wait loop never ran its body at a zero budget and failed with a message naming no holder. All of lock's own output moved to stderr, so stdout carries only the guarded command's.

  • #11 ae6a118 - Port conflicts now announce themselves at the first surface that can see them. why names the server and project holding a stopped server's port instead of answering only "not running (stopped)", and the machine-wide status sweep carries the same annotation, so the menu bar app and doctor see it too. doctor gains a port-collision finding for two unrelated projects that declare one port, which the host-keyed signature table could never report because the hostnames differ while the bind does not; sibling worktrees stay excluded since they rebind by design. A server whose healthcheck is answered by another supervised server now fails with portConflict.state: "foreign" rather than reporting healthy while serving nothing, and a port held by this server and a stranger at once is reported as "shared". A listener outside the process tree that cannot be attributed to a managed server is annotated but left running, because a container-backed or daemonizing server keeps its socket in a process devctl never parented. An unavailable lsof can never fail a healthy server, since an empty result is not treated as evidence.

    config check warns when a healthcheck declares type: http with no url, which silently falls back to a TCP probe: a mistyped key otherwise yields a server reporting healthy while its HTTP layer was never checked. doctor also stops calling a listener unmanaged when another supervised server is the one holding the port.

    devctl daemon status --json now reports reachable. Every other command's hint points at this one, and it previously answered {"launchd": "state = running"} with exit 0 when nothing was listening, so a script or agent following the hint was told things were fine. launchd: running only means a job is loaded, never that the socket accepts.

  • #10 b7bc910 - Lock resume waits until claimed ports are free before re-ensuring, so a pause no longer races a dirty bind. Composite servers declare a port span or named subports so sibling worktrees rebind a whole claim block (relative offsets move, absolute ports stay singleton), and devctl why keeps the refusal lines from the last run across ensure retries instead of going blank on exit 0.

  • #15 aa209df - devctl restart <name> is a real command. Agents were writing devctl stop X && devctl ensure X by hand, and several assumed the verb already existed. That pair has two problems this fixes: another session's ensure can land between the two commands, and a refusal (a held resource, a paused server, a config that no longer parses) arrives only after the server is already down, leaving it down. A restart now refuses before it stops anything, and keeps the server's resume-on-boot intent, which a manual stop clears.

    A server can also list the config files it reads at boot but does not reload on its own, and devctl restarts it when one changes. Without that, a long-lived supervised server keeps running the old config, so a correct fix looks like it did nothing and a test harness keeps checking stale behavior. A server whose framework already reloads its own config declares nothing and behaves exactly as before. A config a server writes during its own startup will not bounce it, one save touching several files is a single restart, and a server that rewrites its own watched file has its watch suspended with a log line naming the culprit rather than restarting forever.

  • #16 22d5743 - A daemon that is coming back up no longer looks like one that is gone. While devctld restores supervised servers at boot it kept its socket closed, so every client got daemon-unreachable, which is the same answer a daemon that was never started gives. An agent polling across a daemon install or daemon restart read a busy daemon as a dead one and tried to start another.

    The daemon now accepts as soon as its listener is up and says which state it is in. devctl daemon status reports restoring while it works, and any other command waits the window out instead of failing, saying on stderr what it is waiting for. Commands that reach the daemon mid-restore are refused with daemon-starting rather than served against half-restored state, so the ordering guarantee that made the socket closed in the first place is unchanged.

  • #10 b7bc910 - Two git worktrees of one repo can both run under supervision without editing devservers.json. When a sibling checkout already holds the committed port, ensure auto-rebinds to a free port, keeps the main origin on the declared host, and gives the worktree an ephemeral worktree-*.<preferred>.localhost URL; unrelated projects and unmanaged listeners still get a loud port-held naming the holder. Session context and ensure/status JSON warn about latent and rebound conflicts so agents use the live URL, and lock --no-pause lets a harness take the mutex without stopping the server it reuses. Discarding a worktree stops its servers and frees the ephemeral host without a manual doctor pass. Bad-state servers in the session-start block still lead with a devctl why recommendation and a stderr line count, never the server's own output.

Patch Changes

  • #16 22d5743 - A number in devservers.json can no longer take the daemon down. Out-of-range ports were already caught, but the values beside them were not: a ports entry's offset, a portSpan that overflows when added to its port, and a healthcheck's healthyAfter, intervalMs, timeoutMs and unhealthyAfter all reached code that assumed they fit. devctl config check now reports each of them by name with the range it expected, and refuses the start rather than letting it crash.

    The offset case was the one nothing could see. It was checked for being too small but never for being too large, so config check called the file clean and reported no errors at all, and the failure only arrived later as an unrelated-looking daemon-unreachable.

    One bad project no longer stops the others. Reading status across every project validated each one's config along the way, so a single unusable number anywhere on the machine took down the daemon supervising all of them, and the menu bar's polling brought it straight back to do it again.

    A damaged state file is no longer fatal either. A process id too large to be one was read back from disk and used directly, which crashed the daemon on startup, and starting again re-read the same file. Such a value is now treated the way an exited process already was.

    devctl lock no longer reports success while protecting nothing. When a project's config could not be read, the lock found no servers declaring the resource, paused none of them, and said it had taken the hold, so the guarded command ran against a live server still holding the resource open. It now refuses.

  • #16 22d5743 - A repo's devservers.json can no longer reach outside what it describes. A server name became a path component of the log directory verbatim, so a name containing ../ made the daemon create directories elsewhere on the machine and write that server's raw output into them. Names are now flattened to a single component, and two names that flatten alike keep separate homes.

    Session context is devctl's own words again. A server name, url, or head went into the fenced block unescaped, and a JSON key legally holds a newline, so a pulled branch could close the fence and continue as though the harness were speaking. Those values are now kept to one line and the fence is left intact. The port-conflict warning also carried the squatting process's own command line, chosen by that process; it now says which port and what state, which is the part devctl knows.

    A port a TCP port cannot hold took the daemon down. config check accepted "port": 70000, and the first probe against it crashed devctld, which under launchd came straight back, re-read the same config, and crashed again. Out-of-range ports, including a portSpan that runs past the end, are now config errors, and the probe answers that nothing is listening rather than failing.

    Logs no longer repeat themselves. A server writing while devctl was reading its output had the overlap ingested twice, so lines appeared in duplicate and error tallies counted them twice.

    Two crashes in the same moment now raise two notifications. The menu bar tracked how far it had read using a value it advanced mid-pass, so when several servers went down together, only the first was announced.

  • #16 22d5743 - devctl lock now catches a change to a large database file that it used to miss. A lock resource above 8 MiB was fingerprinted by its head, its tail, its size, and its mtime, so a command that rewrote the middle while preserving all four was reported as no change at all. A local sqlite database is exactly the shape that happens to, and not noticing is the worst answer a check that exists to notice can give.

    A file is now hashed whole at any size, read in chunks so the cost is memory-flat, and the identity no longer claims to be exact when it is not. A directory keeps a byte budget, since its cost is the sum over the whole tree and the fingerprint is taken twice per guarded command.

  • #19 73280a7 - A cloned repo's devservers.json can no longer start itself. devctl's rule is that it never acts on a project's committed config until you approve it by running a server there once, but boot restore skipped that check: after a reboot it would bring back a committed server for a project that was never approved. It now honors the same gate every other path does, so an unapproved project's config stays inert until you start it by hand. An explicit start, ensure, or up still records that approval, exactly as before.

    devctl register now screens a server the same way the config file is screened. Registering a server directly was the one way into the daemon that skipped validation, so a spec devctl config check would reject (an out-of-range port, an empty command, a name containing the reserved ::) could still be registered and then started. It is refused up front now.

    devctl switch validates the branch's devservers.json before running that branch's lifecycle commands. A config the daemon would refuse to load no longer has its commands handed to the shell anyway, and an empty lifecycle command is now caught by config check.

    A bad --grep pattern can no longer hang the daemon. A regular expression that nests one unbounded repeat inside another (the classic (a+)+) makes the engine run for minutes on a single log line; devctl logs --grep now rejects that shape before it runs, with a message that names the fix.

    devctl no longer hangs waiting on a stuck daemon. A wedged daemon used to leave devctl and the menu bar app blocked with no output and no way out; requests now fail in bounded time and point you at devctl daemon restart, while a legitimately long ensure, wait, or group rollout is given the room it needs.

    Editing a project's config through the menu bar can only write to a project devctl already tracks, closing a path where a crafted request could have dropped a devservers.json anywhere on disk.

  • #18 0069434 - The installer no longer tells you to fix a PATH that is already fine. It asked the app's own process for its PATH, and the app is launched by Finder, so that was launchd's rather than your shell's: it can never contain ~/.local/bin, so the warning appeared for everyone whatever their shell actually had.

    Asking a login shell was not enough either. zsh -l without -i runs .zshenv, .zprofile and .zlogin and skips .zshrc, which is where most tools put themselves. On the machine this was found, that meant devctl handed every server it started a PATH missing ~/.local/bin, pnpm, conda and gcloud, so a server script calling devctl could not find it. Both the warning and the PATH your servers inherit now reflect what your shell really has.

    Reading that PATH means running your shell profile, which devctl does not control, so it now gives up after a while and falls back rather than waiting forever. A profile that waits on the network or on a terminal that is not there used to hang the app at launch with nothing on screen. DEVCTL_RESOLVING_ENVIRONMENT is set while it runs, so a profile can skip whatever needs a real session.

  • b87a4cc - Installing and upgrading devctl from the DMG is clean, with none of the glitches that used to appear around the handoff to Applications.

    Installing no longer leaves two menu bar apps running. A second copy of the same app doubles everything you see: two icons, two pollers, and two notifications for one crash. Nothing prevented it, and the install hands off by asking macOS for a new instance by name, so any second trigger produced one, whether that was the relaunch button racing the automatic handoff, a squatting copy that would not quit, or opening the app in Finder while it was already running. A copy that finds the same app already running now steps aside on its own, whichever way it was launched.

    Upgrading no longer flashes two menu bar icons. Double-clicking the app in the disk image used to briefly show its icon next to the one for the copy already installed, before the handoff finished. The copy on the disk image now runs purely as an installer: it shows only its setup window and never adds a menu bar icon, so the single icon you see stays the installed one throughout. The disk image copy and the Applications copy still run side by side for the moment the handoff needs, since that pair is the one case where two is correct.

    The disk image ejects right after you install. The daemon could end up running its program straight from the mounted image instead of the copy in Applications, because the image and the installed app share an identity and macOS sometimes preferred the mounted one; while that lasted the disk reported itself in use and refused to eject until the daemon happened to restart. The daemon now notices at startup when it is running from a mounted volume and relaunches itself from the installed copy first, so the image is free to eject as soon as you have dragged the app to Applications.

    Confirming an upgrade now stops rather than replacing the app in Applications while an old copy is still running it, and says which app to quit. It used to try for a few seconds, give up quietly, and replace the bundle anyway, leaving that copy running code no longer on disk.

  • #16 22d5743 - devctl hook install can no longer erase your harness settings. It merges its session hook into a file it does not own, and it writes that whole file back, so it has to read everything already in it first. When that read failed, for a stray character mid-edit or anything else that stopped the file parsing, it treated the file as empty and wrote it back with only its own hook in it, taking every other hook, permission and setting along with it, then reported a successful install. It now leaves the file alone and says which file it could not read and why.

    The port shown for a server is the port it is actually on. When a server rebound to a different port to avoid a collision with a sibling checkout, the menu bar and the statusline still showed the port it had asked for, sending you somewhere nothing was listening, while the session context shown to agents had it right. All three now agree.

    A version mismatch between devctl and a running daemon is reported rather than skipped. If the opening handshake failed partway, the connection was left half-open and every later request on it went out without the check ever running again.

    A timestamp before 1970 no longer comes out in a form devctl cannot read back.

  • #12 f5831ac - A server that loses its port to another supervised server now names that server the way a person reads it. The message carried devctl's internal server id, so it read managed server '/Users/me/code/proj::web', and it now says 'web' in /Users/me/code/proj with a devctl stop command that can be run as printed.

  • #21 327fc60 - Server teardown is now safe against pid reuse. Every signal devctl sends while stopping or cleaning up after a server is checked against the identity it recorded for that process while it was alive, so a pid the kernel has since handed to an unrelated process is never signaled. The deliberate-stop path and the crash-cleanup path now share one revalidated sweep, and the crash path, whose process is already gone, no longer directs a signal at its former process group at all.

    devctl stop now also cleans up a descendant that escaped into the background. A server that spawns a helper which itself exits, leaving a grandchild reparented away, used to leave that grandchild running after a stop; the stop now sweeps the server's whole session, not only the processes still directly parented to it.

    The daemon refuses to start rather than erase its own records. If a saved store (the registry, run state, or resource locks) exists but cannot be read, from an I/O error or too many open file descriptors, devctld now exits and lets launchd retry instead of treating the data as absent and overwriting it on the next write. A missing file still starts clean, and an unparseable one is still quarantined and rebuilt.

  • #10 b7bc910 - Installing or restarting the daemon no longer floods the menu bar with false "server crashed" alerts for the expected bounce, and boot restore finishes before clients connect so upgrade re-ensure does not race and leave servers on a bad port. devctl why also diagnoses file-backed projects correctly, and an unexpected server exit tears down leftover child processes so the next ensure is not blocked by stale locks.

  • #12 f5831ac - A head or healthcheck url written as a path (/admin) now resolves against the server's own effective base, so it follows a rebind or a worktree host swap. It previously serialized as //:33334/admin, a value that reads as a URL in the menu bar, Spotlight, devctl open, and agent context, and works in none of them. config check now rejects a head or healthcheck url that is neither an absolute URL nor a path, and one written as a path on a server that declares no port or url to resolve it against, so the mistake is caught while it is still cheap.

    devctl up now spawns with the same spec ensure does. It prepared each server's spawn and then re-resolved the supervisor inside its dependency waves, which re-applied the committed config to anything not yet running and discarded the prepared spawn: the rebound port, the worktree host, the substituted {port} / {host} argv, and the injected environment. In a linked worktree that meant the child bound the committed port while devctl reported the rebind, so up in two sibling checkouts left both servers stuck starting. Sibling worktrees now coexist under up exactly as they already did under ensure.

  • #16 22d5743 - A crashed server no longer leaves its workers running. When a supervised server spawned a helper process and then crashed, that helper could survive forever, holding its port and its files while devctl reported the server as gone. The next start would then fail on a port held by a process nothing was tracking.

    Two things had to line up, and both are common. A helper started through most process APIs lands in its own process group, so signalling the server's group never reaches it, leaving devctl's record of live descendants as the only way to find it. That record was refreshed when the server started and then not again until its first healthcheck, which for a server declaring no healthcheck is a couple of seconds later. A helper started in between was in no record at all.

    Teardown now also sweeps by session, which is the one relationship that survives the server exiting and its helpers being adopted by the system. The record is refreshed throughout startup as well, so the common case is caught before the sweep is needed.

  • be2e7e4 - A non-finite --timeout no longer crashes the command or the daemon. Passing --timeout inf (or a value large enough to overflow) to ensure, wait, restart, up, switch, or lock took the command down with a runtime error, and the same value sent on to the daemon crashed the daemon itself, which then respawned under launchd. The command now falls back to its normal response deadline, and the daemon clamps any timeout it receives over its socket to a safe bound before use, so a crafted request cannot bring it down either. This matters most for the agents and scripts that drive devctl with computed timeouts.

@github-actions
github-actions Bot force-pushed the changeset-release/main branch 4 times, most recently from ac06333 to 83e19d2 Compare August 10, 2026 03:11
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 83e19d2 to 4de1d64 Compare August 10, 2026 03:46
@quantizor
quantizor merged commit 77f8983 into main Aug 10, 2026
4 checks passed
@quantizor
quantizor deleted the changeset-release/main branch August 10, 2026 03:52
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