Skip to content

Release 0.5.0: faster startup, agent mode, streaming and one transport - #165

Open
dg-coreylweathers wants to merge 88 commits into
release/0.4.1from
release/0.5.0
Open

dg-coreylweathers wants to merge 88 commits into
release/0.4.1from
release/0.5.0

Conversation

@dg-coreylweathers

@dg-coreylweathers dg-coreylweathers commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

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 --version and dg mcp start 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
  • a6a212a feat(bench): add competitive benchmark harness with gates
  • 27f5859 perf(cli): start dg without importing click for --version and mcp
  • 1ccf4a0 perf(core): import deepctl_core names lazily
  • a3c698a build(release): version every first-party package in lockstep
  • 92d0130 build(deps): require click 8.2 and cap majors of runtime deps
  • b7fd286 feat(cli): load commands from a generated manifest
  • 745e258 perf(cli): cache plugin discovery and help output
  • edab526 refactor(commands): keep network modules out of command import time
  • 394f6c2 perf(core): load config without pydantic on the help path
  • 680e5fb perf(cli): render subcommand help from specs

Login for agents, and listen, speak and debug streaming (11–28)

dg login accepts real API keys and asks for --yes instead of cancelling, and agents can finish device login with --complete. Live dg listen finishes cleanly on Ctrl-C and end of input with bounded memory; dg speak streams audio as it arrives and gains --out-file (-o/--output for the audio file is deprecated). Agent mode drops status chrome from stderr. dg debug network sends no stored credentials, debug browser and probe run on websockets, and numpy, ffmpeg-python and requests are dropped.

Commits
  • a5052b6 fix(login): accept real API keys and ask for --yes instead of cancelling
  • 582c3f0 feat(login): let agents finish device login with --complete
  • e80a8a3 perf(core): drop the pre-flight key check and reuse one HTTP client
  • b0e0780 fix(output): drop status chrome from stderr in agent mode
  • c58d7b5 fix(listen): finish live streams cleanly on Ctrl-C and end of input
  • 9ddea92 perf(listen): bound live-stream memory and frame latency
  • b80e66f fix(listen): keep machine output to the final result
  • ee0b1cc perf(speak): stream audio to pipes and players as it arrives
  • 361bf3e feat(speak): add --out-file and deprecate -o/--output for the audio file
  • 6244ab9 fix(speak): don't read stdin or write audio to a pipe in agent mode
  • c915891 fix(listen): report a failed stdout write as an output error
  • 1550284 fix(cli): treat Ctrl-C while the CLI is loading as cancelled
  • 8cc787e perf(listen): stream prerecorded uploads from disk
  • ecba215 feat(listen): add --timeout for prerecorded transcription
  • 4491eb1 perf(listen): check URL sources without fetching them
  • 61eaa62 fix(debug): send no stored credentials from debug network
  • 960b0c3 build(deps): drop numpy, ffmpeg-python and requests from commands
  • 9466058 refactor(debug): run debug browser and probe on websockets

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.yml with trusted publishing. The update check never waits, detects pipx, uv and npm installs correctly, and reports an unreachable index as an error.

Commits
  • b9d015a feat(core): resolve credentials in one place
  • 1a409f2 fix(core): send API requests through one transport
  • 85dedaf fix(debug): take the probe's and network's host from the transport
  • 327547e ci: keep the CLI smoke runs from sending telemetry
  • 4d194c0 ci: publish every release from release.yml with trusted publishing
  • 9dc82c1 fix(update): never wait for the update check
  • 9c18f1b fix(update): detect pipx, uv and npm installs correctly
  • fc32cc3 fix(update): report an unreachable index as an error

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 init clones only Deepgram templates, keeps repository settings from running programs, can resume, and needs --yes for setup steps outside a terminal. JSON output off a terminal skips rich. dg listen prints the server body for -o json, adds -o text, and saves large results to a file in agent sessions.

