Skip to content

Latest commit

 

History

History
114 lines (100 loc) · 62 KB

cli-contract.md

File metadata and controls

114 lines (100 loc) · 62 KB

directa CLI contract

The JSON surface agents depend on. Every schema here is generated from the Codable types in Sources/DirectaKit 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.

Envelope

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, project-still-exists, request-too-large, resource-locked, resource-mutated, spawn-failed, usage, version-mismatch. (Grows append-only.)

A request line the daemon has not yet seen a newline for is capped at a fixed size (a request is always small; the largest legitimate one, a whole devservers.json through project.writeConfig, is nowhere close). Exceeding it answers request-too-large on that connection and closes it, with no hint: the cause is a client writing raw NDJSON to the socket without a newline, and there is no directa command that fixes someone else's framing bug. This never fires for the CLI or the app, which always send one small, complete, newline-terminated request at a time; it only guards a client writing directly to the socket. A daemon response carries no such cap, since directa logs without --tail can legitimately answer with tens of megabytes.

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": []}. A project-scoped command run from a current directory that no longer exists (a removed worktree) fails usage (exit 2) at once, saying the current directory is gone, with hint run: directa status --all; it never walks a relative path. lock refuses the same way even with --project, since its command runs in the caller's directory. Session hooks and statusline stay silent instead (Antigravity's hook writes its empty envelope). --project must name an existing directory; a project name is refused usage (exit 2) rather than answered as an empty project.

Every --timeout and --acquire-timeout takes a number of seconds from 0 to 86400. Anything else (text, inf, nan, a negative number, a larger number) fails usage (exit 2) with a message naming the flag, the value, and the range, through the envelope above under --json, before any request reaches the daemon.

Environment

  • DIRECTA_SOCKET: the daemon socket every command talks to.
  • DIRECTA_DATA_DIR / DIRECTA_LOGS_DIR: the CLI's local data and logs directories, the counterparts of ddirecta --data-dir / --logs-dir. Without DIRECTA_SOCKET, the socket follows DIRECTA_DATA_DIR (<data dir>/daemon.sock), matching a daemon started with --data-dir alone. An empty value counts as unset. daemon.info reports the answering daemon's dataDir and logsDir, and doctor uses those over the local ones.
  • Any of the three set turns auto-bootstrap off: a command never installs or starts the background agent for a layout the agent does not use. daemon install|start|restart|uninstall and uninstall (with or without --purge or --agent-only) manage that agent, which runs only the default locations, so with any of the three set they refuse usage (exit 2) before touching anything, naming the variables to unset, with hint run: env -u DIRECTA_DATA_DIR -u DIRECTA_LOGS_DIR -u DIRECTA_SOCKET directa <command>. daemon stop asks whichever daemon the resolved socket reaches to shut down, which is how a daemon started by hand with --socket/--data-dir/--logs-dir is stopped.

ServerStatus (the core schema)

{
  "blockedOn": "interactive-auth",  // present when the last runs died the way an interactive credential prompt blocks: self-exit, nonzero, bounded tens-of-seconds lifetime, repeatedly, without ever passing a healthcheck. Heuristic, in directa's own words; clears when a run dies differently or a healthcheck passes
  "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"
  "icon": "assets/icon.png",    // absolute path after config validation, when the config declares one; feeds Spotlight thumbnails
  "lastExit": { "at": "…", "code": 1, "signal": null },   // exit of the run that just ended (crashed, failed, or stopped). Absent on the run that replaced it, including while that run is still starting
  "lastHealthAt": "…",          // when the current run last passed a health check; absent until it does, and cleared when the run ends
  "lastHealthCheck": { "at": "…", "detail": "HTTP 500", "failures": 4, "passed": false },  // the current run's latest health check; `failures` counts the failed checks in a row ending at it (0 when it passed), `detail` says why it failed (absent on a pass). Absent until the run's first check and on a server with healthcheck "none"; cleared when the run ends
  "locks": ["d1"],               // resources this server holds while running; `directa lock <resource> -- …` gets exclusive access without stopping it
  "logPath": "~/Library/Logs/directa/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"
  },
  "ports": { "admin": 3743 },    // resolved named secondaries when the spec declares `ports`
  "project": "/Users/me/code/myproj",
  "recentLogTail": ["…"],       // crashed/failed only
  "server": "web",
  "spawnError": { "errno": 2, "message": "…" },            // failed only
  "specStale": false,           // the merged config differs from the spec the current run started with; a run started by restart, ensure, or start is never stale until the config changes again
  "terminalEvidence": ["…"],    // short out/err/sys lines persisted across ensure truncate / daemon rehydrate, for `why`
  "uptimeSec": 123,
  "url": "http://myproj.localhost:3742/",
  "worktree": "review",         // present only when the server runs from a linked git worktree: display label, host unaffected
  "mainProject": "myproj"       // with worktree: the main checkout's slug, so displays read "myproj · review"
}

