{"id":"07134b06-b2d6-49cd-afc4-778eff8e7c25","slug":"processkit-cli","kind":"skill","name":"ProcessKit-CLI","summary":"CLI for rust async child-process management: whole-tree kill-on-drop (no orphans), streaming, pipelines, timeouts, and process supervision.","owner":"ZelAnton","repoUrl":"https://github.com/ZelAnton/ProcessKit-CLI","license":"MIT","licenseTier":"permissive","trust":"community","stars":4,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-07-20T09:17:27.000Z","addedAt":"2026-10-22T01:31:56.562Z","logoUrl":"https://avatars.githubusercontent.com/u/2218481?u=1d13eabaaf20986fb0a08f18093ec334b9999b0b&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["writing"],"tags":[],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T19:13:17.010Z","sample":false,"url":"https://conduits.dev/tools/processkit-cli","language":"Rust","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"![ProcessKit: async child-process management with a kernel-backed no-orphan guarantee](docs/processkit-cover.png)\n\n# processkit-cli\n\n[![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-0A7BBB?logo=github)](https://zelanton.github.io/ProcessKit-CLI/)\n\n`processkit-cli` is a standalone, cross-platform command runner built on the\npublic API of [`processkit`](https://crates.io/crates/processkit). It runs one\nprogram inside ProcessKit's kernel-backed containment boundary and reports the\nrun lifecycle without requiring Python or a development virtual environment.\n\nThe CLI ensures a completed or failed command cannot leave descendants behind.\nIt never kills processes by name; cleanup is restricted to the current run's\nProcessKit container.\n\nEvaluating it against `timeout`, process groups, systemd, containers, Tini, or\nPowerShell? Start with [Why ProcessKit CLI?](docs/why-processkit-cli.md), which\nalso calls out where those alternatives are the better fit.\n\nThe project owns the versioned JSONL event contract used by runner clients and\nfuture adapters, including `processkit-py`. ProcessKit-rs remains the sole\nowner of containment, teardown, PID-reuse discipline, and lifecycle semantics.\n\nThe complete [GitHub Pages guide set](https://zelanton.github.io/ProcessKit-CLI/)\nstarts with [installation](docs/installation.md), a task-oriented\n[cookbook](docs/cookbook.md), and focused guides for\n[agent and automation workflows](docs/agent-workflows.md),\n[running commands](docs/running-commands.md),\n[I/O and capture](docs/io-and-capture.md),\n[timeouts](docs/timeouts-and-cancellation.md),\n[resource limits](docs/resource-limits.md), and\n[platform support](docs/platform-support.md). For adapter authors, the\n[integration guide](docs/integration.md) walks from fail-closed preflight through\nJSONL consumption, supervision, and housekeeping. The\n[architecture overview](docs/architecture.md) maps modules and data flow; the\n[troubleshooting guide](docs/troubleshooting.md) is organized by operator\nsymptom.\n\n## Installation\n\n`processkit-cli` ships as a single self-contained binary — running it needs\nneither a source build nor a Python or development virtualenv, which is the whole\npoint of the project.\n\n### Prebuilt binaries (recommended)\n\nInstall the latest matching archive in one command. Both installers verify the\npublished SHA-256 sidecar before extracting and refuse to overwrite a destination\nthat does not identify itself as `processkit-cli`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/ZelAnton/ProcessKit-CLI/main/install.sh | sh\n```\n\n```powershell\nirm https://raw.githubusercontent.com/ZelAnton/ProcessKit-CLI/main/install.ps1 | iex\n```\n\nThey default to `~/.local/bin`; download the script to pass a pinned `--version` /\n`-Version`, a custom install directory, or an explicit target. See the\n[installation guide](docs/installation.md#one-command-verified-install).\n\nEvery release attaches a prebuilt archive per platform to its\n[GitHub Release](https://github.com/ZelAnton/ProcessKit-CLI/releases), each with\na `<archive>.sha256` checksum next to it. Download the archive for your platform,\nverify it against its published SHA-256 checksum, extract the single\n`processkit-cli` binary (`processkit-cli.exe` on Windows), and put it on your\n`PATH`:\n\n```sh\n# Linux x86_64 (glibc), for the vX.Y.Z release:\narchive=processkit-cli-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz\nbase=https://github.com/ZelAnton/ProcessKit-CLI/releases/download/vX.Y.Z\ncurl -sSL -O \"$base/$archive\"          # the archive\ncurl -sSL -O \"$base/$archive.sha256\"   # its SHA-256 checksum\nsha256sum -c \"$archive.sha256\"         # macOS: shasum -a 256 -c \"$archive.sha256\"\ntar -xzf \"$archive\"\n```\n\nArchives are named `processkit-cli-v<version>-<target-triple>.<ext>` — `.tar.gz`\nfor Linux and macOS, `.zip` for Windows — and each ships a matching\n`<archive>.sha256`.\n\nEach release also attaches checksum-derived distributor manifests for winget,\nScoop, and a Homebrew tap. The release workflow publishes the Homebrew formula\n(and the Scoop manifest) into a tap/bucket repository of the project's own as\nsoon as an operator creates it and adds the matching token secret; winget is\nsubmitted by hand, since its review happens in `microsoft/winget-pkgs`. Neither\na tap nor a bucket is published yet, and an attached manifest is not a claim\nthat a public channel has accepted a release, so check the [package-manager\navailability table](docs/installation.md#package-manager-manifests) before using\na `winget`, `scoop`, or `brew` install command.\n\nEvery archive also carries a signed [build-provenance\nattestation](https://docs.github.com/actions/security-guides/using-artifact-attestations).\nFor a stronger, cryptographic check that the archive was built by this\nrepository's release workflow (not just that its bytes are intact), verify it\nwith the [GitHub CLI](https://cli.github.com):\n\n```sh\ngh attestation verify \"$archive\" --repo ZelAnton/ProcessKit-CLI\n```\n\n### With cargo-binstall\n\nRust users who already have\n[`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall) can install the\nmatching prebuilt release in one command, without compiling ProcessKit or this\nrunner locally:\n\n```sh\ncargo binstall processkit-cli\n```\n\nThe manifest metadata points cargo-binstall at the same root-layout GitHub\nRelease archive described above (`.tar.gz` on Unix, `.zip` on Windows). The\narchive's neighboring `.sha256` file and GitHub attestation remain available for\nindependent verification; cargo-binstall does not verify those two sidecar\nartifacts on the user's behalf. Download the archive manually when an\ninstallation also needs the bundled completions, man pages, or schema files.\n\n### From crates.io\n\nThe prebuilt binaries do not replace `cargo install` — building from source stays\na first-class path:\n\n```sh\ncargo install processkit-cli\n```\n\nBuilding from source also (re)generates the shell completions and man pages\ndescribed below, in `target/assets/` — see \"Shell completions and man pages\".\n\n### Shell completions and man pages\n\nEvery release archive (above) also ships a `completions/` directory (bash, zsh,\nfish, PowerShell, and Elvish) and a `man/man1/` directory (one page per\nsubcommand, plus `processkit-cli.1`), generated straight from the CLI's own\n`clap` definition by `build.rs` at build time — there is no separate,\nhand-maintained copy of the flags to fall out of sync, and (unlike a\n`generate-completions` subcommand would) this never adds anything to the CLI's\nown runtime surface, so the fail-closed `probe --require-surface` preflight\n(see \"Command interface\" below) never sees it. `cargo install`/`cargo build`\nfrom source produce the same two directories under `target/assets/` — see\n`build.rs`'s module doc for the full mechanism. (The release archive ships a\nthird directory too, `schema/` — see \"JSONL event schema\" below; `cargo\ninstall`/`cargo build` do not produce it, since it is a static copy of a\ntracked fixture, not `build.rs` output.)\n\nInstall the completion script for your shell, e.g.:\n\n```sh\n# bash (adjust the destination to your distro's completion directory)\ninstall -Dm644 completions/processkit-cli.bash /etc/bash_completion.d/processkit-cli\n\n# zsh (anywhere on $fpath)\ninstall -Dm644 completions/_processkit-cli \"$HOME/.zsh/completions/_processkit-cli\"\n\n# fish\ninstall -Dm644 completions/processkit-cli.fish \"$HOME/.config/fish/completions/processkit-cli.fish\"\n```\n\n```powershell\n# PowerShell — dot-source it from your $PROFILE\nCopy-Item completions\\_processkit-cli.ps1 $HOME\\Documents\\PowerShell\\\nAdd-Content $PROFILE '. $HOME\\Documents\\PowerShell\\_processkit-cli.ps1'\n```\n\nAnd the man pages, onto any directory on your `MANPATH`:\n\n```sh\nmkdir -p /usr/local/share/man/man1\ncp man/man1/*.1 /usr/local/share/man/man1/\nman processkit-cli-run\n```\n\n### Platform matrix\n\nPrebuilt binaries are published for the targets below. The **Container mechanism**\ncolumn is the kernel-backed containment the runner *actually* reports in the\n`run_started` event's `mechanism` field on that platform (see the\n[JSONL event schema](#jsonl-event-schema)) — not a generic promise:\n\n| Platform | Target triple | Container mechanism |\n| --- | --- | --- |\n| Windows x86_64 | `x86_64-pc-windows-msvc` | Job Object (`job_object`) |\n| Windows aarch64 | `aarch64-pc-windows-msvc` | Job Object (`job_object`) |\n| Linux x86_64 (glibc) | `x86_64-unknown-linux-gnu` | cgroup v2 (`cgroup_v2`) |\n| Linux aarch64 (glibc) | `aarch64-unknown-linux-gnu` | cgroup v2 (`cgroup_v2`) |\n| Linux x86_64 (musl, static) | `x86_64-unknown-linux-musl` | cgroup v2 (`cgroup_v2`) |\n| Linux aarch64 (musl, static) | `aarch64-unknown-linux-musl` | cgroup v2 (`cgroup_v2`) |\n| macOS aarch64 (Apple Silicon) | `aarch64-apple-darwin` | process group (`process_group`) |\n\nThe **musl** builds link libc statically, so they run on minimal, glibc-less\ncontainer images (Alpine, distroless) as a single dependency-free file. They are\nshipped **alongside** the corresponding glibc Linux build, not as a replacement.\n\nThe four mechanisms are not equally strong, and the runner reports which one is\nin force rather than papering over the difference:\n\n- **Windows — Job Object.** The whole process tree is reaped even if the runner\n  itself dies abruptly (the OS closes the Job on its last handle). This is the\n  strongest guarantee.\n- **Linux — cgroup v2.** The run's cgroup bounds the entire tree and teardown\n  reaps every member. It requires cgroup v2 (the unified hierarchy — standard on\n  modern distros). Where cgroup v2 delegation is unavailable, the runner honestly\n  falls back to the POSIX **process-group** mechanism below and reports\n  `process_group`; it never claims a cgroup it did not get. If the runner itself\n  dies abruptly, the enabled parent-death signal kills the direct child, but the\n  cgroup persists and does not automatically kill grandchildren.\n- **FreeBSD — process reaper.** ProcessKit uses FreeBSD's `procctl(2)` process\n  reaper for whole-tree membership and normal teardown, including descendants\n  that call `setsid` or double-fork. FreeBSD has no ProcessKit resource-limit or\n  statistics support, and `abrupt_cleanup` remains `none` because the reaper is\n  owned by the runner.\n- **macOS and non-FreeBSD Unix — process group.** Teardown signals the process group;\n  a descendant that deliberately leaves it (`setsid` / double-fork) can escape,\n  and a just-exited child may still be listed in the post-kill snapshot. The\n  current ProcessKit API provides no parent-death cleanup on these targets.\n\nEvery `run_started` event reports this separate abrupt-owner-death contract as\n`abrupt_cleanup`: `whole_tree` on Windows, `direct_child_only` on Linux, and\n`none` on FreeBSD, macOS, and other Unix. Normal completion, timeout, and a cancel signal — a\n`Ctrl-C`, on Unix a `SIGTERM`/`SIGHUP`, or on Windows a `Ctrl-Break`/console\nclose/logoff/system shutdown, all of which the runner catches — still run\nthe owned container's ordinary teardown path on every supported platform; this\ntri-state applies only where the runner never gets to run it at all (a crash, a\n`SIGKILL`, an outer Job Object terminate).\n\n## Installable agent skill\n\nAgents that routinely launch external tools can install the compact\n[`using-processkit-cli` skill](skills/using-processkit-cli/SKILL.md). It teaches\nwhen to wrap a command, fail-closed `probe` preflight, bounded foreground and\ndetached recipes, and authoritative `runner_exit` handling. Codex and Claude Code\ninstallation instructions are in the [`skills` catalog](skills/README.md); CI pins\nthe skill's flags and exit-code claims to the built binary.\n\n## Command interface\n\n```text\nprocesskit-cli run     [--run-id <id>] [--label <KEY=VALUE>]... [--cwd <dir>]\n                       --jsonl <events.jsonl>\n                       [--create-no-window] [--windows-graceful-ctrl-break]\n                       [--timeout <duration>]\n                       [--idle-timeout <duration>]\n                       [--grace <duration>] [--snapshot-interval <duration>]\n                       [--max-memory <size>]\n                       [--max-processes <n>] [--cpu-quota <cores>]\n                       [--capture-dir <dir>] [--capture-max-bytes <size>]\n                       [--capture-overflow <truncate|cancel>]\n                       [--no-echo] [--detach] [--argv-raw]\n                       [--inherit-stdio | --inherit-stdin | --stdin-file <file>]\n                       [--env-clear] [--env-remove <KEY>]... [--env-file <file>]...\n                       [--env <KEY=VALUE>]... [--run-id-env <KEY>]\n                       -- <program> <args...>\nprocesskit-cli inspect (--run-id <id> | --all [--label <KEY=VALUE>]...) [--json]\nprocesskit-cli cancel  (--run-id <id> | --all [--label <KEY=VALUE>]...)\nprocesskit-cli kill    (--run-id <id> | --all [--label <KEY=VALUE>]...)\nprocesskit-cli attest  --run-id <id> [--json]\nprocesskit-cli wait    --run-id <id> [--timeout <duration>] [--report-outcome]\nprocesskit-cli wait    --all [--label <KEY=VALUE>]... [--timeout <duration>] [--report-outcome]\nprocesskit-cli events  (--run-id <id> | --file <events.jsonl>)\n                       [--json | --validate] [--follow]\nprocesskit-cli list    [--json] [--label <KEY=VALUE>]... [--health <live|stale|unprobed>]\nprocesskit-cli prune   [--json] [--dry-run] [--label <KEY=VALUE>]...\nprocesskit-cli probe   --json [--require-schema-version <N>]\n                       [--require-exit-code-band <start>-<end>]\n                       [--require-surface <token>]...\nprocesskit-cli doctor  [--json] [--timeout <duration>]\n                       [--check-resource-controller]\n                       [--require-mechanism <name>]\n                       [--require-abrupt-cleanup <level>]\n                       [--require-resource-controller]\nprocesskit-cli doctor  --scratch-child <duration>\n\n# accepted by every subcommand above, before or after it:\n                       [--error-format <human|json>]\n```\n\nThe command is intentionally shell-free: `run` executes `<program> <args...>`\ndirectly, with no shell to expand or re-interpret anything after `--`. Child\nstdout and stderr are echoed through unchanged; runner diagnostics and JSONL\nevents never use stdout.\n\n`probe` is a side-effect-free preflight: it prints this binary's compatibility\nsurface (version, `schema_version`, exit-code band, and CLI surface tokens) as one\nJSON line and — with `--require-*` — verifies it, so a consumer can confirm a\nrunner is usable **before** launching a payload. It spawns no child and touches no\nregistry or container.\n\n`doctor` is its side-effecting counterpart, and the pair is deliberate: `probe`\nqualifies the **binary**, `doctor` qualifies the **host**. A binary can pass every\n`--require-*` check and still fail its first real run on a registry directory it\ncannot create, a containment mechanism the kernel will not hand out, or a local IPC\nendpoint that will not bind — so `doctor` runs a bounded scratch containment of this\nbinary's own harmless child (`--scratch-child`, which sleeps briefly and does nothing\nelse), drives it through the real control plane (`inspect`, `cancel`, terminal wait),\nand reports what it observed: the registry path and its owner-only protection, the\ncontainment mechanism and abrupt-cleanup level, the transport round-trip, a\n**confirmed**-empty teardown, optionally the resource controller, and per-phase\ntimings. It cleans up everything it created on success and keeps a named diagnostics\ndirectory on failure. `--require-*` here gates only the exit code\n(`HOST_UNQUALIFIED`, 116); the report carries the observed facts either way. Run it\nonce per host at setup time — see [Troubleshooting](docs/troubleshooting.md) and\n[the integration guide](docs/integration.md).\n\n`--error-format` is the CLI's one **global** option: it parses before or after the\nsubcommand and every subcommand honors it. `--error-format json` replaces the\n`processkit-cli: <message>` prose a failure prints on stderr with exactly one\nbounded, versioned JSON object — `{\"error_version\":1,\"code\":103,\"kind\":\"stale\",\n\"operation\":\"inspect\",\"run_id\":\"build-42\",\"retryable\":false,\"message\":\"…\"}` — so an\nadapter can branch on a stable `kind` instead of parsing English (a single `103`\ncovers eight different situations). It is opt-in and stdout-safe: without it stderr\nis byte-for-byte what it always was, and with it stdout is untouched, so it can be\nleft on for every invocation. clap's own parse-time usage errors stay\nhuman-readable in v1. See [the exit-code contract](docs/exit-codes.md), and\n`fixtures/schema/cli/error.schema.json` for the published shape.\n\nBy default, live output is **pipe + echo, not a real inherited terminal**:\nProcessKit reads the child's stdout/stderr through pipes and this runner re-emits\nthem onto its own stdout/stderr. The child therefore sees **no TTY**, so colors,\nprogress bars, and other cursor tricks may degrade. `--inherit-stdio` is the\nexplicit alternative for interactive commands: it gives the child the runner's\nthree standard handles directly, preserving an existing terminal without a pump.\nIt does not allocate or emulate a PTY; if the caller supplied pipes or files, the\nchild sees those pipes or files.\n\n## Standard I/O\n\nBy default the child receives closed/null stdin, so a noninteractive command cannot\nwait forever for bytes the runner does not own. Three mutually exclusive opt-ins\nchange the standard-I/O path:\n\n- `--inherit-stdio` gives the child the runner's stdin, stdout, and stderr handles\n  directly. There is no output pump, echo, tee, or transcript capture in this mode;\n  the child writes to the inherited destinations itself. JSONL events still go only\n  to `--jsonl`, and runner diagnostics still avoid stdout. This mode conflicts with\n  `--capture-dir`, `--create-no-window`, `--inherit-stdin`, `--stdin-file`,\n  `--no-echo` (there is no pump to suppress in this mode), and `--detach` (a\n  detached run has no terminal to hand over).\n  `probe --json` advertises the capability as `run:--inherit-stdio`.\n\n- `--inherit-stdin` gives the child the runner's own stdin handle. It can read from\n  the caller's terminal, file, or pipe directly; the runner neither mediates nor\n  records those bytes.\n- `--stdin-file <file>` streams the named readable file through\n  `processkit::Stdin::from_file` and closes the child's stdin at EOF. Input bytes\n  never appear in the child argv or lifecycle JSONL. The file is checked before a\n  child is spawned; an unreadable path is a `SETUP` (111) failure.\n\nThe two input-only modes leave stdout/stderr on the default pipe-and-echo path and\ndo not create a PTY. Their `Ctrl-C` behavior remains runner-owned cancellation with\nthe documented `cancelled` event and exit code `107`.\n\n`--no-echo` suppresses only the runner's own live retransmission of the child's\nstdout/stderr — the pipe and the output pump stay exactly as they are otherwise:\n`--capture-dir` still receives every byte through the same tee, `--idle-timeout`\nstill re-arms on every observed chunk, and the JSONL event stream is unaffected.\nIt is meant for an embedding orchestrator that reads results from `--jsonl`/\n`--capture-dir` and finds the child's raw output — interleaved with the runner's\nown — pure noise. It conflicts with `--inherit-stdio`, which runs no pump to\nsuppress in the first place (a parse-time error, like `--capture-dir` and\n`--idle-timeout`). `probe --json` advertises the capability as `run:--no-echo`.\nWithout `--no-echo`, nothing changes: the live echo behaves exactly as before.\n[`--detach`](#detached-runs) reuses this exact path rather than suppressing output\nits own way: the detached runner is started with `--no-echo`, so a detached run's\necho behavior is this one, described once.\n\n`--inherit-stdio` preserves native terminal signal delivery instead of pretending\nto mediate it. On Windows and containment mechanisms that keep child and runner in\nthe same foreground group, the host may deliver `Ctrl-C` to both; when the runner's\nlistener observes it, the result is the normal `cancelled`/`107` outcome. On Unix\nwhen ProcessKit uses a separate process group, the runner temporarily makes that\ngroup foreground so terminal input works, and the terminal delivers `Ctrl-C` to the\nchild group; a child that exits from the signal is therefore reported as a child\nexit. In every case the runner restores the original foreground group and tears\ndown its ProcessKit container before returning. Callers that require a deterministic\nrunner-owned cancellation outcome should use the control-plane `cancel` command.\n\n`inspect`, `cancel`, `kill`, and `attest` all communicate with a live `run` process\nover the\nsame local IPC control plane, addressing it by `run_id` through the per-user registry\n— never by PID. `inspect` is read-only and, like `list`/`prune`, has an optional\n`--json`: without it, `inspect` prints a human-readable rendering of the snapshot\n(snapshot version, run id, mechanism, root pid, start time, and a member table); with\n`--json` it prints the snapshot as a single JSON line, unchanged. `cancel` ends the\nrun through the *same* soft-stop → grace → hard-kill teardown a `Ctrl-C` uses\n(exiting the run with the reserved code `108`), and `kill` hard-kills the whole tree\nimmediately with no grace (code `109`). By `--run-id`, the scope of a cancel/kill is\nonly the target run's ProcessKit container (the `--all` form below widens this to\nevery confirmed-live registry entry, still **never** processes matched by executable\nname — that boundary holds for both forms). Both\noutcomes are also written to the run's JSONL stream (a `cancelled` / `killed` event\nand a terminal `runner_exit`), so an external observer reading the event file sees\nthe command too, not just the control client. If the runner has already died, the\nregistry entry is stale rather than an invitation to address processes by PID, and a\ncancel/kill against it is a bounded `CONTROL` (103) failure — never a hang. Cleanup\nafter an abrupt runner\ndeath follows the platform-specific `abrupt_cleanup` guarantee above; only Windows\ncurrently guarantees the whole tree.\n\n`attest --run-id <id>` is the fourth, read-only client, and the one whose answer is\nabout **the caller** rather than about the run: it asks the live runner whether the\nprocess running the command is inside that run's ProcessKit container. The runner\ntakes the caller's identity from the control connection itself — unix socket peer\ncredentials, `GetNamedPipeClientProcessId` on the Windows named pipe — while the\nconnection is open, so PID reuse cannot turn a departed client into a false positive\nand, crucially, the caller cannot name a process: there is no `--pid`, because\nproving that *some chosen* pid is a member proves nothing about the asker. That is\nthe difference between this and an environment variable carrying a run id, which any\nprocess can hold and which nothing checks. The verdict is `member` (exit `0`),\n`not_a_member` (the reserved `115`, a *decided* answer rather than an unreachable\ntarget), or `peer_identity_unsupported` (a `103` refusal from a platform that cannot\nname its callers — never a silent degradation to an unproven \"ok\"; a consumer rules\nit out at preflight with `probe --json --require-surface attest:peer-identity`). It\nis a containment fact inside the existing same-OS-user threat model, **not**\nauthentication between hostile peers — see\n[the control plane](docs/control-plane.md) and [the threat model](docs/threat-model.md).\n\n`inspect --all` snapshots the confirmed-live registry entries once, then inspects\nthose exact record paths and endpoints. Repeated `--label KEY=VALUE` filters are\nconjunctive. The default human view starts with one terminal-safe summary row per\ntarget (`inspected`, `already_gone`, or `failed`) and expands every inspected target\nthrough the same detailed snapshot renderer as single-run `inspect`. With `--json`,\nthe original single array remains byte-for-byte unchanged: each target carries\neither `snapshot` or an explicit per-run `error`, so a disappearing runner cannot\nsilently vanish from a fleet observation.\n\n`cancel --all` / `kill --all` (mutually exclusive with `--run-id`, exactly one\nof the two required, the same clap shape `wait --all` established) are the mutating\ncounterpart to `wait --all`: instead of one named run, they act on every run confirmed\nlive in a **snapshot** taken the moment the invocation starts. Repeated `--label\nKEY=VALUE` filters narrow that snapshot using logical AND; runs missing any exact\npair are outside the invocation. The snapshot keys each\ntarget by its unique registry-record path and remembered endpoint, not by `run_id`, so\ntwo concurrent records sharing one explicit id are both reached independently; the\nby-`run-id` form remains an ambiguity failure. Before dispatch the client reconfirms\nthat exact record is still live and still advertises the remembered endpoint. A run\nthat registers after the\nsnapshot, or one that is only `unprobed` (not confirmed live) *at that instant*, is\nout of scope, mirroring `wait --all`'s own asymmetry. Instead of one ack, `--all`\nprints a single JSON array on stdout — one entry per snapshot target with `run_id`,\n`accepted`, and `status` (`accepted`, `already_gone`, or `failed`), plus `error` only\nfor `failed`. `already_gone` means the runner finished after the snapshot but before\nits turn: no command was acknowledged (`accepted` stays false), yet the aggregate's\ndesired terminal state is already reached, so this is not a failure. An empty snapshot\nis likewise not an error, mirroring `prune`: an empty report and exit `0`. A partial or\nfull failure is never a silent `0`, though — it reuses the reserved `CONTROL` (103)\ncode with a summary on stderr, so `cancel --all` before `wait --all`/`prune` in a\nteardown sequence cannot silently swallow a target it failed to reach. See\n[`docs/control-plane.md`](docs/control-plane.md), \"`cancel --all` / `kill --all`\".\n\n`wait` is the *lifetime* counterpart to those three: it blocks while the named run is\nlive in the registry and exits `0` as soon as it is not. It exists for a supervisor\nthat did **not** start the run — an adapter that restarted, a cleanup step, anything\nholding only a `run_id` — and so has no child process to wait on. Like `list`/`prune`\nit is registry-only: it never connects to the run's control transport, never mutates\nregistry state, and never ends or otherwise disturbs the run (a run whose transport\nnever came up is still waitable, since `wait` needs no endpoint). Because the liveness\nsignal is an OS advisory lock with no event to subscribe to, waiting is honest periodic\nprobing, not a notification. `--timeout` bounds **the wait**, not the run: when it\nelapses the run is left running and `wait` exits with its own reserved code `112`,\nnever the run's `TIMEOUT` (`106`) — and an ambiguous `run_id` (more than one live run\nunder it) is the same `CONTROL` (103) refusal every other by-`run-id` command gives.\nNothing is printed on ordinary success; the exit code is the answer. With the\n`--report-outcome` opt-in, `wait` remembers each snapshot target's JSONL locator and\nprints one outcome entry after completion. `--run-id` prints one JSON object;\n`--all` prints one JSON array in stable `run_id`/record-path order, with one entry\nper target. Each entry has `run_id`, `status` (`reported` or `unknown`), and\nalways-present `code`, `source`, and `child_code` fields. The latter three mirror\nterminal `runner_exit` when it is available and are `null` when it is not. This is\ndata only — `wait` still exits `0` for a completed target or barrier rather than\nforwarding a child or runner code.\n\nOne deliberate design choice deserves a caller's attention: since a clean exit\ndeletes its own registry entry, an **unknown** `run_id` is indistinguishable from one\nthat already finished and was cleaned up, so both exit `0` — meaning a typo'd\n`run_id` returns success immediately, and a reporting wait that never observed the\nrun live honestly emits `status:\"unknown\"`. Neither form of success proves the run\nexisted. See\n[`docs/registry.md`](docs/registry.md), \"Waiting — `wait`\".\n\n`wait --all` (mutually exclusive with `--run-id`; exactly one of the two is required)\nis the aggregate counterpart: an orchestrator teardown barrier that blocks until no run\nthis invocation considers in scope is still live, for the typical \"cancel everything,\nwait for it all to be gone, then `prune`\" sequence. Its target set is a **snapshot**,\nfixed once at the moment `--all` starts to exactly the entries **confirmed** live right\nthen — an entry that is only unprobed (not confirmed live) *at that instant* is\nexcluded from the target set outright, and a run that registers afterward is likewise\nout of scope for that invocation; repeated `--label KEY=VALUE` filters additionally\nrequire every exact pair (logical AND). Re-issue `wait --all` to catch either. This is a\ndeliberate asymmetry with `--run-id`, which has no equivalent \"excluded before\ntracking begins\" step of its own: a registry holding only unprobeable entries and no\nconfirmed-live ones makes `--all` return `0` immediately, the same as an empty\nregistry. Once an entry *is* in the target set, though, it stays outstanding on every\nlater pass for as long as its liveness cannot be re-probed, rather than being silently\ndropped — the same conservative stance `--run-id` takes. See\n[`docs/registry.md`](docs/registry.md), \"Waiting — `wait`\", \"The aggregate barrier —\n`wait --all`\".\n\n`wait --all --report-outcome` is accepted alongside\n`wait --run-id <id> --report-outcome`; in aggregate mode it adds data only and\ndoes not change the barrier's exit-code contract.\nWith `--report-outcome`, the barrier prints one JSON array only after every\nsnapshotted target is over. Entries retain the snapshot's `run_id` and JSONL\nlocator, are ordered by `run_id` then record path, and use the same fields and\n`reported`/`unknown` statuses as the single-run object. An unreadable, unavailable,\nor malformed stream produces an `unknown` entry with null outcome fields; it does\nnot change the barrier's exit code. A timeout still prints no report and exits\n`WAIT_TIMEOUT` (112), exactly as an ordinary `wait --all` timeout does.\n\n`events` is the *story* counterpart: it reads back the JSONL lifecycle stream a run\nwrote with `run --jsonl`, so the detach → observe → conclude loop needs no external\n`tail`/`jq` recipe. It names the stream either through the registry\n(`--run-id <id>`, which resolves the `jsonl` locator `list --json` publishes — for a\nlive run *and* for a finished-but-not-yet-reaped one) or directly\n(`--file <events.jsonl>`, the escape hatch for a stream whose record is already\ngone, and the way to check a file this registry never knew about, such as an\nadapter's own fixture). The two are mutually exclusive and exactly one is required:\nthere is deliberately no precedence rule, so passing both is a `USAGE` (100) error\nrather than a silent choice between them. Like `list`/`wait` it is read-only — it\nopens the registry read-only, never connects to a run's control transport, and\nmutates nothing.\n\nBy default it renders each event as one line, `<time>  <event>  key=value …`, with\nevery fragment passed through the same terminal-safety barrier the other\nhuman-readable output uses (an events file is untrusted input). `--json` instead\npasses each line through **verbatim**, the runner's own bytes, never re-serialized\nthrough this binary's event types — so a stream written by a newer runner keeps\nfields this build has never heard of; a line that is not JSON is reported on stderr\nrather than emitted, so stdout stays parseable JSONL. `--follow` keeps reading a\ngrowing stream until the terminal `runner_exit` event, or until the registry says\nthe run is over and the stream has stopped growing (what an abruptly killed runner\nleaves behind, explained on stderr); it is bounded by the run's own lifetime and\nnever invents a deadline of its own.\n\n`events --validate` is the conformance checker: it checks every line against the\nJSON Schema this binary embeds — the very document `probe --print-schema` prints —\nreports each violation with its line number and what it violated, and exits `0`\nwhen all lines conform or the reserved `EVENTS_INVALID` (`114`) when any does not.\nA stream that cannot be read at all is still `SETUP` (`111`) and a `--run-id` that\nnames no single stream is still `CONTROL` (`103`), so a CI job can tell \"invalid\"\napart from \"could not be checked\". See\n[`docs/detached-runs.md`](docs/detached-runs.md), \"Reading the stream back —\n`events`\".\n\n`list` is the discovery counterpart to `inspect`/`cancel`/`kill`: it scans the same\nper-user registry and prints every entry it finds — `run_id`, health (`live`/\n`stale`/`unprobed`), `started_at`, the run's worker-shape `hint` and one-way argv\nfingerprint `argv_sha256`, operator `labels`, and `endpoint` — for an operator or orchestrator\nthat has lost (or never had) a `run_id`. It is read-only and never connects to any\nrunner's control transport, so it has none of their unreachable-run failure modes.\nThe two command fields are what make several live runs distinguishable without\ndisclosing a command line: the fingerprint is equal for two entries exactly when\nthey are running the same command, and the hint names a recognized worker shape\n(`msbuild_node_reuse`, …) or is `null`. They are the same pair the JSONL\n`run_started` event carries, from the same implementation; the raw argv is never\nwritten to a registry record under any flag, `--argv-raw` included. The table\nabbreviates the fingerprint to its first 12 hex characters, `--json` carries the\nfull digest.\nWithout `--json` it prints a human-readable table (`no runs registered` for an\nempty registry); with `--json` it prints one JSON object per entry, one per line. An\nempty registry is not an error — `list` exits `0` either way — and a stale entry is\nlisted rather than hidden, since surfacing a leftover from an abruptly-died runner\nis exactly the point. An entry whose liveness lock could not even be *probed*\n(permission denied, a rejected symlink/reparse point) prints as `unprobed`, never\nthe confirmed-dead `stale`, so an operator never mistakes \"could not tell\" for \"the\nrunner is gone\" — the same distinction `prune --json`'s `unprobed` tally, `wait`, and\nan `inspect`/`cancel`/`kill`/`attest` refusal against such an entry all make. See\n[`docs/registry.md`](docs/registry.md), \"Discovery — `list`\".\n\n`list` shows those stale leftovers; `prune` removes them. By default it reaps every\nregistry entry it can **confirm** is stale — the `.json`/`.lock` pair a runner that died\nabruptly never cleaned up — and leaves every other entry alone. The safety rule is\nstrict and one-directional: prune deletes **only** an entry whose liveness probe\n*succeeded and reported stale*. A **live** entry is never touched; an entry whose\nprobe merely **failed** (its lock file could not be opened, so its liveness is\nunknown, not confirmed dead) is **left in place**, on every repeated run; and no\nentry is ever addressed by PID — a stale record is reaped through the path the scan\nalready found, so PID reuse can never misdirect a deletion. A confirmed-stale entry\nis removed while its lock is still held, so a second concurrent prune cannot race on\nthe same files. A separate pass applies the same confirm-before-delete rule to\n**orphaned lock files** — a lone `.lock` with no `.json` sibling, which the pass\nabove can never see — and reaps those too, tallied separately since it deletes one\nfile, not a pair. On unix a reaped entry takes a third leftover of the same death with\nit: the private `pkc-…` directory and socket that record published as its control\nendpoint, which nothing else ever cleaned up — reaped only after the endpoint passes a\nstrict shape check (it is untrusted data) and without ever following a symlink.\nWithout `--json` it prints a one-line summary (`no stale entries to prune` when there\nwas nothing to do); with `--json` it prints a single JSON object\n`{\"pruned\":N,\"live\":N,\"unprobed\":N,\"orphaned_locks\":N}` — unchanged, since a reaped\nsocket is counted by its own entry. An empty or never-created registry is not an error\n— `prune` reports a zero tally and exits `0`, and pruning a missing registry does not\ncreate it. See [`docs/registry.md`](docs/registry.md), \"Reaping — `prune`\".\n\nRepeatable `prune --label KEY=VALUE` filters use the same exact-match, logical-AND\nsemantics as the other fleet commands. Only paired records carrying every filter are\ncounted or reaped. An explicit filter leaves lone orphaned `.lock` files untouched:\nwithout their record, their labels and ownership are unknowable. The same rule applies\nto `--dry-run`, so a filtered preview remains an exact preview of the filtered reap.\n\n`prune --dry-run` previews that same reap without deleting anything: the identical\nscan and the identical liveness classification, just never a `fs::remove_file`, so an\noperator can see what a real `prune` would do before committing to it. Its aggregate\ncounts are exactly what a following real `prune` over the same, untouched registry\nstate would report. Without `--json` it lists each confirmed-stale candidate (a paired\nentry's `run_id`/`started_at`, plus ` socket_dir=<path>` when a control socket would\nbe reaped with it, or an orphaned lock's file name) followed by a \"would prune …\"\nsummary line; with `--json` (`prune --dry-run --json`) it prints the same\n`pruned`/`live`/`unprobed`/`orphaned_locks` fields plus an additional `candidates`\narray of the same candidates, each tagged `\"kind\":\"entry\"` (with\n`run_id`/`started_at`/`socket_dir`) or `\"kind\":\"orphaned_lock\"`. See\n[`docs/registry.md`](docs/registry.md), \"Reaping — `prune`\".\n\n## Detached runs\n\n`--detach` hands the run to a **detached copy of this binary** and returns as soon\nas that copy has provably started it, instead of keeping the caller as the runner's\nparent for the whole run. The copy is re-spawned on the caller's own command line\n(minus `--detach`): on Unix in a new session (`setsid`), so a terminal hang-up or a\n`Ctrl-C` in the caller's session no longer reaches it; on Windows with\n`DETACHED_PROCESS`, so it holds no console handle. Everything else about the run is\nunchanged — same container, same registry entry, same control transport, same JSONL\nstream, same teardown — because the detached copy runs the very same code path a\nforeground `run` does.\n\n```sh\nprocesskit-cli run --detach --run-id build-42 --jsonl build-42.jsonl -- cargo build\n# returns as soon as the run has started; supervise it from anywhere:\nprocesskit-cli inspect --run-id build-42 --json\nprocesskit-cli wait --run-id build-42\n```\n\n`list --json` and `inspect --json` publish the absolute JSONL locator and the\noptional capture-directory locator from the owner-only registry. A supervisor that\ndid not launch the detached run can therefore find its lifecycle stream and\ntranscripts using only the discovered `run_id`. These operator-selected paths can\ncontain private project names; treat the machine-readable output as private\noperational metadata.\n\n- **\"Started\" is an observation, not an assumption.** The call returns only once the\n  detached runner's `run_started` event is readable in `--jsonl`, which it writes\n  *after* creating the container, publishing the registry record, and spawning the\n  child — so on return the run is already discoverable by `list`, reachable by\n  `inspect`/`cancel`/`kill`, and waitable by `wait`. That also means the run id is\n  always readable from the events file on return, so a caller that let the runner\n  generate one can still find it. The call's own streams end with it, too: a caller\n  that *captures* output (a shell pipeline, `subprocess.run(capture_output=True)`)\n  sees end-of-file when the call returns, not when the run ends — the detached runner\n  is left holding none of the caller's handles.\n- **The exit code reports the start, and only the start.** This is the one mode in\n  which the runner's exit code is *not* the child's: a confirmed start is `0` even\n  for a child that later fails, and a start that failed keeps the same reserved-band\n  code the run would have reported in the foreground — a missing program is still\n  `SPAWN` (`101`), an unusable container still `BACKEND` (`102`), an unwritable\n  `--jsonl` still `SETUP` (`111`). A failed start is never reported as success. The\n  child's real exit code is not lost, it simply moves to where a detached caller can\n  observe it: the terminal `runner_exit` event in `--jsonl` (see\n  [the exit-code contract](docs/exit-codes.md), \"Detached runs\").\n- **No live echo.** The detached runner runs with `--no-echo`'s discarding sinks —\n  there is nobody left to echo to — while `--capture-dir`, `--idle-timeout`, and the\n  JSONL stream keep observing the child's output exactly as in the foreground.\n  `--jsonl` stays required, and is the only channel a detached caller has left.\n- **Not for interactive runs.** `--detach` conflicts with `--inherit-stdio` and\n  `--inherit-stdin` at parse time: a detached run has no terminal to hand over. The\n  detached copy's own stdin/stdout/stderr are `null`, so it can neither read from nor\n  write into a caller's terminal or pipe after that caller is gone.\n- **Windows:** because the detached runner has no console of its own, a console child\n  gets a fresh one from the OS — a stray console window unless `--create-no-window`\n  is passed as well, which is exactly what that flag is for (see\n  [Windows console](#windows-console)). A detached process also stays in any Job\n  Object the caller belongs to, so a caller inside a kill-on-close job cannot detach\n  a run out of it.\n- **Unix:** the detached copy is this process's direct child only until this process\n  exits moments later; `init` adopts it from there. If the start never completes\n  within its startup budget, the detached copy is killed rather than left behind\n  unreported — with the same reach an abrupt runner death has on this platform (the\n  `abrupt_cleanup` tri-state above).\n\n## Exit codes\n\nThe runner's exit code **is** the child's exit code; the runner's own failures\n(bad arguments, spawn failure, backend error) and the four runner-*imposed*\nendings — a `--timeout` (`106`), a local stop-signal cancel (`107`: `Ctrl-C`, on\nUnix `SIGTERM`/`SIGHUP`, or on Windows `Ctrl-Break`/console close/logoff/system\nshutdown), a control-plane `cancel`\n(`108`), and a control-plane `kill` (`109`) — use a distinct, reserved code band so\nthey can never be mistaken for a child result, and so each ending is tellable from the\nothers by code alone. This is part of the project's compatibility surface — see\n[the exit-code contract](docs/exit-codes.md). The preflight `probe` adds one code\nto the reserved band — `PROBE_INCOMPATIBLE` (`110`) — for an incompatible launch\ncandidate, and `wait` adds one more — `WAIT_TIMEOUT` (`112`) — for its own deadline\nelapsing while the run it was watching is still live. That last one describes the\n*waiting client*, not the run: nothing was stopped, so it is deliberately distinct\nfrom the run's own `TIMEOUT` (`106`). `events --validate` adds `EVENTS_INVALID`\n(`114`) in the same spirit — a verdict about a *document*, not about any run: the\nstream it checked does not conform to this binary's event schema.\n\n`attest` adds `NOT_A_MEMBER` (`115`) in that same spirit, and for the opposite\nreason: it is a *decided verdict* — the runner was reached, it named the caller from\nthe control transport, and the caller is not in its container — which is exactly why\nit is not folded into the `CONTROL` (`103`) that means \"no answer to act on\".\n\n`doctor` adds the last one, `HOST_UNQUALIFIED` (`116`), and it is the host-side twin\nof `PROBE_INCOMPATIBLE` (`110`): `110` says *this binary* does not expose the surface\nyou need, `116` says *this machine* did not complete the qualification — or completed\nit and is not the machine you required. The two call for opposite responses (install a\ndifferent build, versus fix or avoid the host), which is why they are different\nnumbers.\n\nA code is necessarily coarse — `CONTROL` (`103`) alone covers eight different\nsituations, from a stale registry entry to an ambiguous run id. A consumer that\nneeds the finer verdict without parsing prose adds the global `--error-format json`,\nwhich prints one bounded JSON object on stderr naming that same code plus a more\nspecific `kind`; see\n[Machine-readable failures](docs/exit-codes.md#machine-readable-failures---error-format-json).\n\n`run --detach` is the single exception to the first sentence, and it mints no code\nof its own: with the run handed to a detached copy there is no child left for this\nprocess to forward a code from, so the code reports whether the run *started* — `0`\nonce it has, otherwise the very same reserved code the failed start would have\nreported in the foreground. See [Detached runs](#detached-runs) above.\n\n## Timeouts, cancel, and grace\n\nIn the default and input-only I/O modes, `run` bounds a run three ways — a\nwhole-run deadline, an idle (no-output) deadline, and a cancel signal (interactive\nor external) — and all of them end in the **same** teardown path:\n\n- `--timeout <duration>` is a hard deadline for the whole run. When it elapses the\n  runner ends the run and exits with the reserved `TIMEOUT` code (`106`) — never\n  the child's own code, because the child did not choose to stop.\n- `--idle-timeout <duration>` is a deadline on child **silence**, for the classic\n  stuck-worker case (a build worker that is alive but has long stopped making\n  progress). Its deadline is *re-armed* on every chunk of the child's output, so a\n  child that keeps talking is never reaped no matter how long it runs — only one\n  that goes quiet past the window is. An idle expiry reuses the **same** `TIMEOUT`\n  code (`106`) and the same teardown as `--timeout`; the two are told apart by the\n  `timeout` event's `reason` field (`overall` vs `idle`) in the JSONL stream, not by\n  a distinct exit code. Because it needs the runner's output pump to observe the\n  child, it cannot be combined with `--inherit-stdio` (a parse-time error, like\n  `--capture-dir`); it does compose with `--capture-dir`, which re-arms the same one\n  timer through its tee.\n- **`Ctrl-C`** cancels a run in progress. The runner ends it and exits with the\n  reserved `CANCELLED` code (`107`), distinct from a timeout and from any child\n  code, so \"I interrupted it\" is never confused with \"it ran too long\" or with a\n  child that merely returned non-zero.\n- **`SIGTERM` and `SIGHUP` (Unix)** are caught too, and take the *same* path: an\n  external `kill <pid>`, a `systemctl stop`, a cancelled CI job, or a hung-up\n  terminal ends the run through the full teardown rather than killing the runner\n  where it stands. This matters because the default disposition of those signals\n  would skip teardown entirely — no terminal JSONL events, a registry entry left\n  behind, and no explicit kill of the container, whose abrupt-owner-death reap\n  covers only the direct child on Linux and nothing on macOS (the `abrupt_cleanup`\n  tri-state under [Platform matrix](#platform-matrix)). A signal your environment\n  has deliberately neutralized before launching the runner (`SIG_IGN` — which is\n  exactly what `nohup` does to `SIGHUP`) is **left alone**: the runner does not\n  un-ignore it behind your back, and nothing is lost by that, since an ignored signal\n  would not have stopped the runner either.\n- **`Ctrl-Break`, console close, logoff, and system shutdown (Windows)** are caught\n  too, through the same console-control-handler mechanism `Ctrl-C` already used, and\n  take the *same* path. Uncaught, their default handling would likewise skip\n  teardown entirely: the terminal JSONL events (`cancelled`, `cleanup_started`,\n  `cleanup_finished`, `runner_exit`) would never be written and the run's registry\n  entry would be left behind stale until `prune` — the run would go dark to any\n  observer of the event stream or registry, even though the tree itself is not left\n  orphaned: closing the runner's last Job Object handle still reaps it (`whole_tree`\n  on Windows, the `abrupt_cleanup` tri-state under [Platform matrix](#platform-matrix)).\n  Catching these events turns that invisible-but-contained ending into a reported,\n  ordinary one. The console-close event carries an OS-imposed deadline: Windows\n  gives the handler only about 5 seconds before terminating the process regardless,\n  so the runner caps the *effective* `--grace`\n  for that one trigger to a budget comfortably under that window — a longer request\n  degrades to the shorter, honest wait rather than risking the OS killing the runner\n  before it even finishes reporting the teardown. Logoff and shutdown are left\n  uncapped, since their real deadline is a system-wide policy this runner cannot\n  reliably discover (see the `wait_for_cancel_signal`/`effective_grace_for` doc\n  comments in `src/run/signals.rs` for the full reasoning). **Known limitation:** unlike a\n  repeat Unix `SIGTERM`/`SIGHUP`/`Ctrl-C` mid-teardown (silently absorbed — the OS\n  keeps the disposition installed for the process's lifetime regardless of listener\n  state), a *second* Windows console-control event that arrives after teardown has\n  already begun is **not** absorbed: it falls through to the OS's default handling\n  and terminates the runner outright, mid-teardown, before the terminal JSONL events\n  are written. This is an accepted trade-off, not a bug — see the `#[cfg(windows)]`\n  arm of `wait_for_cancel_signal` in `src/run/signals.rs` for why keeping listeners\n  alive for the whole teardown was rejected.\n\nAll of the signals/events above share the `CANCELLED` code (`107`); which one\narrived is recorded in the JSONL `cancelled` event's `source` field (`ctrl_c` /\n`sigterm` / `sighup` (Unix) / `ctrl_break` / `ctrl_close` / `ctrl_logoff` /\n`ctrl_shutdown` (Windows)).\n\nWith `--inherit-stdio`, native terminal delivery takes precedence; see\n[Standard I/O](#standard-io) for the platform-dependent reporting contract.\n\n`--grace <duration>` sets the pause between the *soft* stop and the *hard* kill on\nboth paths: the runner first asks the process tree to stop, waits up to the grace\nwindow, and only then hard-kills whatever remains. The hard kill is always the\nowning container's kernel-backed **kill-on-drop** (a Windows Job Object close, a\nLinux cgroup / POSIX-group teardown), so the whole tree — including any leaked\ngrandchild — is reaped on every ending.\n\nDurations use a small grammar: a non-negative integer with an optional unit —\n`ms`, `s` (the default), `m`, or `h` (e.g. `30`, `500ms`, `5s`, `2m`, `1h`). A\nmalformed value is a usage error (exit `100`), not a surprise at runtime. `0` is\nlegal for `--grace` — it means \"no pause\" between the soft stop and the hard\nkill. It is **not** legal for `--timeout`/`--idle-timeout`: a `0` there would arm\na deadline that is already elapsed on the very first poll, tearing the child down\nimmediately after spawn — almost certainly an operator typo rather than an\nintentional immediate teardown — so both flags reject a total of `0` (in any\nunit) at parse time, the same \"degenerate cap\" treatment `--max-memory`,\n`--max-processes`, and `--cpu-quota` already give their own `0`. Omit either flag\nto leave that deadline unbounded instead.\n\n**Honest degradation on Windows.** A Windows Job Object has no POSIX signal, so the\nsoft stop there is a best-effort soft *close*: `WM_CLOSE` for windowed members and,\nwhen `--windows-graceful-ctrl-break` is set, `CTRL_BREAK` for the direct console child\nand its console process group. The opt-in conflicts with `--create-no-window` and\n`--detach`, which deliberately remove the shared console it needs; it is a no-op on\nnon-Windows targets. Without an eligible window or opted-in console leader, the\npre-stop capability probe reports `none` and the Job Object is killed atomically.\n`cleanup_finished.shutdown` records that scope and ProcessKit's structured stop\nobservations; the runner never claims a request was delivered when none was.\n\nThe machine-readable form of these outcomes is the `timeout` / `cancelled` event\n(and the terminal `runner_exit`) in the versioned JSONL stream written to\n`--jsonl` — see [the JSONL event schema](#jsonl-event-schema) — alongside the exit\ncode and the stderr message.\n\n## Windows console\n\n`--create-no-window` maps directly onto ProcessKit's\n`Command::create_no_window()` (the `CREATE_NO_WINDOW` creation flag on Windows; a\nno-op elsewhere). **It defaults to off.** A bare `run` should behave as much like\nlaunching the child directly as possible, so the runner does not force the flag —\ndoing so unconditionally would diverge from a direct launch and could hide a\nchild that legitimately wants its own console. The runner itself never allocates\na console, so it spawns no extra console host on its own account. Headless\nWindows deployments that want to suppress a stray `conhost` window for the child\npass `--create-no-window` explicitly. It cannot be combined with\n`--inherit-stdio`: suppressing the child's console and promising to preserve the\ncaller's terminal are contradictory requests.\n\n`--windows-graceful-ctrl-break` is the opposite console policy: it keeps the shared\nconsole and opts the child into a dedicated console process group so timeout/cancel\nteardown can send `CTRL_BREAK` before hard escalation. It conflicts with\n`--create-no-window` and `--detach` at parse time.\n\nA [detached run](#detached-runs) is the case where passing it matters most: the\ndetached runner is created without a console at all, so a console child cannot\ninherit one and Windows gives it a fresh console of its own — visible unless\n`--create-no-window` says otherwise.\n\n## Environment\n\nBy default the child inherits the runner's own environment unchanged, exactly as\nlaunching it directly would. Five flags give control over that, mapping straight\nonto `processkit::Command`'s own environment builder (`env`/`env_remove`/\n`env_clear`) — the runner never reimplements this logic itself:\n\n- `--env-clear` clears the child's entire inherited environment, so it starts\n  from an empty slate rather than the runner's own environment.\n- `--env-remove <KEY>` (repeatable) removes one inherited variable by name.\n- `--env-file <file>` (repeatable) reads a UTF-8 file of `KEY=VALUE` lines. Empty\n  lines and lines whose first non-whitespace character is `#` are ignored. A missing,\n  unreadable, non-UTF-8, or malformed file is a pre-spawn `SETUP` (111) failure.\n- `--env <KEY=VALUE>` (repeatable) sets one variable for the child, splitting on\n  the *first* `=` (so a value that itself contains `=` is preserved verbatim).\n- `--run-id-env <KEY>` sets `KEY` to **this run's final id** — see\n  [Publishing the run id to the child](#publishing-the-run-id-to-the-child) below.\n\nFor every entry source, `KEY` must be non-empty and contain no whitespace or\ncontrol characters (one shared rule, so no flag accepts a name another rejects).\nInvalid command-line entries are usage errors; invalid file\nentries fail setup before the child starts. Parser diagnostics never repeat the\nvalue, so a malformed secret-bearing entry does not disclose its secret.\n\n**Applied order: clear, then remove, then env-file, then explicit set, then\n`--run-id-env`** — regardless of the order the\nflags are given on the command line. Concretely: `--env-clear` first empties the\nslate (or is skipped if absent), `--env-remove` then strips any of the remaining\ninherited variables it names, each `--env-file` is applied in argument order,\n`--env` is applied after those, so an explicit `--env`\nalways wins over an `--env-remove` of the same key, and the `--run-id-env`\ninjection closes the chain. This is the intuitive \"what I\nset is what I get\" outcome: combining `--env-clear` with an `--env` for the same\nkey still leaves that key set (`--env` fills the slate back in after the clear),\nand combining `--env-remove KEY` with `--env KEY=VALUE` always sets `KEY=VALUE`\nregardless of which flag was written first on the command line.\n\n`--env-file` is the secret-safe path: values live in the file rather than the\nrunner's argv (and are never copied into lifecycle events or registry records).\n\n### Publishing the run id to the child\n\n`--run-id-env <KEY>` sets one child variable to this run's **final** id: the\nexplicit `--run-id` when one was given, otherwise the id the runner generated.\nIt is the same value `run_started.run_id`, the registry record, and every\ncontrol-plane reply carry, so a child — and everything it spawns — can name the\nrun it belongs to:\n\n```sh\nprocesskit-cli run \\\n  --run-id-env PROCESSKIT_RUN_ID \\\n  --jsonl build.jsonl \\\n  -- ./build.sh\n```\n\nWithout it, a supervising adapter that needs the child to know its run has to\nmint the identity itself and pass it twice (`--run-id <id> --env KEY=<id>`) —\ntwo copies that can drift, and which rule out a runner-generated id entirely,\nsince a generated id is not knowable outside the run until the run has started.\n\n- **Strictly opt-in.** Omit the flag and nothing is injected; there is no default\n  key and no unconditional `PROCESSKIT_RUN_ID`.\n- **It is applied last** (see the order above), so it wins over an `--env-file`\n  entry or an `--env-remove` for the same key whichever order those flags were\n  written.\n- **An explicit `--env KEY=…` for that same key is refused**, not silently\n  overridden: two flags asking for two different values of one variable is a\n  caller mistake, and it fails at parse time as a `USAGE` (100) error before\n  anything runs. The outcome never depends on the order the two flags were\n  written — that is the reason it is a refusal rather than a precedence rule.\n  \"The same key\" follows the platform's own rule for environment names: on\n  Windows they are case-insensitive, so `--env KEY=…` collides with\n  `--run-id-env key` exactly as it does with `--run-id-env KEY`; elsewhere the\n  two really are different variables and only the identical spelling collides.\n- **The value is correlation data, not a credential or a security proof.** It\n  identifies which run some work belongs to; it proves nothing about who started\n  that run, grants no authority, and is trivially forgeable by anything that can\n  set an environment variable. Use it to label work, never to authorize it.\n\nOperator labels are separate, non-secret metadata: repeat `run --label KEY=VALUE`\nto group runs for discovery and aggregate operations. Keys are 1-64 ASCII bytes,\nstart with a letter or `_`, and otherwise use letters, digits, `.`, `-`, or `_`;\nvalues are at most 256 characters and, like an explicit `--run-id`, cannot contain\nterminal control or invisible formatting characters (bidi overrides and isolates,\nzero-width marks, tag characters). A later run label replaces an earlier value for\nthe same key; a label already on an older registry record that fails either check\nis dropped from that record's map while the record itself is kept. `list` and\n`run_started.labels` expose the resulting map; aggregate label filters combine with\nlogical AND.\n\n## Bounded output capture\n\n`--capture-dir <dir>` records a transcript of the child's output to files —\n`<dir>/stdout.log` and `<dir>/stderr.log` — **alongside** the live echo, which is\nunchanged: the same output still streams to the runner's own stdout/stderr. The\nchild's stdout and stderr are captured to separate files, never interleaved.\nIt cannot be combined with `--inherit-stdio`, because direct child writes never\npass through the runner's bounded tee.\n\nThe capture is **bounded**. ProcessKit's line pump drains the child's pipes under a\nbyte-capped [`OutputBufferPolicy`](https://docs.rs/processkit) — so the runner\nwrites no draining or volume-limiting of its own — and each file is held to a\nper-stream ceiling. For each stream the runner records, in the `output_captured`\nJSONL event (see [the schema](docs/schema.md)):\n\n- a **full byte counter** — every byte the stream produced, so it stays honest even\n  when the file was clipped;\n- a **SHA-256** of the bytes written to the file (the same one-way digest used for\n  the argv fingerprint), so a consumer can verify the file it holds; and\n- an **explicit truncation flag** — set when the stream outran the ceiling, so\n  \"captured in full\" is told from \"clipped at the limit\" by the flag, not by\n  guessing from the file's size.\n\nWithout `--capture-dir`, nothing changes: no capture files, no `output_captured`\nevent, and the event stream is byte-for-byte identical to a plain run.\n\n`--capture-dir` is independent of `--no-echo`: capture is fed by the same tee\nregardless of whether the live echo is suppressed, so a run with both flags\nrecords exactly the same `stdout.log`/`stderr.log` bytes and `output_captured`\nevent as one with `--capture-dir` alone — only the runner's own stdout/stderr\nretransmission is affected by `--no-echo` (see [Standard I/O](#standard-io)).\n\nThe per-stream ceiling defaults to 8 MiB and is configurable with\n`--capture-max-bytes <size>`, only meaningful together with `--capture-dir`.\nSame grammar as `--max-memory`: a byte count with an optional binary unit —\n`1048576`, `512k`, `256m`, `2g` (`k`/`m`/`g` are KiB/MiB/GiB, 1024-based). A\nmalformed value is a usage error (exit `100`) rejected at parse time, not a\nsilent fallback to the default. Omitting the flag leaves the default 8 MiB\nceiling in place, so a bare `run --capture-dir` (no `--capture-max-bytes`)\nstays byte-for-byte unchanged; `output_captured`'s shape and its `truncated`\nflag's meaning are unaffected — `truncated` still just means \"the stream\noutran whichever per-stream ceiling was in effect,\" regardless of its size.\n`--capture-max-bytes` is independent of the pump's separate in-flight\nline-assembly ceiling (64 MiB, not user-configurable): that one bounds a\nsingle still-unterminated line's in-memory assembly, a pathological-input\nconcern sized once for the whole binary, while `--capture-max-bytes` bounds\nthe on-disk per-stream transcript size, an operator's disk-budget choice per\ninvocation — the two are deliberately kept separate rather than one deriving\nfrom the other.\n\nBy default, overflow keeps this historical truncate-and-continue behavior. The\nopt-in `--capture-overflow cancel` policy instead ends the run when either stream\nfirst exceeds the ceiling. It emits `output_overflow` with the first stream and\nlimit, follows the same soft-stop → `--grace` → hard-kill path as a timeout, and\nreturns runner-owned `OUTPUT_OVERFLOW` (113); terminal `runner_exit.source` is\n`output_overflow` and `child_code` is `null`. The policy works with `--no-echo` and\ndetached runs because both retain the same capture tee. `--capture-overflow\ntruncate` is an explicit spelling of the default.\n\n## Resource limits\n\nThree optional flags cap the run's **whole process tree** — not a single process —\nmapping straight onto ProcessKit's own `ProcessGroupOptions` resource caps (the\nrunner never reimplements the OS-level enforcement):\n\n- `--max-memory <size>` — total tree memory. A byte count with an optional binary\n  unit: `1048576`, `512k`, `256m`, `2g` (`k`/`m`/`g` are KiB/MiB/GiB, 1024-based).\n- `--max-processes <n>` — number of live processes in the tree (`n` > 0).\n- `--cpu-quota <cores>` — CPU as a fraction of a **single** core: `0.5` is half a\n  core, `2` is two cores' worth (a finite value > 0).\n\nOmit a flag to leave that resource unbounded; a bare `run` requests no caps at all\nand behaves exactly as before. A nonsensical value (`--max-memory 0`, a\nnon-positive or non-finite `--cpu-quota`, `--max-processes 0`) is a usage error\n(exit `100`) rejected at parse time, not a surprise at runtime.\n\n**Enforcement is platform-specific, and honestly fail-fast — never a silent\nno-op.** A whole-tree cap needs a real container:\n\n| Platform / mechanism | Resource limits |\n| --- | --- |\n| Windows — Job Object | **Enforced** (memory, CPU, and active-process count). |\n| Linux — cgroup v2 **at the real hierarchy root** | **Enforced.** Requires the runner to be a direct member of the real cgroup-v2 root (a minimal, non-systemd init). **Not** under a systemd session/scope/service, and **not** in an ordinary container (incl. Docker/Kubernetes and **typical CI such as GitHub Actions**) — there the controllers can't be enabled and the request is *unenforceable*. |\n| FreeBSD — process reaper | **Unsupported for resource caps.** The reaper still provides whole-tree normal containment, membership, and kill/teardown semantics, but memory, CPU, and process-count caps are unavailable, so any cap request fails fast. |\n| macOS and non-FreeBSD BSD — process group; Linux process-group fallback | **Unsupported** — no whole-tree resource-limit container exists, so any cap request fails fast. |\n\nWhere a cap **cannot** be applied, the run does not silently continue unbounded:\nit fails fast **before the child is spawned**, emits a `limit_hit` JSONL event\nnaming which limit (`memory` / `processes` / `cpu`), and exits with `BACKEND`\n(`102`) — the `limit_hit` event, not the exit code, is the authoritative signal\nthat the ending was a resource limit (see [the exit-code\ncontract](docs/exit-codes.md)). An adapter that *depends* on a cap should treat a\n`limit_hit` as a hard failure rather than a warning.\n\n**What the tree actually consumed is a separate event.** `limit_hit` and\n`limit_evidence` are both about a *cap* — whether it could be installed, and whether it\nfired. Neither says how much the tree used. Every run that spawned a child, capped or\nnot, additionally emits a terminal `resource_summary` event with slots for peak memory,\ntotal CPU, IO bytes read and written, and peak process count. There is no flag: it is one\nread of counters the kernel already keeps. **Which slots carry a number is a property of\nthe mechanism, not a promise of the event**, and each measurement is independently `null`\nwhere the mechanism does not account for it — never `0`, and never a number this runner\nimproved by sampling itself. So `peak_process_count` is always `null` on Windows. FreeBSD's\nprocess reaper has no `resource_summary` accounting, so all five measurement fields are\n`null`; macOS, non-FreeBSD BSDs, and the Linux process-group fallback likewise report no\nmeasurements. Linux cgroup v2 needs the `io` controller for IO values, and on Linux\ncgroup v2 memory and CPU are summed over the members still live when the read happens, so\na run whose child exited on its own reports both as `null` while a `--timeout`/cancel/kill\nending, read while the tree still runs, reports them. The two platforms' IO counters are\n**not** comparable with each other. See [Resource limits](docs/resource-limits.md), \"What\nthe tree consumed\", for the normative matrix — it is the authority on which axis is `null`\nwhere — and preflight the event itself with `probe --json --require-surface\nrun:resource-summary`.\n\n**Linux `--max-processes` caveat.** On Linux the kernel checks the process-count\ncap only when a process forks *inside* the cgroup, so `--max-processes` reliably\nbounds the **descendants** a contained child spawns (its own fork bomb), but does\n**not** reject additional top-level launches into the group. Treat it as a bound on\na tree's own growth, not an exact admission count. (Memory and CPU caps are\nwhole-cgroup and have no such asymmetry; on Windows the Job Object's\nactive-process limit does cap every launch into the job.)\n\n## JSONL event schema\n\n`run` writes a versioned stream of **JSONL lifecycle events** to the file named by\n`--jsonl` — one JSON object per line, each carrying a `schema_version`, and\n**never** to stdout (the child's streams stay pristine). This repository owns that\ncontract as a public API: adapters such as the processkit-py CLI pin\n`schema_version` and reimplement the shapes, so it is versioned, documented, and\ngolden-tested.\n\nThe stream covers the run lifecycle: `run_started` (run id, root PID, containment\nmechanism, working directory), `members_snapshot`, `root_exited`, the\n`cleanup_started` / `cleanup_finished` teardown pair, `timeout` / `cancelled`,\nlaunch and container errors (including a `limit_hit` when a `--max-memory` /\n`--max-processes` / `--cpu-quota` cap could not be applied — see \"Resource\nlimits\"), a `resource_summary` recording what the contained tree consumed (peak\nmemory, CPU, IO bytes, peak process count — on every run that spawned a child, with\neach measurement `null` where the mechanism does not account for it, or where it kept\nno record surviving to the read point; see \"Resource limits\" for the normative\nper-mechanism matrix), an `output_captured` event when `--capture-dir` is set (see \"Bounded\noutput capture\"), and a terminal `runner_exit` that preserves the child's own code\neven when the runner itself fails — so a child's code is never lost or aliased.\n\nThe command line is **redacted by default** (`argv` is recorded only under\n`--argv-raw`); in its place a one-way SHA-256 fingerprint of argv (`argv_sha256`)\nand, for recognized worker shapes, a categorical `hint` are recorded on every run —\nneither can reveal the command line. Both the fingerprint and `--argv-raw`'s\nrecorded `argv` are derived from each argument's exact bytes, so an argument the\nplatform accepts but Unicode cannot express (ill-formed bytes on Unix, an unpaired\nsurrogate on Windows) keeps its own identity instead of collapsing onto every other\nsuch argument — see [`docs/schema.md`](docs/schema.md) (\"Command redaction\") for\nthe encoding and for how such an element is written into JSON. Member snapshots carry each member's `ppid`,\nexecutable `name`, and start-time token wherever ProcessKit's `members_info()` can\nreport them (nullable per-field on platforms/members it can't).\n\nA run records its tree shape once, right after spawn. `run --snapshot-interval\n<duration>` opts into a **periodic** re-emission of that same `members_snapshot`\nfor as long as the child runs (told apart by the event's `reason` field, `spawn`\nvs `interval`), so a long, quiet, or detached run leaves a recorded history of how\nits tree evolved — when the worker fleet grew, whether helpers lingered, what the\ntree looked like just before a deadline fired — instead of only a start-of-run\nshape plus a teardown count. The cadence samples the container's own member list\nrather than the output pump, so it composes with every I/O mode including\n`--inherit-stdio`, and it stops as soon as the run's ending is decided. A sample\nwhose member read fails is still recorded, flagged `read_error: true` with an empty\nmember list, because a detached runner's stderr goes nowhere and the stream is the\nonly place such a failure can be reported.\n\nOmitting the flag adds no event: the run emits the same one `members_snapshot`, at\nthe same point in the stream, that it always did. The event's **wire form** did\nchange for every run, flag or not — `members_snapshot` now always carries `reason`\nand `read_error` — which is additive within schema v1 (see\n[`docs/schema.md`](docs/schema.md), \"Versioning\") but is not \"unchanged\", so an\nadapter that pinned this event's exact field set rather than the fields it reads\nwill see the difference on the default path too. The cadence is also the first\nthing that makes the stream's size grow with a run's duration; that boundary is\ndeliberate and its arithmetic is in\n[`docs/running-commands.md`](docs/running-commands.md), \"Recorded tree snapshots\".\n\n- Normative field reference: [`docs/schema.md`](docs/schema.md).\n- Golden sample stream for adapters:\n  [`fixtures/schema/v1/events.jsonl`](fixtures/schema/v1/events.jsonl).\n- Machine-readable JSON Schema (draft 2020-12) for the same contract:\n  [`fixtures/schema/v1/schema.json`](fixtures/schema/v1/schema.json).\n- **Checking a stream against it, offline.** `processkit-cli events --file\n  <stream.jsonl> --validate` checks a stream — a run's own, or an adapter's\n  fixture — against that very schema as embedded in the binary, reporting each\n  violation by line and exiting `EVENTS_INVALID` (`114`) if any line does not\n  conform. No clone, no separate validator, and no second implementation of the\n  schema to keep in sync.\n- **Getting the schema without a git checkout.** A consumer holding only an\n  installed binary or an unpacked release archive — no clone, no tag to match —\n  has two offline ways to get the exact schema its own version emits: run\n  `processkit-cli probe --json --print-schema`, which prints the schema document\n  embedded in that binary at build time (byte-for-byte identical to the\n  `schema.json` above), or unpack the release archive and read the bundled\n  `schema/schema.json` and `schema/events.jsonl` it ships alongside the binary\n  and the shell completions/man pages.\n\n## Status\n\n`run` is implemented: it spawns the child into a ProcessKit container the runner\nowns, echoes the child's output live by default or directly inherits all three\nstandard handles with `--inherit-stdio`, forwards the child's exit code exactly,\nenforces `--timeout`, `--grace`, and stop-signal cancellation (`Ctrl-C`, plus\n`SIGTERM`/`SIGHUP` on Unix, plus `Ctrl-Break`/console close/logoff/system shutdown\non Windows) with a guaranteed\nteardown of the whole tree (see \"Timeouts, cancel, and grace\"), and writes the\nversioned JSONL event stream to `--jsonl` (see \"JSONL event schema\"). `--run-id`\nand `--argv-raw` are consumed by that stream, `--snapshot-interval` adds a recorded\ncadence of `members_snapshot` events to it while the child runs, and\n`--capture-dir` records a bounded\nstdout/stderr transcript with per-stream byte counts, hashes, and truncation flags\n(see \"Bounded output capture\"). `inspect`, `cancel`, `kill`, and `attest` all reach live runs\nthrough the local control plane: `inspect` prints a snapshot, `cancel` ends a run\ngracefully (its shared soft-stop → grace → hard-kill teardown, exit `108`), `kill`\nforce-kills the whole tree immediately (exit `109`), and `attest` answers whether the\ncalling process is inside the run's container — a kernel-checked containment fact,\nwith its own reserved code for a decided negative (`NOT_A_MEMBER`, 115) — each a\ndistinguishable outcome in\nthe JSONL stream and by exit code, and each a bounded `CONTROL` (103) failure when the\nrun cannot be reached. `list` scans the same registry read-only and prints every\nentry, whatever its health (`live`/`stale`/`unprobed`), as a table or (with\n`--json`) as JSON Lines — the discovery counterpart for a caller that has lost or\nnever had a `run_id` — and `prune` reaps the confirmed-stale leftovers it shows. `wait`\nblocks on that same registry until its target — one named run (`--run-id`) or every\nrun confirmed live in a snapshot taken at the start (`--all`) — is no longer live, for\na supervisor that is not the runner's parent, bounding itself with `--timeout` and its\nown reserved exit code (`112`). `events` reads the run's own JSONL stream back —\nrendered, followed to its terminal event, passed through verbatim with `--json`, or\nschema-checked with `--validate` (`EVENTS_INVALID`, 114) — resolving it through the\nsame registry and mutating nothing. `probe` reports and\nverifies the binary's compatibility surface for a consumer's fail-closed launcher\npreflight, with no side effects, exiting `PROBE_INCOMPATIBLE` (110) on an\nincompatible candidate; `probe --print-schema` instead prints the binary's\nembedded JSONL event-schema document and exits (see \"JSONL event schema\"). See\n[the roadmap](docs/ROADMAP.md) for delivery status and the remaining\nProcessKit-rs dependencies.\n\n## Development\n\n```powershell\ncargo build\ncargo test\ncargo clippy --all-targets -- -D warnings\ncargo fmt --all --check\ncargo run --bin processkit-cli -- --help\n```\n\n## Benchmarks\n\nA [criterion]-based regression lane lives under `benches/`, gated behind the\n`bench` Cargo feature so a plain `cargo build`/`cargo test`/`cargo publish`\nnever builds it — like the `e2e` end-to-end tier, this is dev-only tooling. It\ncovers two things:\n\n- **Internal primitives**, in isolation: incremental SHA-256\n  (`src/hash.rs`, `benches/hash_bench.rs`), bounded-capture `absorb`\n  (`src/capture.rs`, `benches/capture_bench.rs`), and the argv worker-shape\n  hint classifier (`src/events.rs`, `benches/hint_classifier_bench.rs`). Each\n  reaches the primitive directly rather than through its caller (see each\n  module's doc comment on why a given item is `pub`) — the library is not a\n  stable API (see \"Target structure: library and binary\" in\n  [`docs/architecture.md`](docs/architecture.md)), and reaching internals\n  directly for exactly this purpose is part of its documented design.\n- **Through-the-binary scenarios**, driving the built binary like the\n  integration tests do: `benches/echo_overhead_bench.rs` compares a direct\n  invocation of a child that writes an exact byte count against the same child\n  run under `run` (plain echo, and echo plus `--capture-dir`) at a couple of\n  payload sizes; `benches/startup_latency_bench.rs` measures the latency from\n  just before `run` is invoked to the `time` the runner itself stamps on its\n  `run_started` JSONL event, over a short series of invocations, against a\n  `direct` control arm that spawns the exact same child with no runner at all\n  — the same-host delta between the two is the publishable number, since\n  either arm's absolute figure alone is not comparable across hosts.\n- **Phase attribution inside that startup window**:\n  `benches/registry_open_bench.rs` isolates the mutating \"owner-only registry\n  open\" `run` performs before it can publish a record, against a bare\n  `create_dir_all`, a no-op read-only open, and the same open forced to actually\n  write the permissions — each swept over registry sizes, since on Windows the\n  hardening ACE is inheritable and its write re-propagates over every record the\n  registry holds. It prints a plain-text attribution table before the criterion\n  groups, so the growth question is answered directly rather than inferred from\n  an end-to-end number.\n\nRun the whole lane locally with:\n\n```sh\ncargo bench --features bench\n```\n\nCI publishes results to the `perf` job's step summary on every push/PR and\nuploads one machine-readable `perf-history-<os>` artifact per commit for 90\ndays. Each run compares its Criterion medians with the latest successful\n`main` artifact on the same OS; increases above 20% create an Actions warning\nand a table in the summary. This remains a data source, not a gate: the job is\nnon-required and `continue-on-error`, because absolute numbers on shared CI\nhardware are noisy. Treat repeated same-OS alerts as a trend worth\ninvestigating, not a single-run verdict.\n\nThe table below is one informal, single-run snapshot from a Windows\ndevelopment host (not tuned, not a target) — a sanity baseline for a reader\nskimming this document, not the authoritative source; run the lane yourself\nfor numbers on your own hardware, or read the CI step summary for the\ntracked trend.\n\n| Benchmark | Result |\n| --- | --- |\n| Incremental SHA-256, 64KiB chunks (256KiB total) | ~116 MiB/s |\n| `StreamCapture::absorb`, 64KiB chunks (256KiB total) | ~98 MiB/s |\n| Hint classifier, no match / MSBuild match | ~520 ns / ~830 ns |\n| Echo overhead, 4MiB payload: direct vs. under `run` (no capture) | ~19 ms vs. ~224 ms |\n| Echo overhead, 4MiB payload: under `run` with `--capture-dir` | ~801 ms |\n| Startup latency: direct child creation vs. `run`'s call to `run_started` | ~22 ms vs. ~150 ms |\n| Owner-only registry open, 0 / 64 / 256 / 1024 entries | ~0.10 ms, flat |\n| The same open when it must write the permissions | ~0.75 ms / ~20 ms / ~96 ms / ~443 ms |\n\nThe live-output figures use ProcessKit's raw byte tee. Profiling the former\ndecoded-line path found that its two awaited writes and mutex acquisition per\nline dominated a chatty payload; chunk-based raw forwarding reduced the 4MiB\nno-capture median by about 96% while preserving the runner's byte-for-byte\npassthrough contract. Capture deliberately remains on its established decoded\nsink so its counters, hashes, and truncation boundary do not change.\n\nA release-build phase trace of short Windows runs against `processkit` 3.1 —\npredating both the registry-ACL fix below and this task's re-measurement, and\nnow superseded by both — attributed less than 1 ms to opening JSONL plus\ncreating the container, about 36-41 ms to re-asserting the registry\ndirectory's owner-only ACL, and another 166-228 ms to `ProcessGroup::start`.\nNeither of the latter two figures is current; the paragraphs below cover what\nreplaced each one.\n\nThe middle phase has since been attributed properly and reduced. It was never a\nconstant metadata write: the Windows ACE is inheritable and applied to a\ndirectory, so writing it re-propagated across every `.json` record and `.lock`\nfile the registry held — which is why the figure depended on how many runs the\nmeasuring host happened to remember, and why an absolute number from one machine\nwas never evidence. `benches/registry_open_bench.rs` measures the growth\ndirectly (see the two rows above: ~0.15 ms per file, ~443 ms at 1024 entries).\n`run` now verifies the directory's DACL and writes only when it does not already\nmatch, which makes the phase flat at ~0.1 ms whatever the registry holds; the\nsecurity guarantee is unchanged, and a directory whose permissions were widened\nout of band is still repaired (see [`docs/registry.md`](docs/registry.md),\n\"Permissions\").\n\nThe remaining `ProcessGroup::start` phase is inside ProcessKit's public API.\nThe 3.1-era trace above attributed the whole 166-228 ms of it to the crate;\nupstream disputed that reading (thread\n`msg-send-ba9dc66e1b832e104c35c9a1e75a6588`), correctly: part of it is the OS's\nown process creation happening inside `start` — on a Windows host with a\nreal-time antivirus, routinely the same order of magnitude — not a fixed cost\n`run` adds on top of nothing. Separately, upstream's own profiling found the\ndominant share of their measured crate-owned time was an all-threads\n`CreateToolhelp32Snapshot` walk of the host to find a just-spawned child's\nprimary thread; that is fixed as of `processkit` 3.2 (direct `NtGetNextThread`\nresolution, falling back to the old walk only where the export is\nunavailable), and this crate already depends on 3.3.\n\n`benches/startup_latency_bench.rs` replaces that 3.1-era absolute with a\nsame-host delta, re-measured against `processkit` 3.3: a `direct` control arm\nspawns the exact same child with no runner at all, timed only up to the point\nthe OS reports it created — the same \"process created, not yet exited\"\nboundary `run_started` is stamped at, so both arms stop at the same kind of\nevent rather than one of them also folding in the child's own runtime, exit,\nand reap. On the numbers above (~22 ms direct, ~150 ms under `run`), the\n~128 ms delta is what going through `run` costs beyond launching the same\nchild directly on this host — the whole price of the wrapper, including the\nOS's own cost of creating the *runner* process itself, not only what\n`ProcessGroup::start` does once the runner is already running. It is\ntherefore not a breakdown of `ProcessGroup::start` in isolation, and the\nsuperseded 166-228 ms trace above should not be read as a stand-in for one;\nthe crate does not currently instrument that phase separately from the\nrunner's own startup. Treat all figures as host snapshots and the CI history\nas the regression signal.\n\n[criterion]: https://github.com/bheisler/criterion.rs\n\n## Safety boundaries\n\n- No reimplementation of ProcessKit containment or lifecycle behavior.\n- No shell mode, PTY support, or global cleanup by executable/process name.\n- Raw command arguments are opt-in only; the default diagnostics contract uses\n  redaction-safe hashes and worker hints.\n"}