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, not-found, not-trusted, port-held, 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] --json→{servers: [ServerStatus]}. Named lookup that matches nothing exits 4 (not-found); unnamed always exits 0.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> [--timeout 60] --json→{reason?, server}. 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.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
start/ensure: a declared port held by a managed server elsewhere →port-heldnaming that server and project; held by an unmanaged listener →port-heldwith pid + command when lsof can name it. Runs only when the target is not already up, so a server never conflicts with its own listener. - 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?}. WalksdependsOnto the deepest broken dependency; evidence carries recent err-stream lines, declared-vs-observed port mismatches, and spec-drift notes.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 (--fixprunes those).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> -- <command…> [--acquire-timeout 300] [--timeout 120]→ runs the command holding a project resource exclusively. The daemon pauses servers that declare the resource in theirlocks(devservers.json) and re-ensures them on release (even on command failure);ensure/startof a declaring server is refused (resource-locked, naming the holder pid) while a live holder owns it. 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 mechanism for test harnesses whose private servers share mutable state (a local database) with a managed server.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").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). A deliberatedevctl stop/downclears the intent. Renamed or deleted servers leave orphan state rows that recover drops.devctl context: the harness-agnostic session context: a fenced<devctl-servers>plain-text block (server phases, URLs, log paths, the ensure/wait/why/logs cheat-sheet) for the cwd's project. 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 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, "healthcheck": "http", // "http" | "tcp" | "none" "lastExit": { "at": "…", "code": 1, "signal": null }, // crashed only "lastHealthAt": "…", "logPath": "~/Library/Logs/devctl/myproj-a1b2c3d4/web/current.log", "observedPort": 3000, // from post-healthy listen scan; may differ from declared "phase": "running", // stopped|starting|running|unhealthy|stopping|crashed|failed "pid": 4242, "project": "/Users/me/code/myproj", "recentLogTail": ["…"], // crashed/failed only "server": "web", "spawnError": { "errno": 2, "message": "…" }, // failed only "specStale": false, "uptimeSec": 123, "url": "http://myproj.localhost:3000/" }