Commits
  • a3c4a4a feat(telemetry): honor DO_NOT_TRACK and CI
  • 6096c73 perf(telemetry): skip the flush when no report was queued
  • f76f032 feat(telemetry): add DEEPCTL_TELEMETRY=log and =off
  • 707672e docs(telemetry): publish the report's field schema
  • 8823fb0 fix(init): clone only Deepgram templates, with plain names
  • 4a762a2 fix(init): keep repository settings from running programs
  • bbaa6ba feat(init): resume a rerun in a clone dg init recorded
  • a4eefcb feat(init): setup steps need --yes outside a terminal
  • 5e85c87 perf(output): write JSON off a terminal without rich
  • dd53c99 test(transport): close the shared client after each test
  • 1034dba feat(listen): print the server's body for prerecorded -o json
  • 3aa3436 feat(listen): add -o text for the transcript alone
  • 39b2847 feat(listen): save large results to a file in agent sessions

dg mcp, agent mode, CLI and config fixes (50–73)

dg mcp shares one proxy and tool table and keeps stdout for JSON-RPC from the start. AI agents are detected from one documented table, and dg help agent-mode explains 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 --complete checks the base URL, test and README fixes).

Commits
  • 0a33a42 refactor(mcp): share one proxy and tool table for every dg mcp path
  • 9b32d50 fix(mcp): keep stdout for JSON-RPC from the start of dg mcp
  • 4eee896 docs(mcp): describe dg mcp as the documentation search it is
  • eee53c1 feat(core): detect AI agents from one documented table
  • 6a74a66 feat(help): explain agent mode with dg help agent-mode
  • aefb1ba fix(cli): keep warnings and notices off stdout
  • b7d589d style: say what each check does in config and skills comments
  • c1dc7e9 fix(cli): escape characters the output encoding can't write
  • 4318989 fix(login): show a profile's config-file key in profiles --list
  • 24c31b1 fix(config): count ~/.config on macOS when XDG_CONFIG_HOME names it
  • ac2d323 fix(config): wait for a locked config file once per run
  • 337ba3c fix(listen,speak): name Flux models, not API versions
  • 37e2414 fix(listen): give each OS its own stdin and microphone help
  • b9048ef test: give timing checks room on a busy host
  • 26a7890 fix(cli): use coded messages for cancel and unexpected errors
  • 7c415cb test(debug-probe): run the ignored-SIGINT check only on Linux
  • bf22611 fix(plugin): refuse a plugin named like a built-in alias before importing it
  • 020cec3 fix(login): check the base URL before dg login --complete saves a key
  • 085b54c test: merge the two fixtures that clear AI agent markers
  • d19dad9 docs: compare with 0.4.1 in comments, the release before 0.5.0
  • 62fe497 build: regenerate the stale package READMEs and run the generator with uv
  • b2e1889 docs(telemetry): say this version sends no telemetry
  • 659e71b test(cli): patch both built-in tables in the missing-package test
  • c5511b2 test(mcp): let the docs lint pass the entry point's transport refusal

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-file and -o work without os.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 ~/.config only 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 mcp needs mcp 1.23.1, and the tests run against installed packages at their floors. Ends with chore: release 0.5.0, which carries Release-As: 0.5.0.

Commits
  • f8467e8 fix(login): save the pending login and init's records on Windows
  • df1505f fix(speak): write --out-file and -o files correctly on Windows
  • 9935ef2 fix(speak): don't take a Windows pipe or console for another file
  • 6930c53 test: let the 0.5.0 tests run on Windows
  • 63762d0 fix(config): on macOS, follow ~/.config only where platformdirs does
  • df1e7da test: make the terminal, config path and socket tests pass on macOS
  • e880751 test(speak): send each test signal to one thread, in a child process
  • fc69d48 build(deps): require mcp 1.23.1 or later for dg mcp
  • 5217565 test: let the unit tests run against installed packages at their floors
  • ea7c121 fix(cli): key cached root help on the telemetry sink setting
  • 4ebe797 fix(init): keep an agent's resume records and logins under the real home
  • dfc9e9b fix(update): retry an update check that didn't finish after an hour
  • d8f8096 test(debug-probe): check the Origin refusal on a running proxy
  • cedf89c test(cli): run 0.4.1's output encoding checks on 0.5.0
  • 2e97002 chore: release 0.5.0

