Skip to content

THE RECORD

What changed.
When it shipped.

New features, thoughtful improvements, and fixes. Follow what’s shipping in devctl.

Sourced from CHANGELOG.md on main.

Newest first. 45 versions.Keep a Changelog / SemVer0.25.0

RELEASE HISTORY

Newest first.

NOW / UNRELEASED

On the way

Queued on main.

See the diff

Added

  • The TUI and web Logs views can hide health-check lines: supervisor probes, plus request logs for HTTP health, ready, and live endpoints and gRPC grpc.health.v1.Health from the service or the proxy. Level chips choose which severities to show (for example info and warn, or warn and error). MCP get_logs and get_log_stats take the same levels and hide_health arguments; recent_errors accepts hide_health. See Logs and MCP.
  • devctl web rotate mints a new web console control token. --print-url prints the new access link when the console is running. See Web console.
  • hooks.pre_start and hooks.post_start accept environment in the same shape as a service environment. Those values are resolved when the hook runs and laid over the service's resolved environment for that command. Hook keys win, and defaults fill keys that are still empty. The service process keeps its own environment. A string or argv list is still just the command. See Services.
  • Logs can hide or show gRPC proxy lines without dropping HTTP proxy logs or service stdout. TUI: b or /grpc, and the grpc chip on the log filter bar. Web console: hide grpc (also b). CLI: devctl logs --hide-grpc (and logs export). MCP: hide_grpc on get_logs, get_log_stats, and recent_errors. Pages, follow, export, and facet counts follow the choice. See Logs.
  • Log ingest yields between reads, coalesces output, and spills to a capped on-disk spool so a fast service cannot freeze or OOM the daemon. Lines are not dropped and stay in order: only when memory and the spool are both full does devctl stop reading, and the service waits in its write. The live window stays the last logs.max_memory_events records. Session files, the not-yet-parsed spool, and the logs directory are capped (logs.persistence.max_session_bytes, logs.spool.max_bytes, logs.persistence.max_total_bytes). A session over its cap rolls each service's file to numbered parts and deletes the oldest, so it keeps its newest lines. Output a crashed daemon had read but not parsed is parsed into that daemon's session by the next one.
  • On Linux and macOS, services keep stdout and stderr across a daemon restart, crash, or kill -9. Each captured stream is a FIFO the daemon reads directly. While no daemon runs, one drainer per repository spools the output, capped by logs.spool.max_bytes; the next daemon replays it with its original timestamps. A service whose output has nowhere to go waits in its write and never gets a broken pipe. Windows uses pipes.
  • Past sessions are paged. /history in the TUI, the web console, and MCP can reach a session's older records, not only its tail, and the read runs on the log worker instead of the daemon's main thread.
  • The daemon sheds capture bodies and shrinks the log ring when the container's working set (usage less inactive file cache, as docker stats shows) crosses 90% and 75% of the cgroup limit; with no limit, its own RSS against host memory. Shedding never stops reading service output. status includes event-loop lag and log pipeline counters.
  • devctl down --force stops a daemon that has no heartbeat, including one still running after an upgrade. A daemon that is making progress is not replaced. Before it signals the pid in a lock left by an older devctl, it checks that the pid still runs the devctl daemon; a lock whose pid now runs something else is removed instead.
  • The daemon reaps the orphans its services leave behind where PID 1 does not, such as a dev container whose PID 1 is sleep infinity, tail, pause or cat. Stopping or restarting a shell-wrapped service orphans its child already dead, and each one used to stay a zombie for the life of the container. The daemon becomes a child subreaper there, so those orphans show it as their parent while it runs. supervisor.reap_orphans: false turns this off and true turns it on under any PID 1. It needs Linux with glibc; elsewhere, and for services a restarted daemon adopted, devctl doctor still points at running the container with an init. status --json reports daemon.orphanReaper.
  • The log worker and the event-loop watchdog run in every build. The npm package ships them beside its bundle, and each compiled binary embeds them, so release binaries no longer parse logs on the daemon's main thread.

Changed

  • The TUI runs on OpenTUI 0.7, whose rendering moved into native code. Two things look different: a log line too long for its column now wraps at a space where one fits instead of at the column edge, and inside an SSH or mosh session OpenTUI now reads TERM and COLORTERM, so a truecolor terminal that reaches the server as TERM=xterm-256color without COLORTERM (iTerm2 or WezTerm through the stock macOS ssh) gets 256-color approximations. Setting COLORTERM=truecolor on the server, or forwarding it with SendEnv/AcceptEnv COLORTERM, keeps exact colors; Kitty is still detected as truecolor. Signing in to Google from the TUI now hands the terminal to gcloud only once the TUI has let go of it, and quitting waits up to a second for the terminal to be restored before devctl exits.
  • Dependencies are updated to their latest releases, among them TypeScript 7, dotenv 18 (its parse gives the same values), google-auth-library 11.3 and React 19.3.1. If traces disappear after an app moves to OpenTelemetry JS 3.0 and nothing is logged, SimpleSpanProcessor now takes { exporter }. See Troubleshooting.
  • Saved MCP and web bearer tokens are kept until you rotate them. They are no longer reminted after 7 days unless regenerate_session_tokens is true in tui.json (TUI Settings → Regenerate tokens, the web console Settings page, or set_preferences). The preference is off by default and applies the next time that listener reads the token. See MCP and Web console.
  • devctl runs on Bun 1.4.4. The npm package depends on Bun 1.4.4 and release binaries are built with it, and source development needs Bun 1.4.4 or later. On Windows, Bun 1.4.2 crashed or hung in about one test run in eight on CI runners, inside JavaScriptCore's compiler and garbage collector threads; in 60 runs on 1.4.4 it did neither.
  • On Windows, checking whether a process is alive no longer runs tasklist through cmd.exe. That call never returned an answer and held the daemon for 35 to 55 ms each time; the answers are the same, and identifying a process goes straight to PowerShell, as it already did after the failed call.
  • logs.persistence.max_session_logs counts sessions for the current repository. One checkout no longer deletes another checkout's sessions in a shared logs directory.
  • Health checks record a log line when status changes, and remind while a service stays unhealthy, instead of once per probe.
  • LLM and proxy capture keep metadata for 2,000 calls and evict bodies past llm.store_max_bytes and proxy.inspect_store_max_bytes (128 MiB when unset).
  • With persistence disabled, very large lines shrink the in-memory window below 50,000 records instead of exhausting RAM.
  • The watchdog replaces a daemon only after a proven 60 second wedge. It writes a wedge marker and releases the lock first. A client that replaces a wedged daemon signals only a pid that is still that daemon, and sends SIGTERM two seconds before SIGKILL; a daemon that recovers in that time hands its services over instead of stopping them. Clients wait while a daemon is busy or stopped (SIGSTOP). status and down explain a live daemon that does not answer, including one left by an older devctl.
  • llm_calls_page and traffic_calls_page accept summary: true to list rows without bodies. The default response is unchanged. The TUI, MCP and web lists, and the text output of devctl llm and devctl traffic, list this way and fetch a body only for the call being viewed.
  • devctl logs --all reads the window a page at a time and prints as it goes, so a ring of long lines no longer needs hundreds of megabytes in the daemon and the CLI. A page of log records ends at about 8 MiB as well as at its record limit, and the unpaged logs RPC answers with the newest matches up to about 32 MiB (truncated: true when older ones were left out). An export no longer sends its records back to the caller, and it now includes the part of the window that only the session files still hold.
  • Live log updates reach attached clients in batches: at most one every 50 ms, carrying the newest 500 records and a count of the rest. The TUI pages in what a flood left out once the stream is calm. A client that stops reading no longer grows the daemon's memory. Older clients keep per-record events.
  • A log line's timestamp is when devctl read it from the service, even if it then waited in a spool. Multiline folding and proxy request-id pairing go by that time, so delayed output is grouped the same as live output.
  • Each session records the daemon that writes it. Pruning never deletes a session whose daemon is still running, in any repository or container sharing the logs directory, and it applies each session's own retention_days.
  • The proxy's caller lookup reads the managed processes holding the fewest file descriptors first, and each process's newest descriptors first. A service holding thousands no longer makes other services' calls lose their caller.
  • A service that prints nothing no longer costs the daemon a timer for each of its streams. Quiet streams share one check every 100 ms, and on Linux a write to a quiet stream wakes its reader through a file watch, so a line after a pause is read in about a millisecond instead of up to 100 ms (median 1 ms, was 32 ms, in the Docker soak). With 30 quiet services the daemon's main thread used 1.05% to 1.34% of a core across runs, against 1.66% before. macOS has no such watch for FIFOs: a line after a long pause is read within 100 ms there, as before.
  • logs.spool.max_bytes is one cap for all unparsed output on disk: the daemon's spool, the drainer's spool, and what a crashed daemon left. They no longer add up to twice the cap during a takeover.
  • A low disk or a failed write leaves lines out of the session files and counts them (daemon.logs.loss, daemon.logs.degraded in status --json) until it clears. It no longer stops reading service output.
  • With logs.dedupe_access_line on, the daemon remembers the last line of the 256 processes that logged most recently instead of every process that ever logged, so a service restarted all day no longer grows it.
  • A log worker that dies is restarted once with its unacknowledged output; a second loss falls back to the in-process store. A log worker gets five seconds to start, so a slow cold start no longer leaves the daemon on the in-process store for as long as it runs; one that cannot load at all is still given up on at once. The devctl log says why a worker did not start or was lost.
  • The TUI refreshes status within about 30 ms of an event (at most four times a second) and every 2 seconds while idle. Its log window is held within a byte budget sized to logs.max_memory_events (8 to 64 MiB).
  • After the stop grace period, devctl SIGKILLs process-group members that are still alive. A zombie is not treated as running.
  • On Linux the daemon lock records the holder's PID namespace (it was never read before). A lock held from another PID namespace, such as another container sharing the devctl home, is judged by whether its socket accepts, not by a pid that names a different process here.
  • A watchdog worker that fails is retried after 1, 2, 4 … seconds, at most a minute apart, instead of every second. status, status --json (daemon.watchdog, daemon.logStore), and devctl doctor say when the daemon runs without its watchdog or log worker.
  • Each session log part has two small files beside it. <part>.idx is an index the daemon seeks with, so a page far back in a long session is found without probing the part. <part>.patch holds a record that was re-tagged with a request id after it was written; it is no longer appended to the part a second time. Parts are still one whole record a line. Other tools and older devctl versions read them as before and show the untagged copy of a patched record. Both files count toward logs.persistence.max_session_bytes and are deleted with their part.
  • The filter chips' counts for service, level and source cover the whole log window, including records the in-memory ring has evicted to the session files. With a search, a time range, or a trace, request-id or attribute filter they count the records still in memory, as before.
  • A trace's log records are looked up across the whole log window, not only the part still in memory. The trace response says truncated: true (MCP: logs_truncated) when the lookup stopped before it had read the whole window.
  • The trace store has a byte budget, telemetry.store_max_bytes (64 MiB when unset), beside its 10,000-span cap. It was bounded only by count, so spans that carry whole prompts or stack traces could take the daemon past a gigabyte. Over the budget the oldest spans go first, and under memory pressure the daemon halves and then quarters the budget as it does the log ring's. Stacks with small spans keep 10,000 as before; with large spans, older traces leave the trace explorer sooner. The proxy's request spans count toward the budget.
  • devctl status and devctl down wait up to three seconds for a daemon that is alive and only slow to answer, instead of giving up after 200 ms. When a live daemon still does not answer, status says so and no longer also prints supervisor is not running or lists its services as stopped; status --json stays one JSON document and carries the text in notice.
  • devctl down exits 1 and says the supervisor for <repo> is still stopping after <n> s when the daemon has not gone by its deadline (the shutdown grace plus two seconds, at least five). It printed stopped services and the supervisor whatever had happened. The daemon goes on stopping; running down again waits for it.
  • devctl instances prune says why it kept the slot of a daemon that is alive and does not answer (paused, wedged, or left by an older devctl): the same text devctl down prints, which names devctl down --force. It said its supervisor did not stop in time; run devctl instances prune again, or told you to stop the services by hand.
  • devctl instances lists a stack as running while a live process holds its daemon lock. One ping unanswered within 200 ms listed it as stopped.

