The JSON surface agents depend on. Every schema here is generated from the Codable types in Sources/DevCtlKit and locked by golden-file tests; changing a field is an API change and updates this document in the same commit. All timestamps are ISO-8601 UTC with milliseconds. Timeouts are seconds.
Success: the command's result object on stdout. Failure with --json: {"ok": false, "error": {"code", "message", "hint"}} on stdout; hint is the literal remediation command when one exists.
Stable error.code values: already-exists, config-invalid, daemon-starting, daemon-unreachable, internal-error, not-found, not-trusted, port-drift, port-held, resource-locked, resource-mutated, spawn-failed, usage, version-mismatch. (Grows append-only.)
Exit codes: 0 ok · 1 operation failed (crash, timeout, conflict) · 2 usage · 3 daemon unreachable · 4 named server not found. Unnamed status in an unconfigured project exits 0 with {"servers": []}.
Filled in per phase as each lands; golden tests reference the examples in this file.
-
devctl status [name] [--all] --json→{servers: [ServerStatus], trusted?}. Named lookup that matches nothing exits 4 (not-found); unnamed always exits 0.--allis machine-wide (every registered project) and auto-prunes checkouts whose path is gone;trustedis present only on a scoped project query. -
devctl register --name N --cmd word --cmd word … [--port P] [--cwd D] [--write [--force]] --json→{server: ServerStatus}.--cmdrepeats per argv word and accepts dash-prefixed values. The spec is validated the same way a committed one is (a name carrying::, an empty command, an out-of-range port or healthcheck value failconfig-invalid), so a specconfig checkwould reject can no longer be registered.--writealso appends the server to devservers.json, merging so every other entry survives; an existing entry of the same name needs--forceand otherwise failsalready-exists. -
devctl start|stop <name> --json→{server: ServerStatus}.startreturns at spawn withphase: "starting"; health promotion torunningfollows (usewait/ensureto block).stopon an already-stopped server exits 0. -
devctl ensure <name> [--port P] [--timeout 60] --json→{reason?, server}. Optional--portoverrides the committed port for this run (flows through the same effectivePort pipeline as sibling rebind anddevctl.local.json). State matrix: healthy → no-op; starting → joins the in-flight attempt (single-flight, two concurrent ensures cannot double-spawn); unhealthy → no-op reporting the phase; stopped/crashed/failed → start and block until healthy. Fails fast the moment the phase turns crashed/failed.reason: "crashed"|"failed"|"stopped"|"timeout"; reason present ⇒ exit 1, withlastExit/spawnError/recentLogTailforensics inserver. Sibling port conflict auto-rebinds and still returns ok withserver.portConflict.state: "rebound". -
devctl start <name> [--port P] --json/devctl up [--only a,b] [--port P] [--timeout 60] --json: same optional--portoverride as ensure (applied to each started server underup). -
devctl wait <name> [--healthy|--stopped] [--timeout 60] --json→{reason?, server}.--healthy(default) rides through non-terminal transitions (another session's restart) and fails fast on crashed/failed/stopped;--stoppedresolves on stopped/crashed/failed. -
Port pre-check on every start-shaped path (
start,ensure,up,switch, a lock resume, boot recovery): resolveseffectivePortfirst. Held by a managed sibling (same git common-dir) → auto-rebind to a free port and continue (status carriesportConflict.state: "rebound"). Held by an unrelated managed server →port-heldnaming that server and project. Held by an unmanaged listener →port-heldwith pid + command when lsof can name it. After first-healthy, ifobservedPort != effectivePort→port-drift(Vite silent bump is not healthy). The managed-holder scan sees both live supervisors and holders recorded in state whose pid is still alive. The listener probe tries both loopback families. Runs only when the target is not already up. Underup/switchan unhandled held port fails the whole rollout withport-held. -
Ownership check after first-healthy: a passing healthcheck proves something answered, not that this server answered. Every pid listening on
effectivePortis compared against the server's process tree. Listeners both inside and outside →portConflict.state: "shared", phase untouched, since this server is serving and only the port is ambiguous (a v4-only listener beside a v6 wildcard bind, for instance). All listeners outside the tree →portConflict.state: "foreign", and the phase changes tofailedonly when one of those pids is another server this daemon supervises, which is what makes theft provable. An unattributable outside listener is annotated and left running: a container-backed or daemonizing server has its socket held by a process devctl never parented, and that is indistinguishable from theft by lsof alone. No listeners reported → nothing is recorded, because lsof can be unavailable and an empty result is not evidence. -
Latent port conflicts are annotated on every status read, including the machine-wide sweep and
why, so a stopped server names the holder keeping it down instead of reporting only "not running". -
devctl.local.json(project root, gitignored by convention): partial per-server overlay merged after committeddevservers.json(port, portEnv, portSpan, ports, command, env, url, host, heads). Never committed.portSpanclaims a consecutive block from the effective primary without naming each secondary;portsnames secondaries as{ "offset": N, "env"? }(moves with sibling rebind) or{ "port": N, "env"? }(absolute, machine-singleton). OverlappingportSpanand named offsets is a config error. -
errorSummaryis devctl's own count of the last run's stderr lines with the first and last timestamps, captured when a server turns crashed, failed, or unhealthy. It is arithmetic over the log, never the lines themselves, so the session-context hook can surface that errors piled up without emitting attacker-influenceable child output; the agent reads the actual lines withdevctl why. -
terminalEvidence/recentLogTailon status: short out/err/sys lines captured at terminal transitions (and persisted across ensure truncate / daemon rehydrate) sowhystill sees a refusal after a retry. -
Healthchecks: explicit
healthcheckblock wins; else a declaredportimplies a TCP probe; else healthy = alive past a 2s stabilization window.ServerStatus.healthchecksays which ("http"|"tcp"|"none") so agents know whenrunningis unverified.unhealthyAfterapplies only after first-healthy: a slow boot staysstarting. -
devctl logs <name> [--follow] [--tail N] [--since 5m|ISO] [--since-mark <id>] [--grep RE] [--stream out|err|sys|mark] --json→ one{at, stream, text}object per line on stdout.--grepis the Swift Regex dialect. A pattern that will not compile, or one that nests an unbounded quantifier inside another (the(a+)+catastrophic-backtracking family, which Swift's regex engine runs for minutes on a single line), failsusagerather than running.--followpolls incrementally (restart-safe). Structured lines are sanitized: ANSI/OSC escapes stripped, NULs removed, CR spinner rewrites collapsed to the final frame; timestamps are per-file monotonic. -
devctl mark <name> <text> [--label L] --json(or--all <text>) →{marks: [{at, id, server}]}. The id feeds--since-markonlogsandevents, so no clock agreement is needed. Marks flow through the same append path as process output; ordering is exact. -
devctl events [--all] [--since …] [--since-mark <id>] [--tail N] --json→{events: [{at, detail?, kind, project, server}]}with kindsstarted|stopped|crashed|failed|healthy|unhealthy|marked|registered|unregistered. Scoped to the current project unless--all. This is the "what happened while I was compacted" answer. -
devctl why <name> --json→{findings: [{server, phase, summary, evidence[]}], rootCause?}. Diagnoses over the merged project view (committeddevservers.jsonplus ad-hoc registry), the same set asstatus/ensure. WalksdependsOnto the deepest broken dependency; evidence prefersrecentLogTail/ persistedterminalEvidence, else a structured-log window (out+err+sys). Exit 0 after a short life notes that controlled refusals are common. Agent session context never embeds raw child lines; it points here. -
devctl up [--only a,b] [--timeout 60] --json→{results: [{reason?, server}]}. Wave-parallel in dependency order (Kahn waves);--onlypulls in transitive dependencies; a wave with a failure stops the rollout (later waves depend on it); anyreason⇒ exit 1.waitFor: "started"on a server launches it without blocking on health; the default blocks until healthy.devctl down --jsonstops in reverse waves, always exit 0. -
devctl trust --json→ approves the project's committed config. Trust is also recorded implicitly by an explicitstart/ensure/upon a file-sourced server (the invocation is the approval); the SessionStart hook advertises only trusted projects.status --jsoncarriestrustedwhen project-scoped. -
devctl open <name> [head]→ opens the server's URL in the default browser, or a named head of a multi-headed server. URLs derive ashttp://<host>:<port>/from the projecthost(default<slug>.localhost), overridable per server (hostorurlkeys). Aheadsmap (display name → URL) models one server fronting several surfaces (a Host-routing dev proxy); heads appear inServerStatus.heads, the agent context block, and the app's row/detail menus. A head is written either as an absolute URL or as a root-relative path (/admin), which resolves against the server's own effective base so it follows a rebind or a worktree host swap; the same two shapes apply tohealthcheck.url. Anything else, and a root-relative value on a server that declares neitherportnorurl, is aconfig checkerror. -
devctl link <verb> <name> [head] [--json]→ prints adevctl://URL for the cwd project (devctl://ensure/<slug>/<name>, etc.). Verbs:open,ensure,stop,why.--json→{url}. For Raycast/Shortcuts/docs; the menu bar app handles the same URLs via Launch Services. -
devctl x-url <url> [--json](hidden): dispatches adevctl://URL through the sameDeepLinkRunnerthe app uses (no Launch Services). Smoke/CI entry. Success →DeepLinkRunResult{verb, projectPath, detail?}; bad URL / unknown slug → usage/not-found. -
devctl config check --json→{errors, host, servers, warnings}from the daemon's own validator (cycles, unknown dependencies, and unresolvableheads/healthcheck.urlvalues are errors; duplicate declared ports, unknown versions, an http healthcheck with no url, and bare-loopback hosts and heads are warnings); errors ⇒ exit 1. Name servers after the project (not a genericweb) and give each a<project>.localhosthost, not barelocalhost: the subdomain keeps browser cookies/storage/service workers isolated per project. -
devctl restart <name> | --all [--port P] [--timeout 60] --json→{results: [{reason?, server}]}. Stops and re-ensures as one daemon-side transition, so no other session'sensurelands in between and a refusal (a held resource, a paused server, a config that no longer parses) arrives before anything stops rather than after. The stop is non-retiring, so resume-on-boot survives it. A barerestartwith neither a name nor--allis a usage error; anyreason⇒ exit 1. -
A server may declare
watch: project-relative files it reads at boot and does not reload itself ("watch": ["vite.config.ts"]). A change to one restarts that server. A server whose framework already reloads its own config declares nothing and behaves exactly as before. Paths are literal, relative to the project root, and may not exist yet (their appearance is a change); a glob, an absolute path, a path outside the project, or a directory is aconfig checkwarning and is ignored. The baseline is taken after the server has been up for a settle window, so a config the server writes during its own boot does not bounce it, and a change must hold still for a quiet window, so one save touching several files is one restart. A restart is refused while a resource the server declares is held, and the pending change fires when that hold releases rather than being dropped. Repeated restarts inside a short window suspend the watch with asyslog line naming the paths, since devctl cannot tell a server rewriting its own file from a person saving repeatedly; an explicitdevctl restartre-arms it.DEVCTL_NO_WATCH=1disables the sweep for the whole daemon. -
devctl config init [--dry-run] [--force] [--host H] [--name N --cmd word … [--port P]] --json→{check: CheckResult, content, notRecovered?, path, written}. Writes devservers.json from the servers the daemon already knows, which is the way back from losing a gitignored file. Refuses an existing file withalready-existsunless--force;--dry-runreturns the content and writes nothing. The projection writes only what the file declares: an effective or rebound port, a worktree-derived host, a materialized url, an absolute icon path, and the port and host keys devctl injects into the environment are all dropped, so the file stays portable to another checkout on another machine.lifecyclehas no runtime counterpart and cannot be recovered; it is named innotRecoveredrather than silently lost. The written file is indented, unlike every wire frame. -
devctl config check --jsonalso reportseffectiveHostandeffectiveHostReason(linked-worktree,local-overlay,server-override) when a start from this directory would use a host other than the declared one, plusserverHostsfor the servers that differ from the project. A linked worktree gets an ephemeralworktree-<label>.<preferred>.localhost, so any origin the app itself pins (an auth callback, a CORS allow list, an API key referrer) is wrong before anything starts. Not a warning: an ephemeral host is correct behavior, so a worktree checkout still reportsconfig ok. -
devctl doctor [--fix] --json→{findings: [{detail, kind, severity}]}: daemon/launchd state, captured-PATH staleness, the host:port signature table with conflicts, cross-project port collisions (port-collision: two unrelated projects declaring one port, which no host:port signature can catch because the hostnames differ while the bind does not; sibling worktrees are excluded since they rebind by design), unmanaged listeners on managed ports, and registry entries whose project path no longer exists (the daemon auto-prunes those on boot and machine-wide status;--fixremains an idempotent force path for leftovers). Also reportsharness-hookfindings (a detected harness with no devctl hook, or a hook pointing at a path that no longer exists); aninstall-shadowfinding when a second devctl copy shadows the Homebrew install (amake installCLI in~/.local/binahead of brew's bin on PATH, or a foreign/Applications/devctl.appHomebrew could not overwrite, either of which lets a baredevctlor the daemon keep running an old version after an upgrade); and, when a newer release is available, anupdatefinding read from the shared update-check cache (refreshed only when stale). All are report-only: doctor names the exact command to run and never edits a file the user owns. -
devctl switch <branch> [--no-fetch] [--timeout 120]→ clean-tree guard (refuses dirty; never stashes), fetch, group down,git switch(remote-tracking fallback), then the project'slifecycle.switchplaybook (argv arrays run sequentially from the project root; failures stop withdevctl upas the resume hint), then group up. The branch's devservers.json is validated after the checkout and before the playbook runs, so a configconfig checkwould reject (including an empty lifecycle command, which the validator now covers) failsconfig-invalidinstead of feeding argv to the shell. Playbooks live in devservers.jsonlifecycleand are agent-configurable. -
Config extras: project-level
icon(project-relative path, per-server override) feeds Spotlight thumbnails; every server and head is indexed in Spotlight as<project> · <head>with subtitledevctl · <url>(best-effort; not a Top Hit launcher);headsand pins surface in the menu bar app. -
devctl lock <resource> [--no-pause] [--acquire-timeout 300] [--timeout 120] -- <command…>→ runs the command holding a project resource exclusively. By default the daemon pauses servers that declare the resource in theirlocks(devservers.json) and re-ensures them on release (even on command failure).--no-pausetakes the mutex without stopping declarers (for harnesses that reuse the live server).ensure/startof a declaring server is refused (resource-locked, naming the holder pid) while a live holder owns it, regardless of--no-pause. Locks are path-scoped (canonicalPath::resource); they do not pause other checkouts. Locks persist across a daemon crash: a dead holder auto-releases and resumes the paused set; a still-live holder keeps them paused so the harness stays exclusive. Exit status is the command's. The--is required and devctl's own options go before it; everything after--is captured verbatim, so a nested--, a dash option, and an empty string all reach the command untouched. A missing terminator or an unknown option is rejected by the parser at exit 64 rather than being passed through. A contended acquire writes the holder's pid, how long it has been running, and what it paused or left running to stderr, then repeats a still-waiting line every 15s, so a wait is never silent;--acquire-timeout 0makes exactly one attempt and fails immediately withresource-locked. All of lock's own output is stderr: stdout belongs to the guarded command.A
locksentry is written either as a bare name ("d1") or as an object naming where the resource's state lives ({"name": "d1", "path": ".wrangler/state/v3/d1"}); both forms parse and the bare form re-encodes bare. With a path declared,lockfingerprints that state before and after the command (device and inode, size, and a SHA-256 over a bounded manifest) and reports a change: under the default paused mode it is a note on stderr, and under--no-pausewith a declaring server still running it is aresource-mutatedfailure, because that server holds the old state open and can write its cached pages back over the change. Two servers declaring one resource with different paths is a config error rather than a guess. What the check cannot catch: it flags the risk window, not the damage, since the flush that corrupts can land after the command exits; it cannot see state outside the declared path (a sibling-walfile whenpathnames only the.sqlite), divergence that never reaches disk, or a change that reverts to byte-identical state inside the window; a directory stops hashing file contents past a byte budget and reportsexact: falsewhen it did, so a change confined to a file past that budget is missed; and it never names which process wrote. A resource that is a single file is hashed whole at any size, so a middle-only rewrite that preserves size and mtime is caught. -
devctl context: the harness-agnostic session context: a fenced<devctl-servers>plain-text block (server phases, effective URLs, log paths, latent/rebound port-conflict warnings, the ensure/wait/why/logs/lock cheat-sheet) for the cwd's project. Linked worktrees get a banner naming the preferred host. Silent (exit 0) when the project is unregistered or untrusted or the daemon is down; never bootstraps; never contains raw log lines or command strings. -
devctl daemon status --json→{daemon?, launchd, reachable}.reachableis whether the daemon answered over the socket, and it is the field to branch on:launchdreportingrunningonly means a job is loaded, so a loaded-but-not-listening daemon prints a reassuring launchd line withreachable: false.daemonis present only when reachable. Exit stays 0 either way, because the launchd half is still a useful answer.daemon.restoringis present and true only while boot restore is running, and absent otherwise; a client waiting for a usable daemon wantsreachableand norestoring, since the daemon accepts before restore finishes and declines work until it does. -
Boot restore is a window, not an outage. From the moment the socket accepts,
daemon.infoanddaemon.shutdownanswer and every other method is refused withdaemon-startingand the hintrun: devctl daemon status, rather than the daemon being unreachable while it works. The distinction is what a client must branch on:daemon-unreachablemeans nothing answered and starting a daemon is the fix,daemon-startingmeans one is already coming up and starting another is wrong. The CLI waits outdaemon-startingfor up to 30 seconds, saying on stderr what it is waiting for, then fails with exit 3 if the daemon never finishes. The session-start hook does not wait: it talks to the socket directly and stays silent, because a session must not block on a daemon. -
devctl uninstall [--agent-only] [--purge] --json→{actions: [string], agentOnly, purged}. The one uninstall verb. Stops nothing that is running: it shuts the daemon down and its children survive it. Removes the background agent (and any legacy home LaunchAgent), then agent hooks from every harness, then the CLI and daemon binaries devctl itself installed at~/.local/bin(a Homebrew install keeps its CLI in brew's bin, so those paths do not exist there and this is a no-op; the cask removes brew's symlink). Data and logs are kept unless--purge.--agent-onlystops after the agent and never touches hooks, binaries, or data: it is what the Homebrew cask calls on every upgrade, so it must stay narrow.purgedin the result is true only when data was actually removed (never under--agent-only). -
devctl daemon uninstall [--purge] --json: deprecated alias fordevctl uninstall --agent-only(with--purgemapping to data removal). Kept working because the CLI JSON contract is a public surface; the deprecation notice goes to stderr so--jsonstdout stays clean. Removal is a future major. -
devctl daemon install|start|stop|restart|status: launchd lifecycle.stopdrains and writes a deliberate-stop marker that auto-bootstrap honors;restartandinstall(upgrade) both capture running servers, bounce the daemon, and re-ensure them by name ("servers bounce, then come back"). The new daemon accepts beforerecoverAtStartupand refuses real work withdaemon-startinguntil it returns, so that re-ensure waits the window out rather than racing a half-finished restore.installalso stages-and-renames the daemon binary and captures the login-shell PATH into the agent plist. Reboot recovery: the LaunchAgent runs at load; starting a server records resume-on-boot; a machine shutdown drains without clearing it;recoverAtStartupresolves specs through the merged config+registry view (so committeddevservers.jsonservers come back, not only ad-hocregisterentries) and restores those servers one at a time so sibling port claims observe each other. Restore is autonomous, so it honors the trust gate: a committed server whose project was never approved (no explicitstart/ensure/uprecorded trust) is not spawned, which keeps a cloned repo's devservers.json from running itself after a reboot. A deliberatedevctl stop/downclears the intent. Renamed or deleted servers leave orphan state rows that recover drops. -
devctl hook install [--harness claude|cursor] [--devctl-path PATH] [--statusline]: idempotently wires a session-start hook into the harness's settings (claude: SessionStart with matcherstartup|resume|clear|compactin ~/.claude/settings.json, emittinghookSpecificOutput.additionalContext; cursor: sessionStart in ~/.cursor/hooks.json, emitting{additional_context}).--devctl-path(absolute) overrides the path recorded in the hook command, which the menu bar app passes as the CLI owner's path so a Homebrew install records the stable shim in brew's bin rather than the internal bundle path this binary resolves to. After a successful install it also prints a one-bullet discovery tip for the project's CLAUDE.md/AGENTS.md (wired to the nearest devservers.json's first server when one exists); the tip is printed for a human to paste and is never auto-appended to those files. Adding a harness: CONTRIBUTING.md. -
devctl hook uninstall [--harness claude|cursor] --json: idempotently removes devctl's session hook from a harness's settings, leaving everything else the file holds untouched (an entry that held only devctl's hook is dropped whole; a foreign hook alongside it is kept). Omitting--harnessremoves from every detected harness.--jsonsuccess is{}, matchinghook install. -
devctl statusline: reads harness statusline stdin JSON (workspace.current_dir or cwd), printsmyproj:3000 ok · api crashedfor the project, empty otherwise.
{ "declaredPort": 3000, // committed / ad-hoc declaration "effectivePort": 3742, // what this run binds (override, overlay, sibling rebind, or declared) "errorSummary": { "count": 3, "firstAt": "…", "lastAt": "…" }, // stderr tally for the last run; present once it turns crashed/failed/unhealthy "heads": { "admin": "http://admin.myproj.localhost:3742/" }, // multi-headed server: display name → URL (follows effective host/port) "healthcheck": "http", // "http" | "tcp" | "none" "lastExit": { "at": "…", "code": 1, "signal": null }, // crashed only "lastHealthAt": "…", "locks": ["d1"], // resources this server holds while running; `devctl lock <resource> -- …` gets exclusive access without stopping it "logPath": "~/Library/Logs/devctl/myproj-a1b2c3d4/web/current.log", "observedPort": 3742, // from post-healthy listen scan; must match effectivePort or the server fails with port-drift "phase": "running", // stopped|starting|running|unhealthy|stopping|crashed|failed "pid": 4242, "portConflict": { // present when a conflict was handled or is latent / fatal "declaredPort": 3000, "effectivePort": 3742, "holder": "web@/Users/me/code/myproj", "message": "port 3000 held by sibling; rebound to 3742", "state": "rebound" // "rebound" | "held" | "drift" | "foreign" | "shared" }, "project": "/Users/me/code/myproj", "recentLogTail": ["…"], // crashed/failed only "server": "web", "spawnError": { "errno": 2, "message": "…" }, // failed only "specStale": false, "uptimeSec": 123, "url": "http://worktree-review.myproj.localhost:3742/" }