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: config-invalid, daemon-unreachable, internal-error, not-found, not-trusted, port-drift, port-held, resource-locked, 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] --json→{server: ServerStatus}.--cmdrepeats per argv word and accepts dash-prefixed values.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. 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.--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.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 and unknown dependencies are errors; duplicate declared ports, unknown versions, and bare-loopback hosts 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 doctor [--fix] --json→{findings: [{detail, kind, severity}]}: daemon/launchd state, captured-PATH staleness, the host:port signature table with conflicts, 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).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. 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.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 install|uninstall [--purge]|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 finishesrecoverAtStartupbefore accepting socket clients, so that re-ensure cannot race 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. A deliberatedevctl stop/downclears the intent. Renamed or deleted servers leave orphan state rows that recover drops.devctl hook install [--harness claude|cursor] [--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}). 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 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": "…", "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" }, "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/" }