Fixed

  • A daemon the watchdog stopped as wedged is reported instead of vanishing. devctl status with no daemon running says so and that its services may still be running unsupervised. The next daemon logs a warning, and for a day status shows a WATCHDOG line and devctl doctor warns. See Logs.
  • On macOS and Windows too, a daemon taking over after a crash knows its services by their start time, not by their command line, so a service whose shell rewrote its command is adopted rather than started twice. The start comes from ps -o lstart (read in UTC) and PowerShell's UTC start time. Both are read in the background after a service starts, so starting a service does not wait on them.
  • On cgroup v1 without a cgroup namespace, the daemon finds the memory limit on its own memory cgroup (the memory: line of /proc/self/cgroup), not only at the root of the memory hierarchy. It used to see host memory as its limit there, so the log ring was sized and memory-pressure shedding began as if the container had the host's RAM.
  • After a daemon crash, the next daemon adopts services whose command a shell rewrote. A service started as sh -c "<cmd>" execs into <cmd> without its quotes and with variables such as $PORT expanded, so it no longer matched its stored command: the new daemon started a second copy and left the original running, untracked, past devctl down. On Linux, the state file now records each service's kernel start time and boot id, and adoption trusts that over the command line.
  • Output a crashed daemon left in the spool is written into that daemon's session with the session's byte cap, part files, and indexes. It was appended to one file without a cap, and a stored record is several times its raw line, so one crash with a full spool could leave a multi-gigabyte session.
  • An environment overlay that sets one key keeps environment.helm from the base service. Previously a modular file or local overlay that touched environment dropped the Helm source.
  • Stopping a proxy or gRPC listener that never bound, or stopping it twice, no longer calls close() again. On Windows that call crashes Bun. The HTTP proxy and token endpoint already skipped a server that had not listened; gRPC now does too, and a second stop is a no-op.
  • Stopping the proxy closes a WebSocket upgrade that is still waiting for its upstream. On Bun 1.4.4, as in Node, the proxy's server waits for such a connection when it closes, so devctl down or a daemon restart waited on it; on earlier Bun the client was left connected after the proxy had stopped.
  • A client devctl does not run no longer makes each of its new connections read every file descriptor of every managed process, which was the only way to rule out a managed caller. With one service holding 5,000 descriptors that cost the daemon about 30 ms of CPU per connection, 127% of a core at 40 connections a second. At 130 a second the lookups fell behind and went on reading every descriptor after their connections had closed, until the daemon used every core and more than a gigabyte of memory. devctl now finds the process those connections come from while one of them is still open, reads that process before any larger service, and stops a lookup once the kernel says its connection has closed. In the same load the daemon used 9% of a core at 40 connections a second, 44% at 130, and 70% at 130 with a hundred connections open at once. This needs the kernel's socket lookup below: where devctl cannot ask the kernel (a sandbox that refuses the netlink socket, or a system without glibc), a client whose connections close within a millisecond or two is seldom found and costs what it did. One case changes: a connection that a process devctl does not run holds together with a service, because one handed its socket to the other (descriptor passing, or a service's daemonized worker sharing a connection it inherited), now gets no caller instead of the service. A service's own connections are attributed as before.
  • On Linux the proxy's caller lookup asks the kernel for the caller's socket by both ends of the connection (netlink sock_diag) instead of reading all of /proc/net/tcp for each new connection. A client that closes its connection after every request leaves each one listed in TIME_WAIT for a minute, so the table grows with the connection rate, and every lookup copied the whole of it: at 130 connections a second the daemon read about 940 KiB per request and used three times the CPU it used for the same load with a near-empty table. It now reads nothing from the table per request and costs the same however large the table is. Callers are attributed as before. Where the kernel cannot be asked (a sandbox that refuses the netlink socket, or a system without glibc), devctl still reads the table, but lookups that arrive while a read is under way share the next read instead of each starting their own.
  • A gRPC response that the client can no longer take does not crash the daemon.
  • devctl down, devctl instances prune and the teardown of devctl test wait until the daemon has released its lock. They took the daemon as gone the first time it missed a 200 ms ping. A daemon that was slow for a moment while it stopped its services made down print stopped services and the supervisor while both were still running, and made instances prune keep the slot with its supervisor did not stop in time. instances prune also keeps the slot of a live daemon that does not answer, where it could free it before.
Latest release

02 / 0.25.0

0.25.0

See the diff

Changed

  • ${identity.user} and ${user.identity} in service env and Helm YAML resolve to the developer email shown on the TUI identity screen. Detection is the Google ADC email (or the ADC account field), then gcloud core/account, then git config user.email. The same address is injected as DEVCTL_USER_EMAIL. See Environment.

03 / 0.24.0

0.24.0

See the diff

Added

  • environment.terraform reads terraform.tfvars, *.auto.tfvars, and a path that points at a .tfvars file. A bare var.name in an env value is filled from that assignment or the variable's default. Other .tfvars files are still ignored. See Environment.
  • environment.helm reads literal env values from a Helm chart or Kubernetes YAML (values.yaml, chart templates, and container env lists). Go templates and valueFrom are skipped. An explicit YAML key still overrides. See Environment.

04 / 0.23.0

0.23.0

See the diff

Added

  • log.grpc.ok accepts status 0 and inspect: false, so a high-volume gRPC method (Temporal PollWorkflowTaskQueue / PollActivityTaskQueue on status 0 and 14) can stay out of the traffic inspector ring and the proxy log without turning inspect off for the route. Status 0 does not change error counts. See Proxy.
  • Traffic search accepts a leading ! to exclude a substring (!Poll). !! searches for a literal !. The same query works in the TUI proxy screen (f), the web console, devctl traffic --search, and MCP get_traffic_calls. See Proxy.
  • devctl doctor warns when an authorized_user ADC file has no account field. The warning does not fail the command. See Doctor.

Changed

  • When an IAP route's credentials file is not the well-known ADC path, devctl doctor names that file and tells you to regenerate it. gcloud auth application-default login is only suggested when the route points at the ADC file itself. See IAP.

05 / 0.22.0

0.22.0

See the diff

Security

  • A service no longer starts against a proxy or token endpoint that another process holds. When the proxy's port was taken, for example by a second checkout of the same config, devctl start logged the bind error and launched the services anyway. Those services got DEVCTL_PROXY_URL and DEVCTL_TOKEN_URL pointing at the configured port, so their requests and DEVCTL_INTERNAL_TOKEN went to whatever process held it. Now services in that start are blocked with proxy failed to start (…), and services already running keep running. A proxy that binds only part of its listeners releases them all. DEVCTL_PROXY_URL, DEVCTL_TOKEN_URL, and DEVCTL_HTTP_<NAME>_URL are set only while this checkout's own listener is bound, so after devctl proxy stop they are omitted. See Proxy. (GHSA-jwfq-4q9p-2jcr)