Filled in per phase as each lands; golden tests lock these schemas as exact strings.

  • directa status [name] [--all] --json → {servers: [ServerStatus], trusted?}. Named lookup that matches nothing exits 4 (not-found); unnamed always exits 0. A not-found for a name the resolved project lacks (from every command that takes a server name, CLI or daemon side) looks for the name in the other registered projects: none has it → hint run: directa status --all; exactly one → the message names that project and the hint is the same command with --project '<path>'; several → the message names every project path that defines it and the hint is run: directa status --all <name>. A name is never resolved into another project on its own. The linked-worktree case keeps its own wording: copying devservers.json into the worktree is the fix that keeps the worktree's own server, and --project with the main checkout's path acts on the main checkout's server instead, which the message says. --all is machine-wide (every registered project) and auto-prunes checkouts whose path is gone; trusted is present only on a scoped project query. Human mode: an unnamed scoped query that finds no servers while other projects on the machine have some names directa status --all; the JSON is unchanged.

  • directa register --name N --cmd word --cmd word … [--port P] [--cwd D] [--write [--force]] --json → {server: ServerStatus}. --cmd repeats 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 fail config-invalid), so a spec config check would reject can no longer be registered. --write also appends the server to devservers.json, merging so every other entry survives; an existing entry of the same name needs --force and otherwise fails already-exists.

  • directa unregister <name> --json → {}. Refuses not-found for a name that is not registered ad hoc (including one declared only in the project's committed devservers.json). If the server has a process (running, starting, or failed for its port with the process still up), unregister stops it first through the normal stop path and waits for it to exit before dropping the registry entry and the log directory. Whatever the stop does, the server is left with no restore-at-launch intent, so a committed server of the same name does not come back on the next daemon launch. A stop that does not finish in time keeps the log directory for doctor to report later; a process that had not appeared yet is stopped the moment it does, and one still running at the next daemon launch is stopped then, provided its start time matches the recorded one (a process that merely reuses the number is left alone). A restart of the same server that is in flight when unregister lands does not start it again; its result carries reason: "stopped".

  • directa start|stop <name> --json → {server: ServerStatus}. start returns at spawn with phase: "starting"; health promotion to running follows (use wait/ensure to block). stop on an already-stopped server exits 0.

  • directa ensure <name> [--port P] [--timeout 60] --json → {reason?, server}. Optional --port overrides the committed port for this run (flows through the same effectivePort pipeline as sibling rebind and directa.local.json). State matrix: running and the latest health check for this process passed → no-op; running while that check is failing → wait without restarting; starting → joins the in-flight attempt (single-flight, two concurrent ensures cannot double-spawn); unhealthy → restarts it as one daemon-side stop-then-spawn (the restart's port re-check included) and blocks until healthy, the stop recorded with detail restarted by ensure: unhealthy; 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, with lastExit/spawnError/recentLogTail forensics in server. Sibling port conflict auto-rebinds and still returns ok with server.portConflict.state: "rebound".

  • directa start <name> [--port P] --json / directa up [<name>] [--only a,b] [--port P] [--timeout 60] --json: same optional --port override as ensure (applied to each started server under up).

  • directa 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. It resolves only once a health check has passed for the process that is up now: a check from the process it replaced does not count, and while the latest checks are failing the wait keeps blocking even though phase may still read running until enough failures accumulate to mark it unhealthy. --stopped resolves once the server has no live process: stopped, crashed, or failed with its process gone; a server failed for its port whose process still runs keeps the wait open, and a human-mode timeout on one names directa stop <name>. Human mode: a --healthy wait that falls short on crashed or stopped names directa ensure <name> as the next step. A timeout while the server is still starting (from wait, ensure, restart, up) still exits 1, and its human line says the server is still starting, not that it failed, and names directa wait <name> --timeout <seconds> to keep waiting.

  • Port pre-check on every start-shaped path (start, ensure, up, switch, a lock resume, boot recovery): resolves effectivePort first. Held by a managed sibling (same git common-dir) → auto-rebind and continue (status carries portConflict.state: "rebound"): the rebound block (primary, span, named offsets) has no listener and clears every port a live managed server holds, a declared portSpan reserving its whole block rather than only the ports it listens on. Held by an unrelated managed server → port-held naming that server and project. Held by an unmanaged listener → port-held with pid + command when lsof can name it. After first-healthy, if observedPort != 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; a server holds its whole claim while running, starting, or unhealthy (never while stopping, since it is releasing its ports), and a server failed for its port holds the port it was seen listening on while its process lives. A listener on a port the target itself holds is the target's own run, not a conflict. The listener probe tries both loopback families and runs before the holder scan, so a run another request spawned a moment earlier (a concurrent ensure of the same server) is found as managed rather than reported as an unmanaged listener. Runs only when the target is not already up. Under up/switch an unhandled held port fails the whole rollout with port-held. A transition that stops a run and spawns the next one in one step (restart, the watch sweep's restart, start/ensure over a port-failed run) checks again between the stop and the spawn: it waits a bounded time for the server's claimed ports to have no listener that belongs to the run just stopped, cannot be attributed, or sits on a port that had no listener before the stop (a listener outside that run on a port that was already listening before the stop is the shared or foreign case the pre-check let through, and it does not block the spawn), and a listener still there is named in the server's sys log (pid, command, and whether it belonged to the run just stopped) and fails the transition with port-held naming it and hint run: directa why <name> --project '<path>', so the new run never answers its first health check through a process it did not start. A transition refused this way leaves that server stopped, and a multi-name restart ends at that entry: earlier entries stay restarted and later ones are untouched, their spec, port, and url exactly as before the request (preparing a spawn never alters a live run's reported spec). A watch-sweep restart refused this way records a stopped event whose detail names the held port, so status, events, and the hooks show why the server is down.

  • Ownership check after first-healthy: a passing healthcheck proves something answered, not that this server answered. Every pid listening on effectivePort is 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 to failed only 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 directa 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; when lsof did answer and named no listener on effectivePort, the server's sys log says so, since a pass that nothing was listening for afterward means some other process answered it. A server failed for port-drift or a proven foreign owner keeps its own process running: stop, unregister, and forgetting its project stop that process, start/ensure stop it before spawning the next run, lock --pause stops it like any running declarer and a non-pausing lock lists it as live, and a status read leaves the settings it was spawned with (an overridden or rebound port, the url built from it) in place.

  • 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".

  • directa.local.json (project root, gitignored by convention): partial per-server overlay merged after committed devservers.json (port, portEnv, portSpan, ports, command, cwd, env, url, host, heads, healthcheck, shell). Never committed. portSpan claims a consecutive block from the effective primary without naming each secondary; ports names secondaries as { "offset": N, "env"? } (moves with sibling rebind) or { "port": N, "env"? } (absolute, machine-singleton). Overlapping portSpan and named offsets is a config error.

  • errorSummary is directa'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 with directa why.

  • terminalEvidence / recentLogTail on status: short out/err/sys lines captured at terminal transitions (and persisted across ensure truncate / daemon rehydrate) so why still sees a refusal after a retry.

  • Healthchecks: explicit healthcheck block wins; else a declared port implies a TCP probe; else healthy = alive past a 2s stabilization window. ServerStatus.healthcheck says which ("http"|"tcp"|"none") so agents know when running is unverified. unhealthyAfter applies only after first-healthy: a slow boot stays starting. timeoutMs bounds each probe in full (an HTTP answer that arrives inside it passes however slow it is); a probe falls back from IPv4 to IPv6 only when the IPv4 connection was refused, never after a timeout.

  • directa logs <name> [--follow] [--tail N | --head N] [--all] [--since 5m|ISO] [--since-mark <id>] [--grep RE] [--stream out|err|sys|mark] --json → one {at, stream, text} object per line on stdout. --head N keeps the oldest N matching lines of the window (the read-what-was-skipped shape: --since <ts> --head 200) and fails usage with --tail, --all, or --follow. With none of --tail/--head/--since/--since-mark/--follow/--all, the request carries an implicit tail: 200 (client-side; the wire itself is unchanged, so this applies against any daemon version) rather than answering a long-running server's whole history. --all asks for that full history outright and fails usage if combined with --tail (naming incompatible amounts of output); combined with --since/--since-mark it is a no-op on the tail, since those already leave it unbounded on their own. --follow's own initial-backlog default (tail: 50) is separate and unaffected. --grep is 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), fails usage rather than running. --follow polls past the daemon's cursor (restart-safe): the first answer seeds it even when no line matched, and each poll returns exactly the lines appended since, same-millisecond lines included, with stdout flushed after every poll. Under CLAUDECODE=1 with stdout not a terminal, --follow prints one stderr line pointing at directa monitor <name> and the Monitor tool. --head and --follow against a daemon whose answer lacks cursor fail version-mismatch (exit 3, hint run: directa daemon restart) rather than read an unbounded window. Structured lines are sanitized: ANSI/OSC escapes stripped, NULs removed, CR spinner rewrites collapsed to the final frame; timestamps are per-file monotonic.

  • logs.query wire fields (all optional, append-only both ways; an older client's params and an older daemon's result still decode):

    • LogCursor {at, count, position?}: every record before at, plus the first count records stamped exactly at (all streams, file order), lie behind it. {"at":"1970-01-01T00:00:00.000Z","count":0} is the cursor of a family with no records. position {file, offset} is present once count reaches the daemon's threshold (a large same-millisecond run, which is every record written after a backward clock step until the clock catches up): file is the inode number of the log file holding the newest record behind the cursor (a rotation's rename keeps it) and offset is the byte just past that record. A poll past a positioned cursor resumes at that byte rather than counting through the millisecond again, and the next cursor's count adds what arrived to the one the caller sent. A position that no longer names a record ending there stamped at (its file rotated out of the family, or a made-up value) is ignored and count applies, so a cursor stays valid without it and callers pass the cursor back exactly as received.
    • LogStreamCounts {err?, mark?, out?, sys?}: one number per stream, always an object. LogStreamTotals {err, mark, out, sys}: the same object with all four fields present.
    • Params: after: LogCursor (exclusive lower bound), head: N (oldest N matches), tailByStream: LogStreamCounts (newest N per stream, so one stream's burst never crowds another out; nil leaves a stream untrimmed, 0 excludes it), maxLineCharacters: N (each text cut to N characters, the last being …). Refused usage with a hint: after with since or sinceMark; tail with tailByStream; head with tail or tailByStream; any negative count (after.count, head, tail, a tailByStream field); maxLineCharacters below 1.
    • Result: cursor: LogCursor on every answer, even an empty one (the position after the newest record in the family when the query ran; the next after); totals: LogStreamTotals only when the query carried after or tailByStream (records matched per stream before trimming, so totals minus the returned lines is exactly what the trim withheld). A head query without after carries no totals, and the daemon stops reading once it holds the N lines.
  • directa monitor <name> [--lines-per-minute N] [--lines-per-arm N] [--errors-per-minute N] [--errors-per-arm N] [--tick S] [--json]: streams a server's output shaped for an agent's own streaming tool (Claude Code's Monitor tool, Grok Build's monitor tool). Attaches with a start marker (checkout path, phase, pid, last exit, effective budget, directa logs <name> --tail 200 for earlier output), then polls logs.query past the daemon's cursor once per --tick (default 2, range 0.5-60 seconds), applying repeat suppression and per-stream budgets to what returns; server.status every 10s feeds a health line only on change. Budget flags, each refused by the argument parser outside its range (exit 64, the message naming the flag and the range, as for --tick): --lines-per-minute (default 120, range 1-1200), --lines-per-arm (default 600, range 1-20000: the total for the run, permanent, re-arm by running the command again), --errors-per-minute (default 30, range 1-600), --errors-per-arm (default 300, range 1-5000, independent of stdout's so stderr keeps flowing after stdout is spent). Attaches with a tail: 0 query (the end cursor, without reading the log's history) and gates on its cursor, the same gate logs --head/--follow use, failing version-mismatch (exit 3) rather than degrading; the first tick gates on totals the same way, ending the run with a line naming the message and exit 3. A name unknown to the resolved checkout is not-found (exit 4) before any output; the same condition discovered after attaching instead ends the run gracefully (ended (server unregistered)), never a nonzero exit. Only daemon-unreachable, daemon-starting, and config-invalid are waited out, each reported with one line when it begins, retrying with backoff up to 10s; an answer the client cannot decode is reported the same way. Unreachable and undecodable answers share one clock: 60s of them exits 3 (unreachable) or 1 (undecodable) before attaching, or ends gracefully after. Any other error exits through the failure envelope before attaching, or after attaching ends the run with ended (<message>; <hint>) and the exit status that code maps to. Monitor never starts the daemon: watching must not change what it watches, since a daemon may be stopped on purpose (directa daemon stop) and an agent re-arms its monitor in a loop, so an absent daemon is waited out and reported instead. After 29 minutes (below Claude Code's 30-minute Monitor-tool kill) the run reads past its cursor once more, prints what arrived, and ends naming the command to run again and directa logs <name> --since <final cursor time> --head 200 for anything that lands before the new run attaches. Every directa logs <name> ... hint names the server only when the name is shell-inert (ASCII letters, digits, -, _, ., at most 64 characters) and says <name> otherwise. Each tick asks for at most 300 lines each of out and err and 50 each of sys and mark; lines the daemon matched past those caps are never dropped silently: they get a suppressed line naming the count, the stream, the cap, and a directa logs <name> --since <cursor time> --stream <stream> --head 200 command, except out or err lines trimmed while that stream is already over its per-minute budget, which count toward its resume line instead. A read-what-was-skipped hint starts where the skipped lines start: the oldest suppressed repeat for the repeated-lines summary, the cursor the query read past for lines the daemon trimmed. A reprinted multi-line block (a stack trace) collapses only lines already seen; the first new line ends it and shows. Every child line shown, a (repeated xN) collapse included, spends the stream's budget. Human-mode lines use one of two namespaces: <label> out| /<label> err| for child output (and its (repeated xN)/(again, Nth in <window>) collapses), directa <label>: for everything else (the start marker, lifecycle, marks, health, budget/transient/suppressed-summary lines); <label> is <name> in a main checkout or <name>@<worktree label> in a linked worktree. --json emits one MonitorEvent object per line (NDJSON) instead of the human line: {at, count?, kind, label, stream?, text}, kind one of attached|line|repeated|suppressed|lifecycle|health|budget|transient|ended (append-only), count present only for repeated/budget/suppressed (the repeat count, the crossed budget cap, or the suppressed total), stream one of out|err|sys|mark present on line/repeated (picks the namespace) and on any kind that names a specific stream.

  • directa mark <name> <text> [--label L] --json (or --all <text>) → {marks: [{at, id, server}]}. The id feeds --since-mark on logs and events, so no clock agreement is needed. Marks flow through the same append path as process output; ordering is exact.

  • directa events [--all] [--since …] [--since-mark <id>] [--tail N] --json → {events: [{at, detail?, kind, project, server}]} with kinds started|stopped|crashed|failed|healthy|unhealthy|marked|registered|unregistered. kind is append-only: a client must tolerate a value outside that list (a newer daemon can add one) rather than fail the whole response, and must round-trip an unrecognized value unchanged if it relays events rather than interpreting them. Scoped to the current project unless --all. A negative --tail (on the wire, events.query tail) is refused usage with a hint, as a logs query refuses one. This is the "what happened while I was compacted" answer.

  • directa why <name> --json → {findings: [{server, phase, summary, evidence[]}], rootCause?}. Diagnoses over the merged project view (committed devservers.json plus ad-hoc registry), the same set as status / ensure. Walks dependsOn to the deepest broken dependency; evidence prefers recentLogTail / persisted terminalEvidence, else a structured-log window (out+err+sys). Exit 0 after a short life notes that controlled refusals are common. A running server is called healthy only when its latest health check passed; one whose latest checks failed reads as running with that many failed health checks in a row and the latest failure's detail. Agent session context never embeds raw child lines; it points here.

  • directa up [<name>] [--only a,b] [--timeout 60] --json → {results: [{reason?, server}]}. Wave-parallel in dependency order (Kahn waves); a positional <name> is shorthand for --only <name> (both together fail usage); --only/<name> pulls in transitive dependencies; a wave with a failure stops the rollout (later waves depend on it); any reason ⇒ exit 1. waitFor: "started" on a server launches it without blocking on health; the default blocks until healthy. directa down [<name>] --json stops the whole project in reverse waves, exit 0 always; a named server stops only that server, never its dependents, through the same server.stop every other single-server command uses (never group.down's only, so every daemon version understands it) rather than a group teardown, and exits 4 not-found for a name that does not exist.

  • directa trust --json → approves the project's committed config. Trust is also recorded implicitly by an explicit start/ensure/up/restart on a file-sourced server, and by switch (which records it before its lifecycle playbook runs); the invocation is the approval. The SessionStart hook advertises only trusted projects. status --json carries trusted when project-scoped.

  • directa open <name> [head] → opens the server's URL in the default browser, or a named head of a multi-headed server. URLs derive as http://<host>:<port>/ from the project host (default <slug>.localhost), overridable per server (host or url keys). A heads map (display name → URL) models one server fronting several surfaces (a Host-routing dev proxy); heads appear in ServerStatus.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; the same two shapes apply to healthcheck.url. Anything else, and a root-relative value on a server that declares neither port nor url, is a config check error.

  • directa link <verb> <name> [head] [--json] → prints a directa:// URL for the cwd project (directa://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.

  • directa x-url <url> [--json] (hidden): dispatches a directa:// URL through the same DeepLinkRunner the app uses (no Launch Services). Smoke/CI entry. Success → DeepLinkRunResult {verb, projectPath, detail?}; bad URL / unknown slug → usage/not-found.

  • directa config check --json → {errors, host, servers, warnings} from the daemon's own validator (cycles, unknown dependencies, and unresolvable heads / healthcheck.url values 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 generic web) and give each a <project>.localhost host, not bare localhost: the subdomain keeps browser cookies/storage/service workers isolated per project.

  • directa restart <name> | --all [--port P] [--timeout 60] --json → {results: [{reason?, server}]}. Stops and re-ensures as one daemon-side transition, so no other session's ensure lands 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 bare restart with neither a name nor --all is a usage error; any reason ⇒ exit 1, and human mode prints each server that fell short with its reason, the same line up and switch print. The CLI reads server.status first, then sends server.restart once and never repeats it blindly: when the connection drops before the answer (the daemon crashed, was killed by the system, or was restarted), it says so on stderr, waits for a daemon to answer again (the restart's --timeout, at least 30 seconds, waiting out daemon-starting, with each status read given only what is left of that wait, so a daemon that accepts the connection and never answers cannot stretch it), and decides per server from a fresh status read against the pids it read first: the same process still running means the restart never reached it, and only those servers are restarted again; no process, with none running before or with the server stopped, is finished with server.ensure (the same --port); anything else (a new process, or a new run that crashed) is waited on with server.wait --healthy, so its result is what the restart would have answered. A connection lost again repeats that decision, a bounded number of times. The result has the same {results} shape. A daemon that never answers again fails daemon-unreachable (exit 3) with hint run: directa status and a message saying the restart may already have happened.

  • 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 a config check warning 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 a sys log line naming the paths, since directa cannot tell a server rewriting its own file from a person saving repeatedly; an explicit directa restart re-arms it. DIRECTA_NO_WATCH=1 disables the sweep for the whole daemon.

  • directa 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 with already-exists unless --force; --dry-run returns the content and writes nothing. The projection writes only what the file declares: an effective or rebound port, a materialized url, an absolute icon path, and the port and host keys directa injects into the environment are all dropped (a worktree label host from state an older build persisted is dropped too), so the file stays portable to another checkout on another machine. lifecycle has no runtime counterpart and cannot be recovered; it is named in notRecovered rather than silently lost. The written file is indented, unlike every wire frame.

  • directa config check --json also reports worktree (the linked git worktree's display label) when run inside a worktree, plus serverHosts for servers whose effective host differs from the project's; only a directa.local.json overlay produces a difference today (effectiveHost/effectiveHostReason at the top level are populated only by an older daemon). A linked worktree deliberately keeps the declared host unchanged: every *.localhost name resolves to loopback, so a label subdomain never disambiguated a bind, while a third-level subdomain breaks any origin the app itself pins (an auth callback, a CORS allow list, an API key referrer, a cookie domain). A worktree checkout reports config ok.

  • directa 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 at boot, on machine-wide status, and on a timer sweep, once a path has been observed missing continuously for a full sweep interval rather than on the first miss, so a network blip or a slow unmount does not cost a project its trust or log history and the servers of a genuinely discarded checkout stop themselves within about a minute; --fix calls project.forget (params {project}) for a leftover the automatic sweep has not yet caught, which stops its supervisors, drops its registry row, trust, locks, and state, and removes its log directory, refusing project-still-exists if the checkout has reappeared since; each attempt reports its own fixed or error finding rather than assuming success). Also reports harness-hook findings (a detected harness with no directa hook, or a hook pointing at a path that no longer exists); an install-shadow finding when a second directa copy shadows the Homebrew install (a make install CLI in ~/.local/bin ahead of brew's bin on PATH, or a foreign /Applications/directa.app Homebrew could not overwrite, either of which lets a bare directa or the daemon keep running an old version after an upgrade); an orphan-log-dir finding for a directory named the way directa names a project's log directory (<slug>-<8 lowercase hex>) under the daemon's own logs dir (daemon.info's logsDir) that matches no project the daemon claims (daemon.info's claimedProjects: every registry project plus every project with a resident supervisor, not the narrower set with a currently readable devservers.json, so a trusted project mid-edit on an invalid config keeps its log directory), naming its apparent size and directa doctor --fix as the command that removes it (unregistering a project's last server, or the daemon forgetting a missing one, already removes its log directory; this catches one that predates that cleanup or was orphaned some other way, since only directa uninstall --purge removes the whole logs tree automatically). --fix lists the unclaimed directories without sizing them and asks the daemon to remove each one by name through logs.removeOrphan (params {directory}, a single directory name inside the daemon's logs dir, never a path; result {outcome, path, reason?, remedy?}, outcome one of removed|refused|failed). The CLI never deletes a log directory itself. The daemon checks each name against the projects it claims at that moment, with no other request running in between, so a project started while doctor ran keeps its directory. It removes the directory only when it sits directly inside the logs dir (both paths compared with links resolved, so a /var and a /private/var spelling agree), is a real directory rather than a symbolic link, has directa's log directory name shape, and is claimed by no project; anything else is left in place, and each attempt reports its own fixed or error finding, a refusal naming a next step where one exists and never a deletion command. Against a daemon that does not know logs.removeOrphan, --fix removes nothing, reports the leftover directories as it would without --fix, and adds one error finding saying the daemon is too old, with directa daemon restart as the fix. A second daemon started on another socket with the same logs dir is outside this check: its projects are not in the answering daemon's claimed set. A symbolic link, or a folder without that name shape (a logs dir shared with other apps holds theirs), is never reported. The finding, --fix included, is skipped entirely against a daemon whose daemon.info predates claimedProjects, never guessed from machine-wide server status; and, when a newer release is available, an update finding read from the shared update-check cache (refreshed only when stale). Doctor reads and writes the files a daemon owns (the update-check cache, agent.path, the logs tree) in that daemon's own dataDir and logsDir from daemon.info, and in the local layout (the Environment section) only when no daemon answers. A jetsam-coalition warning fires when a live server still shares the daemon's jetsam coalition, the condition that lets memory pressure kill the daemon instead of that server. A jetsam warning fires when the agent last exited OS_REASON_JETSAM (macOS killed ddirecta as the largest compressed process; KeepAlive then relaunches it, and recover adopts each server whose launchd child job is still running rather than restarting it, bouncing and restarting only a server it cannot adopt, and leaving a running server alone when launchd will not say which jobs it has). A leftover-job warning fires when one-shot dev.quantizor.directa.job.* labels remain registered with no live server: a jetsammed daemon skips the spawn-path bootout, and recover reaps them. Without --fix every finding is report-only and names the exact command to run. --fix changes only directa's own state: it forgets stale registry entries and removes unclaimed log directories under directa's own logs dir, and never edits a file the user owns outside that.

  • directa switch <branch> [--no-fetch] [--timeout 120] → clean-tree guard (refuses dirty; never stashes), fetch, group down, git switch (remote-tracking fallback), validate the branch's devservers.json, record trust for the project (a failure warns on stderr and switch continues; running the playbook is the approval, and recording it first keeps the state coherent if the playbook dies midway), then the project's lifecycle.switch playbook (argv arrays run sequentially from the project root; failures stop with directa up as the resume hint), then group up. The branch's devservers.json is validated after the checkout and before the playbook runs, so a config config check would reject (including an empty lifecycle command, which the validator now covers) fails config-invalid instead of feeding argv to the shell. Playbooks live in devservers.json lifecycle and 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 subtitle directa · <url> (best-effort; not a Top Hit launcher); a worktree server's entries lead with the main project (myproj · review · web) and carry it as a keyword so a typed "myproj" still lands; heads and pins surface in the menu bar app.

  • directa lock <resource> [--acquire-timeout 300] [--pause] [--timeout 120] -- <command…> → runs the command holding a project resource exclusively. By default the servers that declare the resource in their locks (devservers.json) keep running: the lock serializes access against other holders and the fingerprint guard below flags a live server corrupting the state. --pause stops those declarers for the command and re-ensures them on release (even on command failure), for state a running server holds open (a local database). The command starts only after every paused declarer's whole process tree has exited, its descendants included; a declarer whose stop gives up with processes still running fails the lock with resource-locked, naming that server and hint run: directa why <name>, resumes whatever was already paused, and never runs the command. The failing declarer itself is not resumed, since its processes may still hold the port. The CLI fails at once on a resource-locked refusal that names no holder, rather than retrying it until --acquire-timeout: when lock.status shows none after a refusal, the CLI retries the acquire once at once (the holder may have just released) and fails only if that retry is refused with no holder again. The command's environment adds DIRECTA_LOCKS, the comma-joined canonicalPath::resource keys held around it, appended to any inherited value, so a tool with its own lock for the same resource can tell it is already serialized. directa lock itself reads it: a command whose inherited DIRECTA_LOCKS already lists the key it asks for runs the command at once without a second acquire (re-entrant), so a guarded script that locks the same resource again cannot wait on its own parent. A resource name containing , or :: is refused (usage), since the joined list could not be split back apart. The key is reserved from the moment an acquire begins pausing declarers, so a start or ensure of a declarer, or a second acquire, that lands during the pause is refused as contended rather than slipping in. A paused declarer whose stop was already in flight counts as stopped only once that stop's whole tree has exited. ensure/start of a declaring server is refused (resource-locked, naming the holder pid) while a live holder owns it, with or without --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. A server the daemon would bring back at boot (or resume on another lock's release) that declares a resource a live holder owns joins that holder's paused set instead of being refused: it reads stopped until the release, then starts the way a --pause declarer resumes, and a server declaring several held resources waits on each in turn. Boot restore marks a server crashed only when its process died with the daemon; a server the daemon stopped on its way down reads stopped if its restore is refused for any other reason (trust, config). Exit status is the command's. The -- is required and directa'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. The command runs in the caller's own current directory, never the resolved project root (they can differ: a monorepo subpackage below the root, or an explicit --project pointing elsewhere), so a relative file argument or a tool that finds its own config by walking up from cwd sees what it would running unwrapped. 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 0 makes exactly one attempt and fails immediately with resource-locked. All of lock's own output is stderr: stdout belongs to the guarded command.

    A locks entry 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, lock fingerprints that state before and after the command (device and inode, size, and a SHA-256 over a bounded manifest) and reports a change: with a declaring server still running (the default) it is a resource-mutated failure, because that server holds the old state open and can write its cached pages back over the change, and under --pause, with the declarers stopped, it is a note on stderr. 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 -wal file when path names 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 reports exact: false when 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. A bare-named lock (no path) has no state to fingerprint, so the default hold cannot detect a change at all; when such a hold leaves a declaring server running, lock warns on stderr that the guard is off and names the exact fix: which declaring server to edit and the object form to give it ({"name": "d1", "path": "<path to d1's state>"}), or --pause.

  • directa context: the harness-agnostic session context: a fenced <directa-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. A linked worktree gets a banner naming its display label. 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.

  • directa daemon status --json → {daemon?, launchd, reachable}. reachable is whether the daemon answered over the socket, and it is the field to branch on: launchd reporting running only means a job is loaded, so a loaded-but-not-listening daemon prints a reassuring launchd line with reachable: false. daemon is present only when reachable. Exit stays 0 either way, because the launchd half is still a useful answer. daemon.restoring is present and true only while boot restore is running, and absent otherwise; a client waiting for a usable daemon wants reachable and no restoring, 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.info and daemon.shutdown answer and every other method is refused with daemon-starting and the hint run: directa daemon status, rather than the daemon being unreachable while it works. The distinction is what a client must branch on: daemon-unreachable means nothing answered and starting a daemon is the fix, daemon-starting means one is already coming up and starting another is wrong. The CLI waits out daemon-starting for up to 30 seconds, saying on stderr what it is waiting for, then fails with exit 3 if the daemon never finishes. After restore, a server whose surviving process restore could not yet decide to adopt (launchd's job list did not answer) keeps that refusal for start-shaped and stop-shaped requests naming it (stop, down, unregister, a pausing lock) and for wait, with a message naming the server and its pid, until the daemon reads the job list again; its status shows the recorded phase and pid meanwhile. A deferred survivor whose process has exited needs no job list and is settled at once. Once the retry window has passed with the job list still silent, the refusal names run: directa daemon restart as the fix. A refusal for a deferred survivor is not a daemon still starting: the CLI fails at once with its message rather than waiting it out as unreachable. A drain stops deferred survivors too. 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.

  • directa uninstall [--agent-only] [--purge] --json → {actions: [string], agentOnly, purged}. The one uninstall verb. Stops every running server: the daemon drains on its way down, since nothing would supervise them afterward. --agent-only drains too, so a Homebrew cask upgrade restarts running servers. Removes the background agent (and any legacy home LaunchAgent) and Start at Login, then agent hooks from every harness, then the CLI and daemon binaries directa 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). A non-Homebrew /Applications/directa.app is moved to the Trash; a Homebrew cask's bundle is left for brew. Data, logs, preferences, and caches are kept unless --purge. --agent-only stops after the agent and never touches Start at Login, hooks, binaries, or data: it is what the Homebrew cask calls on every upgrade, so it must stay narrow. purged in the result is true only when data was actually removed (never under --agent-only).

  • directa daemon uninstall [--purge] --json: deprecated alias for directa uninstall --agent-only (with --purge mapping to data removal). Kept working because the CLI JSON contract is a public surface; the deprecation notice goes to stderr so --json stdout stays clean. Removal is a future major.

  • directa daemon install|start|stop|restart|status: launchd lifecycle. stop drains and writes a deliberate-stop marker that auto-bootstrap honors; restart and install (upgrade) both capture running servers, bounce the daemon, and re-ensure them by name. Under the background agent they hand off rather than drain: the old daemon exits without stopping its servers and the new one adopts them, so servers keep running across the bounce and the re-ensure is a no-op for each adopted one; a daemon run with --foreground drains instead, and its servers bounce and come back. The handoff is the wire request daemon.shutdown with params {"handoff": true} (handoff optional, absent or false meaning drain); a daemon that predates the field drains. The menu bar app hands off the same way before it re-registers the agent (a daemon older than the app, or one it could not reach). A handed-off daemon answers the SIGTERM that follows without draining, and an adopted server's structured log resumes from the byte the old daemon last read, so output written during the bounce is kept. The new daemon accepts before recoverAtStartup and refuses real work with daemon-starting until it returns, so that re-ensure waits the window out rather than racing a half-finished restore. install also 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; recoverAtStartup resolves specs through the merged config+registry view (so committed devservers.json servers come back, not only ad-hoc register entries) 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 explicit start/ensure/up recorded trust) is not spawned, which keeps a cloned repo's devservers.json from running itself after a reboot. A deliberate directa stop/down clears the intent. Renamed or deleted servers leave orphan state rows that recover drops.

  • directa hook install [--harness antigravity|claude|cursor|grok|opencode] [--cli-path PATH] [--statusline]: idempotently wires a session-start hook into the harness's settings (antigravity: PreInvocation in ~/.gemini/config/hooks.json. Antigravity has no session-start event, and PreInvocation fires before every model call, with invocationNum starting again at 0 on each message. The hook emits injectSteps with ephemeralMessage once per conversation, and again when initialNumSteps drops (the harness shortened the conversation). Later calls, and PostInvocation after tools, follow the tool-change rule below; when that rule quotes err lines, PostInvocation sets terminationBehavior to force_continue so the model can deal with them, at most three times per conversation. The conversation record, including Antigravity's step count, is one file per agent under $TMPDIR/directa-hook-snapshot (DIRECTA_HOOK_SNAPSHOT_DIR relocates the directory); invocations on one record take turns under a file lock, and one that cannot get the lock within a few seconds goes ahead without it rather than stall its agent. A missing step count counts as one past the recorded count. An empty project still records the conversation, so later messages do not ask the daemon again; claude: SessionStart with matcher startup|resume|clear|compact in ~/.claude/settings.json, emitting hookSpecificOutput.additionalContext, and silent when the payload carries cursor_version while directa's Cursor hook is installed, because Cursor also runs ~/.claude/settings.json hooks by default and its own hook already carries the block; PostToolUse with matcher * under the tool-change rule below; cursor: sessionStart in ~/.cursor/hooks.json, emitting {additional_context}, plus postToolUse under the tool-change rule below; grok: PostToolUse with no matcher in ~/.grok/hooks/directa.json (the event whose additionalContext Grok delivers after the tool result; it follows the tool-change rule below). Install removes this command from any other event (a leftover SessionStart, PreToolUse, UserPromptSubmit, or Stop), and writes a managed ~/.grok/rules/directa.md standing instruction to run directa context because home rules apply to every project and cannot hold a live snapshot; opencode: a managed ~/.config/opencode/directa.md with that same standing instruction, wired through the instructions array of the winning global config (opencode.jsonc preferred over opencode.json) because OpenCode has no session-start injection point to pipe directa context through, and entries effective from the losing config file are carried into the array the winner holds so they do not go inactive). Omitting --harness installs every harness detected on this machine (present, whether or not a hook is installed yet), printing one line per harness installed and one per harness skipped as not detected; --harness X always installs exactly X, detected or not, the same as every prior version. --cli-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.

  • Tool-change rule (every after-tool hook, and every Antigravity call past a boundary): the full summary is pasted only at a boundary (Claude Code and Cursor session start, compaction included; Antigravity's first call and a dropped initialNumSteps), which also drops that agent's pictures of every other project, or when the conversation has no record yet for the project the call resolves to (hooks installed mid-session, Grok's first call, the first call from another checkout). A conversation is one agent: a Claude Code subagent, whose payload carries agent_id beside its parent's session_id, keeps a record apart from its parent's, and a record keeps one picture per project, so an agent moving between checkouts compares each against that checkout's own last picture rather than reading the switch as servers removed and new. An untrusted project counts as having no servers, so its servers read as new once trust is recorded. Any other call writes nothing unless a server changed in one of these fields: phase, displayed port, url, port-conflict state, specStale, last exit code or signal, spawn errno, worktree label, or the server appearing or disappearing. Stderr growth and a new err tail alone never speak; directa monitor is the stream for that. When the rule speaks, the <directa-servers> fence holds one line per changed server and nothing about unchanged ones: <name>: <phase> (was <old phase>) when the phase moved, else <name>:, followed by ; -joined clauses for each other changed field (port <n> or port cleared, url <url> or url cleared, port conflict <state> or port conflict cleared, config changed, restart to apply or config applied, exit <code> or signal <n>, spawn errno <n>, worktree <label> or worktree cleared; an exit or spawn errno that clears updates the record silently, and a line with no clause is never written); a new server reads <name>: new, <phase>; url <url>, a gone one <name>: removed. Every value on a line is sanitized like an err line. A server now crashed, failed, or unhealthy ends its line with ; run: directa why <name> (the name bare only when shell-inert, else <name>). A server that entered crashed, failed, or unhealthy in this delta is followed by err: <line> for each line of its last 5 err lines not already shown to this conversation, sanitized (a line once shown is never requoted, even after a restart; a failed log read still speaks the line, with no quotes); no other call reads the log. Only these quotes count as new err lines for Antigravity's pull-back.

  • directa hook uninstall [--harness antigravity|claude|cursor|grok|opencode] --json: idempotently removes directa's session hook from a harness's settings, leaving everything else the file holds untouched (an entry that held only directa's hook is dropped whole; a foreign hook alongside it is kept). Omitting --harness removes from every detected harness. --json success is {}, matching hook install.

  • directa statusline: reads harness statusline stdin JSON (workspace.current_dir or cwd), prints myproj:3000 ok · api crashed for the project, empty otherwise.