Repository navigation
Release 0.5.0: faster startup, agent mode, streaming and one transport - #165
Open
dg-coreylweathers wants to merge 88 commits into
Open
dg-coreylweathers wants to merge 88 commits into
dg-coreylweathers wants to merge 88 commits into
Conversation
dg-coreylweathers
requested review from
GregHolmes and
deepgram-kiley
as code owners
November 7, 2026 02:39
This was referenced Nov 7, 2026
Adds bench/, a Docker-based harness that times dg and a pinned set of other CLIs and MCP servers, and checks the results against gates.json. - hyperfine -N timing for --version, --help, subcommand help, cold help (page cache dropped), MCP initialize and tools/list, data-path rows against a loopback fake API, install size and interpreter floors - deterministic rows: connect() count under strace plus a never-answering proxy, import allowlist and forbidden-module lists, source compiles under PYTHONVERBOSE - rows fail closed: a non-zero exit, unparsed interpreter output or an untraceable command records an error, never a value - every measured container runs with --network none; competitor pins are versioned and sha256-verified, base images pinned by digest - check.py: gates activate at their since version; timing gates are report-only until marked blocking; paired base/head rule max(10%, 3xMAD, floor), with failed rounds counted as red - a release profile (dg only, rows that can block), a self-test against a fake CLI, and unit tests - competitive-bench.yml (SHA-pinned actions, read-only permissions, no secrets), callable with a ref for a future release job
deepctl.main now imports only sys and os and peeks at argv:
- `dg --version` prints the version constant, byte-identical to 0.4.x
("deepctl, version V" for every console script), and exits 0.
- `dg mcp` (stdio, no other options) runs deepctl.mcp._fast, a
stdlib-only loop that answers initialize, ping and tools/list from
fixed local text and a local tool table. Every other method
(server/discover included) gets -32601 locally and never causes a
connection. The first tools/call after initialize goes to
deepctl_cmd_mcp.serve_after_handshake, which replays the handshake to
the local MCP server in deepctl_cmd_mcp.proxy. If the proxy can't
start, that call gets an isError result and the server keeps running:
"dg mcp can't start the documentation search: ..." for local causes,
"dg mcp couldn't reach Deepgram's documentation search: ..." for
network ones, and 0.4.1's E_KEY_UNTRUSTED + "A person must fix this
file..." when an agent session left the config file's key unused
(config notices are flushed to stderr first). stdin EOF exits 0.
- Everything else imports deepctl._cli (the old main.py) and runs Click.
Also:
- deepctl/__init__ is stdlib-only and exports no `main`; new
deepctl/__main__ makes `python -m deepctl` (and -I) work.
- `dg --version` into a closed pipe or with stdout closed exits quietly.
- main() pops DEEPCTL_LAUNCHER first thing into deepctl.main.LAUNCHER.
- The rich traceback hook (show_locals=False) loads only when an
exception reaches the top level.
- --timing counts from main()'s first line (startup_imports).
- Past --version, main() marks httpx._main as unavailable, so httpx uses
its stub CLI and skips pygments and rich.syntax.
- Commands load inside run()'s error handling, not at import. An
unreadable HOME, plugin venv or cache gives a single warning line
("WARN: plugins unavailable: <reason> (while loading <what>)"); other
plugin errors keep their per-plugin message. Startup notices skip an
unreadable cache; `dg --help` and `dg <command> --help` run without a
readable config (help for a group's subcommands still needs one), and
the telemetry help notice says off when it can't read one.
Old names (cli, load_commands, helpers) still resolve from deepctl.main
(reads only; patch deepctl._cli). git shows _cli.py as a new file, not a
rename of main.py.
deepctl_core/__init__ maps each public name to its submodule through a
PEP 562 __getattr__, so `import deepctl_core` imports nothing else and
`from deepctl_core import Config` imports only config. A TYPE_CHECKING
block keeps the names visible to mypy, pyright and griffe, __dir__ lists
them, `from deepctl_core import *` still resolves all of __all__ (which
is unchanged), and submodules stay reachable as attributes. The module
__getattr__ hooks exist only at run time, so type checkers flag a
misspelled name instead of typing it Any.
Import moves only, no logic changes:
- client.py: the Deepgram SDK loads on first use (_sdk() binds DGClient,
DeepgramClientEnvironment and ApiError; a module __getattr__ keeps
patch("deepctl_core.client.DGClient") working).
- auth.py: httpx and keyring load when the first AuthManager is built,
and `deepctl_core.auth.keyring` is still the real module, so patches
of it behave as before; PasswordDeleteError and rich.progress move
into the functions that use them.
- output.py: console and stderr_console are built, and rich imported, on
first use; rich renderables are imported where they are rendered.
With the httpx CLI guard in main(), `dg --help` no longer loads
pygments.
Every first-party package (the root plus the 29 packages under packages/, not the plugin example) now ships at the root's version, and every first-party dependency is pinned `==` that version. - release-please has one component for all of them. Its extra-files move each package's version and every pin line marked `# x-release-please-version`, so a release PR proposes one version and one tag. The packages no longer get their own GitHub releases. The plugin example stays its own component, with the root excluding its path. - Every package declares `deepctl==` the same version. Without that back-edge, `pip install -U deepctl-cmd-listen` upgraded only that package and left the CLI and the other packages behind; with it, the resolver moves the whole set or refuses. - deepctl-cmd-plugin now depends on deepctl-cmd-update, which it imports. - scripts/check_dependency_floors.py becomes the lockstep check: it fails on any version or pin that differs, a `>=` floor, a missing back-edge, a package root does not list, an unmarked pin line, a missing workspace source, or a first-party requirement it can't read line by line, and `--fix` repairs what it can. The CI job is now "Lockstep versions". - uv.lock is regenerated (uv 0.13.6); generated package READMEs no longer list first-party pins. Mixing an older deepctl-cmd-* with a newer deepctl now conflicts; upgrade deepctl itself. Release PRs are re-pinned in two jobs, so no third-party code runs next to a write credential: - sync-release-pr has no token and no secrets. It runs the lockstep check with --fix, `uv lock` and the check again with uv 0.13.6 and a hash-pinned `packaging`, refuses any change outside the pyprojects and uv.lock, and hands the result over as a patch artifact. - push-release-pr runs no Python and no uv. In the `release` environment it mints a token from the release GitHub App, scoped to this repository with contents: write only, applies the patch on the commit it was computed against, checks the patch touches only the pyprojects and uv.lock, and pushes. The token reaches git through GIT_CONFIG_* environment entries, never a URL, an argument or .git/config, with hooks off. Commits pushed with the App token trigger the PR's workflows, so the required checks run on the release PR's final head; a GITHUB_TOKEN push triggered none of them. Both jobs refuse anything but a release-please branch. The App's client ID is the RELEASE_APP_CLIENT_ID variable and its private key the RELEASE_APP_PRIVATE_KEY secret of the `release` environment, which only main may deploy to. For tests, tests/upgrade/run.sh runs the upgrade matrix behind the lockstep layout against a local PEP 503 index, in --network none containers: - lockstep: fresh installs with pip, uv pip, uv tool and pipx resolve exactly the 30 first-party dists; `-U` of any one dist from a release moves all of them to the next one; upgrades from the field versions land on the release with every dist at the same version; and a sentry-sdk 3.0 prerelease can't be resolved into an install. - release-please: runs the release-please version the release workflow uses over the tree, offline, and checks that one release PR moves every version and pin and nothing else. - layout: the planned 1.0 layout (relocated code, tombstone packages) against the field versions. CI checks the closure at its floors. Every package declares `deepctl==` the release version, so any install pulls the whole closure. A new test.yml job (Python 3.10 and 3.12) builds every package and installs that closure once with every third-party dependency at the lowest version the 30 pyprojects together allow (wheels only). In that environment it runs `pip check`, `dg --help` and every command's and subcommand's --help, and the unit tests, one suite per process. It also lists declared floors that sit below what the closure resolves. The job is part of the "All checks" rollup.
Every package now requires click>=8.2.0. The CLI's own tests rely on click 8.2 APIs (CliRunner capturing stderr separately, and click.exceptions.NoArgsIsHelpError), so 8.2 is the oldest click the CLI is tested against; at 8.1 those suites fail even though the commands themselves still load. The root also drops its typer dependency: nothing in deepctl imports typer, so installing deepctl no longer pulls typer (or shellingham) in. click, httpx, pydantic, rich, platformdirs, keyring and websockets get an upper bound below their next major version (click<9, httpx<1, pydantic<3, rich<16, platformdirs<5, keyring<26, websockets<18), next to the existing sentry-sdk<3 and deepgram-sdk<8 caps. A new major's prerelease can then never be resolved into an install, the way a sentry-sdk 3.0 prerelease breaks every uncapped release. The caps change no resolved version in uv.lock.
Each built-in command has a fragment in src/deepctl/_manifest.d/ with its module, class, distribution, help summary, hidden aliases, whether it is a group and whether it needs auth. deepctl._manifest_build.assemble() checks the fragments and the rules across them (a command, alias or tool name may appear once; a subcommand's group must exist) and builds deepctl/_manifest.json. The setuptools build_py hook writes that file into every wheel (setuptools 77 or newer); a source tree assembles the fragments in memory instead. The JSON Schema is docs/contracts/manifest.schema.json. A missing or invalid manifest ends the run with one line on stderr and exit 1. The root group is a LazyGroup. Root --help lists the built-ins from the manifest, and a command's module is imported only when that command runs or shows its own help. Built-ins load from the manifest's module, never through an entry point, and the reserved names in plugin_manager, hidden aliases included, come from the manifest. Plugins are discovered only for a name that isn't built in, for root --help, for shell completion, for dg plugin list and for a built-in listed in plugins.allow_override, with the 0.4.1 checks unchanged. A command whose module can't be imported reports dependency_missing (exit 1, details.dependency names the distribution). Hidden aliases such as debug-audio replace the hyphenated-command preprocessor and its entry point scan; only the command word is rewritten. An alias always runs its built-in, unless allow_override lists it and a plugin registers that name. Any other <group>-<x> runs <group> <x> when <group> is a built-in group and <x> one of its subcommands, a plugin's included; an unknown one falls through to plugin discovery. DEEPCTL_EAGER_LOAD=1 loads every command up front as 0.4.x did (0.5.x only). DEEPCTL_PROG_NAME sets the program name in usage lines, python -m deepctl shows dg and leaves the working directory off sys.path. For people, a usage error starts with Click's usage and Try lines; in agent mode (CI included) it prints only "ERROR: <message>" and "HINT: Run <prog> <command> --help to see valid options." The hint for a global option given after the command names the real program and command. The dependency_missing agent hint reads "Tell the user <prog> needs <dependency>. Suggested fix: <hint>". Help runs skip the background update and AI-CLI checks. jsonschema becomes a test dependency. dg mcp's fragment has requires_auth false: it reads the key on each tool call.
Root --help lists plugin commands, which took an entry point scan and an import of every plugin on each run. The discovery result is now kept in the user cache dir and reused while its stamp holds: the mtimes of every sys.path directory and of the plugin venv under $HOME and the real home, plus agent mode and the working directory under an agent. A cache hit lists plugins without importing them, pydantic or Config. When no plugin is installed at all, a quick entry point check skips discovery. Only a result the cache can reproduce exactly is kept. A discovery that wrote to stderr (a skipped plugin, a load error) or let a plugin replace a built-in runs again next time, so its warnings show on every --help. Each row stores its summary as the paragraphs Click shows, so a plugin with multi-line help caches too. Rows are checked when written and again when read: an invalid or reserved name, or text with a character in Unicode categories Cc, Cf (other than ZWNJ and ZWJ), Zl, Zp, Co or Cn, keeps a discovery out of the cache, and a file holding one is ignored. Built-ins always win. Under an AI agent the discovery cache lives under the real home's cache directory, never one HOME or XDG_CACHE_HOME points to. For the native launcher, a root dg --help run saves its output as <real cache>/help/<version>-<prog>-<width>-<tty>.txt, with <entry>.key.json listing every file (path, mtime_ns, size, inode or absent), environment variable, width and tty state the text depends on, and text_sha256, which binds the key to the text written before it. That happens only when the launcher sets DEEPCTL_HELP_CACHE=1 (removed from the environment at startup), outside an AI agent, for a clean run (exit 0, nothing on stderr) whose stdout is UTF-8, and only if the key's inputs didn't change while the help was rendered. No key over 32 files or 64 environment variables, and no entry over 256 KiB, is written. The cache directory comes from the account (getpwuid or the LocalAppData known folder), never from HOME or XDG_CACHE_HOME. An unchanged entry isn't rewritten, and entries for other versions are pruned. docs/contracts/help-cache.md and help-cache-key.schema.json describe the contract. Cache directories are 0700 and files 0600, written through a temp file in the same directory and renamed into place; stale temp files are pruned. A cache directory that isn't a real directory owned by this user, or that its group or others can write, is neither read nor written, and any error skips the cache. The help paths also stop importing modules they don't use: yaml in deepctl_core.output loads only to write YAML, secrets in config only to name a temp file, and rich.markup in hosts only to print a warning.
dg <cmd> --help imports the command's module, so a module-level import of asyncio, ssl, httpx, websockets or the SDK was paid by every help and every run of that command. Those imports move into the functions that use them: asyncio in listen, mcp, update and debug browser/probe; httpx in api, debug audio, debug toolkit, init, the update version check and shared input validation; aiohttp in debug browser/probe; requests and urllib3 in debug network. Modules whose tests patch asyncio or httpx through the command module resolve the name lazily. deepctl_cmd_update exports its names lazily, so the per-run startup check no longer imports the update command. A test imports every built-in command module from the manifest in a fresh interpreter and fails if any of these modules loads, and another runs dg <cmd> --help for every built-in against the subcommand-help forbidden list. dg debug <sub> --help still imports httpx, ssl and keyring through the debug group callback; that check is a strict xfail.
Config(scan_only=True) reads the user file and deepgram.yaml and runs the same trust, permission, custom-key and project-file checks as a full load, queueing the same notices, but doesn't build the settings. It merges a file's data only when the merge could fail, so a file the merge can't take prints the same warning; the environment and --base-url steps, which only set values, are skipped. A scan-only Config keeps config_path and user_config_text for telemetry and raises if its settings are read. The settings models move to deepctl_core.config_models and load on first use; yaml loads when a file is parsed. deepctl_core.config and _plugin_trust import neither, and the old import paths still work. setup_output() stores quiet on the lazy console instead of building the rich Console, which is applied when the console is first used.
Each built-in fragment now carries a "help" block: its command's --help as data (help text with examples and the agent line, and every parameter with its type, flags and metavar). deepctl._help_spec turns a Click command into a spec and a spec back into a callback-less command. python -m deepctl._manifest_build --write-help writes the blocks from each command as PluginManager builds it, after rebuilding the command from the spec and comparing the rendered help at three widths. --check now also fails when a block no longer matches its command. A command that uses a Click feature a spec can't carry (a shown default, an environment variable, a custom class) is refused instead of written incompletely. assemble() stays stdlib-only. With a help directory it writes one file per command to deepctl/_help/ (the build hook does this for every wheel); the blocks never go into _manifest.json, which root --help and dg mcp parse. _manifest.help_spec() reads the file, or the fragment in a source tree. The manifest schema describes the block. dg <cmd> --help and dg <group> <sub> --help for a built-in leaf now build the command from its help spec instead of importing it, and the root callback only scans the config files (Config(scan_only=True)). Click still renders the help, so width, program name and the telemetry footer are unchanged, and every config notice prints as before. The spec path is taken only for exactly those argv shapes, for a leaf with a spec, when plugins.allow_override lists neither the command nor its group, outside DEEPCTL_EAGER_LOAD, and for a subcommand only when every entry point its group would scan is the built-in's own; that scan reads every distribution, so a "yes" is kept next to the discovery cache for the same plugin stamp, with the same directory and agent rules. Group help, a command without a spec and everything else take the normal path. The spec path never writes a help cache entry. Intended changes: a built-in whose package can't be imported still prints its help (running it still reports dependency_missing), and no command module is imported for a leaf's help. dg debug <sub> --help with an unreadable HOME now exits 0, lazy or eager: Click runs the debug group's callback before the subcommand parses --help, and that callback built a Config, which failed. When the root found no readable config for a --help run, a group callback now leaves the subcommand to show its help (a subcommand that runs still fails on its own Config). The toolkit subcommand also treats an unreadable cache as an empty one instead of failing to load. New tests compare spec help with the lazy path and with eager loading across every leaf, width, output environment and config fixture. CI now runs the debug-toolkit package tests.
dg login no longer checks for an sk-/pk- prefix: Deepgram keys don't have one, and login_with_api_key already verifies the key with the API. A 40-hex key used to stop with "Login cancelled" whenever no one could answer the "Continue anyway?" prompt. The two login confirmations (DEEPGRAM_API_KEY is set; the profile already has a saved login) are asked only of a person at a terminal. In any other run they return confirmation_required, exit 1, with details.flag "--yes" and a next_step that reruns the command with --yes, or null when the command line holds a key. They no longer return "cancelled", which is kept for a person saying no. -y/--yes answers both; -f/--force-write stays as the same flag. BaseCommand gains can_prompt(), the check confirm() already made, so a command can tell "no one can answer" from "the answer was no".
In an AI agent session (or with DG_AGENT=1, --no-browser, or neither
stdin nor stdout a terminal), dg login now asks for a device code,
saves it and returns at once with the link and code (status "pending",
exit 0). dg login --complete checks once: auth_pending
with details.retry_after until the user approves, then saves the key
as before; auth_denied or auth_expired otherwise, and auth_expired with
next_step "dg login" when no login is waiting. --wait keeps 0.4.0's
behavior of polling until done; a person at a terminal still gets it
by default.
The pending code lives in {state_dir}/login-pending.json: written 0600
through O_CREAT|O_EXCL|O_NOFOLLOW and an atomic replace, in a 0700
directory, holding the device code, interval, expiry, login host and
profile, never a key. state_dir() is ~/.deepctl, the one state-dir
helper later changes reuse. In an AI agent session the file must be
owned by this user with no group or other write bit; otherwise it is
ignored with one WARN: line and left in place. A code is only sent back
to the login host that issued it.
Before any request, login checks that it could save the result: an
unwritable state or config directory is state_unwritable
(E-STATE-UNWRITABLE); in an AI agent session, a user config dg won't
save over is state_unwritable with details.reason "untrusted_config".
Both come before the --yes confirmations, and both apply
to --api-key and --complete too. A home dg can't write no longer stops
every command while Config picks its default path.
The token poll is a POST to the RFC 8628 token endpoint, so the device
code never lands in a URL, and a fixed device name replaces this
machine's hostname. Polls are at least 6 s apart whatever the server
asks; slow_down, 429 and server errors back off by 5 s or the server's
Retry-After, and --wait keeps polling through them until the code
expires. A device-code answer that isn't a JSON object is
network_unavailable. The login link must be https (or loopback http).
Status lines go to stderr. In mode A they are dropped, and the link and
code are an INFO: line; a person's terminal shows the same line instead
of the separate code and link lines. --complete refuses --profile for
another profile than the pending login's, and --save-base-url. The
legacy config migration prints to stderr.
Every authed command listed projects before its real request, twice
(once in BaseCommand.execute and again when the SDK client was built),
on a second TCP connection. That cost two round trips per command and
31 s on a stalled network.
- guard() checks only that a key is present. The host check still runs
first, before any key is read, and the missing-key (auth_required) and
E_KEY_UNTRUSTED paths are unchanged. The removed check's error text
("Network error during verification: ...", "Your API key may have
expired or been revoked.") is gone.
- New deepctl_core.transport: one httpx.Client per process, created on
first use and passed to the SDK as httpx_client. It carries no base
URL, no credentials and keeps no cookies; DeepgramClient still takes
the base URL from hosts.check_credential_host(). Its timeout matches
the SDK's own client (60 s). It follows no redirects, as 0.4.1's SDK
client doesn't, and a 3xx keeps 0.4.1's refusal.
- Request failures map to the catalogue's transport_mapping codes: DNS,
connect, TLS, reset, a proxy refusal, a cut-off body (E-NETWORK-CUT)
or a 2xx body that won't parse (E-NETWORK-UNREADABLE) ->
network_unavailable; timeout -> timeout; 401 auth_invalid; 403
auth_forbidden; 404 not_found; 429 rate_limited; 400, 422, other 4xx
invalid_argument; 5xx server_error. The SDK's own retries still run
first. Other failures keep their 0.4.0 wording.
- BaseCommand renders a mapped failure as two stderr lines (ERROR:/HINT:
in mode A, a cross and the hint otherwise) and, under a machine
format, a coded result on stdout with code, hint, next_step,
schema_version 1 and details; exit 1. details.key_source follows dg
whoami's order. The 0.4.1 coded results keep their keys.
- dg speak marks stdout as its payload stream once audio starts, so a
failure mid-stream goes to stderr only, never as JSON after the audio.
- projects --set-default still warns and asks when the key can't see the
project (404/403); other request failures stop it.
The 0.4.1 host tests that used the pre-flight as their probe now send
the command's own request through the shared client.
In mode A every stderr line carries a prefix and status chrome is suppressed. The commands that use the shared client (billing, keys, listen, members, models, projects, read, requests, speak, transcribe, usage) now run their handler with stderr filtered: only ERROR:/WARN:/INFO:/HINT:/OK:/DEPRECATED: lines, and 0.4.0 "Error"/"Warning" lines, get through. Each write is kept or dropped as a whole. --verbose keeps everything, and every other command (login's device code, dg api, debug-probe) is unchanged. Lines in those commands that a caller needs now render as warnings or errors instead of chrome, so they survive the filter: listen's Flux --diarize note, its --encoding assumption and the microphone audio warning; speak's --play note; projects' "Failed to ..." errors and the missing project_id warning. The listen and speak warnings print under --quiet too (print_warning gains ignore_quiet), since --quiet hides only INFO and OK lines.
Live stdin and microphone streams run through one session engine (deepctl_cmd_listen.live) with defined endings: - Ctrl-C (Ctrl-Break on Windows): send CloseStream, keep receiving for at most 1 s, keep the finals and --save-to, exit 2. On --mic, Ctrl-C is how the session ends: the same drain, then exit 0 with the transcript so far, also when it comes before the session has connected. Ctrl-Break stays a hard stop (exit 2) on --mic. A second Ctrl-C exits at once. The handler is signal.signal, not add_signal_handler, so it works on every platform and on Python 3.10. - SIGTERM on POSIX (docker stop, systemd, Kubernetes): the same drain, then the process re-raises SIGTERM with the default action, so the wait status stays "killed by signal 15" (143 in a shell). A second signal ends it at once. - End of input: send CloseStream and wait for the server's close under an idle deadline (10 s since the last server message) and an absolute cap (max(30 s, 2 x the audio still behind for PCM/mulaw/alaw, or 2 x the time spent sending otherwise)). If either expires the run ends with stream_drain_timeout, exit 1. Ctrl-C during the drain switches to the 1 s cap and exit 2. - A signal that arrives while the server is closing still counts: the run ends as stopped by that signal. - The server closes: exit 0, or exit 1 with server_error when it reported an error. - The connection drops: exit 1 with network_unavailable. - Whoever reads stdout goes away (`| head -n 1`, `| true`): the stream ends quietly and exits 0. Captions are flushed line by line, so they also arrive as they are produced. - stdin can't be read (a write-only or broken descriptor, EIO on a hang-up): exit 1 with invalid_argument and a hint to pass a file or URL instead. Each failure is a coded error whose details carry the finals received so far (transcript, transcript_chars, saved_to), so nothing that arrived is lost. Text the server sends back (close reasons, Flux error descriptions) is stripped of control characters and of the API key and any Token/Bearer value before it is shown or saved. Stdin is read with os.read in a daemon thread that hands each buffer to the loop through run_coroutine_threadsafe and waits for it to be taken. The thread checks a closing event before every read and hand-off, shutdown sets that event before it cancels any task, and anything raised in the thread ends it quietly. This removes the interpreter abort (exit 134, "Fatal Python error") that a reader blocked in sys.stdin.buffer caused at shutdown. The reader blocks SIGINT and SIGTERM so they always reach the main thread, and waits on a stdin left non-blocking by its parent instead of failing. Regular files, pipes, /dev/null and TTYs all work. At interpreter exit the remaining objects are frozen out of the garbage collector, so the process ends a few milliseconds after the server's close instead of about 100 ms later. The session opens through deepctl_core.websocket.open_async, the same no-redirect open as connect_async but returning the connection and taking websockets options, so a 3xx on the upgrade is still refused before a second connection (mic and stdin), as in 0.4.1. deepctl-core now declares websockets>=13.1,<18, which that module imports.
- Stdin reads take at most 50 ms of audio, computed from the encoding, sample rate and channels (whole samples; 4 KiB for encodings without a known byte rate). A pipe read returns what is there, so paced input goes up at the writer's pace: 20 ms frames at 16 kHz linear16 and at 8 kHz mulaw, where 4 KiB reads held telephony audio for about 500 ms. - The send queue holds at most 32 buffers. The reader waits for room, so a slow uplink holds back the producer instead of filling memory. - The microphone callback can't wait, so when the queue is full the oldest buffer is dropped and counted; one warning per stream reports how much audio was dropped. Mic blocks are 50 ms. - listen v1 streams send a KeepAlive after 5 s without audio, so a paused producer doesn't time the stream out. - The WebSocket is opened with compression=None: audio doesn't compress, and deflate cost CPU on every frame.
When JSON or YAML was asked for (-o json, -o yaml) or chosen because an AI-agent marker is set, a live stream writes no transcript lines, interim rewrites or live captions to stdout. The final Result object is the one document on stdout, whatever the stream's status, so `dg -o json listen < call.raw | jq` parses. With --verbose, final lines go to stderr as INFO: lines. --save-to still writes the transcript or captions. JSON implied only by the TTY/CI heuristics (cron, CI, a container with no TERM) keeps 0.4.0's live output: lines as they arrive, then the final Result, because those pipelines read stdout as a stream. To tell the cases apart, deepctl_core.output gains format_source(): "explicit", "hard_agent", "soft" or "default". Human output is unchanged. Under a hard AI-agent signal, `dg listen` with no SOURCE and no --mic is refused before authentication with invalid_argument (E-LISTEN-NO-SOURCE, exit 1) instead of streaming a non-TTY stdin until an end of input that may never come. `dg listen -` reads stdin explicitly. CI and people keep auto-detection.
Speak v1 wrote each chunk to stdout without flushing, so a pipe saw nothing until 8 KB had piled up (about 640 ms with mp3-sized chunks), and --play buffered the whole utterance before starting the player. Each chunk is now written and flushed as the SDK yields it, and --play with a stdin player (ffplay, paplay, aplay) starts the player before the request and feeds it chunk by chunk, as Flux already did. afplay keeps the buffered temp-file path. When the reader of stdout or the player goes away (EPIPE), the stream stops, the response is closed and the command ends quietly with exit 0; fd 1 is pointed at the null device so the exit flush can't fail. Any other stdout write error (ENOSPC, EIO, EFBIG, EDQUOT) is the new coded error output_unwritable: exit 1, two stderr lines, no envelope on stdout and no "Exception ignored". The same holds when the result document itself can't be written to stdout: EPIPE exits 0 quietly, any other error is output_unwritable. Closing a stream now waits at most 2 s. A WebSocket closing handshake the server doesn't answer, as on Ctrl-C mid-stream, used to hold the exit for up to 10 s; the close is abandoned after 2 s instead.
--out-file FILE writes the audio to a file without ever replacing one by accident. Before the request, the target is checked with lstat: an existing file or symlink is refused (E-OUT-FILE-EXISTS) unless --force is given, and a directory, a block device, a socket, stderr itself, a --force over a /dev/fd entry, or a missing or unwritable parent is output_unwritable, so nothing is sent or billed. A character device or FIFO is written as it is, with no --force. It is opened before the request without blocking, so a FIFO with no reader is refused rather than waited on, and the opened file must be the one that was checked: a path swapped for a regular file in the meantime is never written. A new file is opened when the first chunk arrives, with O_EXCL as the race guard, so a failed request, a cut stream or an empty one leaves no file; a partial file is removed on any later error, and a failed write is output_unwritable with the envelope on stdout. --force writes a 0600 temp file in the same directory and replaces the target at the end, keeping an existing file's permission bits (never setuid, setgid or sticky), so a symlink is replaced rather than written through. Flux still buffers its WAV so the header carries the exact length. During the request and stream, SIGTERM and SIGHUP remove the partial or temp file (an old file is kept) and then end the process by the signal; Ctrl-C does the same cleanup and cancels as before. If the unwind stalls, as in a WebSocket close the server doesn't answer, the signal is delivered again after 4 s and ends the run. A signal that was ignored when dg started (nohup, a background job, trap '') stays ignored and the file is completed. Signals that arrive while the file is created, committed or removed are held until that step is done; the most severe of them is then delivered. On Windows, dg never calls os.kill on its own process. When --out-file is stdout itself (/dev/stdout), the audio is the only thing written there (fd 1 is checked again, not reopened) and the summary goes to stderr; an explicit -o json or -o yaml is refused before the request (E-STDOUT-BYTES). -o/--output FILE keeps working with one DEPRECATED line per process, printed even under -q, and now replaces the file through a temp file, so a failed run leaves the old file intact. A bare format value (-o json) is refused with a runnable next_step, and -o together with --out-file is refused. Help, examples and the legacy hints now name --out-file. Changes to -o FILE for the changelog: a symlink at FILE is replaced, not written through; the directory holding FILE must be writable; and FILE becomes a new file with the same permission bits (setuid, setgid and sticky are dropped), so hard links to it keep the old bytes.
Under a hard AI-agent signal, dg speak with no text argument and no --file read stdin until it closed, which can be never, and audio bound for a pipe was written into whatever an agent's tool call was capturing. With a hard signal, stdin is now read only when the text is - ; anything else fails at once with E-SPEAK-NO-TEXT. Audio that would go to a stdout that isn't a terminal, a regular file or /dev/null, with no output file, --play or --list-voices, fails with E-SPEAK-NO-OUTPUT. Its next_step reruns the same command with --out-file and a new file name, and is null (with the matching hint) when the text came from stdin or the command line holds a secret. Both refusals come before the request. - as the text reads stdin in every mode. CI and people keep 0.4.0's behavior otherwise. The speak tests' person fixture lives in the root conftest, scoped to speak's tests: a second tests/unit/conftest.py would clash with dg mcp's when CI collects both.
When stdout couldn't be written during a live stream (dg listen - > /dev/full), the receive loop took the ENOSPC for a dropped connection: the run printed a network hint, then failed again flushing stdout at exit and ended with "Exception ignored" and exit 120. A stdout write error other than EPIPE, in the receive loop, while flushing Flux's last turns, or while writing the final result, is now reported as output_unwritable. fd 1 is pointed at the null device, the error and hint go to stderr, the finals received so far travel in details, and the run exits 1. EPIPE still ends quietly. A failed write of a coded error also silences stdout, so the exit flush can't fail. The stdin catch-all now takes only read errors; an error of any other kind is reported without the stdin hint. Live listen now takes die_by_signal from deepctl_core.signals, the one copy speak also uses.
A Ctrl-C in the first few hundred milliseconds, while main() imports the CLI, reached the top level as KeyboardInterrupt and printed a traceback with exit 1. It is now a cancelled run, like a Ctrl-C once run() has started: the same "Operation cancelled by user" line on stderr, exit 2, and no traceback. main() also replaces Python's default SIGINT handler with one that holds a Ctrl-C back for a moment (at most 200 ms) when it lands in importlib's own code. A KeyboardInterrupt raised there can be swallowed and leave the import lock held, so the run then hangs on its next import.
dg listen <file> read the whole file into memory before sending it, so a 500 MB file peaked at 1.6 GB RSS. The file now goes to the SDK as a FileBody (deepctl_core/upload.py) with an explicit Content-Length, read in 1 MiB chunks as httpx sends them. httpx sends no chunked encoding. - FileBody opens the file once and keeps the descriptor. Each pass reads from offset 0 of that descriptor, so the SDK's retries (connect errors, a connection dropped before the response, 408/409/429/5xx) resend the whole file, and a file swapped in at the same path between retries is never sent. - The file validated is the file uploaded: listen takes the file's (st_dev, st_ino) before validating it, and FileBody compares the opened descriptor against it. A path swapped in between stops with E-UPLOAD-FILE-CHANGED before anything is sent. - Before each chunk, and after the last, the descriptor's size and mtime are compared with the values at open. A change stops the upload with invalid_argument E-UPLOAD-FILE-CHANGED; a failed read with E-UPLOAD-FILE-UNREADABLE. Both print the two stderr lines and the coded result a transport error does, with next_step null and details.path. - A pipe or device (dg listen <(...) --no-validate) is read once at open, as before, so a retry can resend it. - The descriptor closes when the request finishes.
A read waits up to 5 s for a writer's lock on the config file, then skips the file with a warning. A command reads the file more than once, so a writer stuck holding the lock made `dg profiles --list` wait two or three times as long (about 16 s on macOS). Once a wait for a file has timed out, later reads of that file in the same run try the lock once and skip it straight away. A run now waits at most one bound.
Flux errors and the speak --model help said "Flux STT (listen v2)", "Speak v2" and "Speak v1": API names nobody types. They now name the model families (Flux models, flux-* and aura-* voices) and say what to do instead.
The stdin banner said to send EOF with Ctrl+D, which doesn't end input in a Windows console; Windows now gets "Ctrl+Z, then Enter". A refused microphone printed PortAudio's text and macOS-only advice. It now uses the catalogue's mic-denied message and hint, plus the setting's place on macOS or Windows. Other microphone errors keep their own text.
Some tests that came from earlier releases measured wall-clock time with bounds a loaded machine can miss. Each check keeps what it proves, with a wider margin: - skills: the lock wait is 2 s, so one wait (2 s) and one per tool (4 s) stay apart; a stale lock is taken well inside a 10 s timeout. - config save: one 5 s lock wait is under 8.5 s; two would not be. - FileInfo creation is measured in CPU time, 2 s for 1000 models. - dg mcp e2e: no connection attempt within 12 s (one waits 15 s).
Ctrl-C and Ctrl-D now print "Cancelled." (exit 2), and an exception nothing else handles prints "Command failed: <message>" with a next step (exit 1). In agent mode both print exactly two prefixed lines, ERROR: and HINT:, with the agent hint from the catalogue. The early Ctrl-C path in main() prints the same lines without rich or Click.
The check reads the SigIgn mask from /proc/self/status, which exists only on Linux, so on macOS and Windows it failed opening the file. It now skips there and, on Linux, always checks the mask. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ting it A plugin entry point named like a built-in command was refused before its module was imported, but one named like a built-in alias, such as debug-audio, was imported first and only refused once loaded. The check before import now includes the top-level aliases too, so none of that plugin's code runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dg login checks that the API base URL is trusted before it starts a device login, but dg login --complete went straight to polling and saving the approved key. It now runs the same check first, so an untrusted DEEPGRAM_BASE_URL or --base-url stops it before any request, and the pending login is kept for a retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The speak tests had their own autouse fixture that cleared the agent markers and DG_AGENT, which the root fixtures already clear for every test. It is removed, and the remaining fixture's docstring covers the agent output rules the speak tests relied on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments, docstrings and the manifest schema compared behavior with a version number that was never released. The release they mean is 0.4.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h uv The deepctl-core and deepctl-cmd-debug-probe READMEs didn't list the websockets requirement they now have. make readmes and make readmes-check ran the system python3, which on some machines is too old for the generator (it needs tomllib, Python 3.11+); they now use uv run python, like the other Makefile targets, and the --check message points at make readmes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/telemetry.md described error reports as sent by default. This version has no built-in destination: the reporting code runs only with a loopback DEEPCTL_TELEMETRY_DSN, for tests and local development. The page now says so up front, and describes the switches, DEEPCTL_TELEMETRY=log and the field schema as they apply to a loopback sink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test removed a built-in's package by pointing its manifest entry at a module that doesn't exist, but plugin_manager had already read the built-in module table from the real manifest. The module then wasn't the built-in's own, so the check that runs before import refused it as a plugin replacing a built-in, and the test never reached the dependency_missing error. That order is right: a module that isn't the built-in's own is never imported. The test now patches both tables, as an uninstalled package leaves them, and gets dependency_missing again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
python -m deepctl_cmd_mcp still parses the transport names an old MCP config may use, streamable-http among them, so it can print the stdio-only error instead of an argparse error. The lint that keeps shipped text from promising other transports flagged that refusal. It now skips that file for that one check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows can't open a directory with os.open, so saving a pending device login always failed there: `dg login` without a terminal returned state_unwritable and `--complete` had nothing to finish, and `dg init` warned that its progress wasn't saved and never resumed. Where a directory can't be opened, the state directory and init's records directory are now checked by path, and the files are written, replaced and read by path. Without O_NOFOLLOW, a pending login or clone record that is a symbolic link is still refused, and the file opened must be the one that was checked. The tests run the same contract with directories that can't be opened and no O_NOFOLLOW, on any OS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows before Python 3.13 there is no os.fchmod, so replacing a file (`-o existing.mp3`, or `--out-file X --force`) ended with an AttributeError that skipped the cleanup and left the .part file behind. The temp file's mode is now set only where fchmod exists. A new file is opened with O_BINARY, so a 0x0A in the audio is no longer written as 0x0D 0x0A. A device such as NUL is no longer switched back to blocking mode where it was never opened non-blocking: Windows has no O_NONBLOCK, and os.set_blocking works only on pipes there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows reports file index 0 for every pipe, console and device, so os.path.samestat called any two of them the same file. In an agent session a piped stdout was taken for NUL and given audio, and `--out-file NUL` with stderr on a console was refused as stderr. A file with no index now never matches another. Where stdout has no index, a character device (a console or NUL) takes audio and a pipe doesn't. A large listen result in an agent session now tells NUL from a console there by GetConsoleMode, instead of counting every character device as the null device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On windows-latest, pytest stopped at collection: two skip conditions called os.geteuid(), which Windows doesn't have. Past that, tests that send SIGINT to the pytest process with os.kill (which ends the process on Windows), run a #! script as a fake ffprobe (which never starts, so the test waits for ever), or check POSIX modes, owners, XDG config paths, pipes and preexec_fn now skip on Windows, each saying why. The speak output tests and the help cache tests skip there as files; the Windows paths of --out-file have their own tests. The agent-mode help golden pins the platform, and the listen result's 0600 check is POSIX only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In an AI agent session the one config that may approve hosts is the default file under the account's home. On macOS that was taken to be ~/.config/deepctl/config.yaml whenever XDG_CONFIG_HOME named the XDG default, but platformdirs before 4.6 ignores XDG_CONFIG_HOME on macOS and reads Application Support, which the declared floor (3.0) allows. The XDG default is now followed only when the installed platformdirs puts the config there too, so the trusted file is the one dg reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Tests that give a child a terminal read it while the child runs. macOS drops what is still unread once the child's side closes, and a full terminal blocks the child, so the agent-mode notice test timed out and the NO_COLOR test read nothing. - The help parity fixtures and the legacy config migration test put the config where platformdirs looks on the OS (Application Support on macOS), instead of ~/.config, and the migration test flushes notices an earlier test queued. - The socket test binds a relative name: macOS limits a socket path to 104 bytes, and the temp directory can be longer. - The login skills tests pin the console's height too, since rich uses 80 columns on TERM=dumb unless both are set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS the signal tests failed, or ended the whole pytest run by SIGTERM. os.kill on the test's own process lets the kernel hand the signal to any thread that doesn't block it, at a time it picks: while a file section blocks it in the main thread, another thread takes it, and Python could run the handler only after the speak guard had put the default action back. The deferral itself holds: a signal the main thread sees inside a section waits for its end, and one that comes later is handled as usual. The tests now send each signal to one thread, the main thread (raise_signal or pthread_kill) or a helper thread started before any section blocks it, and cover both. Every test that signals its own process runs in a child pytest process (the signals_in_child mark), so a missing handler fails that test instead of ending the run. The signal flood script raises only inside the section's own code, so its exception can't escape the loop around it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dg mcp's fast start answers the MCP handshake itself and offers protocol 2025-11-25, then hands the session to the proxy, which replays it through the mcp package. mcp before 1.23.1 doesn't know that version, so at the declared floor (1.22.0) the proxy couldn't take over a session the fast start had agreed to. The lowest-direct CI job found it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lowest-direct job installs the released wheels, without the example plugin, with every dependency at its floor. Plugin discovery on --help and plugin completion need a plugin installed, so those two tests now skip without deepctl-plugin-example. The usage error tests compare with click's own "No such option" text, which click 8.4 changed. The manifest tests that read the fragments or run the builder need the source tree; from an installed wheel the runtime reads the built manifest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root --help ends "This version of dg sends no telemetry." unless a loopback DEEPCTL_TELEMETRY_DSN is set, when it says telemetry is on. The help cache key didn't record that variable, so a launcher could serve one text where the other applies. Nothing reads the cache yet. The locale and PYTHON* variables stay out of the key: an entry is written only for a UTF-8 stdout, and the launcher applies Python's encoding rule itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In an AI agent session the trust root, the plugin venv and the caches come from the account's own home directory, but dg's state directory followed $HOME. An agent could point HOME at a directory holding a record for a clone it forged, and `dg init` would resume there, or a pending device login it planted for `dg login --complete`. In an agent session the state directory is now under the home the OS reports, and $HOME is used only where the OS can't say. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check runs in a daemon thread that dg never waits for, and its attempt was saved before the request went out. A run that ended before the answer came back (most dg commands are that quick) used up the whole interval, so for someone who only runs short commands the check might never complete. An attempt that failed or didn't finish now counts for an hour (or the interval, if shorter); only a check that got its answer waits the full interval. The plugin update check follows the same rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Host and Origin checks were tested by calling process_request directly, so nothing showed that start() passes it to the server. A running proxy now refuses a handshake from another origin with 403 before any upstream connection, and lets a loopback origin through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.4.1 checks the cp1252 stream with `dg --version` too, imports the function it tests by name and keeps PYTHONUTF8 from turning UTF-8 mode on in the child. Use that version here, with 0.5.0's `dg help agent-mode` case added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This release adds features and changes defaults that need upgrade notes, so it is a minor release. With bump-patch-for-minor-pre-major set, release-please would otherwise propose a patch release. Release-As: 0.5.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dg-coreylweathers
force-pushed
the
release/0.5.0
branch
from
November 7, 2026 13:40
28234d4 to
2e97002
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 0.5.0 release, based on 0.4.1 (#159). Review after 0.4.1, and merge only after 0.4.1 is released. 88 commits, on 0.4.1's head
55d3e980.Startup, lockstep versions and dependency floors (1–10)
Startup work:
dg --versionanddg mcpstart without importing click, commands load from a generated manifest, plugin discovery and help are cached, and config loads without pydantic on the help path. Also adds the competitive benchmark harness, versions every first-party package in lockstep, and raises the dependency floors (click 8.2, caps on runtime majors).Commits
Login for agents, and listen, speak and debug streaming (11–28)
dg loginaccepts real API keys and asks for--yesinstead of cancelling, and agents can finish device login with--complete. Livedg listenfinishes cleanly on Ctrl-C and end of input with bounded memory;dg speakstreams audio as it arrives and gains--out-file(-o/--outputfor the audio file is deprecated). Agent mode drops status chrome from stderr.dg debug networksends no stored credentials, debug browser and probe run on websockets, and numpy, ffmpeg-python and requests are dropped.Commits
Credentials resolver, transport, release workflow and update check (29–36)
Credentials are resolved in one place and every API request goes through one transport, so the host checks from 0.4.1 apply everywhere. Releases publish from
release.ymlwith trusted publishing. The update check never waits, detects pipx, uv and npm installs correctly, and reports an unreachable index as an error.Commits
Telemetry switches, dg init, JSON output and listen output formats (37–49)
Telemetry switches (
DO_NOT_TRACK,CI,DEEPCTL_TELEMETRY=log|off) are accepted; this version still sends no telemetry.dg initclones only Deepgram templates, keeps repository settings from running programs, can resume, and needs--yesfor setup steps outside a terminal. JSON output off a terminal skips rich.dg listenprints the server body for-o json, adds-o text, and saves large results to a file in agent sessions.Commits
dg mcp, agent mode, CLI and config fixes (50–73)
dg mcpshares one proxy and tool table and keeps stdout for JSON-RPC from the start. AI agents are detected from one documented table, anddg help agent-modeexplains agent mode. Warnings stay off stdout, config fixes (XDG on macOS, locked config files), Flux model naming, per-OS stdin and microphone help, and coded messages for cancel and unexpected errors. Ends with review fix-ups (a plugin named like a built-in alias is refused before import,dg login --completechecks the base URL, test and README fixes).Commits
Review and CI fixes, and the release marker (74–88)
Fixes from the review and from this PR's first CI run. Windows: the pending device login and
dg init's records are saved by path where a directory can't be opened;--out-fileand-owork withoutos.fchmod, write in binary mode, and don't take a pipe or console for another file; the tests now collect and run there. macOS: the agent trust root follows~/.configonly where platformdirs does, and the terminal, config path, socket and signal tests pass. Tests that signal their own process run in a child pytest process. The help cache key records the telemetry sink, an agent's state directory is under the account's real home, an update check that didn't finish is retried after an hour,dg mcpneeds mcp 1.23.1, and the tests run against installed packages at their floors. Ends withchore: release 0.5.0, which carriesRelease-As: 0.5.0.Commits
Upgrade notes
DO_NOT_TRACK,CIandDEEPCTL_TELEMETRY=log|offare accepted, and only affect a local loopback sink set withDEEPCTL_TELEMETRY_DSN, which is for tests.--no-telemetryis still accepted and documented as doing nothing.deepctl-telemetry, for example, goes from 0.0.7 to 0.5.0. Each package pinsdeepctl==0.5.0. Upgrading onedeepctl-cmd-*now upgrades the whole set, or fails to resolve, and an olderdeepctl-cmd-*can't be mixed with a newerdeepctl. The packages no longer get their own GitHub releases or tags.--version: prints the name it was run as:dg, version 0.5.0ordeepgram, version 0.5.0(0.4.x printeddeepctl, version …for every console script).deepctlandpython -m deepctlstill printdeepctl, version ….-o jsonand-o yamlprint the server's response body, for everyone, not only in agent sessions. A 2xx response whose body isn't a JSON object now exits 1.DEEPCTL_LISTEN_JSON=sdkbrings back 0.4.x's SDK-shaped JSON for prerecorded audio.-o jsonand-o yamlprint only the final result, not every interim message.dg listenwith no source (no file, URL or--stdin) is refused instead of opening the microphone.--check-urlasks for the check.-o/--output FILEis deprecated in favor of--out-file.-o FILEnow replaces the file through a temp file, so it never writes through a symlink.dg init's resume records and a pending device login live under the account's own home directory, not$HOME.--yeswhen not run from a terminal, and outside a terminal the app starts only with--start. Only Deepgram templates are cloned.-y/--yesanswers the confirmations, and-f/--force-writestill works as the same flag;confirmation_requiredinstead of "cancelled";--completefinishes an agent's device login.dg apisends aUser-Agent,X-Fern-Platformand a session ID header, only to hosts the key may be sent to.deepctl-cmd-mcpneeds mcp >= 1.23.1 (was 1.22.0);deepctlpackage itself still listsrequests.DeepgramClient.ws_base_url()is removed, anddeepctl_core.websocket.RedirectRefusedis now the transport's error, no longer aclick.ClickException.release.ymlwith trusted publishing. This needs thereleaseenvironment and the release GitHub App (RELEASE_APP_CLIENT_IDandRELEASE_APP_PRIVATE_KEY) set up before the 0.5.0 release PR.Notes
Release-As: 0.5.0; without it, release-please would propose a patch release instead of 0.5.0.🤖 Generated with Claude Code