Added

  • Parallel stacks: several checkouts of one configuration, such as git worktrees for parallel coding agents, run at the same time without editing ports. The first checkout to start takes port slot 0 and keeps its configured ports. Each other checkout takes the next free slot, and every fixed service port and listener (proxy, token endpoint, gRPC routes, OTLP receiver, web console) moves up by 100 per slot. SERVICE_PORT, <NAME>_PORT, ${services.…} references and a health check on the service's own loopback port follow the shift. A slot stays with its checkout until a full devctl down. devctl instances lists the slots with their checkout, ports and status, and devctl instances prune stops and frees the stacks of deleted checkouts. devctl status shows the slot. See Parallel stacks. (#117)
  • Named instances: devctl --instance <name> <command> (or DEVCTL_INSTANCE=<name>) runs another stack of the same checkout, with its own port slot, supervisor, state, tokens, containers, volumes and MCP port. devctl instances shows the instance name, devctl status shows INSTANCE: <name>, slot N, and down or prune stop one instance without touching the others. Without a name nothing changes. See Parallel stacks. (#117)
  • devctl mcp --write claude|cursor|kilo writes this stack's MCP URL and bearer token into the checkout's .mcp.json, .cursor/mcp.json or kilo.jsonc, keeping other servers there, so each worktree's agent talks to its own stack. See MCP. (#117)
  • container.seed_from fills a stack's named volume from a prepared volume on first use, and container.shared_volumes lists named volumes every stack mounts as written, such as build caches. (#117)
  • devctl test runs integration tests against a throwaway stack. It starts a named instance of its own, waits until it is healthy, and runs a task or a -- command. The command gets the stack's ports as DEVCTL_<SERVICE>_<PORT>_PORT, plus DEVCTL_PROXY_URL and, with --env-from <service>, that service's resolved environment. On failure it writes a bundle to --artifacts. It then tears the stack down, leaving nothing running, and exits with the command's code, or 5 or 6 if the stack failed. devctl start --wait [--timeout 5m] blocks until every started service is ready, and exits 6 naming the services that are not. devctl bundle [--since 10m] [--output file.tgz] writes the same bundle on demand: status, logs, traces of failed requests, traffic, doctor, config diff, bootstrap log and versions. Every file is redacted, including literal secret values from the config. See Tests and CI, which has a GitHub Actions example. Experimental. (#118)
  • A roadmap with Phases 1–3 before v1.0 and their issues, linked from Platform bets and the README. Features that may still change without a deprecation period carry one Experimental line in their docs, listed in Experimental features: transform.request_body, auth.suppress_authorization, LLM capture.field_map and cost_per_token, gRPC body decoding, environment.sops, environment.terraform, and parallel stacks. Graduations will be noted here. A "Doesn't fit my stack" issue template collects onboarding reports from other stacks. (#115)
  • CI runs bun test on macOS (macos-tests on macos-15), matching windows-tests. Coverage stays on Linux. Docker lifecycle tests stay on the Linux container-tests job because hosted macOS runners have no Docker. See Testing and CI. (#116)
  • secrets.redact: false turns redaction off for newly captured logs, spans, traffic, LLM payloads, env output, and MCP results. The default stays on. Values already stored as ******** are not restored, and with redaction off those secrets can be written to ~/.devctl/logs. See Security.
  • The OTLP receiver accepts application/x-protobuf on POST /v1/logs and POST /v1/traces, and Content-Encoding: gzip. Stock exporters that send protobuf, such as Python's opentelemetry-exporter-otlp-proto-http, now reach devctl with no per-service configuration. Before, every batch got 400 {"error":"invalid json"} and no spans or logs arrived. Other content types and encodings get 415 naming the accepted ones. See Telemetry. (#112)
  • A service can set environment.terraform to a .tf file or directory and take literal environment values from that Terraform (env blocks and environment_variables / env_vars / env maps). resource limits the read to one resource or module. Interpolations and secret refs are skipped. An explicit YAML env key still overrides, so local-only values stay in YAML. See Environment.
  • get_setup_guide section debug is the procedure for diagnosing a service devctl is already running (status, logs, print-env, proxy traffic). It does not author .devctl YAML. Same text as skills/devctl-debug.

Changed

  • A named volume in container.volumes belongs to one stack: pgdata is mounted as devctl-<id>-pgdata, so parallel checkouts no longer share a database. The first time a stack uses it, the volume is filled from the unprefixed pgdata if that exists, so a stack keeps the data it had before. Bind mounts are unchanged. See Parallel stacks. (#117)
  • The demo-platform integration test (supervisor.integration.test.ts) moves the identity service to a free port. It no longer fails with identity blocked: … is using port 18001 while the demo is running locally.
  • Web overview Failed requests counts proxy failures (HTTP 4xx/5xx or a proxy error) instead of ERROR log lines. Graph splits that into requests versus failed requests, and a separate logs versus ERROR/FATAL chart. Recent errors on the overview stays the log stream. See Web console.
  • CI runs an end-to-end suite (app/e2e/) on Linux and macOS against the real CLI and daemon: auto-port health checks, stock Python and Node OpenTelemetry exporters at pinned versions, log parsing, compose import, command validation, and the config examples in docs/. Scenarios for open bugs (#117, #135, #136) are known failures that flip when fixed. Run it locally with bun run e2e:setup && bun run e2e from app/. See Testing and CI. (#114)
  • Secret redaction no longer blanks ordinary data in logs, traffic, and LLM payloads. Objects are walked instead of replaced wholesale; numbers, booleans, and null stay. Metadata such as token_type, page_token, token_count, secret_name, and DEVCTL_TOKEN_URL stays visible. A field named exactly token is masked only when the value looks like a credential, so logprob pieces stay. Bearer is masked only when the next token looks like a credential, and short eyJ… lookalikes are left alone. Credential names (password, api_key, access_token, authorization, cookie, …), real JWTs, ya29. tokens, and access_token= / id_token= assignments are still masked. See Security.
  • Proxy response headers are forwarded to the client unchanged. A JWT in Set-Cookie, Location, or WWW-Authenticate is no longer rewritten to ******** on the way through. Redaction still applies to stored request-log paths and traffic-inspector bodies. See Proxy.
  • Freeing a busy port from the TUI Doctor screen rechecks that port only. It no longer reruns the full doctor. See Doctor.

Fixed

  • Freeing a busy port no longer risks stopping the wrong process. Doctor's free-port action used the process it found when the row was drawn, and sent SIGTERM and then SIGKILL to that pid without checking that it still held the port. If that process had exited and its pid been reused, or it let go of the port on SIGTERM, an unrelated process was signalled. Now devctl looks up the port again before each signal, stops nothing when another process holds it, and reports a port that is already free. When a service stops, devctl frees its port only if the holder is provably that service's process: same command, plus a matching working directory or start time. A name match alone no longer counts, so on Windows, where tasklist reports only the program name, an unrelated process of the same program is left running. For a service that a reload removed, the holder is matched against the process devctl last recorded for it. See Doctor. (#145)
  • When the proxy or token endpoint fails to bind, stopping it no longer tries to close a listener that never opened. On Windows that crashed Bun (Segmentation fault at address 0x8), which showed up in CI in the test for the proxy bind fix.
  • On Windows, services keep running after the supervisor exits, as on macOS and Linux. They were not spawned detached there, so they ended with the supervisor: devctl down --keep-services had to leave the supervisor process running until they exited, and a supervisor crash took them down instead of leaving them for the next start to adopt. Services now start detached on every platform. On Windows they also start with no console window. down --keep-services ends the supervisor everywhere.
  • The port-holder lookup no longer reports the port number as the holding process. When lsof found no listener, devctl fell back to fuser and took the first number from its combined output. If fuser could not list the holder (a process owned by another user) or printed an error (macOS fuser has no -n tcp), that number was the port from its <port>/tcp: label. Doctor's busy-port check and port release then named, and could offer to stop, a pid equal to the port. Only the pids fuser prints on stdout are read now.
  • The demo platform's Python services (identity, invoices-api, invoices-worker, llm) no longer stall at startup on macOS. Python's HTTPServer does a reverse DNS lookup (socket.getfqdn) after binding and before it accepts connections; on macOS that lookup can take longer than the 20 s startup timeout, so the service looked hung and failed its health check. The demo servers now skip that lookup. The new macOS CI job caught this in the demo integration test.
  • Errors from the supervisor show their hint once. A blocked start printed it twice, as in web blocked: python3 (pid 8725) is using port 18777 — Open Doctor and press enter to free port 18777, … — Open Doctor and press enter to free port 18777, …, and so did every other hinted error returned to the CLI or TUI.
  • devctl config import compose writes published ports in the form the loader reads, ports: {http: 6379} with a matching container.ports. Before, any service with ports: imported as a list that decoded to port_0: 0, so validation failed and --write refused to save. A port with no host side ("9000") now imports as auto, matching Docker's ephemeral host port. Port ranges and ${VAR} ports are listed under dropped fields instead of being skipped silently. (#135)
  • A command array whose items contain ;, |, or &&, such as [node, -e, "a(); b()"] or [psql, -c, "select 1; select 2"], now validates and runs without shell: true. Array items are passed to the process unchanged, so these were never shell syntax. An item that is exactly an operator (|, &&, ;, >, …) is still rejected, and so is a string command containing ;, |, or &&. See Services. (#136)
  • Starting several ports: auto services together could fail with duplicate port <n> used by <a> and <b>. Allocation bound a loopback port, read the number, and closed the socket before the next service was assigned, so the kernel could issue that same port again. Those probe sockets now stay open until every port in the batch is chosen, then close so each service can bind its own port. A port already recorded for another service is skipped. See Runtime.
  • devctl down (with or without --keep-services) now ends the supervisor process. Before, it stopped the services and closed the socket, so status reported the supervisor gone, but the Bun process kept running until killed by hand. The event-loop watchdog worker, plus at least one other handle left open after shutdown, kept it alive. The supervisor now exits once teardown finishes, as it already did on SIGTERM / SIGINT. If teardown fails, the supervisor reports the error and exits with status 1. On Windows, down --keep-services still leaves the supervisor running, because services there are not spawned detached and would exit with it; a later SIGTERM / SIGINT ends it. (#132)
  • An http or grpc health check can target a ports: auto service. ${services.<name>.…} in health.url and health.address now expands from the currently assigned ports before every probe, instead of being passed to the probe literally (which left the service UNHEALTHY forever). devctl config validate rejects a reference in those fields that could never expand, and names the field. See Services. (#111)
  • A plain-text log line that ends with a JSON object or Python dict, such as upload failed: 400, reason: {"error": "quota exceeded"}, keeps the whole line as its message. The object's fields, including its severity, request id, trace ids, and time, go into attributes. Before, the object replaced the message, and the text before it survived only in raw. A logger preamble (timestamp, level, logger name, bracketed tag) before the object is still stripped as before. (#113)

06 / 0.21.0

0.21.0

See the diff

Added

  • On an iap or service_account proxy route, ${token} in transform.request_body replace / with is the same minted, cached token used for Authorization and auth.headers. auth.type: none still rejects it at validate time. Inspect redacts that substituted token the same way it redacts a bearer. See Proxy.

07 / 0.20.0

0.20.0

See the diff

Added

  • Proxy routes can rewrite the request body before forwarding (transform.request_body). Each rule replaces every occurrence of replace with with; regex: true treats replace as a JavaScript regular expression, and with is inserted literally. ${NAME} / ${env.NAME} expand in both strings at request time, so a payload that baked in http://127.0.0.1:<proxy-port> can be rewritten to the remote origin before it is stored upstream. The body is buffered (16 MiB cap) and must be uncompressed UTF-8. Invalid on gRPC and recipe routes. See Proxy.

08 / 0.19.1

0.19.1

See the diff

Fixed

  • After a laptop sleep or a long pause — including WSL and a VS Code dev container — the supervisor could stop answering status and logs_page (30s timeout) and ignore restart and quit, while its processes still held their ports. The client reconnects after a resume. A supervisor that still does not answer is replaced, and services that are still listening are adopted instead of left holding their ports.
  • SOPS decrypt used --output-type dotenv, so a nested value failed the whole file and secrets containing #, spaces, or newlines were truncated or left with literal \n. Decrypt now uses JSON. Nested keys become env names (db.password → DB_PASSWORD). See Environment.

09 / 0.19.0

0.19.0

See the diff

Added

  • The README, security docs, docs site, and npm package page state that you use devctl at your own risk. It is provided as-is. See Security.
  • environment.sources: [sops] decrypts a SOPS file (environment.sops.file) at daemon start and on reload, with an optional key_map from env var name to SOPS key. The source sits before secret_manager, so Secret Manager still wins when a fetch succeeds. A missing sops binary or a failed decrypt skips the source with a warning; plaintext stays in memory. See Environment.

10 / 0.18.1

0.18.1

See the diff

Changed

  • Secret Manager env source no longer aborts start when ADC or IAM is missing. HTTP 401/403, missing credentials, and transport failures skip that key so .env / .devctl/secrets.env / process env remain; listing secret_manager also enables dotenv. A malformed environment.secrets resource or HTTP 404 still fails. See Environment.

11 / 0.18.0

0.18.0

See the diff

Added

  • Web console Logs follows the daemon's 50k ring with cursor paging and a virtualized list (pause, search/regex, ERROR+, system sources, wrap, split, history, NDJSON export). Doctor (#/doctor) and read-only Identity (#/identity, header ADC chip) match the TUI screens; busy-port stop stays TUI/CLI-only. Overview/Graph restart asks named-only vs cascade when dependents exist. See Web console and Logs.

Fixed

  • ${NAME} / ${env.NAME} interpolation from process env plus .devctl/secrets.env now matches what 0.16.0 documented: devctl config validate accepts those templates in service env, HTTP recipes, and proxy routes (including .devctl/proxy/routes.yaml) without requiring the variable to be set, and no longer treats an IAP auth.credentials env ref as a filesystem path. Values still expand at start, fetch, request, or mint — not at load.
  • The in-memory log ring keeps ingesting after logs.max_memory_events (default 50,000): wrapping a slot is O(1) and a slow logs query no longer kills the log worker (which previously dropped every later line). Status logs.total stays at the cap; logs.seen keeps growing.
  • A service that has already passed a health probe is no longer killed when later probes fail (Vite HMR / optimizeDeps blips). It stays UNHEALTHY until the next success or the process exits. startup.wait_for_healthy also covers that first-bind window so a slow dev server cannot race the startup wait.

Changed

  • Web overview no longer treats Logs as a 200-row replace poll. MCP get_logs still defaults to 200 per page (pass limit, max 5000); the SPA initial load uses limit=500. get_logs honors direction, regex, and returns prev_cursor. MCP get_log_stats and GET /api/logs/stats return facet counts without events.

12 / 0.17.0

0.17.0

See the diff

Changed

  • Web console and docs site browser tabs name the page (and the console, the project) instead of the bare product name devctl.

Fixed

  • TUI traffic inspector request/response bodies (pretty JSON and raw, including the overlay tree) wrap to the pane and scroll vertically instead of overflowing into neighboring panes.

Added

  • Traffic inspector caller filter on the TUI proxy screen and web console, matching CLI devctl traffic --caller and MCP get_traffic_calls. TUI /caller <service> on the proxy tab keeps one originating service (or - for hops with no caller); the same command on the LLM screen still filters LLM calls. See Proxy.
  • Proxy auth.suppress_authorization on iap / service_account routes mints as usual (audience, identity, client_id / client_secret, credentials) and still applies auth.headers with ${token}, but does not write Authorization: Bearer. Use it when the caller already owns Authorization (Workspace OAuth) and IAP must go in Proxy-Authorization: "Bearer ${token}". Requires auth.headers. Invalid on auth.type: none. See Proxy and IAP.
  • LLM inspector type: proxy sources can set capture.field_map so proprietary JSON (model_name, metadata.input_tokens, a gateway price, …) fills inspector model / tokens / cost / finish reason. Paths are a JSONPath subset against {request, response} ($.request. / $.response., dotted keys, [n]). A present mapped value wins over the OpenAI parser; cost_per_token remains the fallback when field_map.cost is unset or misses. Invalid on litellm and plugin sources. See LLM inspector.

13 / 0.16.0

0.16.0

See the diff

Added

  • Log ingest copies devctl.request_id from a proxy hop onto a nearby service line that names the same gRPC method or HTTP method+path (50ms window), so --dedupe-request-id can collapse the proxy+SDK pair. See Logs.
  • Settings can raise the default inspect/LLM body cap (1 / 4 / 8 / 16 MiB). That writes proxy.inspect_max_bytes and llm.capture_max_bytes into .devctl/config.local.yaml and reloads; a route or source that sets max_bytes still wins. See Web console and Proxy.
  • Query-time devctl logs --dedupe-request-id / MCP get_logs.dedupe_request_id collapses nearby events that share devctl.request_id (keeps proxy attributes and the richest body), and optional services.<name>.logs.dedupe_access_line drops a plain uvicorn access line that duplicates the previous structured event from the same pid. See Logs.
  • devctl start --overlay <name> applies .devctl/overlays/<name>.yaml as a sticky session config layer after config.local.yaml (recorded in state.json, shown in provenance), and --env KEY=VAL (repeatable) sets ephemeral process env for targeted services only.
  • Proxy routes can set optional timeout.idle_ms / timeout.total_ms (0 or omitted = unlimited) so a stalled hop returns HTTP 504 or gRPC DEADLINE_EXCEEDED without hanging the client. See Proxy.
  • Built-in health.type: grpc probes grpc.health.v1.Health/Check over HTTP/2 (h2c, with TLS fallback), and gitignored .devctl/secrets.env (plus weaker ~/.devctl/secrets.env) is always loaded for ${env.NAME} interpolation and as a service-env layer after dotenv. Process environment still wins. There is no ${secret:} template syntax.
  • Proxy routes can strip a matched path prefix when forwarding (strip_prefix) and treat listed gRPC statuses as non-errors (log.grpc.ok), so Temporal long-poll 14 / workflow-task 3 no longer inflate stats().errors. Per-service proxy: fragments now keep the full RouteConfig (inspect, transport, response headers, and the new fields). See Proxy.
  • Traffic inspector decodes gRPC request and response bodies: split multi-message frames, inflate gzip in the capture adapter, pretty-print JSON (application/grpc+json or JSON-looking payloads) or proto3 decode_raw field numbers, and optionally a named inspect.grpc.decoder plugin (trafficDecoders). Captured data stays the original base64. See Proxy.
  • Traffic inspector inspect.capture_sse (default false) stores text/event-stream responses as a JSON array of blank-line-delimited frames, or pretty OpenAI chat.completion JSON when the stream is chat/completion-shaped. The forwarded stream is still teed with no extra delay; max_bytes / truncated still apply. See Proxy.
  • Status snapshots and MCP/web list_services expose start_period_remaining_ms / start_period_total_ms while a service is still inside health.start_period_seconds.
  • LLM inspector type: proxy sources can tag multiple proxy routes with via.routes: [a, b]; via.route remains singular sugar for one name. See LLM inspector.
  • Proxy auth.log_identity on auth: none copies inbound X-Goog-Authenticated-User-Email onto the traffic record as caller_email; IAP routes with a credentials file report credentials_valid on devctl status. See Proxy.
  • ${identity.user} in service env still resolves at process start; the same placeholder in proxy auth.headers (including service proxy: fragments) is not run through resolveEnvMap and stays literal. devctl config validate prints a warning: (load still succeeds). ${token} on minting routes is unchanged. See Environment and Proxy.
  • Doctor statically checks each IAP route's auth.credentials (or folded proxy.credentials) authorized_user file: exists, JSON, refresh_token, and client_id match. Failures hint gcloud auth application-default login with a client secret file that matches client_id (or omit client_id), plus TUI /auth login / devctl auth login. See Doctor and IAP.
  • LLM inspector type: proxy sources can set optional cost_per_token.input / output (both >= 0) so a captured call gets cost as promptTokens * input + completionTokens * output when that usage split is present; missing tokens leave cost unset. Invalid on litellm and plugin sources. See LLM inspector.

Changed

  • Web Logs keeps every service and level chip after a filter is applied (configured services plus names already seen). The TUI all chip keeps the unscoped service total instead of the currently selected service's count.
  • Web JSON tree nodes collapse and expand when you click the row (same as the TUI), and collapse state stays put while the same record is polled. See Web console.
  • TUI and web consoles format JSON everywhere it is shown: syntax-colored pretty text in compact TUI inspectors, a click-to-expand tree in TUI overlays, and a navigable web viewer (tree / pretty, path breadcrumb, find, copy path or value) on Traffic, LLM, Logs, and span attributes. See Web console and TUI.
  • Config reload hot-swaps proxy routes when listen addresses are unchanged, so HTTP, token-endpoint, and gRPC sockets stay up; a listener restarts only on bind/enable change. See Proxy.

Fixed

  • Process log severity now strips ANSI before classification, and stdout/stderr fold Python tracebacks, bare HTTP status continuations, and optional logs.multiline start/continuation regexes into one event.
  • Review follow-ups for the platform-audit Phase 1 surface: log.grpc.ok decode keeps malformed entries so validate can reject them, status is an integer 1–16, and method names match on a / boundary; reload loads a candidate plugin registry before checking inspect.grpc.decoder; gRPC health origins bracket IPv6 hosts; gzip inflate honors inspect.max_bytes; snapshot() no longer flushes pending multiline buffers; multiline buffers are keyed by service/source/stream/pid/identity; parseJSONLogLine keeps the original raw line; schema-free decode_raw keeps fixed-width wire bits as hex.
  • Review follow-ups for the platform-audit Phase 2 surface: malformed or non-finite route timeouts stay invalid instead of decoding to unlimited 0; a config-disabled proxy starts again on reload when proxy.enabled becomes true (unless suppressed); gRPC sessions are keyed by upstream URL and do not open after a deadline; HTTP timeouts disconnect a started response; request-body forwarding preserves backpressure; SSE capture matches text/event-stream exactly, keeps frame whitespace, and joins split data: fields; listen-key compare canonicalizes equivalent IPv6 literals; --env KEY=VAL stays off the stored client environment.
  • Request-id log dedupe keeps the earlier sequence/time when a later richer record survives, --dedupe-request-id exports apply the same collapse, and via.route validation uses the trimmed name.
  • Proxy-hop request-id tagging re-emits and persists a reverse-order update, keeps every candidate ingested in the last 50ms of arrival time (matching requires both the 50ms event-time window and the pair's ingest arrivals; folded process lines keep the first line's arrival), and ignores HTTP hop text whose target is not /…, an absolute URI, host:port, or *. TUI live append replaces a repeated positive seq inside the same batch.
  • Malformed proxy.inspect_max_bytes / llm.capture_max_bytes values stay invalid instead of decoding to the 1 MiB default.
  • A supervisor that dies on bind (for example EADDRINUSE on the demo OTLP port) no longer opens the TUI as Configuration error / Fix .devctl/config.yaml. The empty state is Supervisor failed to start, and the bootstrap log's first line (including the OS code) is in the message.

14 / 0.15.0

0.15.0

See the diff

Added

  • Per-repo settings in TUI /settings and web #/settings: user-global ~/.devctl/tui.json plus a checkout overlay at ~/.devctl/state/<repoID>/tui.json (repo wins; MCP listen always lands there). Settings can patch only web.enabled / web.listen.port into .devctl/config.local.yaml. MCP get_preferences / set_preferences (web control). See TUI, Web console, Building from source.
  • Profiles can bind named overlays (profiles.<name>.environments.<svc>) and set per-service env (service_environment) that wins over service vars, so a UI-only profile can point at deployed backends. See Profiles.
  • LLM inspector proxy-capture sources can list extra capture.paths so a tagged route records proprietary (non-OpenAI) POST JSON endpoints such as /generations/v1alpha2. Built-in /chat/completions, /completions, and /embeddings capture is unchanged. Custom paths store the raw request/response — SSE is kept as text, not reassembled into a chat.completion — and copy model, token usage, and finish reason only when those standard JSON fields are present. See LLM inspector.
  • Traffic inspector for proxied HTTP and gRPC hops: set inspect.enabled (and optional inspect.max_bytes, default 1 MiB) on a proxy.routes entry to capture redacted request/response bodies. List APIs omit bodies; get_traffic_call / devctl traffic show / the TUI proxy inspector / web #/traffic/:id include them. Direct sockets that never hit the proxy are not captured. /reveal cannot unmask these payloads. See Proxy.

Changed

  • Breaking: devctl start --profile X starts exactly that profile's members. Omitted YAML/HTTP dependencies are not spawned. Named devctl start invoices-api with no profile still expands the local closure. Leftover ${services.X.url} refs to omitted services fail that member with a blocker.
  • TUI and web LLM/traffic inspectors pin the selected call by id, so a newer hop does not steal the highlight or replace the payload you are reading. The TUI list keeps its scroll position when a newer hop arrives; j/k still bring the cursor on screen. Click a web Traffic row (not just the timestamp) to open it. See TUI and Web console.
  • TUI LLM inspector is a list plus live inspector, matching the services screen. The call list has column headers (time, status, caller, model, latency, tokens); the inspector shows status chips, caller / via, and a conversation transcript when the body is chat-shaped (otherwise JSON). r (or the conversation/json chip) switches the inspector and overlay to the raw request/response JSON. Click selects; click again or enter opens the full overlay. Empty states mention proxy sources as well as LiteLLM. See TUI.
  • TUI proxy screen is a routes pane plus a traffic list and live inspector (pretty JSON / raw, r toggle, enter for overlay). Empty state explains inspect.enabled and that unproxied sockets are invisible.
  • Web console LLM view is a list plus live inspector: selecting a call keeps the table visible and opens a conversation transcript (copy, find) or a wrap-able request/response JSON split. Search hits stored bodies; j/k moves the list. See Web console.
  • Web console Traffic view (#/traffic and #/traffic/:id) inspects captured proxy hops the same way; overview request paths link into a hop when a body was captured.
  • Demo platform (examples/demo-platform) routes local HTTP hops through the proxy with inspect.enabled, and ships an OpenAI-compatible LLM stub (type: proxy, no LiteLLM) so the traffic and LLM inspectors have live bodies without Google or a cloud key.

Fixed

  • TUI tab chips (nav, log service filters, and other chip buttons) no longer start a text selection highlight when you click them. Drag-to-copy still works on logs and inspector bodies.
  • Web console authorization now persists like MCP: the control token is reused from ~/.devctl/state/<repoID>/web-token across listener restarts (7-day TTL), and the SPA stores it in localStorage so closing the tab and opening the same origin again stays authorized. devctl web start --print-url is still needed for the first visit, after the TTL, or when a different repository is using that port.
  • Start waves wait for condition: service_healthy edges before the next wave, not every health-checked member. A slow or degraded dependency with the default service_started condition no longer blocks the fleet.
  • Traffic inspector redacts secrets inside base64 data (decoded bytes / gRPC-JSON frames), not only the pretty text view.
  • gRPC proxy capture respects stream backpressure instead of buffering an entire streaming RPC in the daemon.
  • Changing the web console port to one already in use logs the bind failure instead of an unhandled rejection.
  • Settings layer badges under DEVCTL_TUI_CONFIG report override / default, not an ignored repo overlay.
  • TUI web-port arrows preview locally; Enter writes .devctl/config.local.yaml and reloads (same draft-then-commit as the MCP port field).

15 / 0.14.1

0.14.1

See the diff

Changed

  • The npm tarball no longer re-exports the full application dependency tree. Install-time dependencies are the official bun runtime, native packages that must exist on disk (@opentui/core today), and the few libraries the bundler cannot inline because they are reached through a runtime import() (node-fetch, pulled by the Google auth integration through gaxios). Every other app/package.json dependency is bundled into dist/devctl.js automatically, and the build fails if the bundle references any external package that is not declared, so adding a JS library later cannot silently ship a broken install graph. Native packages and the Bun runtime are pinned to the exact installed version; the pure-JS runtime imports ship as caret ranges so security patches still reach consumers.

16 / 0.14.0

0.14.0

See the diff

Added

  • LLM inspector caller filter across every surface. CLI devctl llm --caller <service> and MCP get_llm_calls's caller already filtered by originating service; the web console now has a caller dropdown and the TUI a /caller <service> command (shown in the LLM screen meta bar). All four accept the reserved value - (CLI also none) to show only calls with no known caller, so unattributed traffic can be isolated. A bare - can never be a real caller (rejected at ingest), so the selector is unambiguous.

Fixed

  • devctl status process memory and identity now read from /proc on Linux when ps/lsof are absent (the same minimal-container gap as the caller fix). Resident memory comes from /proc/<pid>/statm and the command/cwd/start-time from /proc/<pid>/cmdline, the cwd symlink, and /proc/<pid>/stat, so the memory column and process detail populate in a uv/python-slim container. CPU% still comes from ps (a single /proc sample cannot reproduce its lifetime average without assuming the clock rate), so it reads 0 where ps is unavailable.
  • LLM inspector caller attribution (loopback TCP peer, method 3) now reads /proc directly on Linux instead of shelling out to lsof and ps. A minimal dev-container image (for example a uv/python-slim base) ships neither tool, so ownerPidForPort and the parent/process-group walk silently returned nothing and every type: proxy capture stored an empty caller. The daemon now resolves the socket owner from /proc/net/tcp[6] and its ancestry from /proc/<pid>/stat, both always present in a Linux container, so caller attribution no longer requires lsof/ps to be installed — a service running in the same container/namespace as the daemon (e.g. a FastAPI or Temporal worker started with uv) is resolved from the kernel socket tables with no header or metadata on the service side. lsof/ps remain the fallback on Linux and the primary path on macOS; Windows still uses netstat. Reading another process's /proc/<pid>/fd requires the same uid, so a client running as a different user than the daemon, or in a *different* container/namespace (or any non-loopback client), is still best-effort — send X-Devctl-Service or completion metadata.service.

17 / 0.13.2

0.13.2

See the diff

Fixed

  • Compile smoke no longer generates GitHub release notes from the live CHANGELOG.md. The notes test uses a fixture, so a 0.13.1 Fixed bullet can describe the uploads HTTP 500 retry without failing CI.

18 / 0.13.1

0.13.1

See the diff

Fixed

  • Config plugins must now resolve to a path inside the repository root. An absolute path or file: URL that escapes the repo (or a ../ climbing above it) is refused by config validate and skipped by the loader, so a cloned repo's .devctl cannot point the in-process plugin import at code elsewhere on the machine. Path resolution is unified in one shared helper used by the loader, validator, reload watcher, and log worker (the log worker now resolves against the repo root, not its cwd). Containment is also checked on the realpath so an in-root symlink cannot point the import at code outside the repo. Doctor lists configured plugin paths as a warning, since plugin code runs in-process with full supervisor privileges (#75).
  • HTTP recipes can no longer target a link-local / cloud-metadata host (169.254.0.0/16 including 169.254.169.254 and its IPv4-mapped dotted/hex forms, IPv6 fe80::/10, metadata.google.internal, or 100.100.100.200): a literal such URL fails config validate and an interpolated one is blocked at fetch time, before any minted token is sent. Doctor warns on a literal non-https recipe URL (part of #74).
  • HTTP recipe expose now refuses, at config validate time, to publish a token-bearing cached body (a cache.jwt recipe, or an access_token/id_token/token/refresh_token/client_secret output) on its no-inbound-auth loopback route unless the recipe sets expose.allow_token_body: true to acknowledge it. Doctor also warns when an exposed recipe sets Access-Control-Allow-Origin: *. Docs no longer recommend a wildcard CORS header on token recipes (#73).
  • Web console read APIs now require the per-bind bearer token and a loopback peer on every /api/* route (status, logs, config, traces, LLM bodies), matching POST /api/control and the MCP/token endpoints. Only the HTML shell at / is served anonymously (#71).
  • The web control token is delivered in the URL fragment (#token=…) instead of the query string, so it is no longer sent as Referer or written to proxy access logs. devctl web start prints only the console origin by default; devctl web start --print-url prints the full one-time access link when needed (#77).
  • Supervisor RPC connections now cap the pre-auth inbound line buffer at 1 MiB and drop a connection that streams past it with no newline, so a local peer can no longer grow daemon memory before authenticating (#78).
  • Transient task and exec_service output is capped at 1 MiB per stream in the captured result (with a ...[truncated] marker); live log lines are unaffected, so a noisy command can no longer grow supervisor memory without bound (#76).
  • HTTP recipe responses are read with a 1 MiB ceiling, aborting the fetch instead of buffering an oversized upstream body in the daemon (part of #74).
  • LLM proxy capture reads the request body with a read-time byte ceiling as a defensive invariant (#72).
  • Release publishing uploads GitHub assets one file at a time with retries, so a transient HTTP 500: Error saving asset from uploads.github.com no longer aborts the job after some binaries already landed. A failed v* tag can be retried from Actions with workflow_dispatch without moving the tag.

19 / 0.13.0

0.13.0

See the diff

Added

  • Per-service named environment overlays (services.<name>.environments plus default_environment). Switch one service at a time in the TUI (e / /env), CLI (devctl env), MCP (set_service_environment), or the web console Env column without changing other services or the start profile. A running process keeps the overlay it started with until restart. The TUI asks before switching a live process (Enter = switch only, r = switch and restart). devctl status and the TUI service list show the selected overlay.
  • TUI topology screen (g / /topology): the service dependency graph drawn as startup waves, nodes coloured by health, with an inspector showing what a selected service depends on and what depends on it. Navigable by keyboard (j/k, enter opens the service) or mouse.
  • TUI token timeline (/tokens): token mint, refresh, and identity-change events from the auth log stream, keyed by identity and audience over the session. The token itself is never shown.
  • TUI follow a request across views: click a row in the proxy request feed — or press enter on a log line — to open that request's trace and land on its pre-filtered logs in one move. The trace view names the proxy hop (route, identity, status).
  • TUI Stats trends: per-service CPU and memory sparklines (service_series on the status snapshot), and a per-route proxy latency table with p50/p95/p99 and error counts.

Changed

  • TUI docs list every slash command with the same one-line description the / overlay shows, grouped the way the overlay is.
  • Operator docs catch up with the product: wiki home and overview include the web console as a fourth face (not only TUI/CLI/MCP), LLM inspector is documented as LiteLLM plus proxy-capture rather than “later sources”, CLI synopses include --request-id and --caller, Logs keys are 1–5 (five nav tabs), and MCP get_requests is in the inspect group to match the tool catalog.

20 / 0.12.1

0.12.1

See the diff

Fixed

  • LLM inspector proxy-capture records the calling service (caller), not the gateway route name, when the provider sits behind Apigee or another type: proxy source. Peer lookup runs when the request starts (a closed socket no longer leaves caller empty). Completion metadata.service and x-litellm-metadata are read the same way as LiteLLM spend logs. Detail views label the proxy source via so it is not mistaken for the caller.

21 / 0.12.0

0.12.0

See the diff

Added

  • LLM inspector records which service originated each call (caller): X-Devctl-Service (stripped before the upstream), loopback TCP peer mapped to a managed process, LiteLLM metadata.service, or a non-email OpenAI user. Shown in the TUI, CLI, MCP, and web list/detail.

Fixed

  • TUI services env pane is a clipped key/value table so long or multiline values no longer wrap into the next row. Click a variable for the full value.
  • LLM inspector no longer masks usage counts (prompt_tokens, completion_tokens, max_tokens) as secrets. TUI /reveal still only unmasks service env — payload redaction happens at ingest and cannot be undone.

22 / 0.11.0

0.11.0

See the diff

Added

  • LLM inspector proxy-capture source (llm.sources[].type: proxy): captures OpenAI-compatible completion bodies — JSON and streamed text/event-stream — directly off a devctl proxy route named by via.route, feeding the same store as the LiteLLM source. This makes the inspector work behind gateways that only expose /v1/chat/completions and block /spend/logs (Apigee, IAP, API Management), with no management API. Only tagged completion routes are buffered; all other proxy traffic still streams untouched, and capture.max_bytes (default 1 MiB) bounds the stored body. See LLM inspector.

23 / 0.10.0

0.10.0

See the diff

Added

  • In-app notices, starting with new version available. The TUI checks GitHub Releases on attach and shows a one-line banner (Update / Later / Dismiss) plus an ↑ 0.x.0 header chip — it does not steal letter keys. /notify later hides it until the next session; /notify dismiss remembers this version in tui.json. The web console shows the same notice under the header, with a copy-install-command action, and remembers dismissals in the browser.
  • Docs site changelog at /changelog (this file, Keep a Changelog). The landing links it from a Just shipped band and from nav/sidebar, and documents the opt-in loopback web console under When you want a window.
  • Operator-manual screenshots (TUI, CLI, web UI) and a playable TUI showcase video on the GitHub README.
  • Contributor internals guide (docs/internals/) — layer map, RPC, adding features. Not the operator manual.

Changed

  • TUI log search (f) is Logs-tab only. enter keeps the current filter so you can browse matches; esc closes search, clears the query, and jumps to the live tail. Remap with keybinds.search. See Logs.
  • Web console and docs landing use Phosphor icons (same forest-teal hairline marks).
  • npm package ships Bun 1.4.2 (engines.bun ≥ 1.4.2). 1.4.0 could segfault the TUI on the bun:ffi path OpenTUI uses, especially after a long sleep.

Fixed

  • TUI restores mouse tracking and the alternate screen on catchable termination signals, uncaught exceptions, and unhandled rejections — not only on a clean exit.
  • npm launcher restores the terminal when the Bun child dies of a fatal signal or a non-zero code (a native panic is below JS, so only the parent can recover the TTY). The restore sequence is written synchronously to fd 1/2 before the wrapper re-raises the signal, so Windows does not drop the bytes.

24 / 0.9.0

0.9.0

See the diff

Added

  • HTTP recipes (http.<name>): named outbound calls with interpolation, JWT-aware cache, and optional loopback expose. See Custom HTTP APIs.
  • LLM inspector (llm.sources, off by default): LiteLLM spend logs in MCP, web #/llm, TUI, and CLI. See LLM inspector.
  • Proxy request table shows duration and proxy hop timing.
  • TUI log follow stays pinned to the bottom while new lines arrive, and lets go when you scroll away.

Changed

  • Supervisor RPC is protocol 2 and requires a per-checkout rpc-token. Existing daemons need devctl down then start.
  • Web UI POST /api/control requires Authorization: Bearer (per-bind token from devctl web start) plus a loopback http/https Origin or Referer. HTML is not framed. Loopback Host still allows remapped WSL / Dev Container ports.
  • MCP rejects a spoofed Host and loopback peer miss, advertises no CORS, expires bearer tokens after 7 days (devctl mcp --rotate), and keeps exec_service off until opted in.
  • Token endpoint mints must match a declared identity/audience pair and are rate-limited. A missing peer address is treated as non-loopback.
  • Managed containers default to 1g RAM, 1 CPU, and 256 PIDs; Doctor warns on root images.
  • Free-text logs also redact raw JWTs and Google access tokens. MCP request bodies are capped at 1 MiB.
  • Dependencies: OpenTUI 0.5.11, React 19.3, TypeScript 6, yaml 2.9.1; docs VitePress 1.6.4 / Vue 3.5.42 / Mermaid 11.17.2; demo Vite 8.

Fixed

  • Proxy refuses to start without listen.port and surfaces that in the TUI instead of crashing.
  • Host trailing-dot stripping no longer uses a ReDoS-prone regex; the web-asset inliner matches spaced </script> end tags.
  • JWT log redaction scans eyJ….….… linearly instead of a backtracking regex.
  • TUI restores mouse tracking and the alternate screen on a hard process exit (closed stdout / EPIPE), so a long-running session no longer leaves the shell printing mouse sequences.

25 / 0.8.1

0.8.1

See the diff

Fixed

  • Web UI {"error":"forbidden"} on loopback Host/Origin variants used by WSL, Dev Containers, and forwarded ports ([::1], Host without a port, remapped localhost:<forwardedPort>, https://localhost). Allowlist is still loopback-only; no login or token.

26 / 0.8.0

0.8.0

See the diff

Added

  • Opt-in loopback Telemetry & Trace Explorer (web, off by default, port 18900). Loopback GET API plus POST /api/control for the same mutating MCP tools the TUI uses (start/stop/restart, profile start, proxy, reload, run task; not exec). No login or token, no CORS, Host allowlist; mutating POST /api/control requires a same-origin Origin or Referer and Content-Type: application/json. Bundled SPA: services dashboard with lifecycle buttons, SVG trace waterfall, dependency graph, and client-derived rate/latency/CPU charts. devctl web status|start|stop; devctl status prints a WEB line. See Telemetry.

Changed

  • Web UI uses the same logo and favicon as the GitHub Pages site.

Fixed

  • Python str(dict) stdout ({'key': 'value', ...}) is parsed as a structured log instead of rendering as raw braces in the TUI.
  • Web request and error KPIs no longer freeze at ring size. Proxy requestTotal / requestErrors were already lifetime counts; log snapshots now also expose seen / seenErrors for ingested lines. The request table still shows the last 100, and log pages still show a bounded slice.
  • Web traces and proxy-request tables list newest first, matching Logs.

27 / 0.7.0

0.7.0

See the diff

Added

  • OpenTelemetry-aligned telemetry. Logs are now OTel-shaped records — body, attributes, severityNumber/severityText, optional traceId/spanId, and a resource — instead of a flat message. Structured JSON from pino, bunyan, zap, logrus, structlog, ECS, GELF, and OTLP-shaped lines is mapped into body + attributes + severity + ids; a leading timestamp before the JSON is stripped and retried; and an unrecognized JSON object renders as a key=value summary rather than raw braces. See Telemetry.
  • Opt-in OTLP/HTTP+JSON receiver (telemetry.otlp, off by default, loopback-only, default port 4318). Accepts POST /v1/logs and /v1/traces; when enabled, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL=http/json, and OTEL_SERVICE_NAME are injected into managed host processes (containers and any value you set yourself are left alone). The listen port is validated against the proxy, token-endpoint, and gRPC-route ports.
  • Request tracing. The proxy (HTTP and gRPC) emits one span per request — method, route, status, duration, identity — and propagates a traceparent plus X-Devctl-Request-ID, so a service's own OTLP spans and logs join the request's trace. An in-memory span store correlates spans and logs by trace id and resolves a request id to its trace.
  • TUI. The log details overlay shows an attributes table, severity, and traceId/spanId; a ◎ marker flags rows that have a trace; and a full-width Trace waterfall overlay renders the span tree (Enter or double-click a span opens its logs).
  • CLI. devctl logs gains --trace <id> (span tree plus correlated logs), --request-id, --attribute key=value, and --json (JSONL records).
  • MCP. New get_trace, trace_request, get_requests, and recent_errors tools; get_logs gains trace_id, request_id, and attribute filters and returns body/attributes/severity. All output is redacted.
  • Docs. New Telemetry page; Logs, MCP, Proxy, and Configuration updated for the record model, the receiver, and the trace tools.

Changed

  • Log persistence is now JSONL (one OTel record per line) instead of space-delimited text, so reload is lossless (attributes, ids, severity). The format is detected per session directory, and older space-delimited sessions still load.
  • Secret redaction now walks nested body/attributes, span events, and span status messages, and runs at ingestion — so nothing unredacted reaches the TUI, CLI, MCP, or the on-disk log files.
  • The demo platform's telemetry service now cycles the awkward JSON shapes the parser accepts (no severity, no message key, GELF, prefixed JSON, access logs, nested secrets), posts OTLP/HTTP+JSON logs and traces, and probes the proxy with a W3C traceparent so the Logs ◎ marker, attributes table, Trace overlay, and Proxy request traces have live data. telemetry.otlp is enabled on 127.0.0.1:18418.

Fixed

  • Log list headlines no longer clip at the pane edge. Messages wrap to the column using OpenTUI word wrap, clip mode uses ellipsis, and wrapping every line is the default.
  • The trace overlay waterfall uses the full width. Enter or double-click a span opens its logs overlay; esc returns to the timeline.
  • Table columns keep a one-character gutter, so a value that fills its cell no longer runs into the next column.
  • Trace waterfall bars use a muted track for unused time; only the solid block is the span. Proxy request spans start at request begin (the recorded timestamp is completion), so they line up with OTLP spans.
  • OTLP severity decoding keeps valid OTLP severity numbers (e.g. 10/INFO2, 20/ERROR4) and enum-name strings (SEVERITY_NUMBER_ERROR) instead of remapping them through pino's numeric convention, which had turned some OTLP levels into TRACE/DEBUG or UNKNOWN and could disagree with the shown severity text.
  • A JSON log carrying a top-level body field is no longer dropped for non-OTLP records; its value is used as the message.
  • OTLP body and attribute strings are length-capped like the stdout lane, and int64 attribute values beyond Number.MAX_SAFE_INTEGER are preserved as strings rather than silently rounded.
  • Proxy spans always carry valid W3C ids, and a bare X-Devctl-Request-ID is no longer adopted as a trace id (only a real traceparent sets one), so unrelated requests are not merged into one trace.

28 / 0.6.0

0.6.0

See the diff

Added

  • route.response_headers adds headers to every response on the route, overriding whatever the upstream sent — most often CORS Access-Control-Allow-* for a browser that loads a micro-frontend, Module Federation remote, or iframe from another origin and then calls back through the proxy. A CORS preflight (OPTIONS carrying Access-Control-Request-Method) is answered directly with those headers and a 204; it is not forwarded, since the upstream may not handle OPTIONS and IAP would reject an unauthenticated preflight. Any other OPTIONS is proxied normally, still with the headers applied. Rejected on a gRPC route.

29 / 0.5.0

0.5.0

See the diff

Added

  • gRPC proxy routes (transport: grpc). A dedicated loopback HTTP/2 (h2c) listener forwards every gRPC stream to one https:// upstream over HTTP/2 + TLS, injecting the route's IAP token (Authorization + any auth.headers) into each RPC via the same minting/refresh path as HTTP routes. A gRPC client — e.g. a Temporal worker behind IAP — connects plaintext to the local listen port (Client.connect(host, tls=False)) and stays entirely token-free; per-RPC injection means expiry is handled with no timer in the app. Targets self-hosted Temporal behind a GCP IAP HTTPS load balancer (Temporal Cloud's mTLS + API key is out of scope).

30 / 0.4.1

0.4.1

See the diff

Fixed

  • IAP routes using a custom client_id/client_secret (user identity) now mint an id_token with the correct aud. The refresh-token exchange sends audience (the IAP client id) rather than google-auth-library's target_audience, which returned aud = client_id and was rejected by IAP as an invalid JWT audience.

31 / 0.4.0

0.4.0

See the diff

Added

  • ${identity.user} resolves in configured environment values to the running developer's own detected Google identity (gcloud/ADC), so shared config can map it onto a service's own variable without a hardcoded, team-unfriendly value — e.g. LOCAL_USER_EMAIL: ${identity.user}. The same value is also injected automatically as the runtime variable DEVCTL_USER_EMAIL. Empty when no identity is detected.
  • route.auth.headers injects extra request headers alongside Authorization on a token-minting route; ${token} in a value is replaced with the same minted token (e.g. identity-token: ${token}), and literals pass through. This lets the proxy fully satisfy an upstream's auth expectations without changing the upstream or the calling service. get_config lists the configured header names.

32 / 0.3.2

0.3.2

See the diff

Fixed

  • A separate IAP credentials file now works from a modular .devctl/proxy/routes.yaml. proxy.credentials (and gateway, enabled, token_endpoint) were silently dropped by the modular proxy loader, so routes fell back to gcloud ADC and failed with a client mismatch despite the setting. Modular proxy files now honor the same fields as the top-level proxy block, and get_config surfaces proxy.gateway, proxy.credentials, per-route auth.credentials, and service-reference upstreams. The authoring guide's allowlist documents credentials on proxy and route.auth.
  • Windows resource sampling no longer stalls the daemon's poll on a slow PowerShell launch — the helper spawn is bounded and degrades to an empty sample.

33 / 0.3.1

0.3.1

See the diff

Added

  • IAP routes can mint tokens for a custom client_id from a separate gcloud authorized_user credentials file via auth.credentials (or a proxy-wide proxy.credentials default), instead of the default gcloud ADC — so ADC stays intact for GCS/Firestore and other Google SDK calls. The path accepts ~, absolute, or repo-relative forms; the file's refresh_token must belong to the route's client_id, and the file can supply the client_secret when the route omits it.
  • MCP get_doc returns the full text of one embedded documentation page. search_docs returns short snippets for discovery; pass a hit's path to get_doc to read the whole page.

Fixed

  • proxy.gateway now honors an explicit expose: false on a service instead of publishing it anyway; unknown keys under a service's expose object are rejected.
  • IAP custom-client token minting resolves the OAuth secret only when refreshing, so a still-valid cached token keeps working after its environment secret is removed rather than failing with a 502. A rejected custom-client request is now classified with IAP guidance.
  • Configuration rejects a client_secret that mixes literal text with an environment reference — it must be a plain literal or exactly ${NAME} / ${env.NAME}.
  • Proxy documentation rendering: a stray code fence that hid the per-service routes section is removed.

34 / 0.3.0

0.3.0

See the diff

Added

  • Optional IAP route client_id plus client_secret mints user ID tokens with that OAuth client instead of ADC's default client. Put ${NAME} or ${env.NAME} in client_secret to read the secret from the environment at mint time. MCP get_config, status snapshots, CLI proxy status, and the TUI route details show client_id and env-ref templates (never a resolved secret).
  • MCP search_docs searches the compiled-in product documentation and onboarding skill.
  • service.expose (and proxy.gateway for every HTTP-capable service) synthesizes a host-based, auth: none proxy route that addresses the service by name. The proxy resolves the current assigned port at request time, so a restart onto a new auto port does not require a proxy reload or consumer change. A hand-written route of the same name always wins. Routes may set upstream.service / upstream.port instead of a fixed url.
  • ${services.<name>.url} and ${services.<name>.host} resolve through the proxy when the target is exposed (hub mode), otherwise to the direct loopback address.

Changed

  • The docs site homepage, logos, and default TUI devctl theme use the forest/mint website palette.

35 / 0.2.5

0.2.5

See the diff

Added

  • TUI /restart --cascade (-c) and R confirm (c = cascade) restart dependents the same way CLI and MCP already do.
  • Empty TUI /run and /exec open a picker; Enter on a Config task row runs that task.
  • MCP run_task, start_proxy, and stop_proxy wrap the existing supervisor RPCs. Auth login and config write stay CLI/TUI-only.
  • TUI log search highlights matches (plain and regex) and the dashboard tail honors the same search.
  • TUI first-run Enter runs the 9-step setup wizard and attaches the daemon without restarting the process.
  • devctl config import compose (and TUI /import compose) maps modeled Compose fields and lists dropped ones; --write never invents unknown schema keys.
  • Opt-in service.watch restarts one service when listed paths change (debounced, ignored globs, never the whole repo by default).
  • Configuration reload hot-applies a changed plugins path list; editing an already-imported plugin file still asks for a supervisor restart.
  • TUI log split panes (\\ / /split), /trace <id>, and Stats sparklines from a 60-sample supervisor ring.
  • TUI chords use command on macOS and ctrl on Linux/Windows. Help and the status bar label the OS modifier. The copy chord copies the highlighted selection; esc twice quits.
  • Slash overlay second-word suggestions and usage hints (/auth, /restart, /import, and similar).
  • Log store truncates lines over 16 KiB and skips JSON parse for payloads over 64 KiB; the daemon can keep the ring on a worker thread.
  • CI check:coverage fails the suite when aggregate function or line coverage drops below 86%.
  • CI check:dead (Knip) and check:dup (jscpd) reject unused files/exports and copy-paste clones.

Changed

  • The supervisor is split further into proxy, MCP, identity, environment, and resource-sampler coordinators plus a dedicated RPC server. CLI, TUI, and MCP still share the same commands.
  • Docs no longer claim an implicit proxy listen of 127.0.0.1:8080. proxy.listen.port is required when proxy.enabled is true (validate exits 2); devctl proxy start with port 0 exits 7. Setup still writes 8080 as a starter.

Fixed

  • service.watch debounce of 0 or a negative value falls back to the default instead of disabling the debounce.
  • A detached supervisor child is reaped on shutdown and in tests so down does not leave an orphan listening on the session socket.

36 / 0.2.4

0.2.4

See the diff

Added

  • Ports-and-adapters layout is enforced: bun run check:architecture (and a CI job) rejects inward-layer imports, and docs/architecture.md is the living layer map.
  • Structured JSON-per-line parsing covers metric telemetry (metric_name / value), nginx-style access logs, and OpenTelemetry OTLP records (body.stringValue, attribute arrays), not only pino/zap application logs.

Changed

  • devctl update and TUI /update detect how this binary was installed (npm, npx, Homebrew, GitHub Release, source) and, for npm and Homebrew, run that channel's upgrade. --check and --json still report only. /version stays a check.
  • Application code is split into presentation, application, domain, ports, adapters, shared, and bootstrap. CLI, TUI, and MCP still share the same commands; the supervisor is no longer a single god file.

Fixed

  • Proxy, token endpoint, and MCP listeners refuse IPv6 unspecified (::) and other non-loopback binds, not only 0.0.0.0.
  • A failed spawn no longer leaves the service stuck in STARTING, which blocked every later start of that name.
  • A crash restart is no longer skipped when a stale health probe still reports HEALTHY after the process has already exited.
  • Restarting several services no longer applies the first target's profile environment to the rest. A later start that omits --profile (or sends an empty profile) keeps each service's stored profile instead of clearing it.
  • Session recovery restores each leftover process's own profile (and re-resolves that profile's current environment) instead of assigning every service the last daemon-wide profile.
  • A no-op configuration reload no longer clears outstanding restart_required names. A successful spawn (including crash restart) drops that service from the list; claiming an already-running process does not, and a later health-wait failure does not restore names that already spawned.
  • start --profile against an already-running process no longer rewrites that service's stored profile without applying it. TUI start of named services omits the selected profile the same way CLI devctl start api does; start of the current profile still sends it.
  • Restarting a service keeps its profile name but re-resolves that profile's environment from the current configuration, so edited profile variables take effect.
  • HTTPS upstreams can complete WebSocket upgrades (the proxy used http.request for every upgrade).

37 / 0.2.3

0.2.3

See the diff

Added

  • Structured (JSON-per-line) logs from pino, bunyan, zap, logrus, and similar loggers are parsed instead of shown as a raw blob: message/msg/text, level/severity (including pino's numeric levels), and request_id/trace_id-style fields populate the normal log columns. The full JSON stays available — pretty-printed in the log details overlay (enter), searchable, and unabbreviated in persisted session files and log exports.
  • The Services screen's env var list shows the full variable name and value (no more truncation at a fixed column width), sorts variables set directly in that service's own environment.vars/environment.defaults to the top in a distinct color (ahead of ones supplied by dotenv, a profile, secrets, plugins, or runtime injection), and clicking a variable opens a scrollable panel with its full, wrapped value.
  • examples/demo-platform gained a telemetry service (in minimal/backend/full) that emits pino/zap-style JSON-per-line logs instead of plain text, specifically to exercise the Logs screen's structured-log parsing.

Changed

  • The Logs screen's level column shows — instead of UNKNOWN for a line with no recognizable severity keyword — the common case for plain stdout/stderr output, not an anomaly worth shouting about.

Fixed

  • findPortHolder no longer crashes with an unhandled "Executable not found in $PATH" error when lsof or fuser isn't installed (common on minimal containers and some WSL/Linux images). It now degrades to "holder unknown" instead.
  • The Proxy screen's "No proxy routes" empty state (and every other Banner/EmptyState, e.g. the Credentials screen's action banner) no longer clips its text mid-sentence in narrow panes. Banner had a hardcoded height of 2-3 content rows regardless of how many lines the body/hint actually wrap to; it now sizes to its wrapped content, matching the pattern already used by the proxy "no requests yet" panel next to it.
  • Stopping a service could get permanently stuck showing STOPPING instead of STOPPED, and — because a stop plan stopped dependents wave by wave and one wave's failure aborted the rest — every other service queued behind it in a multi-service stop never got touched at all, still showing RUNNING. A stop plan now attempts every wave regardless of earlier failures, and a service whose kill genuinely fails lands on FAILED (with the error visible) instead of being stranded in STOPPING forever.
  • Stopping a service while it was still in its slow pre-spawn phase (resolving cloud identity, environment, or a pre_start hook) had no effect: the state briefly flipped to STOPPED, but the in-flight start wasn't actually cancelled, so it went on to spawn the process anyway and silently flipped the state back to RUNNING once it finished — the stop never stuck.
  • The Profiles screen's per-profile service list was a single non-wrapping line — a profile with enough services just got clipped mid-name with no indication more were listed. It now wraps across multiple lines.

38 / 0.2.2

0.2.2

See the diff

Hot Fix

  • Improve target detection in the npm packagining.

39 / 0.2.1

0.2.1

See the diff

Added

  • TUI /run <task> and /exec <service> -- <command…>. Task and exec output go to Logs (task:<name> / <service>:exec). /exec <service> --print-env loads the same resolved environment as the CLI (dotenv, profile, secrets, plugins, runtime ports), redacted unless /reveal or --reveal.
  • TUI /diff (config provenance), /daemon (supervisor bootstrap stderr), /auth login and /auth logout, /update (GitHub Releases check; does not overwrite the binary). /version runs that same check after printing the current version.

Changed

  • Documentation matches 0.2.0: empty start and --detach, the demo data profile, MCP/skills indexes, proxy auth types and WebSocket upgrades, Doctor container checks, CONTRIBUTING.md, SECURITY.md, GitHub issue/PR templates, and in-repo agent-skill pointers.
  • TUI log wrap and copy strip ANSI so CSI sequences do not consume width or appear in the clipboard.
  • Proxy route list wraps match and upstream instead of clipping them; devctl proxy status prints the host/path match.
  • Idle dashboard shows leftover PIDs from the previous supervisor session (the same persisted state devctl status prints when the socket is down) without skipping TUI auto-spawn.
  • Config screen lists named tasks.

Fixed

  • /auth login suspends the OpenTUI renderer before spawning gcloud, so ADC login output no longer overwrites the TUI. The TUI is restored when gcloud exits.
  • npm launcher (bin/devctl.cjs) now uses universal CommonJS syntax compatible with Node.js 10+ — eliminates SyntaxError: Unexpected token '?' when the script is executed by an older Node distribution (e.g. the system Node bundled with a Windows installer and run from inside WSL). Running devctl after installing the Windows npm package from inside WSL now prints an actionable error with clear reinstall instructions instead of failing silently.

40 / 0.2.0

0.2.0

See the diff

Added

  • Native Docker and Podman services with image, container-port, environment, volume, log, health, restart, shutdown, and adoption support. Published ports bind to loopback, container exit codes feed restart policy, and the demo's opt-in data profile includes PostgreSQL without adding Docker to its default profiles.
  • Service pre_start and post_start hooks plus named one-off tasks through devctl run. Hooks use the service's resolved execution context and do not rerun during automatic recovery.
  • devctl exec and the exec_service MCP tool for commands or redacted environment inspection in a service's exact working directory and resolved environment.
  • Realistic health startup controls (start_period_seconds, unhealthy_threshold, healthy_reset_threshold) and per-dependency service_started / service_healthy conditions.
  • Configuration provenance across main, modular, home-local, repository-local, and synthesized layers. devctl config diff and get_config_sources explain the winning value and everything it shadowed.
  • A versioned, validated plugin SDK covering environment, health, identity, tokens, logs, and proxy middleware. Custom identities carry provider-owned configuration and token keys; the included generic OIDC plugin supports discovery and client-credentials tokens.

Changed

  • Modular service and profile files load in deterministic filename order, and schema/strict-loader parity is enforced by tests.
  • The demo platform exercises containers, tasks, hooks, and health-gated dependencies while preserving its existing container-free onboarding path.
  • Containers receive declared profile, dotenv, keychain/secret-manager, service/container, plugin, and safe runtime environment layers without copying the developer's complete shell or devctl's internal token into inspectable container metadata.

Fixed

  • Doctor recognizes ports owned by running container services and never offers to terminate the Docker or Podman host process as a remedy.
  • Completion now includes every public top-level command and the config diff subcommand, with a test binding completions to the CLI declaration.

41 / 0.1.5

0.1.5

See the diff

Fixed

  • Proxy WebSocket upgrades now use the same route matching, identity injection, middleware, request logging, and statistics as ordinary HTTP traffic, restoring HMR and other upgraded connections behind devctl routes. Active upgraded sockets are closed during proxy shutdown so devctl down cannot hang.

42 / 0.1.4

0.1.4

See the diff

Fixed

  • The npm Trusted Publishing step now marks the release tarball as an explicit local path, preventing npm 11 from interpreting it as a GitHub repository shorthand.

43 / 0.1.3

0.1.3

See the diff

Added

  • Public @amr-m-abdelgawad/devctl npm distribution: npx and global npm installs run the complete CLI/TUI through a package-local official Bun runtime, so users need only Node.js and do not download an unsigned devctl executable. Releases use npm Trusted Publishing/provenance, a strict five-file package allowlist, and clean-install smoke tests.
  • GitHub Release binaries now include SHA-256 checksums and build-provenance attestations. They remain explicitly documented as unsigned Apple/Microsoft alternatives.

44 / 0.1.2

0.1.2

See the diff

Added

  • Agent skill for onboarding a repository to devctl (skills/devctl-onboard): a procedure for surveying what a repo actually runs — docker-compose, Procfile, per-language project files, Terraform, Kubernetes manifests, .env files — and authoring a .devctl for it, plus two reference files covering the signal-to-service mapping and the rules the config loader rejects on that the JSON Schema does not state. Installable for Claude Code, Cursor, Codex, and Kilo Code; skills/README.md has the per-agent setup.
  • MCP get_setup_guide serves that same guide (sections procedure, authoring, discovery) directly from the binary, so an agent connected to devctl's MCP server can onboard a repository with nothing installed.
  • MCP validate_config returns the exact issues the loader would report. With no arguments it validates what is on disk; passing text validates a candidate config.yaml through the real load pipeline — modular services and profiles, overlays, templates — before it is written. Validation was previously reachable only through the CLI.
  • Setup mode. devctl mcp --on now works in a repository that has no .devctl at all: the daemon boots without a configuration so an agent can be pointed at the MCP server and asked to create one. Nothing is validated and no service can start until a configuration exists; get_status reports setup_mode: true so an agent can tell that state apart from a daemon that failed to start anything. Setup mode clears on the reload that finds a valid configuration, and .devctl/ starts being watched from then on. A configuration that exists but is invalid still fails loudly, so a broken config is never silently replaced with an empty one. Every other command still fails closed with "no devctl configuration found."
  • MCP tools can be enabled and disabled individually. The TUI's MCP page lists them grouped by purpose (inspect, logs, diagnostics, control, setup), each marked read or write, and space toggles the highlighted one — the common case being turning off the whole control group so an agent can read status and logs but not start or stop anything. Everything is on by default. A disabled tool is left out of tools/list and refused if called anyway, since an agent may still hold a tool list from before it was turned off; the refusal names the tool rather than reporting it as unknown. The setting is a deny-list (mcp_disabled_tools in tui.json), so a tool added by a later version is available without editing anything, and the daemon applies it at boot the same way it applies mcp_enabled. An agent cannot change it: mcp_set_tools is a local RPC and is deliberately absent from the MCP host surface.

Fixed

  • The reload warning for settings a running daemon cannot pick up (log capacity and persistence, auth refresh threshold, plugin paths) advised devctl stop && devctl start, which cannot work: stop deliberately leaves the daemon running and only down ends it, so following that advice restarted the services and left the daemon holding the stale settings. Both the daemon's log line and devctl reload's note now say devctl down && devctl start, and share one formatter so they cannot drift apart again.
  • docs/configuration.md's overlay precedence diagram was drawn in the opposite direction to its own "later sources win" caption, and showed ~/.devctl/config.local.yaml overriding the repository's own .devctl/config.local.yaml. The loader does the reverse — the repo-local overlay gets the last word.
  • The compiled-binary CI smoke test removed its temporary directory immediately after signalling the supervisor, racing the daemon's own state and log flush and failing with "Directory not empty" after the test itself had passed. It now asks the daemon to stop, waits for it, and preserves the script's real exit status so a cleanup problem cannot redden a passing build or hide a failing one.
  • The Windows CI job failed the setup-guide drift check on every section. JavaScript normalizes line terminators inside template literals, so on a CRLF checkout the text compiled into the binary was LF while the file on disk was CRLF. Line endings are now normalized when the guide is generated — keeping that generated file byte-identical whichever platform runs the script — and when it is compared.

Changed

  • The TUI's MCP page puts the tool list directly under Server, above Copy agent config, grouping the things you tune and leaving the copy block at the bottom.

45 / 0.1.1

0.1.1

See the diff

Changed

  • tui.json's cursor, scroll_acceleration, diff_style, and attention fields are no longer documented or included in the starter file — none of them were ever wired up to anything the TUI reads. Parsing them is unchanged, so a tui.json that already sets any of them keeps loading cleanly.

Fixed

  • Configuration merging (root overlays, local overlays, modular per-service files, and template inheritance) now checks whether a raw YAML key was actually present instead of comparing its decoded value against a zero default, so an explicit false, 0, or [] is applied instead of being silently discarded as "not set." Several latent instances of the same bug are fixed alongside it: proxy.enabled/proxy.token_endpoint.enabled, the four logs.persistence.* fields, auth.refresh_threshold_seconds, ui.keymap, and secrets.extra_markers/extra_patterns could all be unconditionally overwritten or wiped by an overlay that didn't repeat every sibling field.
  • devctl logs export --output <path> failed every invocation with "required option '--output <path>' not specified" regardless of what was passed: the parent logs command's own (optional) --output claimed the value before the export subcommand's own (required) copy ever saw it. Both devctl logs --output <path> and devctl logs export --output <path> now also resolve a relative path against the CLI's own working directory before sending it to the daemon, instead of the daemon's — a long-running background process that can have an unrelated one — matching how the TUI's own /export already worked.
  • mcp_start (devctl mcp --on with no --port, or any on-demand MCP toggle) now falls back to the saved mcp_port preference before the repo-derived default, matching what daemon boot already does; it previously ignored a previously chosen port whenever MCP was started outside the boot path.
  • The dotenv family now loads .env.local after .env.development, so a developer's personal, gitignored .env.local correctly outranks a checked-in, team-shared .env.development instead of being silently overridden by it.
  • A proxy route with auth.type: none now ignores any leftover auth.identity, matching how the proxy's own request handling already treats "none." A stale identity left over from a template or an earlier config could otherwise make service-account bookkeeping — and, transitively, Google Cloud/ADC preflight checks — require an identity the route will never actually use.
  • --config pointing directly at a .devctl directory now resolves the repository root to its parent correctly on every platform, including Windows, and even when that directory already contains config.yaml (the ordinary case) — both a hardcoded /-splitting path check and a check-ordering bug previously made this resolve to the .devctl directory itself instead.
  • config.yaml's ui.keymap is now actually applied to the TUI's keybindings, below every tui.json layer; it was previously parsed and summarized on the config screen but never affected any real keybinding.
  • The TUI's /clear command now only clears its own on-screen log view. It previously also cleared the daemon's one shared log buffer, so any other attached client — another TUI session, the CLI, MCP — lost their log history too whenever one client cleared theirs; the logs_clear RPC is removed, since nothing else in the codebase used it.
  • Copying logs (and exporting them locally, without a daemon attached) now matches every currently active filter — regex search, source, multi-service selection, the since/until window, and the system-logs toggle — instead of an incomplete, separately reconstructed filter that could silently copy or export different lines than what was actually on screen.

Added

  • File credential fallback stores metadata only; the access token stays in the keychain or the session cache.
  • Windows process inspect fills cwd and samples WorkingSet/CPU.
  • TUI /until and devctl logs --until.
  • Starter config writes a $schema comment for yaml-language-server.
  • Start-plan identity preflight: missing SA or ADC fails that service only.
  • devctl completion zsh|bash|fish and hidden __complete for live service/profile names.
  • CI compile smoke (bun build --compile linux-x64) and windows-latest test job.
  • Homebrew formula at homebrew/devctl.rb (brew install --formula <raw url>).
  • devctl update reports the latest GitHub Release and an install hint; it does not overwrite the binary.
  • Config screen validate/save buffer (v / /buffer); invalid YAML is not written.
  • Per-service proxy route fragments merge into the global proxy at load.
  • Shared withRetry for token mint and doctor live probes; configuration errors are not retried.
  • JSON Schema for .devctl/config.yaml (schema/devctl.config.schema.json) so editors and agents can complete field names.
  • start_services / devctl start with no names use the given profile, then the active session profile, then the first configured profile. They no longer start every service. With no profiles and no names, start fails closed.
  • MCP get_logs accepts since and returns next_since so agents can follow new lines instead of re-pulling the last 200.
  • CLI devctl logs --since <timestamp>.
  • GitHub Actions release workflow: tagged v* builds compile Bun binaries for macOS, Linux, and Windows.

Changed

  • Windows attach is documented as a named pipe (\\.\pipe\devctl-<repoID>). Unix still uses devctl.sock.
  • Removed leftover Go .goreleaser.yaml.

Added

  • devctl setup --force overwrites an existing configuration; without it, setup prints the existing path and writes nothing instead of re-prompting for answers it would discard. setup now honors --config.
  • CI and the release workflow run an end-to-end supervisor smoke test (start / status / stop) against the compiled Linux binary.

Fixed

  • Release (and CI compile smoke) install OpenTUI native packages for every OS/CPU before bun build --compile, so Darwin and Windows targets resolve @opentui/core-<platform>.
  • Compiled-binary installs: the supervisor daemon spawned by start/attach now starts correctly instead of the compiled executable mistaking its own first CLI argument for a Bun script path. Its bootstrap stderr is captured to a repo-specific log, whose path is reported if it fails to come up.
  • A losing concurrent devctl start for the same repo could delete a still-live peer's supervisor socket before discovering the lock was already held; the lock is now acquired first.
  • A crash- or unhealthy-triggered restart scheduled just before a service was stopped, or right as it failed outright, could still fire afterward and resurrect it; those timers are now cancelled on stop, fail, and shutdown.
  • A service that failed its startup health check could be resurrected by the crash-restart handler reacting to the kill fail() itself performed.
  • A health check whose command failed to spawn, or whose plugin check rejected, could crash the check loop instead of reporting unhealthy.
  • Proxy: the client's Host header no longer leaks to the upstream request; responses compressed with an encoding devctl negotiated (gzip, deflate, br) are decompressed correctly instead of the client receiving a body that no longer matches the forwarded Content-Encoding/Content-Length. X-Forwarded-For/Host/Proto are now set.
  • The TUI config buffer's validate/save path now runs unsaved edits through the real modular/overlay/template pipeline instead of a simplified reimplementation that could pass or fail differently than an actual save.
  • The TUI's "no configuration found" setup prompt no longer offers to run setup — silently overwriting the file — when the real problem is an existing-but-invalid configuration; it shows the real error instead.
  • Quitting a locally-run (non-daemon) session with detach now reliably persists the running services' state before exiting, so they're adoptable by a later devctl start/status, and the process exits promptly instead of hanging on the detached services' inherited log pipes.

Added

  • Client/daemon handshake: ping reports {session, version, protocol}; an incompatible daemon blocks ordinary RPCs (except logs) with a hint to run devctl down and start again.
  • config_snapshot RPC returns the daemon's real in-memory configuration (local RPC only; never exposed through MCP).
  • Daemon discovery falls back to a state-directory scan when .devctl has been deleted but a daemon is still running, so a deleted config directory can no longer orphan a live daemon. devctl status --repo <path> targets a repository directly, even without a loadable configuration there.
  • devctl down (and devctl down --keep-services) stops the daemon, and by default its services; --keep-services stops only the daemon.
  • MCP now boots from the saved mcp_enabled/mcp_port preference at daemon startup itself, regardless of whether the daemon was spawned by the CLI or the TUI.
  • start/restart forward the calling CLI/TUI's own environment to the daemon as client_env. The daemon remembers it per service, in memory, so a later crash- or health-triggered restart reuses it instead of the daemon's own environment (which is otherwise a stale snapshot fixed at whenever the daemon was first spawned).

Changed

  • The proxy no longer binds at daemon startup. The first start() binds it if enabled; an explicit proxy stop suppresses that auto-start (sticky across further starts) until an explicit proxy start.
  • devctl start --detach is deprecated: the daemon already outlives the command regardless of the flag, so passing it now only prints a deprecation warning. Docs point to plain devctl start and devctl down instead.
  • The TUI no longer runs an in-process supervisor as a fallback. It always locates and attaches to a real daemon first, spawning a fresh one only when none is reachable, and its effective configuration is always the attached daemon's config_snapshot — refetched on ConfigurationChanged. A failed reload shows a persistent banner under the nav bar instead of a transient status line.

Fixed

  • devctl status and devctl down silently ignored the global --config flag (only --repo worked); both now honor --config the same as every other command.
  • The TUI could fail to open at all when the on-disk configuration was invalid or deleted, even with a live daemon still attachable. It now attaches independent of local configuration validity, falling back to local parsing — to spawn a daemon, open setup, or report a real error — only when no daemon is reachable.
  • Bun.spawn()'s default environment is a snapshot of this process's own process.env taken at its own launch, not a live view of it; the supervisor spawned for start/attach now receives the caller's live environment explicitly, so runtime env changes (e.g. Google Cloud metadata-server detection overrides) reach it correctly.

Added

  • restart --cascade (and MCP restart_services' cascade argument) also restarts a service's transitive dependents; a plain restart touches only the service named.
  • Reload reconciles running services against the new configuration: a newly added service appears immediately as stopped; an already-stopped removed service is forgotten; a still-running removed service is marked orphaned — stoppable by name, but no longer restartable or reachable by a cascade. A reload referencing a health or identity plugin type nothing provides is rejected outright, the same as an unparseable config file.
  • Runtime reports non-secret per-service launch context: profile, env_source (client or daemon), and orphaned.
  • Identity status gains service_account_status (unknown / available / unavailable) per configured service account, alongside the existing boolean compatibility map (which now omits identities nothing has probed yet, rather than defaulting them to unavailable).

Changed

  • Breaking: devctl stop x (and a cascading restart x --cascade) now stops x and its transitive dependents — never its dependencies, which other running services may still need. Previously it stopped x's dependencies instead, which was backwards.
  • Service-account probing is lazy and cached, not automatic: a real token fetch per configured identity now only happens on first use (a service actually starting under it), an explicit auth_refresh, or a doctor inspection — never on the daemon's own boot or after a reload, which only ever refresh ADC/user/project metadata.

Fixed

  • A slow health check still in flight when its service crashed and restarted under a new pid could land on and corrupt the newer process's state instead of being recognized as stale.
  • A port-assignment conflict discovered while assigning a later service in the list could mark an unrelated, earlier-pending service failed instead of the service the conflict actually named.
  • A service's restart count stayed maxed out across a manual stop/start (or the stop/start half of a client-requested restart), so the very next crash could fail it outright instead of getting a fresh retry budget; it now also resets after the service has run healthily for long enough. An automatic, health-triggered restart still preserves the count across its own stop/start cycle, so max_retries remains an actual limit.
  • Supervisor state was persisted only once, at the very end of a whole start or stop plan: an earlier wave's successfully spawned processes, and a crash-restart's respawn (which never goes through the batched path at all), could be lost to a daemon crash instead of being adoptable on the next boot. State is now persisted right after each spawn, adoption, exit, and failure.
  • Adopting an already-listening service via its configured port stamped its start time as "now" instead of the real, already-verified persisted start time, understating its uptime and poisoning the record a future adoption would check identity against.
  • Starting a different service under a different profile could silently change what environment an unrelated, already-running service's next crash-restart resolved with, since both read the same daemon-wide fallback; each service now keeps its own last-used profile and environment.
  • An adopted process's command-type health check ran with a completely empty environment, unable to even resolve PATH to find its own executable; it's now reconstructed from the same configured, reproducible sources (profile, dotenv, defaults/vars, secrets, runtime) a fresh start would use.
  • An unhandled error on an accepted RPC client socket (an abrupt disconnect — killed, crashed, a network blip) crashed the whole daemon; it's now logged and handled like an ordinary disconnect.

Added

  • devctl logs -f, devctl status --watch, and devctl daemon logs [-f] follow their output live from the terminal until interrupted.
  • Log queries are cursor-paginated instead of unbounded: a page defaults to the latest 500 matching events (maximum 5,000), identified by an opaque cursor that supports paging both backward (older) and forward (newer); since/until timestamp filters keep working alongside it. CLI, TUI, and MCP's get_logs all use it.
  • Server-side log facets: total matching events, plus per-service/level/source counts (each under every other active filter but its own), via a new lightweight stats-only query. The TUI refreshes them live every two seconds while its logs screen is open, and immediately after a filter change, a clear, or reconnecting.
  • The detached supervisor's bootstrap log keeps a short rotated history (the 5 most recent boot attempts) instead of each new attempt silently overwriting the last one's stderr.

Changed

  • The TUI's logs screen fetches a bounded page instead of the entire matching log history on every filter change, and only fetches further back into history as you actually scroll there; live-streamed events keep arriving incrementally on top, with no duplicate or dropped events among ones sharing the same millisecond.
  • MCP get_logs follows by an opaque cursor/next_cursor instead of since/next_since; a plain timestamp cursor could duplicate or drop whichever of several same-millisecond events landed on the wrong side of a page boundary, which a sequence-based cursor cannot. since/until are now plain inclusive filters for a fresh query rather than doubling as a follow mechanism. The response also gains has_more (more events are already waiting — fetch again immediately rather than waiting out the poll interval) and session_changed (the daemon restarted since the given cursor was issued, so the latest page was returned instead).

Fixed

  • devctl status --watch and devctl logs -f no longer crash (and could hang indefinitely retrying the same failing write) when their output is piped into something that closes early, like | head, or a terminal that goes away mid-stream — writing to a closed stdout now ends that command quietly instead.

46 / 0.1.0

0.1.0

See the release

Added

  • TypeScript / Bun application: supervisor, TUI, CLI, and localhost MCP on one session.
  • Demo platform (examples/demo-platform) that runs without Google Cloud.

The format follows Keep a Changelog and Semantic Versioning.

LESS FRICTION. MORE FORWARD.

Get your stack together.

Start your first session

Free and open source · MIT licensed

Released under the MIT License. Use at your own risk.