Upgrade notes

  • Telemetry: none is sent, as in 0.4.1. DO_NOT_TRACK, CI and DEEPCTL_TELEMETRY=log|off are accepted, and only affect a local loopback sink set with DEEPCTL_TELEMETRY_DSN, which is for tests. --no-telemetry is still accepted and documented as doing nothing.
  • Versions: every package moves to 0.5.0 together; deepctl-telemetry, for example, goes from 0.0.7 to 0.5.0. Each package pins deepctl==0.5.0. Upgrading one deepctl-cmd-* now upgrades the whole set, or fails to resolve, and an older deepctl-cmd-* can't be mixed with a newer deepctl. The packages no longer get their own GitHub releases or tags.
  • --version: prints the name it was run as: dg, version 0.5.0 or deepgram, version 0.5.0 (0.4.x printed deepctl, version … for every console script). deepctl and python -m deepctl still print deepctl, version ….
  • listen:
    • -o json and -o yaml print 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=sdk brings back 0.4.x's SDK-shaped JSON for prerecorded audio.
    • Live -o json and -o yaml print only the final result, not every interim message.
    • In an agent session, dg listen with no source (no file, URL or --stdin) is refused instead of opening the microphone.
    • URL sources are no longer fetched or checked before the request; --check-url asks for the check.
    • Without the pre-flight key check, a bad or missing key is reported from the API's answer, so those error texts changed.
  • speak: -o/--output FILE is deprecated in favor of --out-file. -o FILE now replaces the file through a temp file, so it never writes through a symlink.
  • Agent sessions:
    • no status chrome on stderr, and warnings stay off stdout;
    • large listen results are saved to a file;
    • speak doesn't read stdin or write audio to a pipe;
    • dg init's resume records and a pending device login live under the account's own home directory, not $HOME.
  • init: setup steps need --yes when not run from a terminal, and outside a terminal the app starts only with --start. Only Deepgram templates are cloned.
  • login:
    • -y/--yes answers the confirmations, and -f/--force-write still works as the same flag;
    • without a terminal, the confirmations return confirmation_required instead of "cancelled";
    • --complete finishes an agent's device login.
  • api: dg api sends a User-Agent, X-Fern-Platform and a session ID header, only to hosts the key may be sent to.
  • debug network: sends no stored credentials.
  • Update check: never waits. A check that failed, or didn't finish before dg exited, is tried again after an hour instead of the next day.
  • Dependencies:
    • click >= 8.2, with caps below the next major of click, httpx, pydantic, rich, platformdirs, keyring and websockets;
    • deepctl-cmd-mcp needs mcp >= 1.23.1 (was 1.22.0);
    • numpy, ffmpeg-python, aiohttp and typer are no longer installed, and the commands no longer depend on requests. The deepctl package itself still lists requests.
  • Plugin API: DeepgramClient.ws_base_url() is removed, and deepctl_core.websocket.RedirectRefused is now the transport's error, no longer a click.ClickException.
  • Releases: published from release.yml with trusted publishing. This needs the release environment and the release GitHub App (RELEASE_APP_CLIENT_ID and RELEASE_APP_PRIVATE_KEY) set up before the 0.5.0 release PR.

Notes

  • Tests on this head: Docker (Linux) Python 3.10 5,648 passed and Python 3.14 5,649 passed, 0 failed; lint, types, lock, floors, READMEs and build pass. The lowest-direct-dependencies job passes on Python 3.10 and 3.12 in Docker. macOS (local) Python 3.10 5,406 passed and Python 3.14 5,407 passed, 0 failed, and the signal and config race tests passed 30 runs in a row on each. Windows can't be run locally: the Windows paths are tested on every OS by taking away what Windows lacks, and CI on this PR runs the real matrix.
  • After 0.4.1's release PR merges, this branch is rebased onto main and the lockstep versions are re-baselined to the versions that release set.
  • Merge with a merge commit, not a squash: release-please reads the individual commits. The last commit carries Release-As: 0.5.0; without it, release-please would propose a patch release instead of 0.5.0.

🤖 Generated with Claude Code

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.
dg-coreylweathers and others added 29 commits November 6, 2026 22:42
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant