All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Add entries to [Unreleased] as you work — manual bullets always win over the
git-cliff auto-fill (config: cliff.toml). On release, promote [Unreleased]
to a dated version section.
0.3.2 - 2026-08-09
- Upstream identifier drift gate: a new opt-in test tier (
tests/spec_drift.rs, thespec-driftCargo feature) holds every projection of a ProcessKit closed enum against the stable-identifier dictionary ProcessKit ships inside its own package (spec/identifiers.json), for the exact versionCargo.lockresolves. It exists because the previous entry was found the slow way:Mechanism::ProcessReaperwas added upstream, this crate compiled unchanged, every test stayed green, and the new mechanism reported itself as the projection'sunknownfallback — a gap between "upstream grew a value" and "we noticed" that was bounded by nothing. The gate covers the seven vocabularies this CLI republishes (Mechanism,ParentDeathCleanup,LimitKind,LimitVerdict,SoftStopScope,SoftSignal,Outcome) and checks each identifier on two surfaces: the Rust projection that renders it — driven with the real variant, so a value falling into the conservative fallback arm fails rather than passes — and every published JSON Schemaenumthat carries it, located by property name acrossfixtures/schema/v1/schema.jsonandfixtures/schema/cli/*.schema.jsonso a value added to the event schema but forgotten in theinspect/attest/doctormirrors is caught too. Every remaining dictionary enum must be recorded as not projected, with the reason, so one added later cannot go unclassified. A failure names the enum and the identifier and changes nothing on its own: what a new upstream value means for a published contract is a decision, and this gate's whole job is to make sure it gets made. The dictionary is located throughcargo metadata's resolve graph — no network, no vendored copy — which also means the gate follows a[patch.crates-io]git checkout, so the scheduled upstream canary now sees a new identifier on ProcessKit's main branch rather than waiting for the release that would deliver it. A missing dictionary (a patched, vendored, or path dependency whose tree omitsspec/) fails loudly and is never skipped: a green "no drift found" that actually means "nothing was checked" would reproduce exactly the blindness the tier removes. The tier is off in the defaultcargo test— it needs a working cargo and the dependency's unpacked source, and its verdict is host-independent — and runs in the new gatingspec-driftCI job, incanary.ymlagainst ProcessKit's main branch, and asjust spec-driftlocally. Documented in CONTRIBUTING.md, "Upstream identifier drift" (including what to do when it fails), and for adapter authors indocs/compatibility.md. No wire, flag, exit-code, or schema change:events::abrupt_cleanup_strgained a pureabrupt_cleanup_scope_str(scope)inner projection (so every arm can be driven on any host, not only the one the machine happens to report), the soft-signal fate and soft-stop scope projections became named functions instead of an inline match and an inline.name(), and fourrunprojections are re-exported for the gate to drive. process_reapercontainment vocabulary: project ProcessKit 3.3's FreeBSD process-reaper mechanism intorun_started,inspect,attest, anddoctor, with schema-v1 fixtures and validators acceptingprocess_reaperplus the intentionalunknownfallback. FreeBSD is documented separately from the POSIX process-group fallback: it has whole-tree kill and membership semantics but no resource-limit or statistics support, while abrupt owner death remainsnone.resource_summary: a new terminal JSONL event reporting what the contained tree actually consumed —peak_memory_bytes,total_cpu_ms,io_read_bytes,io_write_bytes, andpeak_process_count. Until now the stream said nothing about resource usage:members_snapshotcarried onlypid/ppid/name/start_time, so peak memory and total CPU were neither readable nor reconstructible from it. The runner takes oneProcessGroup::stats()reading of what the active mechanism accounts for (via theprocesskit3.2→3.3bump) after the ending is decided and beforecleanup_finishedhard-kills the group — the same read point aslimit_evidence, and immediately after it, because both facts live in the container and vanish with it. It is emitted by every run that spawned a child: no flag, no cap required, every platform, every ending (natural exit, timeout, cancel, kill, and theforegroundcontainer failure alike), exactly once. Choosing "always" over an opt-in flag is deliberate and recorded indocs/schema.md: this is one synchronous read of accumulators the kernel already keeps rather than a sampling cadence, so a flag would save one syscall and one line while leaving the platform that needs the numbers most — Windows, wherelimit_evidencecan only ever answerunknownfor a capped axis — silent unless a caller knew to ask. The honest cost is that a default run's stream is now seven lines rather than six, and this is the one growth a caller did not opt into; the line-count arguments indocs/integration.mdand ADR 0007 are restated against the new count. Each measurement is independently nullable, andnullalways means this mechanism does not account for it — never a stand-in0, and never a value improved by taking a maximum over the runner's own periodic reads, which would describe when the runner looked rather than what the tree did. Consequentlypeak_process_countis alwaysnullon Windows (a Job Object keepsActiveProcessesandTotalProcesses; neither is a peak), both IO counters are alwaysnullon macOS/the BSDs and the Linux process-group fallback, and on Linux they need the cgroup v2iocontroller — which this CLI never enables, sinceprocesskitenables exactly the controllers a requested cap needs. On Linux cgroup v2 the availability ofpeak_memory_bytes/total_cpu_msis a property of the read point rather than of the controller set, and the documentation says so instead of implying completeness: those two are summed from/procover the members live whenstats()runs (the cgroup keeps no CPU/memory accumulator this backend reads), so a run that ended by its child exiting — the commonest ending — reports both asnullwithread_error: false, a run whose child leaked a surviving descendant reports numbers covering only that survivor, and only a runner-imposed ending (timeout/cancelled/killed/output_overflow, read before the soft stop) reports the whole tree. Windows is unaffected: a Job Object's accounting block outlives the processes charged to it. The two platforms' IO counters are explicitly not comparable with each other (a Job Object counts all read/write traffic whatever the target; cgroupio.statcounts only what crossed the block layer), andtotal_cpu_mstruncates, so a run using under a millisecond of CPU reports a measured0—nullremains the only value meaning unknown. The normative platform matrix, and what this event does not prove about a limit, are indocs/resource-limits.md, "What the tree consumed".active_process_countis deliberately not on the event: it is a "how many right now" reading taken after the ending is decided, so it would report the moment the runner looked, and the tree size at teardown already has an honest home incleanup_started.members_before. A failedstats()read does not skip the event: it is emitted withread_error: trueand every measurementnull, mirroringmembers_snapshot/cleanup_started/cleanup_finished's existing honest-degradation flags — and here the flag is load-bearing rather than ceremonial, because an all-nullsummary is also a correct success — on a mechanism with no whole-tree accounting, and equally on a flagless Linux cgroup v2 run that ended by its child exiting — so nothing else could distinguish a gap from a platform fact. The event is additive withinschema_version = 1: no existing field was renamed, retyped, or given a new meaning, and the golden fixture gained one appended line with every prior line byte-for-byte unchanged.events --validateaccepts a conformingresource_summary(including the fully degraded shape) and rejects a corrupted one, andprobe --jsonpublishes a new non-flag capability token,run:resource-summary, so an adapter that will read the summary can pin the event at preflight (probe --json --require-surface run:resource-summary) instead of discovering an older binary's silence only after a run has finished without it. That token's presence guarantees the event, not any particular measurement in it — which axes carry numbers follows the mechanism named byrun_started.mechanism. It is the second token in the---less capability form afterattest:peer-identity, and the first whose absence is a matter of build version rather than platform.doctor [--json]: a runtime qualification of the host, and the side-effecting counterpart toprobe.probeproves this binary exposes the surface a consumer needs — it reads compile-time constants and the in-memory CLI tree, spawns nothing, and touches no registry, container, or transport, which is exactly what makes it safe as a per-launch preflight and exactly why a passing probe is not evidence that a run will work here. A binary can satisfy every--require-*check and still fail its first real run on a registry directory it cannot create, a containment mechanism the kernel will not hand out, or a local IPC endpoint that will not bind.doctorcloses that gap by doing the thing: it performs a bounded scratchrunof this binary's own harmless child (the new report-replacingdoctor --scratch-child <duration>, which sleeps and does nothing else — published rather than hidden, so the claim that a qualification contains only this binary's own code can be checked, and refused by clap in combination with any other flag so a requested qualification can never be silently replaced by a sleep), drives that run as an ordinary control-plane client, and reports the facts it observed: the registry directory and its owner-only protection (re-read from the filesystem, by the same predicate the registry's own tests use), the containmentmechanismandabrupt_cleanuplevel this machine really gives a run, aninspect/cancel/ terminal-wait round-trip over the local transport, a confirmed-empty teardown (read_errorand the remaining members, not one boolean), optionally the whole-tree resource controller (--check-resource-controller), and per-phaseelapsed_msso a slow host is diagnosable rather than a generic hang. It reimplements none of it — every phase drives the same production code a caller's ownrunand control clients take — which is what makes a pass evidence about this containment path on this host. On success every scratch artifact is gone and the report says so, having checked each; on a failed phase a named diagnostics directory is kept (diagnostics_dir) holding the scratch run's JSONL stream, the runner's stdout/stderr, and a copy of the report. The requirement flags (--require-mechanism,--require-abrupt-cleanup,--require-resource-controller) gate the exit code only — the new reservedHOST_UNQUALIFIED=116, the host-side twin ofPROBE_INCOMPATIBLE(110) — while the report carries the observed facts either way; a matching--error-format jsonkind (host_unqualified) accompanies it. The report is published as a ninth machine-output family (fixtures/schema/cli/doctor.schema.json+doctor.jsonl) carrying its owndoctor_version(currently1), versioned because a qualification report is kept: the failure path writes it into the diagnostics directory precisely so it can be read later, elsewhere, by whoever debugs the host rather than by whoever ran the command. Seedocs/troubleshooting.md, "Qualifying a host:doctor",docs/integration.md§1, anddocs/exit-codes.md.attest --run-id <id> [--json]: a read-only control-plane command that asks a live run whether the calling process is inside its ProcessKit container. The runner takes the caller's identity from the control transport itself — unix socket peer credentials (SO_PEERCREDon Linux,LOCAL_PEEREPIDon macOS,LOCAL_PEEREIDon NetBSD,getpeerucredon Solaris/illumos) orGetNamedPipeClientProcessIdon the Windows named pipe — reads it while the connection is open (so pid reuse cannot turn a departed client into a false positive), and checks it against the run's own live container membership through the samemembers_info()pathinspectand the JSONLmembers_snapshotalready use. There is deliberately no--pidand no--all: a caller-supplied pid would only prove that some chosen process is a member, which says nothing about the asker. This turns an adapter's environment-string convention ("the caller belongs to run X") into an invariant the runner checks. Three outcomes:member(exit0),not_a_member(the new reserved exit codeNOT_A_MEMBER=115), andpeer_identity_unsupported(the existingCONTROL=103) — a platform that cannot obtain a kernel-authenticated peer identity fails closed rather than degrading to an unproven "ok", and a consumer rules that out at preflight with the new capability tokenprobe --json --require-surface attest:peer-identity. Two new--error-format jsonkinds accompany them (not_a_member,peer_identity_unsupported). The attestation is published as an eighth machine-output family (fixtures/schema/cli/attest.schema.json+attest.jsonl) carrying its ownattestation_version(currently1), which the client checks strictly — a security verdict is refused rather than read under semantics its sender never promised. Scope, honestly stated: this is a containment fact inside the existing same-OS-user threat model, not authentication between hostile peers, and whatmembercovers follows the run's containment mechanism (whole-tree for a Job Object or cgroup; the process group for the POSIX fallback, which enumerates only its leaders) — seedocs/control-plane.md, "attest", anddocs/threat-model.md.run --run-id-env <KEY>, an opt-in flag that sets one child environment variable to the run's final id — the explicit--run-idwhen one was given, otherwise the id the runner generated — so a child and its descendants can name the run they belong to. The value is the same onerun_started.run_id, the registry record, and every control-plane reply carry. It replaces the "mint an id yourself and pass it twice" plumbing (--run-id <id> --env KEY=<id>) every supervising adapter carried: one value instead of two copies that can drift, and the only way to hand the child a generated id, which was previously not knowable outside the run until the run had already started. Strictly opt-in — no key is injected by default, so a run without the flag has exactly the child environment it always had. The injection is applied after all four--env-*flags (--env-clear,--env-remove,--env-file,--env), so it wins over a file entry or a removal of the same key whichever order the flags were written; the one combination that is refused rather than resolved is an explicit--env <KEY>=…for the same key, which fails at parse time as aUSAGE(100) error before anything runs (asking for two values of one variable is a caller mistake, and the outcome must not depend on argument order either way); "the same key" follows the platform's own rule, so on Windows — where environment names are case-insensitive — a pair differing only in case is that same collision and is refused too.<KEY>is held to the same rule as an--envKEY, through the same validator. The value is correlation data, not a credential: it identifies a run, proves nothing about who started it, and is forgeable by anything that can set an environment variable. New probe surface tokenrun:--run-id-env. SeeREADME.md, "Environment", anddocs/running-commands.md, "Publishing the run id to the child".--error-format <human|json>, the CLI's first global option: accepted before or after the subcommand, honored by every subcommand, and off by default. Under--error-format jsona post-parse failure prints exactly one bounded, versioned JSON object on stderr instead of theprocesskit-cli: <message>prose —error_version,code,kind,operation,run_id,retryable,message— so an adapter branches on a publishedkindrather than on English. The point is that an exit code is coarse:CONTROL(103) alone covers eight situations, andkindsplits it intonot_found/stale/unprobed/ambiguous_run_id/control_unreachable/ipc_deadline/incompatible_contract/peer_identity_unsupported(and splitsSETUP111 intoregistryversussetup). No exit code was minted or changed: the taxonomy is a finer axis over the existing band, and for a failingrunits values are the terminalrunner_exitevent's ownsourcespellings rather than a second vocabulary for the same endings. Arun --detachstill relays the reserved code of the copy it respawned, but not a meaning for it: a coderunitself never mints (110/112/114/115/116, or one no build assigns yet) reportskind: "unknown"rather than borrowing another subcommand's verdict, since that copy can be a different build.messageis deliberately not part of the contract and may be reworded in any release. Invariants: stdout is never touched (a command that prints a report and then fails, likeprobe --jsonexiting 110, still prints exactly what it always did), the default stderr prose is byte-for-byte unchanged, and the exit code is unchanged. The shape is published asfixtures/schema/cli/error.schema.jsonwith a goldenerror.jsonlbeside it and its ownerror_version(currently1) — a versioned family in that directory, for the reason the others are versioned: a captured stderr line is routinely read out of its invoking context. One documented gap: clap's parse-time usage errors (exit 100) stay human-readable in v1, since they happen before the binary knows what it was asked to do. Seedocs/exit-codes.md, "Machine-readable failures:--error-format json", anddocs/integration.md§7.events, a read-only subcommand that reads a run's JSONL lifecycle stream back: it resolves the stream through the per-user registry (--run-id, the same locatorlist --jsonpublishes) or takes an explicit--file <events.jsonl>for a stream whose registry record is already gone, then renders each event for a human (default), passes the runner's own lines through byte for byte (--json), follows a growing stream to its terminalrunner_exit(--follow), or checks every line against the event schema this binary embeds (--validate). Likelist/waitit opens the registry read-only, never contacts a run's control transport, and mutates nothing.EVENTS_INVALID(114), the reserved exit codeevents --validatereturns when a checked stream does not conform to that schema — a verdict about a document, distinct fromSETUP(111) for a stream that could not be read at all and fromCONTROL(103) for a--run-idthat names no single stream. Codes117–119remain reserved (115is nowNOT_A_MEMBER, seeattestabove;116is nowHOST_UNQUALIFIED, seedoctorabove). The check adds no runtime dependency: it interprets the embedded schema document over the keyword subset that document uses, refuses to run on anything it does not implement, and is held to a real JSON Schema engine's verdict — line for line, over the golden fixture and a generated mutation corpus — by the test tier.run --snapshot-interval <duration>, an opt-in cadence that re-emits themembers_snapshotlifecycle event while the child runs, so a long, quiet, or detached run records how its process tree evolved instead of only its shape at spawn. The event gained two always-present fields —reason(spawnfor the post-spawn snapshot every run emits,intervalfor a re-sample) andread_error— on every run, flagged or not; both are additive schema v1 changes, like thetimeoutevent's ownreason, but an adapter that pinned this event's exact field set rather than the fields it reads will see them on the default path too. The cadence samples the container's member list rather than the output pump, so it composes with--inherit-stdio, is forwarded by--detach, and stops as soon as the run's ending is decided, so no snapshot ever lands in the teardown tail. The stream it produces is deliberately unbounded (duration / intervallines);docs/running-commands.mdrecords that decision with the sizing arithmetic for choosing an interval.members_snapshotnow reports a failed member read in the stream instead of skipping the sample: the event is emitted withread_error: trueand an emptymembersarray, matchingcleanup_started/cleanup_finished's existingread_errorconvention. The previous stderr-only warning could not reach a detached run's operator at all (its stderr isnull), which left a failed sample indistinguishable from an unchanged tree in the one artifact such a run has. As a side effect the post-spawnmembers_snapshotnow appears exactly once in every stream, asdocs/schema.md's ordering contract states, where a failed read previously removed it.- Checksum-derived winget, Scoop, and Homebrew distributor manifests attached to every release after the platform archives finish uploading.
- Automatic publication of those Homebrew and Scoop files into the project's own
tap/bucket repositories, as the release workflow's last job. It is off until
an operator creates the target repository and adds a token secret scoped to it
(
HOMEBREW_TAP_TOKEN/SCOOP_BUCKET_TOKEN, each channel independent), skips with a notice while unconfigured, pushes nothing when the target already holds identical bytes, and cannot fail a release even when a configured channel breaks. No tap or bucket is published yet, so every install command stays as documented in the package-manager availability table; winget remains a deliberate manual submission, since its review lives inmicrosoft/winget-pkgsand no automated step there could report real availability. - An adoption-oriented positioning guide comparing ProcessKit CLI with common deadline, process-group, service-manager, container, init, and PowerShell alternatives without overstating the platform-specific cleanup guarantees.
- A ProcessKit-family mdBook documentation site, including the shared cover and
theme, rendered-link validation, and GitHub Pages deployment from
main. - A user-focused Pages guide set covering installation, cookbook workflows, command execution, I/O and bounded capture, detached runs, timeouts, resource limits, platforms, containers, compatibility upgrades, and robust external-process execution from automation agents.
run --windows-graceful-ctrl-break, an opt-in cooperativeCTRL_BREAKtier for Windows console children before Job Object escalation.- Structured
cleanup_finished.shutdownobservations from ProcessKit's pre-stop capability probe andShutdownReport. run --env-filefor pre-spawn UTF-8 environment files whose values stay out of the runner's argv, with explicit--envoverrides.- Operator
run --labelmetadata in lifecycle events, registry discovery, and conjunctive--labelfilters forcancel --all,kill --all, andwait --all. - A POSIX PTY e2e assertion that verifies foreground process-group restoration after an inherited-stdio run returns.
cargo-binstallmetadata for one-command installation from the existing prebuilt GitHub Release archives on every published target.- A scheduled, manually dispatchable, non-gating canary that builds and tests
against ProcessKit's current git
mainon Linux and Windows. - Exact-match
list --labelfilters andlist --health live|stale|unprobed, composable across human and JSON discovery output. - Absolute JSONL and optional capture-directory locators in the owner-only registry,
list, and inspect snapshot, completing detached-run artifact discovery. - Fleet-wide
inspect --all --jsonwith conjunctive label filters and one snapshot-addressed result per live run, including honest per-run errors. - Opt-in
run --capture-overflow cancelprotection for runaway output, with an additiveoutput_overflowJSONL event, graceful teardown, and reserved code 113. - Per-commit Criterion history artifacts and automatic same-OS comparison against
the latest successful
main, with non-gating warnings above a 20% median increase. - Checksum-verifying
install.shandinstall.ps1one-command installers with platform detection, version pinning, custom destinations, and safe overwrite refusal. - An installable
using-processkit-cliagent skill with Codex metadata, a Claude Code marketplace entry, contained-run recipes, and live contract drift tests. - An indexed ADR journal with a reusable template and six retrospective records for the project's settled stream, redaction, control, cleanup, shell, and wait choices.
- ADR 0007 and an integration-guide section recording the decision not to add a
terminal receipt file (
run --outcome-json):runkeeps one durable outcome artifact, the required--jsonlstream, and an adapter that wants a reserved-band exit code disambiguated without opening it reads the--error-format jsonenvelope instead — present for a runner-owned ending, absent for the child's own exit. No flag, event, schema, or exit code changed. - Cross-platform runnable examples for compatibility preflight, foreground event parsing, detached supervision, and label-scoped fleet cancellation, smoke-tested in CI.
wait --report-outcomefor a single observed run, returning terminalrunner_exitfields as one JSON object without changing the waiter's exit code.- A default human-readable
inspect --allreport with per-target status rows and expanded snapshots, while preserving the original--jsonarray. - Conjunctive
prune --label KEY=VALUEfiltering for scoped real and dry-run cleanup, conservatively excluding ownerless orphan locks when filtered. - A seventh release target,
aarch64-unknown-linux-musl, for a single dependency-free binary on Arm64 containers (Alpine/distroless, Graviton, Apple-Silicon Docker hosts), built and test-executed natively on a GitHub-hostedubuntu-24.04-armrunner, with matchinginstall.sh--targetsupport, package-manifest generation (Homebrew Linux Arm64), and documentation. - A phase-attribution benchmark for the mutating owner-only registry open, swept over registry sizes so the Windows DACL propagation cost is measured in-repo rather than inferred from an end-to-end startup number.
processkit3.3.1 is now the version both committed lockfiles resolve — the rootCargo.lockandfuzz/Cargo.lock, the second belonging to the deliberately out-of-workspace fuzz crate that the root verification commands never descend into and that Cargo has no reason to move on its own, since 3.3.0 already satisfied the requirement. The manifest requirement is deliberately unchanged at"3.3": this is a patch inside the line that requirement already admits, and the comment beside the dependency names3.3for a lower bound (limit_evidence()/LimitVerdict, andProcessGroupStats'io_*/peak_process_countfields) that 3.3.1 does not move. Of the release's three fixes, two provably do not reach this crate: ConPTY appears nowhere insrc/, and the upstreamPipelineAPI is unused (every "pipeline" in the tree is the label valuepipeline=ci/pipeline=localin tests, or one doc-comment mention of a shell pipeline). The upstream stable-identifier dictionary (spec/identifiers.json) is byte-identical between 3.3.0 and 3.3.1, so no projected vocabulary —Mechanism,ParentDeathCleanup,Outcome, the limit verdicts — drifted.- A hard kill that reports a failure is no longer discarded in
cleanup_finished. This is the one behavioural consequence of adoptingprocesskit3.3.1, and tracing it corrected the expectation it was adopted under. The release makes the Linux legacy/restricted-cgroup teardown report a refused thaw — the per-pidSIGKILLsweep freezes the subtree so a fork bomb cannot out-spawn it, and if the freeze cannot be cleared afterwards the tree is dead but the cgroup is left frozen and unusable for further spawns — where it previously returnedOk(())on the strength of an emptycgroup.procs. The concern was that this new failure would slide throughmap_launch_error's wildcard arm and turn runs that used to exit0intoBACKEND(102) on such hosts. It cannot:map_launch_erroris reached only fromProcessGroup::start, so it maps launch failures, and no teardown-path error reaches the exit code at all.kill_all's result was discarded outright (let _ =), andstop'sErris already downgraded to a stderr warning plussoft_signal: "failed"while the code staysTIMEOUT/CANCELLED/etc. No exit code, event, flag, or schema changes here, anddocs/exit-codes.mdis deliberately untouched — itsBACKEND(102) wording, which describes a container or registry that "could not be established", remains accurate, because all four sites that mint 102 (create,attach, and the twoforegroundones) still sit beforerun_started. What the trace did expose is a diagnostics gap: with the error dropped,cleanup_finishedreportedremaining: 0, read_error: false— a confirmed-clean teardown — over precisely the state upstream had just refused to call one, since the group's own drop hits the same refused write and the post-kill member read cannot see a freezer at all. The kill result is now projected through a purehard_kill_warningand, when it reports a failure, warned on stderr carrying upstream's message verbatim — that text, naming the cgroup "left FROZEN" with the refusal's errno and remedy, is what tells this case apart from the pre-existing undrained-tree one. It stays non-fatal on purpose: both classes are properties of the host's teardown rather than of the child's work, and forwarding the child's exit code faithfully is this runner's central promise. The projection is unit-tested on every host; the end-to-end condition is not reproducible in CI and is not claimed to be — it needs a Linux host refusing bothcgroup.killandcgroup.freeze(a pre-5.14 kernel or a revoked delegation), which upstream itself reaches only through crate-internal fault injection unavailable to dependents. - The threat model now enumerates the events file read back by
eventsas a fourth untrusted-input surface — namingevents --file's arbitrary caller-specified path, the hand-rolled line reader, schema interpreter, and pattern matcher sitting on it, and the terminal barrier every operator string crosses — and states explicitly that thecargo-fuzztier coverswait --report-outcome's read-back but not those parsers, so the document no longer implies more coverage than exists. docs/compatibility.md's "Schema pinning" section now states the full set of changes a reader must tolerate within one schema version — new event types, repeats of an event type that previously occurred at most once, new fields including always-present ones, new values in open-ended string fields, and unknown fields — and namesdocs/schema.mdas the normative source for all of them. It previously mentioned only "additive optional fields and unknown event fields", which covered neither multiplicity nor always-present fields.- The zero-duration rejection message shared by
--timeout,--idle-timeout,wait --timeout, and--snapshot-intervalno longer describes only the deadline case ("tearing the child down immediately after spawn … omit the flag to leave it unbounded"), which was misleading for a rejected cadence. inspectandinspect --allnow check thesnapshot_versiona runner declares instead of rendering whatever arrives. A runner answering with a version newer than the invoked binary implements is refused withCONTROL(103) — for--all, as a per-targetfailedentry — with a message naming the version that arrived and the range this build reads; previously such a reply was printed under this build's semantics, silently dropping whatever the newer runner added. Older runners are unaffected: asnapshot_version1 snapshot (every release up to 0.3.1 writes one) is still read and rendered, withjsonl/capture_dirasnull, so upgrading the CLI does not cut you off from the runs your previous binary started. Thesnapshot_versionprinted ininspect --jsonis the runner's number, sofixtures/schema/cli/inspect.schema.jsonnow admits the range this build renders (1or2) instead of pinning2. Adapters that classify a103as "runner unreachable" should note this one means the opposite — the runner is healthy and its answer was rejected — and is not fixed by retrying; seedocs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read".runno longer rewrites the Windows registry directory's owner-only DACL when it already matches, removing a per-invocation cost that grew with the number of remembered runs (~443 ms at 1024 entries, now flat at ~0.1 ms); the directory is also created carrying the descriptor, and a pre-existing directory with widened permissions is still repaired.- Extended the fuzz tier to the raw environment-file and operator-label parsers, including invalid-UTF-8 rejection and secret-safe diagnostic coverage.
- CI now executes the default and E2E test tiers for the shipped static musl target instead of only cross-compiling it.
- Update the contained-run backend to ProcessKit 3.1.0 while retaining the existing public CLI, lifecycle schema, and MSRV contracts.
- Split the registry into a stable facade, platform-specific implementation files, and an isolated test module before further record/control-plane growth.
- Split the command-line surface into one module per subcommand family plus a shared value-parser module before further flag and subcommand growth.
- Split the live control plane into a stable facade with separate platform, rendering, and test modules before adding further fleet operations.
- Human-readable registry identity and endpoint fields are visibly truncated at a bounded terminal-safe prefix while machine-readable JSON preserves them exactly.
- Test-only teardown wording no longer contributes a fabricated capability-scope adapter to production builds.
- Registry stale/unprobeable test fixtures now share one typed
Recordserializer and scratch-path factory across unit and through-binary tests. - Default live output now uses ProcessKit's chunk-based raw tee, preserving exact child bytes while substantially reducing per-line echo overhead.
benches/startup_latency_bench.rsgained adirectcontrol arm timed only up to the point the OS reports the child process created (reaped outside the timed window), matching the "process created, not yet exited" boundary the runner arm already stopped at, so the published startup-latency number is a same-host delta (runner vs. direct) between two like-for-like measurements instead of a single absolute — cross-host absolutes were never comparable. README.md's "Benchmarks" section is updated with a re-measurement againstprocesskit3.3 (~22 ms direct vs. ~150 ms underrun, a ~128 ms delta — what going throughruncosts beyond a direct launch, not a breakdown of any one sub-phase) and no longer presents the pre-3.2ProcessGroup::startphase-trace figure (166-228 ms) as a current number; upstream disputed the attribution of that figure to the crate alone — part of it is the OS's own process creation happening insidestart, not its magnitude (threadmsg-send-ba9dc66e1b832e104c35c9a1e75a6588); upstream's own fix for the dominant share it had profiled (an all-threadsCreateToolhelp32Snapshotwalk, replaced by directNtGetNextThreadresolution inprocesskit3.2) is also already included, since this crate depends on 3.3.
- An argv element that is not valid Unicode no longer loses its identity in the
command diagnostics.
argv_sha256and--argv-raw's recordedargvwere both derived after every element went throughto_string_lossy(), so on Unix two arguments differing only in their ill-formed bytes (and on Windows two differing only in an unpaired surrogate) became the same U+FFFD string: two distinct live commands shared one fingerprint in the JSONL stream and in the registry, and--argv-rawhanded back a reconstruction instead of the raw argv it promises. Both are now derived from each element's canonical bytes — the argument's own bytes on Unix, the WTF-8 encoding of its UTF-16 code units on Windows — which, for an element that is valid Unicode, are exactly its UTF-8 bytes: every ordinary command line fingerprints exactly as it did before, on both platforms. An element that cannot be written into a JSON string verbatim is recorded losslessly in a reversible escaped form, opened by U+0000 (a character no real argv element can contain, so a verbatim element is never mistaken for an escaped one);docs/schema.mdstates the encoding and the escape grammar normatively. That escaped element is the only string value in the schema that can carry U+0000, so it obliges an existing reader in two ways — decode it before reconstructing an argv, and check that a sink accepts U+0000 before storing or forwarding a decoded element (PostgreSQL refuses ajsonbdocument containing it, a C-string API truncates the element away). Readers that display, log, or store the JSONL line are unaffected: the wire form is the ordinary six-character JSON escape, so the line stays NUL-free text. --capture-dirsetup is now all-or-nothing. Previously the runner created and emptiedstdout.logbefore it triedstderr.log, so a second stream that could not be opened (a path that already named a directory, an unwritable file) left the run exitingSETUP(111) with a stray emptystdout.log— indistinguishable from a real transcript of a silent child — and, when that file already existed, with its contents already discarded. Both transcripts are now opened before either is emptied, and a failed setup rolls back the files and directories that attempt created. Paths the runner found rather than created are never rollback candidates — the rollback removes and empties nothing it did not create — so a setup that cannot open one of the two transcripts leaves an existing file at either path with its contents, and leaves an existing capture directory in place. A successful setup is unchanged, including its truncation of a stale transcript file.- The cookbook's own JSONL reader recipe told readers to dispatch on a
typefield; the event stream has always named that fieldevent. - The detached-supervision examples now hold their child behind an explicit release marker, eliminating the inspect-versus-fast-exit race in CI smoke runs.
- Headless Windows integration fixtures no longer leave Windows Terminal error panes open after their contained console processes are torn down.
- Environment entries now reject whitespace and control characters in keys, and malformed-entry diagnostics no longer repeat potentially secret values.
- Bounded capture continues after a live echo sink reports zero write progress, treating it like a broken echo instead of disabling the transcript pump.
- Explicit run ids are validated consistently across every by-id command: they must contain 1-256 characters and no terminal control or formatting characters.
- The POSIX installer smoke test now accepts either
python3orpythonfor its local fixture server instead of assuming the legacy executable name exists. - Unix registry test fixtures use the shared atomic counter type, restoring non-Windows all-target, Clippy, musl, and MSRV builds.
0.3.1 - 2026-07-26
- New
cancel --all/kill --allflags: mass teardown of every live run, not just one.cancel/killpreviously addressed exactly onerun_id, so an orchestrator's "cancel everything" step needed a hand-rolled loop overlist --json.cancel --all/kill --all(mutually exclusive with--run-id; exactly one of the two is now required,USAGE(100) if neither is given — the same clap shapewait --allestablished) close that gap: a snapshot of every registry entry confirmed live is taken once, the moment the invocation starts (the same snapshot discipline aswait --all, including its "unprobed at snapshot time never enters the target set" asymmetry and "a run that registers afterward is out of scope" trade-off), and every snapshot entry is addressed by its unique registry record path plus remembered endpoint rather than its non-uniquerun_id, so live duplicate ids are all torn down instead of becoming unreachable ambiguities. The client reconfirms that exact record's liveness and endpoint before dispatch. Instead of one ack,--allprints a single JSON array on stdout withrun_id,accepted, andstatus(accepted,already_gone, orfailed) per target, pluserroronly for failures. A target confirmed gone before its turn isalready_gone: it did not acknowledge this invocation, but the teardown goal is already met, so it does not fail the aggregate. An empty snapshot is not an error — an empty report and exit0, mirroringprune— but a partial or full failure is never a silent0: it reuses the reservedCONTROL(103) code with a stderr summary, socancel --allahead ofwait --all/prunein a teardown sequence cannot silently swallow a target it failed to reach.cancel --run-id/kill --run-idare byte-for-byte unchanged. Appears in theprobesurface tokens automatically (cancel:--all,kill:--all). See README.md, "Command interface", and docs/control-plane.md, "cancel --all/kill --all". - New
wait --allflag: a barrier on every live run, not just one.waitpreviously blocked on only onerun_id; a supervisor or CI teardown step often needs the aggregate version instead — cancel everything, wait until none of it is left, thenprune— which used to mean hand-rolling a polling loop overlist --json.wait --all(mutually exclusive with--run-id; exactly one of the two is now required,USAGE(100) if neither is given) is that barrier: it blocks until no run this invocation considers in scope is still live, then exits0. Its target set is a snapshot, fixed once at the moment--allstarts, to exactly the registry entries confirmed live right then — a run that registers afterward is out of scope for that invocation and is never waited for (re-issuewait --allto catch it), the same "one clear rule beats an unbounded alternative" trade-offwait --run-idalready documents for an unknown id reading as finished. An entry whose liveness cannot be re-probed on a later pass stays outstanding rather than being silently dropped — the exact conservative stancewait --run-idalready applies, but only once an entry is in the target set: one that was itself unconfirmed live at the snapshot instant is excluded from that set from the start rather than waited on, a documented asymmetry with--run-id's own always-tracked target. A bounded--timeoutreports how many snapshot entries are still outstanding and, when any of them was only unconfirmed on the last pass, says so rather than confidently claiming they are all still live; unbounded, it keeps polling. Same reservedWAIT_TIMEOUT(112) as the single-run case, and — unlike it — no aggregateCONTROLoutcome, since--allnever resolves an id at all.wait --run-idis byte-for-byte unchanged. Appears in theprobesurface tokens automatically (wait:--all). See README.md, "Command interface", and docs/registry.md, "Waiting —wait", "The aggregate barrier —wait --all". listnow says which run is which. A registry entry used to carry only itsrun_id, health,started_at, and control endpoint, so an operator with several live runs saw rows that were indistinguishable in every way that mattered — nothing hinted at what any of them was running before picking one toinspect/cancel/kill. Each run now also publishes the two redaction-safe command fields its JSONL stream already carried:argv_sha256, the one-way argv fingerprint (equal for two entries exactly when they run the same command), andhint, the worker-shape category (msbuild_node_reuse, …) ornullwhen the command matches no known shape. Both come from the very implementation therun_startedevent uses, so a run never fingerprints differently in the two artifacts.list --jsonreports both at full precision (the whole 64-character digest, joinable against the run's own events); the human-readable table gainsHINTandARGV_SHA256columns, the latter abbreviated to 12 hex characters plus.... No command line is ever written to a registry record —registeris handed the fingerprint and hint, not the argv, so no flag (--argv-rawincluded) can put one there;root_pidandcwdwere considered for the same purpose and deliberately refused (seedocs/registry.md, "Which run is which"). The record format staysregistry_version1: both fields are optional on read, so a record written before they existed still reads (reported asnull), and a record from a newer writer still reads on an older binary — the mixed registry a mid-upgrade user really has. Their values are untrusted deserialized data like every other field and are shape-checked on read; unlike a malformedstarted_at/lock_file, a malformed one of these drops the field alone and keeps the record, so a cosmetic value can never hide a live run fromlist,wait, or a control client.- A concurrency stress test tier (
tests/stress.rs,stressCargo feature) covering the invariants that only break when many runs contend for the two resources every run shares — the per-user registry and the per-run control plane. It launches dozens of simultaneousruninvocations against one registry directory and drives parallellist/prune/wait/inspect/cancel/killclients at them, asserting thatprunenever reaps a live entry (including one still inside its reservation window), that a registry scan never loses or duplicates a record under concurrent writes and deletions, that a control client aimed at an unreachable or dying runner refuses withCONTROL(103) inside a bounded deadline instead of hanging, and thatwaitnever misses — or invents — a completion. Every scenario carries a positive control, so none of those "never" assertions can pass vacuously. Dev-only tooling: off by default, like thee2eandbenchtiers, so it never affects a plaincargo build/cargo test/cargo publish; CI runs it as a separate, non-gating scheduledstress.ymlworkflow (see CONTRIBUTING.md, "Stress tests"). No runtime behavior, CLI surface, exit code, or event-schema change. cleanup_started/cleanup_finishedgained an additiveread_errorfield:truewhen the underlying container-member read itself failed, so a0/empty fallback is never indistinguishable from a confirmed empty tree or a confirmed-clean teardown (mirrorsoutput_captured'swrite_error). See "Fixed" below.probe --print-schema: prints this binary's embedded JSONL event-schema document (fixtures/schema/v1/schema.json, embedded at build time viainclude_str!) and exits, so a consumer holding only an installed binary or an unpacked release archive can fetch the exact machine-readable schema its own version emits, entirely offline. New release archives also bundleschema/schema.jsonandschema/events.jsonlalongside the binary, completions, and man pages.
- Human-readable tables now measure column widths in Unicode characters, matching Rust's padding unit so ordinary multibyte text no longer over-pads later columns.
- Whole-registry commands now share one
SETUPmapping for registry open and read failures; by-run-id control clients retain their intentionalCONTROLmapping. - Aggregate
cancel --all/kill --allreconfirm each snapshot target by reading and probing only its exact record, avoiding repeated full-registry scans while preserving missing, stale, unprobeable, and identity-change outcomes. - Aggregate control mutations and
wait --allnow take their confirmed-live target snapshot through one registry primitive, so both commands share the same liveness inclusion rule while retaining their command-specific target projections. - Upgraded to
processkit3, and Windows soft-stop reporting is honest again. The dependency moves from2to3(limitsfeature unchanged; the declared MSRV stays1.88, which is stillprocesskit's own floor). Two things in the major release touch this runner. First,processkit::Erroris now a pointer-sized wrapper around a boxedErrorReason, so the runner's two launch/teardown classifications read the failure mode offerr.reason()instead of matching the error directly — the exit codes they select (SPAWN(101) for a not-found/spawn failure,BACKEND(102) for every other backend failure) and every operator-facing message are unchanged, sinceError'sDisplaydelegates to the reason's and adds no envelope. Second, and user-visibly: on WindowsProcessGroup::signalis no longer an unconditional refusal for a soft stop. A Job Object still has no POSIX signal, but ProcessKit now makes a best-effort soft close — aWM_CLOSEto every top-level window owned by a live member — and refuses only when the tree exposes nothing such a close can reach. The runner therefore stops making the blanket claim that Windows has no soft-terminate tier (true when0.1.0shipped, no longer true here — that historical entry is left as the record of what0.1.0did): a Windows--timeout/cancel whose tree owns a window now reportssoft_terminate: "signalled"and says a close was asked for, never that a signal was sent; the far commoner windowless console child still reports"unsupported", and its stderr line now states why nothing was delivered (no windowed member, no console-CTRL leader) instead of blaming the platform. The later[Unreleased]work now adopts the console opt-in and structured stop reporting. Verified rather than assumed while upgrading: the release's switch to raw pipe-byte accounting applies only to the fail-loudOverflowMode::Errorceiling and the*_bytes_seenreadbacks, neither of which this runner uses, so both of its own ceilings (--capture-max-bytesand the in-flight line-assembly cap) are unaffected; and the output-event-stream rename is likewise irrelevant here, sincerunstreams throughstdout_tee/stderr_teeand never touches that stream. inspect --jsonis now optional, mirroringlist/prune: without it,inspectprints a human-readable rendering of the snapshot (snapshot version, run id, mechanism, root pid, start time, and a column-aligned member table) instead of requiring an operator to pass--jsonand read raw JSON at the terminal.inspect --json's output is unchanged, byte-for-byte, from before this change. Theinspect:--jsonprobesurface token is unaffected — the flag still exists, it is simply no longer required.
- Control clients now validate an untrusted registry endpoint against the local Unix-socket or Windows named-pipe shape before opening it, rejecting malformed endpoints with the reserved control error instead of performing arbitrary I/O.
- Human-readable output now replaces Unicode bidi, zero-width, and other formatting characters with spaces alongside terminal controls, preventing invisible or reordered text from surviving the shared terminal-safety boundary.
- On Unix, the Ctrl-C listener now preserves an inherited ignored
SIGINTdisposition, matching the existing SIGTERM/SIGHUP policy and direct-launch behavior; Windows signal handling is unchanged. run_started.cwdis now always absolute, including when--cwdis relative, so event consumers can identify the child's actual working directory without knowing the runner's ambient cwd. Foreground and detached runs use the same resolution path.- Single-run
cancel/killnow reject an acknowledgement whoserun_iddoes not match the requested run, using the same shared acceptance/action/id validation as their--allforms. - Human-readable
inspectnow collapses terminal control characters in snapshot and process-member strings, matching the existing safety boundary inlistandprune --dry-run;inspect --jsonremains byte-for-byte unchanged. - Failed registry reservations now arm lock-file cleanup immediately after
create_newand close the handle before unlinking, so an early lock-probe error or retry does not leak an orphan.lockfile, including on Windows. - Human-readable
listandprune --dry-runoutput now collapses control characters from untrusted registryrun_id/endpointvalues and orphaned lock-file names, preventing forged rows and terminal escape injection while preserving raw values in safely escaped JSON output. - Capture metadata now includes bytes accepted by a partial file write before a
later write error, so
output_captured.sha256and the internal written-byte count continue to describe exactly the bytes present on disk. prunenow reaps the leaked control-socket directory of a run that died abruptly, not only its registry record and lock. On unix a runner's control transport is a socket inside a per-run0700pkc-<token>directory under/tmp(or the platform temp directory), removed only by a clean teardown; aSIGKILL, crash, or outer Job Object terminate stranded that directory forever, since the record naming it was the only thing that pointed at it.prune— documented as the cleanup counterpart that reaps "the confirmed-stale leftovers of runners that died abruptly" — covered only half of them, so repeated abrupt deaths accumulated deadpkc-*directories in the temp directory. Reaping a confirmed-stale entry now removes the socket and its directory too, before the record that names them, and only ever after the record'sendpoint(untrusted deserialized data, like itslock_file) passes a strict shape check: absolute, no./../empty segment as written, final componentc.sock, parentpkc-plus an alphanumeric/-token, directly inside one of the temp bases the control server binds in. No symlink is ever followed — the directory is openedO_NOFOLLOW | O_DIRECTORYand the socket unlinked relative to that handle, only if it really is a socket — and an endpoint failing any of that deletes nothing at all while its record is still reaped. Live and unprobeable entries keep their sockets, exactly as they keep their files, and every deletion stays best-effort: a socket that will not go never aborts the reaping of other entries.prune --json's tally is unchanged (a reaped socket is counted by its own entry'spruned);prune --dry-runreports the directory it would reap in a new always-presentsocket_dirfield on eachentrycandidate (nullwhen there is none), and in the human-readable listing as a trailingsocket_dir=<path>. Windows is unaffected: a named pipe lives in the kernel object namespace and disappears with its creator, leaving nothing on disk to reap.cleanup_started/cleanup_finishedno longer fabricate a confirmed0on a member-read failure. Both emitters previously turned aProcessGroup::members()read error into a silentmembers_before: 0/remaining: 0, remaining_pids: []— indistinguishable from a genuinely empty tree, and inconsistent with the siblingemit_members_snapshot's honest degradation and the teardown policy that a read failure is not a confirmed empty tree. Both now warn on stderr on a read failure and set the newread_error: trueflag instead of letting the fallback stand as an observation; the success path is unaffected.list(--jsonand the human-readable table) no longer prints a registry entry whose liveness lock could not even be probed (permission denied, a rejected symlink/reparse point, an unexpected non-regular file in its place) as"stale"— a positive, unconfirmed claim that the runner is dead. It now reports a distinct"unprobed"health value, matching the three-way vocabularyprune --json'sunprobedtally andwaitalready use for the identical case. Additive change tolist --json'shealthfield.inspect/cancel/killno longer report an unprobeable registry entry as a runner that is gone. All three act only on a confirmed-live entry, so what they do is unchanged — they still refuse withCONTROL(103) — but the refusal used to say "its registry entry is stale — the runner is gone (it exited without cleaning up)" for an entry whose liveness lock could not be probed at all, asserting a death nothing had established and contradicting theunprobedverdictlist/prune/waitreport for that same record. The message now distinguishes the two cases and names the unprobeable oneunprobed, so cross-checking a refusal againstlist(asdocs/troubleshooting.mdadvises) agrees instead of conflicting. Only free-text stderr changed; no exit code, event, or CLI surface did.wait --timeout's give-up message now renders the deadline the same wayrun's timeout/grace diagnostics do (e.g.1500ms), instead ofDuration's{:?}Debug form (1.5sfor the same value) — the two subcommands' stderr no longer disagree on how to print an identical duration. Only free-text stderr changed; no exit code, event, or CLI surface did.
0.3.0 - 2026-07-25
-
New
run --detachflag: start a run and let go. The call re-spawns the CLI detached — a new session on Unix (setsid),DETACHED_PROCESSon Windows,nullstdio either way — and returns as soon as that copy has provably started the run, instead of staying the runner's parent for its whole duration. "Provably" is an observation, not an assumption: the call waits until the detached runner'srun_startedevent is readable in--jsonl, which it writes only after creating the container, publishing the registry record, and spawning the child — so on return the run is already discoverable withlist, reachable withinspect/cancel/kill, and waitable withwait(and the run id is readable from the events file even when the runner generated it). The detached copy runs the ordinaryrunpath unchanged — same container, same teardown, same JSONL stream — so detaching adds a spawn and a handshake, not a second lifecycle. A caller that captures the launch command's output sees end-of-file when the call returns rather than when the run ends — the detached runner is left holding none of the caller's pipes (on Windows this needed an explicitHANDLE_FLAG_INHERITclear, sinceCreateProcess's handle inheritance is all-or-nothing). The exit code changes meaning under this flag, and only under it: it reports whether the run started —0once it has, never the child's own code, which stays in the terminalrunner_exitevent where a detached caller can still observe it. A start that fails is never reported as success: the detached runner's own reserved-band code is passed through unchanged (a missing program is stillSPAWN101, an unusable container stillBACKEND102, an unwritable--jsonlstillSETUP111 — reported here before anything is spawned), so no new exit code was minted and113–119stay reserved. There is no live echo while detached — the detached runner reuses--no-echo's discarding sinks rather than a second suppression path — while--capture-dir,--idle-timeout, and the JSONL stream keep observing the child exactly as in the foreground, and--jsonlstays required. It conflicts at parse time with--inherit-stdioand--inherit-stdin(nothing interactive survives detaching). On Windows, pair it with--create-no-windowfor a console child: the detached runner has no console to lend, so the OS gives the child a fresh one.probe --jsonadvertises the new surface automatically (run:--detach). See README.md, "Detached runs", docs/exit-codes.md, "Detached runs", and docs/integration.md, §2. -
New
wait --run-id <id> [--timeout <duration>]subcommand: block until a run recorded in the per-user registry is no longer live. It closes the one supervision gap the control plane left open — a supervisor that did not start the run (an adapter that restarted, a cleanup step, anything holding only arun_id) has no child process to wait on, and previously had to hand-roll a polling loop aroundinspectand read run lifetime out ofCONTROLrefusals, which conflate "I could not reach it" with "it finished".waitis registry-only: it opens the registry read-only (likelist/prune, so waiting never creates the directory or touches its permissions), never connects to the run's control transport, never ends or disturbs the run, and needs no control endpoint — so a run whose transport never came up is still waitable. Because the liveness signal is an OS advisory lock with no event to subscribe to, it waits by honest periodic probing rather than pretending to be notified. Three outcomes, by exit code: the run is over (0), the wait's own--timeoutelapsed with the run still live (the new reserved codeWAIT_TIMEOUT= 112, see Exit codes below), or therun_idis ambiguous — more than one live run registered under it, so there is no single run to wait for — which reuses the sameCONTROL(103) refusalinspect/cancel/killgive. Nothing is printed on success; the exit code is the whole answer.--timeoutreusesrun --timeout's exact parser and grammar (including its rejection of a degenerate0), and omitting it blocks indefinitely. One deliberate design choice callers must plan for: a run that exits cleanly deletes its own registry entry, so an unknownrun_idis indistinguishable from one that already finished and was cleaned up — both exit0. That keeps the ordinary "the run finished while I was starting up" race from becoming a hard error, at the price that a typo'drun_idalso returns success immediately:wait's0means "not running", never "existed and completed".probe --jsonadvertises the new surface automatically (wait,wait:--run-id,wait:--timeout). See README.md, "Command interface", and docs/registry.md, "Waiting —wait". -
New reserved exit code
WAIT_TIMEOUT(112), taking the next free slot afterSETUP(111) in the reserved100–119band (113–119remain reserved). It is minted only bywait, and only when the waiter's--timeoutelapsed while the run was still live — the run itself was never touched and is still going. Deliberately notTIMEOUT(106), which means the opposite (the runner enforced a deadline and tore the child's tree down), and notCONTROL(103), since the run was resolved unambiguously and found healthy. See docs/exit-codes.md, "A waiter's deadline is not a run's deadline". -
Windows:
Ctrl-Break, console close, logoff, and system shutdown now end a run through the full cancel teardown instead of the OS's default handling silently ending the runner. The console-control eventsCTRL_BREAK_EVENT,CTRL_CLOSE_EVENT,CTRL_LOGOFF_EVENT, andCTRL_SHUTDOWN_EVENT(caught viatokio::signal::windows, the sameSetConsoleCtrlHandlermechanismCtrl-Calready used) joinCtrl-Cin the same race, so they get the same soft-stop →--grace→ hard-kill teardown, the same terminal JSONL events (cancelled,cleanup_started,cleanup_finished,runner_exit), the same registry-entry removal, and the same reservedCANCELLED(107) exit. Previously the OS's default handling terminated the runner outright on all four: the events were never written, the registry entry was left behind stale, and the container was never explicitly killed — the ending went unreported to any observer of the event stream or registry, even though the tree itself was not left orphaned: Windows already reaps the whole tree on abrupt owner death (abrupt_cleanup: whole_tree, closing the runner's last Job Object handle), unlike Linux's direct-child-onlyPDEATHSIGreap. Which event arrived is reported honestly rather than flattened onto a keyboard interrupt: thecancelledevent'ssourcegained the additive valuesctrl_break,ctrl_close,ctrl_logoff, andctrl_shutdownalongsidectrl_c(schema_versionunchanged — a new value of an existing string field), and the stderr line names the event (Ctrl-Break/console close/logoff/system shutdown). All four keep the oneCANCELLED(107) code, the same class of ending.CTRL_CLOSE_EVENTcarries an OS-imposed termination deadline (about 5 seconds): the runner caps the effective--gracefor that trigger alone to a budget comfortably inside that window (a longer request degrades to the shorter, honest wait — and thecancelledevent'sgrace_msreports this effective value, not the raw request — rather than risk the OS killing the runner mid-teardown, before the terminal events are even written);Ctrl-Break/logoff/shutdown carry no such matching deadline this runner can honestly bound, so they are left uncapped. See README.md, "Timeouts, cancel, and grace", and docs/schema.md / docs/exit-codes.md. -
Unix:
SIGTERMandSIGHUPnow end a run through the full cancel teardown instead of killing the runner where it stands. The standard external stop — a plainkill <pid>, asystemctl stop, a cancelled CI job, a supervisor's shutdown timeout — and a hung-up controlling terminal joinCtrl-Cin the same race, so they get the same soft-stop →--grace→ hard-kill teardown, the same terminal JSONL events (cancelled,cleanup_started,cleanup_finished,runner_exit), the same registry-entry removal, and the same reservedCANCELLED(107) exit. Previously their default disposition terminated the runner outright: the events were never written, the registry entry was left behind stale, and — the guarantee that matters — the container was never explicitly killed, so on Linux only the direct child was reaped (PDEATHSIG) and on macOS/BSD nothing was. Which signal arrived is reported honestly rather than flattened onto a keyboard interrupt: thecancelledevent'ssourcegained the additive valuessigtermandsighupalongsidectrl_c(schema_versionunchanged — a new value of an existing string field), and the stderr line names the signal. All three keep the oneCANCELLED(107) code, the same class of ending. A signal the environment deliberately neutralized before launching the runner (SIG_IGN, asnohupdoes toSIGHUP) is left alone rather than un-ignored —nohup processkit-cli run …keeps surviving a hangup, and nothing is lost, because an ignored signal would not have stopped the runner either. Windows was left unchanged by this entry — itsCtrl-Break/console-close/logoff/shutdown handling is covered by the Windows entry above, which now joinsCtrl-Cin the same race. See README.md, "Timeouts, cancel, and grace", and docs/schema.md / docs/exit-codes.md. -
rungained--idle-timeout <duration>, a deadline on child silence for the stuck-worker case (a child that is alive but has long stopped producing output). The deadline is re-armed on every chunk of the child's output, so a child that keeps talking is never reaped no matter how long it runs — only one that goes quiet past the window is. An idle expiry reuses the existingTIMEOUT(106) exit and the same soft-stop → grace → hard-kill teardown as--timeout; the two are told apart by a new always-presentreasonfield on thetimeoutJSONL event (overallvsidle), soschema_versionis unchanged (additive field). Same duration grammar as--timeout, including its parse-time rejection of0(see theChangedentry below); a malformed value is aUSAGE(100) parse-time error. It needs the runner's output pump, so it conflicts with--inherit-stdioat parse time (like--capture-dir) but composes with--capture-dir. The new flag appears in theprobesurface tokens automatically. See README.md, "Timeouts, cancel, and grace", and docs/schema.md / docs/exit-codes.md. -
runresource-limit flags--max-memory <size>,--max-processes <n>, and--cpu-quota <cores>, mapping onto ProcessKit's whole-treeProcessGroupOptionscaps (theprocesskitdependency now enables itslimitsfeature). Enforcement needs a real container — a Windows Job Object or a Linux cgroup v2 at the real hierarchy root — so where a cap cannot be applied (macOS/BSD and the Linux process-group fallback; a cgroup v2 that is unenforceable under systemd/containers/typical CI) the run fails fast before the child is spawned: it now emits the previously reservedlimit_hitJSONL event (namingmemory/processes/cpu) and exits withBACKEND(102), rather than running silently unbounded. A nonsensical value (--max-memory 0, a non-positive/non-finite--cpu-quota) is aUSAGE(100) parse-time error. The new flags appear in theprobesurface tokens automatically;schema_versionis unchanged (thelimit_hitshape was already fixed in v1). See README.md, "Resource limits", and docs/schema.md / docs/exit-codes.md. -
Shell completions (bash/zsh/fish/PowerShell/Elvish) and man pages, generated from the live
clapCLI definition by a newbuild.rsat build time and attached to every release archive undercompletions/andman/man1/(see README.md, "Shell completions and man pages"). Build-time generation, not a CLI subcommand, so the binary's own runtime surface — and theprobecompatibility report a consumer's preflight checks — are unchanged. -
docs/integration.md: a consumer/adapter integration guide walking through the fail-closedprobepreflight, the recommendedruninvocation, reading the JSONL event stream, control-plane supervision (inspect/cancel/kill), registry housekeeping (list/prune), and typical error modes — linking the existing normative documents rather than duplicating them. -
A criterion-based benchmark tier (
benches/,benchCargo feature) covering incremental SHA-256 (src/hash.rs), bounded-captureabsorb(src/capture.rs), the argv hint classifier (src/events.rs), and two through-the-binary scenarios — echo overhead (direct vs. underrun, with and without--capture-dir) and startup latency (call torun_started) — plus a non-gating CIperfjob that publishes results to the step summary (see README.md, "Benchmarks"). Dev-only tooling: off by default, like thee2etier, so it never affects a plaincargo build/cargo test/cargo publish.StreamCaptureandclassify_hintare nowpub(still#[doc(hidden)], no semver guarantee) so the new tier can reach them directly, matching this crate's documented "future benchmarks reach internal primitives directly" design (docs/architecture.md, "Target structure"). -
rungained--capture-max-bytes <size>, a per-stream ceiling for--capture-dir's bounded transcript files, replacing the previously hard-coded 8 MiB constant with a configurable one (same grammar as--max-memory: a byte count with an optional binary unit —1048576,512k,256m,2g; a malformed value is aUSAGE(100) parse-time error). Omitting the flag keeps the prior 8 MiB default, so a barerun/run --capture-diris byte-for-byte unchanged; theoutput_capturedevent's shape and itstruncatedflag's meaning are unaffected. The pump's separate in-flight line-assembly ceiling (CAPTURE_INFLIGHT_MAX_BYTES) stays an independent constant, not derived from this flag (seesrc/capture.rs). Appears in theprobesurface tokens automatically. See README.md, "Bounded output capture". -
rungained--no-echo, an opt-in that suppresses only the runner's own live retransmission of the child's stdout/stderr onto its own stdout/stderr. The pipe and the output pump stay wired exactly as without the flag:--capture-dirstill receives the child's bytes in full through the same tee,--idle-timeoutstill re-arms on every observed chunk, and the JSONL event stream is unaffected — only the live echo write is skipped. Meant for an embedding orchestrator that reads results from--jsonl/--capture-dirand finds the child's raw output, interleaved with its own, pure noise. Conflicts with--inherit-stdioat parse time (like--capture-dirand--idle-timeout), since that mode runs no pump to suppress in the first place. Without--no-echo, nothing changes: the live echo behaves exactly as before. Appears in theprobesurface tokens automatically (run:--no-echo). See README.md, "Standard I/O" and "Bounded output capture". -
New
prune --dry-runflag: preview a reap without deleting anything.Registry::preview_prune(src/registry/mod.rs) runs the exact same two-pass scan and the exact sameprobe_for_pruneliveness classification a realpruneuses, but never callsfs::remove_file— a confirmed-stale verdict releases its probe-acquired lock immediately (there is nothing to reclaim it for) and records the candidate instead of reaping it, so the aggregate tally it returns is exactly what a following, untouchedprunepass over the same registry state would report. Without--jsonit lists each confirmed-stale candidate (a paired entry'srun_id/started_at, or an orphaned lock's file name) followed by a "would prune …" summary line; with--jsonit prints the samepruned/live/unprobed/orphaned_locksfieldsprune --jsonalready does, plus an additionalcandidatesarray tagged"kind":"entry"or"kind":"orphaned_lock".prunewithout--dry-runis byte-for-byte unchanged. Appears in theprobesurface tokens automatically (prune:--dry-run). See README.md, "Command interface", and docs/registry.md, "Reaping —prune".
run --timeout 0andrun --idle-timeout 0are now rejected at parse time (USAGE, exit100) instead of arming an already-elapsed deadline that tore the child down immediately after spawn — almost certainly an operator typo, never a useful deadline in its own right. This mirrors the existing "degenerate cap" rejection--max-memory 0/--max-processes 0/--cpu-quota 0already receive.--grace 0is unaffected and stays legal ("no pause" between the soft stop and the hard kill is a real, useful setting). See README.md, "Timeouts, cancel, and grace".- The JSONL
members_snapshotevent and the control-planeinspectsnapshot now populate the enriched per-member fields (ppid, executablename,start_time) from ProcessKit'sProcessGroup::members_info()(shipped in processkit 2.3.2), instead of always emittingnull. Each field stays independently nullable —members_info()itself reports a fieldnullwherever the platform can't read it (the "bare" BSDs report none of them) — andstart_timeis an opaque, platform-specific start-time token rendered as its decimal string, not a wall-clock timestamp (seedocs/schema.md, "Enriched member fields"). Filling a field the v1 schema always declared but reservednullis a non-breaking change (schema_versionunchanged). - The crate is now a thin binary over an internal library target (
src/lib.rs,processkit_cli): every module moved into the library, andsrc/main.rsonly parses argv and dispatches into it. This is purely a build-structure change — the CLI flags, subcommands, exit codes, and JSONLschema_versionare byte-for-byte unchanged. The library is not a stable public API (every module is#[doc(hidden)]and exempt from semantic versioning); it exists only so the crate's own test, fuzz, and benchmark tiers can reach the runner's internals directly. The supported compatibility surface remains the binary's.
- The control-plane wire protocol now reads its one request/response line under an
explicit byte ceiling on both sides (
serve_oneon the server,converseon the client) instead of an unboundedread_line, so a broken or hostile owner-local control client sending data with no\ncan no longer make a live run's memory grow without limit. - Both interactive terminal-handoff failure paths (a failed foreground-control
handoff, and the failed post-handoff process-group resume) now emit a
container_failedevent — with a newphase: "foreground"— before the terminalrunner_exit, so the failure reason reaches the--jsonlstream instead of only stderr and the "acontainer_errorexit is always preceded bycontainer_failed" invariant holds on these paths too.foregroundis an additive value in the v1container_failed.phaseenum (noschema_versionbump). inspect/cancel/killnow open the run registry read-only, likelist/prunealready did, instead of the mutating openrunuses: a simple query or control command against a run no longer creates the registry directory or re-asserts its owner-only permissions as a side effect when the directory does not yet exist.- An orphaned registry
.lockfile — one with no paired.jsonrecord, whichRegistry::scannever sees and so never reachedprune— no longer accumulates forever.Registry::registernow backstops the reservation it makes before writing the record: if the write never lands, the freshly created lock file is deleted on drop instead of leaked.prune [--json]also gained a second pass that reaps any orphaned.lockfile already on disk (e.g. from a hand-edited registry, or aRegistration::removewhose.jsondelete succeeded while its.lockdelete did not), with the same confirm-before-delete safety as the existing paired-record reap (a live lock is never touched; an unprobeable one is left in place), plus a minimum-age floor so a lock fileRegistry::registerhas only just reserved — created, but not yet locked — is never mistaken for a long-dead orphan by a concurrently runningprune. Its--jsontally gained an additiveorphaned_locksfield alongside the existingpruned/live/unprobed. See README.md anddocs/registry.md, "Reaping —prune".
0.2.2 - 2026-07-24
run --inherit-stdiofor interactive commands that need the runner's stdin, stdout, and stderr handles directly. It preserves an existing terminal while retaining containment, JSONL lifecycle events, cleanup, control-plane access, and exit-code fidelity; the default closed-stdin plus pipe-and-echo behavior is unchanged. The mode is advertised throughprobeand conflicts with capture, no-console mode, and the two input-only modes.
- POSIX inherited-stdio terminal handoff now keeps
SIGTTOUignored while the interactive child owns the foreground terminal, restores terminal ownership first, and then restores the caller's original signal disposition.
0.2.1 - 2026-07-23
run --inherit-stdinandrun --stdin-file <file>opt-ins. The former shares the runner's stdin with the child; the latter streams a readable file through ProcessKit and closes stdin at EOF. The modes are mutually exclusive, leave the default closed stdin unchanged, and are advertised through probe surface tokens.
0.2.0 - 2026-07-23
- ci: drop x86_64-apple-darwin from the release/CI target matrix
0.1.0 - 2026-07-23
- Initial project skeleton.
- Command-line surface: the
run,inspect,cancel, andkillsubcommands are parsed and validated, includingrun's verbatim-- <program> <args...>tail. runexecution: launches the program shell-free inside a ProcessKitProcessGroupthe runner owns (in--cwd, defaulting to the current directory), echoes the child's stdout/stderr live through ProcessKit's pipes (pipe + echo, so the child sees no TTY — colors/progress bars may degrade), and forwards the child's exit code exactly. Runner-own failures use the reserved100..=119band (SPAWN/BACKEND/INTERNAL). Whenrunreturns, the container is torn down by the group's kernel-backed kill-on-drop, so leaked descendants do not survive.--create-no-windowis proxied toCommand::create_no_window()(default off).runnow enforces--timeoutand--graceand handlesCtrl-C, all as distinguishable endings that share one teardown path. A--timeoutthat elapses exits with the reservedTIMEOUTcode (106); aCtrl-Ccancel exits with the reservedCANCELLEDcode (107) — each distinct from the other and from a forwarded child code — with an explanatory line on stderr. Both first ask the tree to stop, wait out--grace, then let the owning container's kill-on-drop hard-tear-down the whole tree, so no descendant survives either ending.--timeout/--graceaccept a small duration grammar (ms/s/m/h, integer, defaults); a malformed value is a usage error (100). On Windows, where the ProcessKit kernel has no soft-terminate tier yet, no soft signal is sent — the grace window elapses and the Job Object is then killed atomically, and the runner reports this honestly rather than implying a graceful stop. (The machine-readable JSONL form of these outcomes lands with the event schema.)- Documented runner exit-code contract (
docs/exit-codes.md) that keeps the runner's own failures in a reserved code band, separate from the child's exit code, and now assignsTIMEOUT(106) andCANCELLED(107). - Versioned JSONL event schema (v1):
runnow writes a stream of lifecycle events to the--jsonlfile — one JSON object per line, each with aschema_version, and never to stdout. The stream coversrun_started(run id, root PID, containment mechanism, abrupt-owner-death cleanup scope, working directory),members_snapshot,root_exited, thecleanup_started/cleanup_finishedteardown pair,timeout/cancelled, launch and container errors, and a terminalrunner_exitthat preserves the child's own code even on the runner's own failure — so a child's code is never lost or aliased. The command line is redacted by default (raw argv only under--argv-raw; the redaction hash and worker-shape hint are reserved fields), and member snapshots are PID-only with the richer per-member fields declared but absent until ProcessKit-rs ships them. Normative reference indocs/schema.md; golden sample stream published atfixtures/schema/v1/events.jsonland gated by a golden test.--run-idand--argv-raware now consumed. - Bounded output capture (
--capture-dir <dir>): the child's stdout and stderr are teed into<dir>/stdout.logand<dir>/stderr.logalongside the unchanged live echo, kept separate per stream. A newoutput_capturedJSONL event records, for each stream, the file path, a full byte counter, a SHA-256 of the captured bytes (the same digest primitive as the argv fingerprint), and an explicit truncation flag — so a consumer distinguishes "captured in full" from "clipped at the limit" without inferring it from the file's size. The capture is bounded by ProcessKit's byte-cappedOutputBufferPolicy(the pump's in-flight memory) plus a per-stream file ceiling; the runner adds no draining or limiting of its own, and the held-descriptor teardown bound is preserved (a descendant keeping an output handle open past the root's exit cannot hang the runner). A run without--capture-diris byte-for-byte unchanged (no files, no event). Additive schema v1 change, reflected indocs/schema.mdand the golden fixture. - Control-plane
cancelandkillsubcommands:cancel --run-id <id>andkill --run-id <id>reach the live runner over the same local transport and registry discovery asinspect(byrun_id, never a PID) and end the run.cancelruns the runner's shared soft-stop → grace → hard-kill teardown — the same path a--timeoutor aCtrl-Cdrives, honest Windows hard-kill fallback included — and the run exits with the new reserved codeCONTROL_CANCELLED(108);killhard-kills the whole tree immediately (no soft stop, no grace) and the run exits withCONTROL_KILLED(109). Both are distinguishable from a Ctrl-C, a timeout, and each other by exit code and in the JSONL stream:cancelwrites acancelledevent withsourcecontrol_cancel,killwrites a newkilledevent withsourcecontrol_kill, and each closes with a terminalrunner_exitcarrying the matchingsource— so an external observer reading--jsonlsees the external command, not just the control client. The kill scope is only the target run's ProcessKit container (discovered via the registry); nothing is ever killed by executable name. The wire protocol gains the two verbs without reshaping its one-request/one-JSON-line framing, each answered with a{"accepted":…,"action":…,"run_id":…}ack, and an unreachable/stale runner is the same boundedCONTROL(103) failure asinspect. Additive schema v1 change (newsourcevalues and thekilledevent), reflected indocs/control-plane.md,docs/schema.md,docs/exit-codes.md, and the golden fixture. - Side-effect-free compatibility probe:
processkit-cli probe --jsonis the preflight a consumer runs on a candidate before launching any payload: it prints the binary's compatibility surface (package name, version, JSONLschema_version, the reserved exit-code band, and the CLI surface tokens derived from the live parser) as one deterministic JSON line, and spawns no child, opens no registry, and creates no container. With--require-schema-version/--require-exit-code-band/--require-surfaceit verifies those dimensions and fails closed with the new reserved codePROBE_INCOMPATIBLE(110) — the next free slot in the100–119band — printingcompatible:falsewith concretemismatchesrather than a silent "ok". The contract is fail-closed across three distinct, parseable outcomes — path missing (NotFoundat spawn), present-but-not-executable (a non-NotFoundspawn error), and present-executable-but-incompatible (exit110) — and forbids any silent fallback to an uncontained launch. The new code is recorded indocs/exit-codes.md. Additive only: no existing flag, exit code100–109, orschema_version: 1changes meaning. - Abrupt runner-death hardening and proof: every spawned command opts into
ProcessKit's public parent-death primitive. The versioned
run_startedevent now reports the actual surviving guarantee asabrupt_cleanup(whole_treeon Windows,direct_child_onlyon Linux,noneon macOS/other Unix), and the E2E tier force-kills the runner with a live child/grandchild to verify each platform's behavior without unsafe kill-by-PID cleanup. - Dependencies on
processkit(the containment backbone),tokio(its async runtime),clap(CLI parsing), andserde/serde_json(the JSONL event schema). - Prebuilt release binaries: the manual
release.ymlworkflow now fans out a downstreambuild-artifactsmatrix that builds a--releasebinary for Windows, Linux, and macOS across x86_64 and aarch64 — plus a statically linkedx86_64-unknown-linux-muslbuild for minimal/container images — and attaches each archive to the same GitHub Release. It runs strictly after the existing crates.io publish + tag, so the release ordering is unchanged and there is still a single release path;cargo install processkit-cliremains a first-class install.README.mdgains an Installation section with a platform matrix that states the actual kernel container mechanism reported per platform (Job Object on Windows, cgroup v2 on Linux, POSIX process group on macOS/other Unix). - Machine-readable JSON Schema (draft 2020-12) for the JSONL event contract v1,
published at
fixtures/schema/v1/schema.jsonalongside the goldenevents.jsonlfixture: one schema variant per event type plus the shared envelope, transcribed from the normativedocs/schema.md. Adapters (processkit-py) can validate against it instead of reimplementing the shapes by hand. A new test (tests/events.rs) validates the golden fixture, and several live streams emitted by the through-the-binary tests, against the schema, so drift between the schema, the fixture, and the code fails the build.docs/schema.mdremains the normative source of truth on any disagreement. list [--json]: a new subcommand that scans the per-user registry (Registry::entries) and prints every entry it finds, live and stale alike —run_id, health,started_at, andendpoint— the discovery counterpart toinspect/cancel/killfor a caller that has lost (or never had) arun_id. Read-only: it never connects to any runner's control transport, so it has none of their unreachable-run failure modes. Without--jsonit prints a human-readable table (no runs registeredfor an empty registry); with--jsonit prints one JSON object per entry, one per line, sorted byrun_idthenstarted_at. An empty registry is not an error (exits0), and a single corrupt/unreadable record never blinds the command to the healthy entries (the same degradationRegistry::entriesalready applies). Additive only — the new subcommand appears in theprobesurface tokens automatically.prune [--json]: a new subcommand that reaps detectably-dead registry entries — after a runner dies abruptly its.json/.lockpair lingers forever, since cleanup only runs on an orderly exit. It probes each entry on its own and removes only those confirmed stale by a successful liveness probe: a live entry is never touched, and an entry whose probe merely fails (its lock file could not be opened at all) is left in place rather than assumed dead — deliberately distinct from the degradationRegistry::entriesapplies for display. Removal reaches files only through the scanned record path, never a PID, and holds the stale entry's lock while deleting its record and lock file. Without--jsonit reports how many entries were reaped, kept live, and left unprobed; with--jsonit prints that summary as one JSON object. An empty or already-clean registry is a no-op (exits0). Additive only — the new subcommand appears in theprobesurface tokens automatically.
- Setup/support failures no longer masquerade as an
INTERNAL(104) runner fault. A new reserved codeSETUP(111) covers a fail-closed setup failure — an async runtime that will not build, an unwritable--jsonl/--capture-dir, or aprobe/inspect/control reply that will not serialize — soINTERNAL(104) now means strictly a genuine invariant violation (a runner bug) and a consumer never reads a bad path as one. The--capture-dirsetup failure's terminalrunner_exitevent gains a matchingsource: "setup"(added to the JSONL schema); codes112–119remain reserved. - The control plane's three clients —
inspect,cancel, andkill— all reach a live runner over the local transport now; no subcommand returns the runner-range "not implemented" code any longer. runnow consumes every flag it parses:--jsonl(the JSONL event stream) and--capture-dir(bounded output capture) are both wired up.- Internal: the control plane's client-side scaffolding is de-duplicated. The
inspect/cancel/killwire exchange (converse/converse_mutation) is now one function generic over the reply type;inspect_async/mutate_asyncshare a single deadline-timeout-to-unreachable_runhelper; and the three current-thread tokio runtime constructions inrun/inspect/cancel/killnow go through one shared builder. No externally visible behavior changes. - Updated the
processkitdependency to 2.3.2 (from 2.3.0).events::abrupt_cleanup_str()now sources the abrupt-owner-death reap scope fromprocesskit's own honest capability report (Command::kill_on_parent_death_scope, new in 2.3.2) instead of reimplementing the per-platform derivation locally; the emittedwhole_tree/direct_child_only/nonewire values are unchanged.
- Unix control sockets now use a short owner-only temporary directory instead of
inheriting the registry's full path, so deeply nested macOS CI/workspace paths
cannot exceed
sockaddr_un::sun_pathand silently disableinspect.