Adds `paddock service install | uninstall | status`, registering Paddock as a
per-user background service: a launchd LaunchAgent on macOS, a `systemd --user`
unit on Linux. Also adds `paddock start` as an explicit synonym for the default,
and a leading-verb check ahead of the flag loop — bare `paddock` is untouched,
flags parse identically after a verb, and an unrecognised leading token is still
`unknown option`.
An AGENT, not a daemon, and the reason is structural rather than stylistic: on
darwin the Claude login is a Keychain item, the login keychain is unlocked by the
account password at login, and a LaunchDaemon has no such session (`UserName`
changes euid, not unlock state). So `claude.credentials: host` and boot-time
start are mutually exclusive, and every surface says Paddock starts AT LOGIN, not
at boot — otherwise it gets filed as a bug.
The generated unit sets no `PADDOCK_DATA_DIR`, so the service and a terminal
`paddock` are one `~/.paddock` instance reached two ways; invokes node explicitly
by absolute path, because launchd's stub PATH cannot resolve the bin's
`#!/usr/bin/env node` under a version manager; restarts on crash but not on a
clean exit; sits in `<data-dir>/service`; and carries PATH and nothing else.
Installing from an npx cache path is refused — hash-keyed and prunable, so the
unit would rot silently at some future login.
No auth token is generated. A service is up longer than an npx run but not
reachable by anything new: a local process that can reach the port could already
read the same Claude login as the same user. Duration, not reach.
Tested on Linux, which bounds what this proves. Both writers' OUTPUT is covered
by golden files, and both backends' subprocess flow runs against an injected
runner (bootout-before-bootstrap, never `load`/`unload`, daemon-reload before
enable). Nothing here executes launchctl, and start at login itself — `RunAtLoad`
after a real logout — remains untested; that is stated in the docs rather than
papered over.
Drive-by: `claude-mcp.ts` still described the per-directory `mcpServers` scope as
"the scope a `--here` workspace hits". #798 removed that flag; a project linked
at the path reaches the same entry, so the name is fixed and the point kept.
Co-Authored-By: Claude <noreply@anthropic.com>
Closes #796.
Adds
paddock service install | uninstall | status, plus the verb-dispatch seam andpaddock start.Verb dispatch
parseArgsstays flags-only. A newparseCommandsplits a leading verb offargv[0]before the flag loop and hands the rest toparseArgsunchanged, so:paddockis byte-for-byte the old behaviour — a parameterised test assertsparseCommand(argv).optsequalsparseArgs(argv)for the pre-existing argv shapes;paddock service install --port 7299works);--host serviceis still a host value;unknown option: …from the flag loop, not a new error —paddock 4100cannot quietly start on the default port.A misspelled action is caught before the flag loop sees it, so you get
unknown service action: instalrather than "unknown option".The generated units
macOS —
~/Library/LaunchAgents/net.edspencer.paddock.plist:ProgramArguments=[process.execPath, <realpath of dist/cli/paddock.js>, "start", …]. Not the bin: it is an npm symlink with#!/usr/bin/env node, and launchd's stub PATH has nonodeon it under a version manager.RunAtLoad,KeepAlive: { SuccessfulExit: false }(restart on crash, not on a clean exit — a bare<true/>would fightlaunchctl bootout),ThrottleInterval: 10.WorkingDirectory/StandardOutPath/StandardErrorPathunder<data-dir>/service.EnvironmentVariables=PATHand nothing else. A plist is a readable file in$HOME, and the value it would be tempting to add is a credential.PADDOCK_DATA_DIR— service and terminal are one~/.paddockinstance.bootstrap gui/<uid>/bootout/kickstart -k. Neverload/unload; a test asserts that.Linux —
~/.config/systemd/user/paddock.service: sameExecStartshape,Restart=on-failure,RestartSec=10,WantedBy=default.target(the user-unit spelling;multi-user.targetdoes nothing here), journald for logs,daemon-reloadbeforeenable --now.installprintsloginctl enable-linger $USERrather than running it.statusreads real state —launchctl print gui/<uid>/<label>/systemctl --user is-active— and reports the port recorded in the installed unit, parsed back out of it, not recomputed from today's defaults.At login, not at boot
Stated in
installoutput, instatus, in--help, in the README and on its own docs page. It is structural:claude.credentials: hoston darwin needs a logged-in session, so a boot-time daemon and Keychain credentials are mutually exclusive.RunAtLoadat login is not verified and nothing here claims it is — the docs carry a "Known unverified" section naming the symptom to watch for.Refusals
packageRootcontaining/_npx/) — refuses, namesnpm i -g @edspencer/paddock.Not built, on purpose
No
scan(Discover +import-chats), noconfigure(the Config screen), noinstall(ambiguous afternpm i -g), and no auth token. #796's body called an always-on unauthenticated agent "a materially larger surface"; its own later comment corrects that, and this follows the correction — a local process that can reach the port could already read the same Claude login as the same user. Duration, not reach. Documented, not gated on.Testing, and its limits
Server 2121/160 (was 2072/159 — this adds 49 tests), web 1774/84, typecheck clean, website builds 52 pages.
This box is Linux. What that means:
SuccessfulExit: false, PATH-only environment, XML escaping, argv round-trip).systemctlin a throwaway$HOME— install, status, uninstall, re-uninstall, every error path,--help,--version.launchctlran. Nothing here proves launchd accepts the plist, thatbootstrapsucceeds, or that a login-time start beats keychain unlock. The one launchd fact checked on real hardware (a turn completing underkickstart, Keychain-only login,ps ewwclean, no dialog) was checked by hand and lives in CLI:paddock service install— run Paddock as a per-user background service (launchd / systemd --user) #796.enable --nowis unexercised against a real one.Drive-by
claude-mcp.tsdescribed the per-directorymcpServersscope as "the scope a--hereworkspace hits". #798 removed the flag; apath:-linked project reaches the same entry, so the name is fixed and the point kept.Two stale absolute claims were also corrected, since this PR falsifies them:
what-paddock-touches.mdsaid the only write outside the data dir wastranscripts: host(now two, both opt-in), and the README said any argument other than a flag is an error.🤖 Generated with Claude Code