ProcessKit: async child-process management with a kernel-backed no-orphan guarantee

Crates.io Docs CI License: MIT OR Apache-2.0

processkit-cli

processkit-cli is the standalone command runner for ProcessKit. It executes one shell-free command inside the public processkit crate's containment boundary, preserves the child's exit code, and records a versioned JSONL lifecycle stream without requiring a Rust or Python runtime at the call site.

The runner owns the command-line and event contracts. ProcessKit-rs remains the single source of truth for containment, teardown, PID-reuse discipline, and platform lifecycle behavior. The cover above represents the wider ProcessKit family; this CLI deliberately focuses on one contained run and its control plane rather than exposing the core crate's pipelines, retries, or scheduling APIs.

For an adoption-oriented comparison with timeout, process groups, systemd, containers, Tini, and PowerShell, see Why ProcessKit CLI?. The guide explicitly identifies the cases where a smaller or wider supervisor is the better fit.

Install

Download a platform archive from the latest GitHub release, or build and install from crates.io:

cargo install processkit-cli

Release archives contain the binary, shell completions, man pages, the JSON Schema, a SHA-256 checksum, and a signed build-provenance attestation. See Installation and distribution for target selection, checksum/attestation verification, completions, man pages, and post-install preflight.

Quick start

Run a command directly, with no shell between the runner and the program:

processkit-cli run --jsonl events.jsonl -- cargo --version

Child stdout and stderr pass through unchanged. Lifecycle events go only to events.jsonl, so an adapter can consume them without parsing or contaminating the child's output.

Use a stable run id when another process needs to inspect or stop the live run:

processkit-cli run --run-id build-42 --jsonl events.jsonl -- cargo test
processkit-cli inspect --run-id build-42 --json
processkit-cli cancel --run-id build-42

The control commands address a per-user registry entry and a live local IPC endpoint, never an operating-system PID. A reused PID therefore cannot retarget an old command at an unrelated process.

Choose a run shape

You needUse
A normal CI command with live outputDefault run: closed stdin, pipe + echo stdout/stderr.
A real existing terminal--inherit-stdio (no capture or idle timeout).
Finite input--stdin-file FILE.
Durable bounded transcripts--capture-dir DIR, optionally --no-echo.
A stuck-worker detector--idle-timeout DURATION.
A recorded history of how the process tree evolved--snapshot-interval DURATION (composes with every I/O mode and with --detach).
External tools launched by an automation agentA foreground run with a unique id, finite deadlines, JSONL, and bounded capture.
Launch now, supervise from another process--detach plus a durable JSONL path and run id.
Whole-tree resource caps--max-memory, --max-processes, --cpu-quota where supported.

The Cookbook gives copyable complete invocations. The narrative guides explain why combinations are accepted or rejected.

Use from automation agents

An automation or coding agent can use this binary without a dedicated SDK. A project instruction can simply require external tools to be launched through processkit-cli run with a unique run id, finite deadlines, lifecycle JSONL, and bounded capture. The agent then has explicit inspect / cancel / wait / kill recovery operations instead of tracking a fragile PID or cleaning up by process name.

This makes agent-driven builds, tests, compilers, and long-lived workers more robust: descendant cleanup is scoped to the run, silent hangs can be bounded, diagnostics survive an interrupted agent turn, and different workloads can use different timeout, output, environment, and resource strategies. It does not pretend that disappearance of an arbitrary agent process is itself a portable cleanup signal; prefer foreground runs, terminate the runner during agent teardown, and use finite deadlines. Detached work needs a separate supervisor.

See Agent and automation workflows for a ready-to-paste agent policy and complete foreground, recovery, and escalation examples.

What the runner guarantees

  • One owned process tree. Normal completion, timeout, cancellation, and runner errors tear down the current run's ProcessKit container. Cleanup never searches by executable name.
  • Exit-code fidelity. A normal child exit is returned unchanged. Runner failures occupy the documented 100-119 band and also emit runner_exit, so a child code is never silently aliased.
  • Separated streams. Child output stays on stdout/stderr; JSONL events stay in --jsonl; runner diagnostics never enter child stdout.
  • Redacted diagnostics. Events contain a SHA-256 argv fingerprint and a classified worker hint by default. Raw arguments require --argv-raw.
  • Bounded capture. --capture-dir tees stdout and stderr into separate, size-capped transcripts with byte counts, hashes, and truncation metadata.
  • Honest platform reporting. run_started records the active containment mechanism and the real abrupt-runner-death cleanup guarantee rather than presenting every operating system as equivalent.

Command surface

CommandPurpose
runStart one contained, shell-free command and write lifecycle JSONL.
inspectSnapshot a live run and its current members.
cancelRequest soft stop, wait through the grace window, then hard-kill survivors.
killHard-kill the run's whole container immediately.
waitWait for one run, or a snapshot of all live runs, to finish.
eventsRead a run's JSONL lifecycle stream back: render, follow, pass through, or validate it.
listDiscover live, stale, and unprobed registry entries.
pruneRemove only entries confirmed stale.
probeVerify the binary's versioned compatibility surface before launch.

Run processkit-cli <command> --help for the complete flag set. The integration guide shows a fail-closed adapter workflow from preflight through cleanup.

Platform behavior

PlatformPreferred mechanismAbrupt runner death
WindowsJob ObjectWhole tree is reaped by kernel kill-on-close.
Linuxcgroup v2, with process-group fallbackDirect child only when parent-death signaling is available.
macOS / other UnixPOSIX process groupNo automatic whole-tree guarantee after an uncatchable runner death.

Every ordinary teardown still uses the active container on every supported platform. The last column is intentionally narrower: it describes only a crash, SIGKILL, or comparable event that prevents the runner from executing its own cleanup path. See the architecture and troubleshooting guide for the exact caveats.

Guides

GuideCovers
Installation and distributionArchives, package-manager manifests, target selection, checksums, attestations, Cargo, completions, man pages.
CookbookTask → command recipes for common foreground, detached, capture, control, and container workflows.
Agent and automation workflowsA drop-in agent instruction, bounded execution strategies, recovery, and honest agent-stop guarantees.
Running commandsShell-free argv, cwd, environment, run ids, foreground lifecycle, and flag interactions.
Standard I/O and captureDefault pipes, inherited handles, stdin files, no-echo, bounded transcripts, TTY caveats.
Detached runsStartup proof, changed launcher exit semantics, recovery, and out-of-band supervision.
Timeouts and cancellationOverall/idle clocks, grace, signals, cancel vs kill, and platform soft-stop behavior.
Resource limitsWhole-tree memory/process/CPU caps and fail-closed enforcement.
Platform supportRelease targets, mechanisms, abrupt cleanup, capability and CI matrices.
Running in containersmusl/glibc images, PID 1, signals, writable paths, cgroup delegation, outer limits.
Integration guideProbe, launch, event consumption, supervision, and housekeeping for adapters.
Compatibility and upgradesSurface tokens, schema/exit-band pinning, rolling upgrades, and acceptance policy.
Live-run control planeIPC transport, inspect/cancel/kill semantics, and safe targeting.
Run registryPer-user records, liveness probing, ambiguity, waiting, and pruning.
JSONL event schemaThe normative schema_version = 1 contract and golden fixtures.
Exit-code contractChild-code fidelity and the reserved runner failure band.
TroubleshootingSymptom-to-cause diagnosis for operators and CI.
Threat modelTrusted boundaries, hostile inputs, local IPC, and supply chain.
ArchitectureModule map and the data flow of one run.

The 60-second tour

# 1. Prove the installed runner supports the contract your caller needs.
processkit-cli probe --json \
  --require-schema-version 1 \
  --require-exit-code-band 100-119 \
  --require-surface run:--capture-dir

# 2. Start one shell-free command with a stable id and bounded transcripts.
processkit-cli run --run-id demo --capture-dir ./demo-output \
  --jsonl demo.jsonl -- cargo test

# 3. While it is live, inspect or cancel it from another process.
processkit-cli inspect --run-id demo --json
processkit-cli cancel --run-id demo

# 4. Read its lifecycle story back — live, or long after it finished.
processkit-cli events --run-id demo --follow
processkit-cli events --file demo.jsonl

# 5. Discover or clean up registry state after an orchestrator restart.
processkit-cli list --json
processkit-cli prune --dry-run --json

The JSONL file is the durable lifecycle record. The registry and control endpoint exist only while a run is live (or as detectable stale leftovers after an abrupt runner death).

Source, release history, and contribution guidance live in the GitHub repository.

Why ProcessKit CLI?

ProcessKit CLI is for one shell-free command whose process tree must be cleaned up on ordinary completion, timeout, or cancellation, while an adapter receives a stable lifecycle record. It is not a universal service manager, container runtime, or security sandbox. Choose the tool that owns the widest requirement; the choices below can also be layered.

Quick choice

Primary requirementStart with
One portable command surface, tree-scoped teardown, exact ordinary child exit status, and versioned lifecycle JSONLProcessKit CLI
The shortest available Unix command deadline with no new installationGNU timeout
Terminal/session detachment or a new POSIX process group, with lifecycle code supplied by the callersetsid / start_new_session
A Linux service or scope owned by the host manager, with native cgroup policy, journaling, and service integrationsystemd-run / a systemd unit
Filesystem, network, user, and image isolation; deployment scheduling or restart policyA container runtime / orchestrator
Correct PID 1 signal forwarding and zombie reaping inside a containerTini or the runtime's --init mode
PowerShell-native asynchronous objects, streams, or remotingA PowerShell job
A custom Windows-only host that already owns Win32 lifecycle codeA raw Win32 Job Object

Comparison at a glance

OptionProcess boundaryIf its immediate launcher dies abruptlyResult and observabilityWhere that option wins
ProcessKit CLIThe obtained ProcessKit mechanism: Windows Job Object, Linux cgroup v2, or a reported process-group fallbackExplicit abrupt_cleanup: whole_tree on Windows, direct_child_only on Linux, none on macOS/other UnixOrdinary child status is preserved; runner-imposed endings and runner failures are distinct. Versioned JSONL, bounded diagnostics, local inspect/cancel/kill/wait.One binary and one adapter contract across Windows, Linux, and macOS.
GNU timeoutA time limit and signal policy around one command; default and foreground modes have different process-group behaviorNo separate kill-on-owner-death containment contractFamiliar shell status conventions and stderr diagnostics, not a versioned lifecycle streamNear-ubiquitous, tiny, and ideal when a deadline is the whole requirement.
setsid / start_new_sessionA POSIX session and process group, not a resource containerNo owner-death reap; a descendant can create another sessionWhatever wait, exit, logging, and cleanup logic the caller writesMinimal mechanism for terminal detachment and shell/job-control composition.
systemd service or scopeA cgroup owned by the system or user service managerThe unit remains manager-owned rather than depending on the short-lived CLI client; stop and restart behavior follows unit policyNative unit state, cgroup accounting, journal integration, and systemd resource controlsDurable Linux host supervision, delegated cgroups, boot integration, restart policy, and administrator tooling.
Container runtime / orchestratorA container boundary, normally including namespaces and cgroupsThe runtime owns container lifetime; daemon/orchestrator and restart settings decide recoveryRuntime-specific status, logs, events, health, and schedulingActual workload isolation, image distribution, network/filesystem policy, and fleet orchestration.
Tini / subreaperOne child, zombie adoption, and signal forwarding; optional process-group signalingTini alone does not add an independent kernel tree containerReuses the child's exit status and solves PID 1 hygiene; no lifecycle JSONL or live run registryVery small, transparent container init when reaping and signal forwarding are the missing pieces.
PowerShell jobA PowerShell job repository plus a child process, remote command, or thread depending on job typeSession-owned child jobs end with the parent session; this is not the Win32 Job Object kill-on-close contractRich PowerShell job state and serialized output/error streamsInteractive PowerShell concurrency, remoting, and object-oriented result handling.
Raw Win32 Job ObjectA Windows kernel job; descendants normally join unless breakaway policy permits otherwiseJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE can provide whole-job termination when the last handle closesWhatever schema, exit mapping, IPC, ACLs, and graceful-stop code the host implementsMaximum control inside an existing Windows-native host without adopting another CLI contract.

The ProcessKit row is deliberately conditional. Read the actual mechanism and abrupt_cleanup values from run_started; do not infer Windows' whole_tree owner-death guarantee on Linux or macOS. The normative matrix is in Platform support.

Compared with GNU timeout

GNU timeout is the right answer for many shell scripts: it is already installed, concise, supports a configurable signal and kill-after interval, and normally preserves a command's status when the command finishes before the deadline.

Choose ProcessKit CLI when the deadline is only one part of the contract: cleanup must be tied to the obtained container, stdout and stderr must remain separate from machine events, another process must inspect or stop a live run, or an adapter must distinguish child exit, timeout, cancellation, and runner failure without reverse-engineering shell status conventions. ProcessKit CLI is larger and requires a JSONL destination; timeout is simpler when no consumer needs those guarantees.

Compared with setsid or start_new_session

POSIX setsid creates a session and a new process group. Python's start_new_session=True requests the same primitive. That is useful for terminal detachment and for giving the caller a group to signal, but it is not a persistent list of every descendant: a cooperating or hostile descendant may start another session, and the session does not gain a kill-on-owner-death policy.

ProcessKit itself may honestly fall back to a process group, especially on macOS or when Linux cgroup delegation is unavailable. In that case the CLI does not claim a stronger boundary: mechanism is process_group, resource-limit requests fail rather than becoming no-ops, and the documented escape and abrupt death limitations apply. Use plain setsid when that primitive plus your own wait/signal code is sufficient.

Compared with systemd-run --scope

On a systemd Linux host, systemd is the natural owner of long-lived service lifecycle and the cgroup tree. A transient service or scope gives administrators native unit state, resource policy, accounting, and journal integration. Systemd's documented delegation model is also the correct way to grant a nested manager its own cgroup subtree. Prefer it for host services, boot integration, restart policy, and durable supervision.

ProcessKit CLI instead standardizes one child run across operating systems and emits its own portable event schema. It does not replace systemd. They can be layered: let systemd own the service and its outer limits, and use ProcessKit CLI inside it when an adapter still needs per-invocation JSONL and the same command contract on Windows and macOS. In a normal non-delegated systemd unit, ProcessKit may report process_group; do not mount or rewrite cgroup state merely to force a different mechanism.

Compared with a container runtime

A container runtime solves a wider isolation and deployment problem. Docker, for example, provides cgroup-backed resource constraints and configurable restart policies, while an orchestrator adds scheduling, health, networking, secrets, and rollout policy. ProcessKit CLI provides none of that isolation and intentionally trusts other processes running as the same OS user.

Use the outer runtime for container lifetime and limits. ProcessKit CLI can be the container entrypoint when one payload still needs its portable JSONL, timeout/cancel outcome, and local control plane. Cgroup v2 is not automatically delegated inside ordinary containers, so this layering can legitimately report the process-group fallback; see Running in containers.

Compared with Tini or another subreaper

Tini is intentionally small: as PID 1 or a Linux subreaper it adopts and reaps zombies, forwards signals to its child, can signal the child's process group, and exits with the child's status. Docker's --init mode addresses the same PID 1 hygiene problem.

That is a feature, not an omission: Tini is an excellent choice when zombie reaping and signal forwarding are the whole problem. ProcessKit CLI adds a command deadline, reported containment, capture, resource-limit negotiation, versioned events, and live-run IPC. It is correspondingly more opinionated and requires persistent paths for JSONL and registry state.

Compared with PowerShell jobs and raw Windows Job Objects

A PowerShell background job is a shell-level concurrency and result abstraction. It exposes job state and PowerShell's output, error, warning, and other streams; remote and thread jobs cover still different execution shapes. The official about_Jobs documentation describes that session-owned repository. Prefer it when a PowerShell operator wants asynchronous objects or remoting rather than a language-neutral binary contract.

A PowerShell job is not the same thing as the Win32 kernel Job Object used by ProcessKit on Windows. A raw Win32 Job Object can group processes, enforce limits, and terminate the job on last-handle close. It is the right primitive for a Windows-native host willing to implement its own spawn race handling, nested-job policy, exit mapping, diagnostics, IPC security, and wire schema. ProcessKit CLI packages that work behind a tested command surface, but a custom host can expose deeper application-specific integration.

Exit status, telemetry, and secrets

ProcessKit CLI preserves the child's exact status only when the child chose the ordinary outcome. A timeout, cancellation, output-overflow stop, or runner failure uses a documented reserved code and a terminal runner_exit event, so those cases cannot masquerade as the child's decision. See the exit-code contract and JSONL schema.

The JSONL stream is separate from child stdout/stderr and versioned for adapters. Argv is hashed by default, with only a classified worker hint; raw argv is an explicit --argv-raw opt-in. Other supervisors may provide richer native logs or object streams, but their command metadata has its own disclosure rules. Do not assume that switching wrappers preserves ProcessKit CLI's redaction contract.

A practical decision rule

  1. Need isolation, deployment, restart, or a durable host service? Choose the container runtime/orchestrator or systemd first.
  2. Need only a Unix deadline, a new session, PowerShell concurrency, or PID 1 reaping? Use the smaller native tool.
  3. Need one cross-platform invocation contract plus tree teardown and lifecycle JSONL? Use ProcessKit CLI, then validate mechanism and abrupt_cleanup for the actual host.
  4. Need both layers? Let the outer supervisor own machine/container policy and ProcessKit CLI own one inner command. Treat the outer supervisor's OOM, eviction, and restart events as authoritative for that outer boundary.

Continue with Installation and distribution, or start from the copyable Cookbook.

Installation and distribution

processkit-cli is distributed as one self-contained executable. A machine that runs the binary does not need Python, a virtual environment, or a Rust toolchain. Choose a prebuilt archive directly or through cargo-binstall for production and CI; use cargo install when building from source is already part of your environment.

Prebuilt archives

One-command verified install

The repository installers select the host archive, download its published .sha256 sidecar, fail closed on any mismatch, and install only after successful verification. They refuse to replace an existing destination unless running that file with --version identifies it as processkit-cli.

Linux or macOS:

curl -fsSL https://raw.githubusercontent.com/ZelAnton/ProcessKit-CLI/main/install.sh | sh

Windows PowerShell:

irm https://raw.githubusercontent.com/ZelAnton/ProcessKit-CLI/main/install.ps1 | iex

The defaults install the latest release under ~/.local/bin. For a pinned version, custom destination, or explicit target, download the script and pass its options directly:

sh install.sh --version X.Y.Z --install-dir /opt/processkit/bin
.\install.ps1 -Version X.Y.Z -InstallDir C:\Tools\ProcessKit

The scripts print the installed binary's --version result and tell you when the destination is not yet on PATH. The POSIX installer supports the published Linux x86_64/Arm64 and macOS Arm64 builds; use --target x86_64-unknown-linux-musl or --target aarch64-unknown-linux-musl when the static musl archive is required for the matching architecture. The PowerShell installer selects x86_64 or Arm64 from PROCESSOR_ARCHITECTURE.

Every GitHub Release contains one archive per supported target:

PlatformTargetArchive format
Windows x86_64x86_64-pc-windows-msvc.zip
Windows Arm64aarch64-pc-windows-msvc.zip
Linux x86_64, glibcx86_64-unknown-linux-gnu.tar.gz
Linux Arm64, glibcaarch64-unknown-linux-gnu.tar.gz
Linux x86_64, static muslx86_64-unknown-linux-musl.tar.gz
Linux Arm64, static muslaarch64-unknown-linux-musl.tar.gz
macOS Apple Siliconaarch64-apple-darwin.tar.gz

The naming convention is processkit-cli-v<version>-<target>.<format>. Each archive has a neighboring .sha256 file and a signed GitHub build-provenance attestation.

Linux and macOS

version=X.Y.Z
target=x86_64-unknown-linux-gnu
archive="processkit-cli-v${version}-${target}.tar.gz"
base="https://github.com/ZelAnton/ProcessKit-CLI/releases/download/v${version}"

curl -sSLO "$base/$archive"
curl -sSLO "$base/$archive.sha256"
sha256sum -c "$archive.sha256" # macOS: shasum -a 256 -c
tar -xzf "$archive"
install -m 0755 processkit-cli "$HOME/.local/bin/processkit-cli"

Windows PowerShell

$version = 'X.Y.Z'
$target = 'x86_64-pc-windows-msvc'
$archive = "processkit-cli-v$version-$target.zip"
$base = "https://github.com/ZelAnton/ProcessKit-CLI/releases/download/v$version"

Invoke-WebRequest "$base/$archive" -OutFile $archive
Invoke-WebRequest "$base/$archive.sha256" -OutFile "$archive.sha256"
Get-FileHash -Algorithm SHA256 $archive
# Compare the printed hash with the value in $archive.sha256 before extracting.
Expand-Archive $archive -DestinationPath .\processkit-cli

Put processkit-cli.exe in a directory already present in PATH, or add its installation directory to PATH for the account that launches the runner.

Verify provenance

The checksum detects damaged or substituted bytes. The attestation additionally proves that GitHub Actions built the archive from this repository:

gh attestation verify "$archive" --repo ZelAnton/ProcessKit-CLI

Both checks are useful in automation: the checksum is portable and offline once downloaded, while attestation verification ties the bytes to the release workflow and repository identity.

Package-manager manifests

After the release-archive matrix settles, the release workflow reads the published .sha256 sidecars for each archive the channels use and attaches distributor-ready manifests for winget, Scoop, and a Homebrew tap. The same files are collected in processkit-cli-v<version>-package-manifests.tar.gz, with a neighboring bundle checksum. The generator rejects a sidecar unless it names the exact archive the manifest will download, so URLs and digests cannot be paired accidentally.

Package managers need a repository of their own; a manifest attached to this project's GitHub Release is distributor input, not a public package source. The current publication boundary is explicit:

ChannelGenerated release assetAvailability
wingetThree-file ZelAnton.ProcessKitCLI manifest for x86_64 and Arm64Submit all three files to microsoft/winget-pkgs; installation is available only after Microsoft's external review accepts the version.
Scoopprocesskit-cli.json for x86_64 and Arm64Ready for bucket/processkit-cli.json in an account-owned bucket; no canonical public bucket is advertised yet.
Homebrewprocesskit-cli.rb for macOS Arm64 and Linux x86_64/Arm64Ready for Formula/processkit-cli.rb in an account-owned tap; both Linux architectures use their static musl archive so the formula does not inherit the release runner's glibc floor. No canonical public tap is advertised yet.

Once a source is actually published, that package manager provides its normal install and upgrade lifecycle. Until its availability row changes, use the verified installer, cargo-binstall, or cargo install; do not paste a future bucket/tap name into automation and assume it exists.

Install with cargo-binstall

If cargo-binstall is already available, install the matching prebuilt binary without a local source build:

cargo binstall processkit-cli

The crate metadata resolves to the same GitHub Release archive documented above: .tar.gz with a root-level processkit-cli on Unix, and .zip with a root-level processkit-cli.exe on Windows. The adjacent .sha256 and signed provenance attestation belong to that same release, but cargo-binstall does not verify those sidecars automatically. Use the manual archive path when policy requires those checks or when you also need completions/, man/, or schema/.

Install from crates.io

cargo install processkit-cli

This builds for the current host and installs into Cargo's binary directory. Use cargo install processkit-cli --locked when you want the dependency graph from the published lockfile rather than a newly resolved compatible graph.

Building from source also generates shell completions and man pages under target/assets/ in the build tree. Release archives already contain those assets.

Shell completions

The completions/ directory contains bash, zsh, fish, PowerShell, and Elvish scripts generated from the same clap definition as the binary.

# bash
install -Dm644 completions/processkit-cli.bash \
  "$HOME/.local/share/bash-completion/completions/processkit-cli"

# fish
install -Dm644 completions/processkit-cli.fish \
  "$HOME/.config/fish/completions/processkit-cli.fish"
$destination = Join-Path (Split-Path $PROFILE) '_processkit-cli.ps1'
Copy-Item completions\_processkit-cli.ps1 $destination
Add-Content $PROFILE ". '$destination'"

The exact filenames are visible in the extracted completions/ directory; shell conventions differ slightly between package managers.

Man pages

Each subcommand has a page under man/man1/, plus the top-level processkit-cli.1 page:

install -Dm644 man/man1/*.1 "$HOME/.local/share/man/man1/"
man processkit-cli-run

Schema files in an archive

Release archives carry schema/schema.json and schema/events.jsonl. They are the machine-readable JSON Schema and golden event stream for the binary's current schema_version. An installed binary can print the same schema without an archive:

processkit-cli probe --json --print-schema > processkit-cli.schema.json

See Compatibility and upgrades before replacing a runner that is consumed by an adapter.

Post-install verification

processkit-cli --version
processkit-cli probe --json \
  --require-schema-version 1 \
  --require-exit-code-band 100-119 \
  --require-surface run \
  --require-surface run:--jsonl

The first command confirms which executable PATH resolves. The second is a side-effect-free compatibility check: it launches no child, creates no registry entry, and exits with 110 when a requirement is not met.

Next steps

  • Cookbook — copyable task-oriented examples.
  • Running commands — argv, working directory, environment, run identity, and foreground behavior.
  • Platform support — containment strength and the meaning of mechanism / abrupt_cleanup.

Cookbook

Task-oriented command shapes for ProcessKit CLI. Each recipe keeps the JSONL destination explicit and places the child after --, so the boundary between runner options and child argv remains visible.

For copyable scripts exercised on Linux and Windows in CI, see the runnable examples.

Run a command and preserve its exit code

processkit-cli run --jsonl run.jsonl -- cargo test

For a foreground run, the CLI exits with the child's exact code after tearing down the contained tree. Runner-owned failures and cancellations use the reserved band; read Exit-code contract when the caller must distinguish them from a child that returned the same number.

Run in another directory

processkit-cli run \
  --cwd ./services/catalog \
  --jsonl catalog-test.jsonl \
  -- cargo test --locked

run_started.cwd records the resolved absolute directory.

Start from a controlled environment

processkit-cli run \
  --env-clear \
  --env PATH=/usr/bin:/bin \
  --env HOME=/tmp/worker-home \
  --env CI=true \
  --jsonl hermetic.jsonl \
  -- worker

Use absolute program paths when clearing PATH entirely. Applied order is clear → remove → set.

Remove one inherited secret

processkit-cli run \
  --env-remove GITHUB_TOKEN \
  --env-remove AWS_SECRET_ACCESS_KEY \
  --jsonl sanitized.jsonl \
  -- third-party-tool

Environment values are not placed in JSONL, but the child can still echo them.

Capture output without echoing it live

processkit-cli run \
  --no-echo \
  --capture-dir ./capture \
  --capture-max-bytes 16m \
  --jsonl captured.jsonl \
  -- noisy-build

Read output_captured.truncated before treating either file as complete.

Feed a finite input file

processkit-cli run \
  --stdin-file request.json \
  --capture-dir ./response \
  --jsonl request-run.jsonl \
  -- json-transform

The file closes the child's stdin at EOF and its bytes never enter argv.

Run an interactive terminal program

processkit-cli run --inherit-stdio --jsonl interactive.jsonl -- repl-tool

The child sees the caller's existing terminal. Capture, no-echo, idle timeout, detach, and --create-no-window are unavailable in this mode. This preserves a terminal; it does not create a PTY.

Bound total runtime

processkit-cli run \
  --timeout 15m \
  --grace 10s \
  --jsonl timed.jsonl \
  -- integration-tests

Expiry emits timeout with reason: "overall", then the cleanup sequence and terminal runner_exit.

Kill a worker that stops producing output

processkit-cli run \
  --idle-timeout 2m \
  --grace 5s \
  --jsonl worker.jsonl \
  -- build-worker

Every observed stdout/stderr chunk re-arms the idle clock. Use only for tools whose silence is a meaningful health signal.

Give no soft-stop grace

processkit-cli run --timeout 30s --grace 0 --jsonl fast.jsonl -- disposable-task

0 is legal for grace and means immediate progression to the hard tier. It is rejected for overall, idle, and wait deadlines.

Launch out of band and supervise later

processkit-cli run \
  --detach \
  --run-id nightly-build \
  --capture-dir ./nightly-output \
  --jsonl nightly.jsonl \
  -- cargo build --release

processkit-cli inspect --run-id nightly-build
processkit-cli wait --run-id nightly-build --timeout 30m

The detached launcher's 0 means “started.” Read terminal JSONL for the child's eventual result.

Inspect a live tree as JSON

processkit-cli inspect --run-id nightly-build --json

The snapshot includes the mechanism, root pid, start time, and current members with nullable enriched fields. It is an observation at request time, not a durable history.

Ask one run to stop cooperatively

processkit-cli cancel --run-id nightly-build
processkit-cli wait --run-id nightly-build --timeout 30s

cancel acknowledges the request; wait is the completion barrier.

Hard-kill one run now

processkit-cli kill --run-id wedged-worker
processkit-cli wait --run-id wedged-worker --timeout 10s

This skips soft stop and grace and produces a distinct killed outcome.

Shut down every currently live run

processkit-cli cancel --all
processkit-cli wait --all --timeout 30s
processkit-cli prune --dry-run
processkit-cli prune

Both --all operations use their own snapshots. Prevent new launches during a global shutdown or repeat the sequence to catch later registrations.

Discover runs without knowing their ids

processkit-cli list
processkit-cli list --json
processkit-cli list --label pipeline=ci --health live

The human table abbreviates argv_sha256; JSON Lines carry the full digest. live, stale, and unprobed are intentionally distinct health states. Label filters are exact and conjunctive, matching the aggregate control commands. New records also expose absolute jsonl and optional capture_dir locators, so a supervisor discovering a detached run can open its artifacts without launch-time state.

Preview stale-record cleanup

processkit-cli prune --dry-run --json

Only entries whose liveness probe succeeded and reported stale appear as candidates. unprobed entries are preserved.

Verify a runner before using it

processkit-cli probe --json \
  --require-schema-version 1 \
  --require-exit-code-band 100-119 \
  --require-surface run:--capture-dir \
  --require-surface cancel:--all \
  --require-surface wait:--all

Exit 110 means the binary is incompatible with at least one requirement. No child or registry entry is created.

Export the exact event schema

processkit-cli probe --json --print-schema > processkit-cli.schema.json

This prints the schema embedded in that exact binary, which is useful when the consumer has an installed executable but no matching git checkout.

Require whole-tree resource caps

processkit-cli run \
  --max-memory 2g \
  --max-processes 64 \
  --cpu-quota 2 \
  --jsonl limited.jsonl \
  -- untrusted-compiler

Unsupported enforcement fails before spawn with limit_hit; it never runs the child without the requested policy. See Resource limits before using this in Linux containers or systemd.

Hide a detached Windows console

processkit-cli run --detach --create-no-window `
  --run-id headless-worker `
  --jsonl headless.jsonl `
  -- worker.exe

Use this only for a child that does not require a console. The runner never forces CREATE_NO_WINDOW by default.

Invoke a shell explicitly

processkit-cli run --jsonl shell.jsonl -- sh -c 'make all && make test'
processkit-cli run --jsonl shell.jsonl -- `
  pwsh -NoProfile -Command 'Get-ChildItem Env: | Sort-Object Name'

The shell is now an explicit child program. Its quoting, expansion, and pipeline semantics are outside ProcessKit CLI.

Keep child stdout machine-clean

processkit-cli run --jsonl events.jsonl -- report-generator > report.bin

JSONL never goes to stdout. Runner diagnostics use stderr, and child stderr is also forwarded there. Use --no-echo --capture-dir when stdout must not be forwarded at all.

Read a run's lifecycle events

processkit-cli events --run-id build-42            # what happened, rendered
processkit-cli events --run-id build-42 --follow   # ... as it happens
processkit-cli events --file build-42.jsonl        # once the record is gone
processkit-cli events --file build-42.jsonl --json # raw lines, for a parser

events is read-only: it resolves the stream through the registry (--run-id) or reads a path directly (--file), never contacts the run, and mutates nothing. It hands out only complete lines, and --follow stops at the terminal runner_exit — or, for a runner killed before it could write one, once the run is gone and the stream has stopped growing.

Check a stream against the event schema

processkit-cli events --file fixture.jsonl --validate

Checks every line against the schema embedded in that exact binary and reports each violation by line number; exit 0 when all lines conform, 114 (EVENTS_INVALID) when any does not. Useful in CI for an adapter's own recorded fixtures — no separate validator, and no second copy of the schema to keep in sync.

Tail lifecycle events safely in your own reader

When something other than events reads the stream, treat JSONL as an append-only sequence of complete lines. A reader should:

  1. buffer until newline;
  2. parse one object;
  3. verify schema_version;
  4. dispatch on the event discriminator, tolerating unknown event types, unknown fields, new always-present fields on an event it already parses, and repeats of an event type that previously occurred only once (see Compatibility and upgrades);
  5. stop only after terminal runner_exit or an explicit external recovery decision.

The file may end with a partial line if the runner is killed during a write. Do not parse that suffix as a complete event.

Recover after the supervising application restarts

processkit-cli list --json
processkit-cli inspect --run-id recovered-run --json
processkit-cli wait --run-id recovered-run --timeout 30s

Use the registry for current liveness and the JSONL file for durable history. Never reconnect by recorded PID.

Give an automation agent a bounded execution policy

Instruct the agent to launch external tools through a foreground runner with a unique run id, finite deadlines, lifecycle JSONL, and bounded capture:

mkdir -p .agent-runs/agent-task-42
processkit-cli run --run-id agent-task-42 \
  --timeout 20m --idle-timeout 3m \
  --capture-dir .agent-runs/agent-task-42/capture \
  --jsonl .agent-runs/agent-task-42/events.jsonl \
  -- <program> <args...>

The agent should cancel and wait by run id, never clean up by PID or process name, and reserve --detach for work with a separate supervisor. See Agent and automation workflows for a ready-to-paste instruction, recovery strategy, and the precise limits of cleanup when the agent itself stops.

Use as a container entrypoint

ENTRYPOINT ["/usr/local/bin/processkit-cli", "run", "--jsonl", "/run/events.jsonl", "--"]
CMD ["/app/worker"]

Exec form preserves signal delivery and avoids a shell wrapper. Ensure /run is writable and the orchestrator's termination grace exceeds the CLI's grace.

Diagnose a failed start

  1. Read stderr for the operator message.
  2. Read JSONL for spawn_failed, limit_hit, or container_failed.
  3. Read terminal runner_exit for runner code and nullable child code.
  4. If a registry entry remains after abrupt runner death, use list and prune --dry-run; do not kill the recorded pid.

Guide map

NeedRead
argv, cwd, environmentRunning commands
terminal, stdin, captureStandard I/O and capture
out-of-band lifecycleDetached runs
deadlines and stop behaviorTimeouts and cancellation
memory/process/CPU capsResource limits
OS differencesPlatform support
agent tool executionAgent and automation workflows
copyable end-to-end scriptsRunnable examples
adapter designIntegration guide
event fieldsJSONL event schema

Runnable examples

The repository ships four paired POSIX and PowerShell examples. They are executed against the built binary on Linux and Windows in CI, so their public command shapes cannot drift unnoticed.

ScenarioUse it whenScripts
Compatibility preflightAn adapter must fail closed when the installed schema, exit band, or required flags drift.POSIX · PowerShell
Foreground JSONLA supervisor needs a finite deadline and must parse the terminal lifecycle event.POSIX · PowerShell
Detached supervisionA launcher hands work to the runner, then uses separate inspect and wait clients.POSIX · PowerShell
Label-scoped fleet stopAn operator discovers and cancels only a uniquely labeled fleet snapshot.POSIX · PowerShell

See the repository's examples README for prerequisites, PROCESSKIT_CLI_BIN selection, and cleanup guarantees.

Running external tools from automation agents

An automation or coding agent can use processkit-cli as the execution boundary for builds, tests, compilers, package managers, development servers, and other external tools. The agent does not need a special SDK: it only needs to be instructed to invoke the tool through the runner instead of starting it directly.

This is especially useful for commands that can spawn workers, become silent, hold output handles open, or outlive the agent action that launched them.

Why use a runner

A direct process launch gives an agent one process handle, but the real workload may be a tree: a compiler can start helpers, a test host can start workers, and a build tool can keep reusable nodes alive. Treating only the root PID as the unit of cleanup creates several failure modes:

  • stopping the agent or cancelling its tool call may leave descendants running;
  • a descendant that retains stdout or stderr can make the caller wait forever;
  • a lost process handle leaves the next agent turn with little reliable state;
  • cleanup by process name can terminate unrelated work;
  • a reused PID can make delayed cleanup target the wrong process;
  • unbounded output and silent hangs consume time, disk, and compute without a useful diagnostic record.

ProcessKit CLI gives the agent a higher-level unit: one run with a contained process tree, explicit deadlines, bounded transcripts, versioned lifecycle events, and a run-id-based control plane.

Agent concernRunner capability
A tool spawns descendantsThe ProcessKit container is the cleanup scope.
A command never finishes--timeout bounds total runtime.
A worker becomes silent--idle-timeout detects missing output activity.
The tool's process tree grew or lingered, and nobody was watching--snapshot-interval records periodic members_snapshot events into the JSONL for post-hoc reading.
Live output is too noisy--no-echo suppresses relay while capture continues.
Output is needed after failure--capture-dir keeps bounded stdout/stderr files with hashes and truncation metadata.
The agent loses its local process handlelist, inspect, and wait operate through the per-user run registry.
Cooperative stop failscancel escalates after grace; kill hard-kills the owned container immediately.
A caller needs machine-readable evidence--jsonl records the versioned lifecycle and terminal outcome.
Several execution policies are neededThe agent can choose deadlines, capture, environment, resource limits, and foreground/detached supervision per task.

A drop-in instruction for an agent

The following policy can be placed in an AGENTS.md, system prompt, tool description, or project automation guide. Adjust paths and default durations to the repository:

When launching an external command that may run for more than a few seconds, spawn descendants, or require reliable cleanup, execute it through processkit-cli run rather than starting it directly. Create a private, per-invocation run directory and a unique, non-secret run id. Pass the program and its arguments after -- without a shell unless shell syntax is explicitly required. Always write JSONL events, set a total timeout, add an idle timeout when silence indicates a stuck tool, and use bounded capture when output may be needed for diagnosis. Prefer a foreground run. On cancellation or uncertainty, address the run by its run id with inspect, cancel, wait, and finally kill if graceful cancellation does not finish. Never clean up by process name or by a PID copied from earlier output. Use --detach only when a separate supervisor deliberately owns the continuing run.

The executable can be preflighted once per agent session:

processkit-cli probe --json \
  --require-schema-version 1 \
  --require-exit-code-band 100-119 \
  --require-surface run:--timeout \
  --require-surface run:--capture-dir \
  --require-surface cancel:--run-id

An agent can then adapt this invocation template to each tool:

# Create RUN_DIR first and choose a unique RUN_ID for this invocation.
processkit-cli run \
  --run-id "$RUN_ID" \
  --timeout 20m \
  --idle-timeout 3m \
  --grace 5s \
  --capture-dir "$RUN_DIR/capture" \
  --capture-max-bytes 16m \
  --jsonl "$RUN_DIR/events.jsonl" \
  -- <program> <args...>

The runner's exit code is the program's exit code on normal completion. The documented 100-119 band is a useful first signal for runner outcomes, but a child can coincidentally return a number in the same range. The terminal runner_exit event is authoritative: its source and separate child_code let the agent distinguish a failing test from a timeout, cancellation, spawn failure, or backend failure without guessing from the number alone.

A robust foreground strategy

Foreground execution is the safest default for an agent action:

  1. Generate a unique run id and create its diagnostic directory.
  2. Start processkit-cli run with an overall timeout and durable JSONL path.
  3. Stream ordinary child output to the agent, or use --no-echo with bounded capture when live output would consume too much context.
  4. Interpret the returned code together with terminal runner_exit.
  5. If the tool call is cancelled, send a supported stop signal to the foreground runner and wait for its normal container teardown.
  6. If completion becomes uncertain, use list --json and inspect --json rather than guessing from a PID.
  7. Request cancel, wait for a bounded interval, and use kill only as the escalation step.
  8. Retain JSONL and capture files for failed runs; discard them according to the project's artifact policy after success.

This makes retries safer because each attempt has a distinct identity and diagnostic record. A later agent turn can tell whether an earlier attempt is live, finished, stale, or unprobeable before deciding whether to retry.

Strategy examples

Build or test with a hard ceiling

mkdir -p .agent-runs/agent-test-42
processkit-cli run --run-id agent-test-42 \
  --timeout 15m --idle-timeout 2m \
  --capture-dir .agent-runs/agent-test-42/capture \
  --jsonl .agent-runs/agent-test-42/events.jsonl \
  -- cargo test --all-features

This protects the agent from both a long-running test suite and a build worker that stops producing output.

Keep verbose output out of the agent context

mkdir -p .agent-runs/agent-build-42
processkit-cli run --run-id agent-build-42 \
  --timeout 20m --no-echo \
  --capture-dir .agent-runs/agent-build-42/capture \
  --jsonl .agent-runs/agent-build-42/events.jsonl \
  -- cargo build --release

The agent can read the bounded transcript only when it needs to diagnose a failure. JSONL still provides the lifecycle and terminal result.

Recover after an interrupted agent turn

processkit-cli list --json
processkit-cli inspect --run-id agent-build-42 --json
processkit-cli cancel --run-id agent-build-42
processkit-cli wait --run-id agent-build-42 --timeout 30s --report-outcome

If the bounded wait expires and the run must stop immediately:

processkit-cli kill --run-id agent-build-42

Control commands resolve the registry identity and live IPC endpoint. They do not target the root PID printed by an earlier observation. The reporting wait keeps its own exit-code contract and emits the remembered run's terminal runner_exit fields as JSON; status:"unknown" means the waiter could not recover that event and must not guess the outcome.

Apply a policy to expensive tools

Where the platform can enforce whole-tree limits, an agent can combine time and resource budgets:

mkdir -p .agent-runs/agent-compiler-42
processkit-cli run --run-id agent-compiler-42 \
  --timeout 20m --max-memory 4g --max-processes 64 --cpu-quota 4 \
  --jsonl .agent-runs/agent-compiler-42/events.jsonl \
  -- compiler <args...>

Limit requests fail before spawn when the active backend cannot enforce them. The agent should treat that as a policy failure, not silently rerun the command without limits.

What happens when the agent stops

The runner substantially improves cancellation and cleanup, but it is important to state the boundary precisely:

  • If the agent host cancels the foreground tool call by delivering a supported stop signal to processkit-cli, the runner performs its normal whole-container teardown.
  • Overall and idle timeouts remain active inside a surviving runner, so work is bounded even if the agent no longer observes the call.
  • The runner does not monitor an agent identity and cannot infer that an unrelated parent application has disappeared. If the host abandons the runner without terminating it, the run may continue until its own deadline or child completion.
  • If the runner itself is killed before normal teardown, the abrupt_cleanup field reports the real platform guarantee: whole_tree on Windows, direct_child_only on Linux, and none on macOS/other Unix.
  • A detached run deliberately outlives the launching call. It requires a durable JSONL path, a stable run id, deadlines, and a separate supervisor or recovery policy.

For a host that must guarantee cleanup when an agent session ends, combine the runner with an explicit host policy: keep run in the foreground, terminate the runner during agent teardown, require finite deadlines, and reconcile remaining registry entries before the next session. Do not describe --detach as a leak prevention mechanism.

Diagnostic workflow for agents

When a command fails or appears stuck, an agent can collect evidence in this order:

  1. Read the processkit-cli exit code.
  2. Read the final complete JSONL records, especially runner_exit and any preceding timeout, cancellation, spawn, container, or limit event — processkit-cli events --run-id <id> (or --file <events.jsonl> once the registry record is gone) renders them without an external tailer, and events --json hands the raw lines to a parser unchanged.
  3. Read bounded stdout/stderr capture only when the program's output is needed.
  4. If the run may still be live, call inspect --json for the current member snapshot and containment mechanism.
  5. Use cancel followed by bounded wait; escalate to kill only when needed.
  6. Use prune --dry-run --json before removing confirmed-stale registry state; add the same --label KEY=VALUE filters to preview and reap when the registry is shared with other projects or orchestrators.

This separates program failure, runner failure, policy failure, and ambiguous external interruption instead of collapsing them into “the command hung.”

See also

Running commands

run executes exactly one program inside a ProcessKit container and keeps the runner alive until the child and its descendants have been reaped. The program is always passed after --:

processkit-cli run [RUNNER OPTIONS] --jsonl <events.jsonl> -- <program> [args...]

The separator makes the ownership boundary explicit. Everything before -- belongs to processkit-cli; everything after it is the child argv.

Shell-free argv

There is no shell mode. The runner does not expand wildcards, interpolate variables, interpret pipes, or process redirection characters:

processkit-cli run --jsonl run.jsonl -- git status --short

This starts git directly with two arguments. A string such as *.rs, $HOME, %TEMP%, |, or > remains a literal argument unless the child itself gives it special meaning.

If a shell is genuinely required, name it as the program so that the security and quoting boundary is visible at the call site:

processkit-cli run --jsonl shell.jsonl -- sh -c 'printf "%s\n" "$HOME"'
processkit-cli run --jsonl shell.jsonl -- pwsh -NoProfile -Command 'Get-Date'

Prefer direct execution whenever possible. It preserves argv boundaries and avoids a second language's quoting and injection rules.

Working directory

By default the child inherits the runner's current directory. Set it explicitly when the caller and payload use different roots:

processkit-cli run --cwd ./service --jsonl service.jsonl -- cargo test

The runner resolves the effective directory before launch and records its absolute form in run_started.cwd. An invalid or inaccessible directory fails before the child starts.

Environment

Without environment flags, the child inherits the runner's environment. Four options make the result explicit:

FlagEffect
--env-clearStart from an empty environment.
--env-remove KEYRemove one inherited key.
--env-file FILERead UTF-8 KEY=VALUE lines without placing their values in argv; blank lines and # comments are ignored.
--env KEY=VALUESet or replace one key. The value may contain =.

Application order is fixed, regardless of flag order: clear, remove, env-file, explicit set. Repeated files are applied in argument order, and an explicit --env therefore wins over every file or removal for the same key. A file read, UTF-8, or syntax failure is SETUP (111) before the child starts. For either --env or a file entry, the key must be non-empty and contain no whitespace or control characters. Diagnostics for malformed entries do not repeat their values.

processkit-cli run \
  --env-clear \
  --env PATH=/usr/bin:/bin \
  --env CI=true \
  --jsonl env.jsonl \
  -- /usr/bin/env

Environment values are not copied into lifecycle events or registry records. They can still reach child output, so capture files and echoed output require the same secret-handling discipline as any other process log.

Run identity

--run-id gives the run a stable application-level name:

processkit-cli run \
  --run-id build-2026-07-27 \
  --jsonl build.jsonl \
  -- cargo build

When omitted, the runner generates an id and writes it in run_started. A caller that needs to address the live run immediately should provide an id or read the first event before calling inspect, cancel, kill, or wait. Explicit ids must contain 1-256 Unicode characters and cannot contain terminal control or invisible formatting characters. The same validation applies to every by-id command, so an unsafe id is rejected before registry access.

Run ids are not operating-system PIDs and are not required to be globally unique. Two live runs with the same explicit id make by-id control ambiguous; the client fails rather than choosing one. list shows every record, and the aggregate --all commands address records rather than resolving a shared id.

Repeat --label KEY=VALUE on run to attach non-secret operator metadata. The resulting map appears in run_started.labels and list; later values replace earlier values for a duplicate key. Keys are 1-64 ASCII bytes, begin with a letter or _, and otherwise contain letters, digits, ., -, or _. Values are at most 256 characters and, like an explicit run id above, cannot contain terminal control or invisible formatting characters; the same check runs on read, so a label an older record already carries is dropped from that record's map when it fails, while the record itself is kept. Repeated label filters on cancel --all, kill --all, and wait --all combine with logical AND.

Foreground lifecycle

A foreground invocation has four high-level phases:

  1. Validate arguments and open the JSONL destination.
  2. Create a ProcessKit container, spawn the child, and publish the registry record/control endpoint.
  3. Wait for child exit, timeout, signal, or control-plane action while pumping output when the selected I/O mode requires it.
  4. Tear down the owned container, write terminal events, remove the registry record, and exit.

On normal completion the runner exits with the child's exact code. A runner failure or runner-imposed ending uses the reserved band documented in Exit-code contract.

Child stdout and stderr

The default mode pipes both child streams through ProcessKit and echoes them to the runner's matching streams. JSONL never goes to stdout. This allows a parent to treat processkit-cli like the command it wraps while independently tailing the lifecycle file.

For terminal inheritance, stdin sources, echo suppression, and bounded transcripts, see Standard I/O and capture.

Time bounds and cancellation

processkit-cli run \
  --timeout 20m \
  --idle-timeout 2m \
  --grace 5s \
  --jsonl bounded.jsonl \
  -- cargo build

The overall and idle clocks are independent. Either expiry enters the shared soft-stop → grace → hard-kill path. Ctrl-C and cancel use the same teardown shape but remain distinguishable outcomes. See Timeouts and cancellation.

Recorded tree snapshots

processkit-cli run \
  --snapshot-interval 30s \
  --jsonl long-run.jsonl \
  -- ./build-fleet

By default the tree's shape is recorded once, right after spawn. --snapshot-interval re-emits that same members_snapshot event on the given cadence while the child runs, so a long, quiet, or detached run leaves a recorded history of how the tree evolved rather than one that is only observable live via inspect. The periodic events are told apart from the post-spawn one by the event's reason field (interval vs spawn), and stop as soon as the run's ending is decided. A sample whose member read fails is still recorded, carrying read_error: true and an empty member list rather than being dropped. See JSONL event schema.

It composes with every I/O mode, including --inherit-stdio, and it is forwarded by --detach — see Detached runs, the scenario it exists for.

Sizing the stream: there is intentionally no ceiling

Before this flag, a run's JSONL file held a fixed handful of events regardless of how long the run took. A cadence changes that: the file now grows with the run's duration, and the runner imposes no ceiling, rotation, or sampling on it. That is a deliberate choice, not an oversight, and it is the opposite of the choice made for captured child output (--capture-max-bytes with an explicit --capture-overflow truncate|cancel policy). Three reasons:

  • The operator already holds the dial. Output volume is dictated by the child and is unknowable in advance, which is why capture needs a runtime ceiling. Here the volume is a direct, computable consequence of a number the operator typed — a ceiling would only second-guess an explicit instruction.
  • Neither truncation policy fits. Silently dropping later snapshots would destroy exactly the end-of-run history the feature exists to record, and ending the run over a diagnostics budget would let an observability option kill the payload it was only meant to watch.
  • The bound belongs where the file does. The stream's destination is the operator's --jsonl path; disk budgets, retention, and rotation are properties of that location, not of the runner.

The arithmetic. One snapshot line costs roughly 130 bytes of envelope plus about 80 bytes per member of the tree, and the run writes duration / interval of them:

RunCadenceTreeLinesAdded to --jsonl
1 hour30s10120≈ 110 KB
8 hours1m100480≈ 4 MB
24 hours1m1001 440≈ 12 MB
24 hours1s10086 400≈ 700 MB

Choosing an interval. Pick the coarsest cadence that still answers the question you expect to ask; the resolution that matters is usually "when did this change", not "what was it at every instant". Seconds-scale intervals are for short investigations, minutes-scale for day-long or detached runs. As a rule of thumb keep the projected line count in the thousands rather than the millions — with a 100-process tree that is a few tens of megabytes, which any run that was worth supervising can afford.

If the file cannot be written, the runner reports the failure once on stderr and then stops emitting events for the rest of the run; the run itself continues. A full disk therefore shows up as a stream that simply ends mid-run, and in a detached run (whose stderr is null) with no warning anywhere. This is the pre-existing behavior of the JSONL emitter, not something the cadence introduces, but a long cadence is the most likely way to meet it — one more reason to size the interval rather than rely on a ceiling that does not exist.

Resource limits

processkit-cli run \
  --max-memory 2g \
  --max-processes 64 \
  --cpu-quota 2 \
  --jsonl limited.jsonl \
  -- build-worker

Limits apply to the whole contained tree where the active mechanism can enforce them. Unsupported limits fail before launch instead of silently running unbounded. Platform constraints are collected in Resource limits.

Windows console policy

--create-no-window maps to Windows CREATE_NO_WINDOW and is a no-op on other platforms. It is intentionally opt-in: a normal run should not suppress a console the child legitimately expects.

It conflicts with --inherit-stdio, because hiding the child's console and promising to preserve the caller's terminal are contradictory. It is especially useful with detached runs, whose detached runner owns no console for a console child to inherit.

--windows-graceful-ctrl-break opts a Windows console child into ProcessKit's cooperative CTRL_BREAK tier before Job Object escalation. It is a no-op off Windows and conflicts with both consoleless modes (--create-no-window, --detach).

Flag interaction summary

CombinationResult
--capture-dir + --no-echoValid: capture continues; only live echo is suppressed.
--idle-timeout + --capture-dirValid: the shared pump drives both.
--inherit-stdio + --capture-dirRejected: inherited output bypasses the pump.
--inherit-stdio + --idle-timeoutRejected: the runner cannot observe activity.
--inherit-stdio + --snapshot-intervalValid: snapshots read the container, not the pump.
--detach + --inherit-stdioRejected: the caller is no longer present.
--detach + --capture-dirValid: the detached runner still captures.
--create-no-window + --inherit-stdioRejected on every platform at parse time.
--windows-graceful-ctrl-break + --create-no-window/--detachRejected: CTRL_BREAK needs a shared console.

Parse-time conflicts are usage failures (100); no child is spawned.

Next steps

Standard I/O and capture

I/O mode determines whether ProcessKit can observe the child's streams, whether the child sees a terminal, and whether the runner can capture or suppress output. Choose the mode from the child's actual contract rather than treating terminal inheritance as a cosmetic switch.

Mode matrix

ModeChild stdinChild stdout/stderrTTY preservedPump available
DefaultClosed / nullPipe → echoNoYes
--inherit-stdinRunner stdinPipe → echoOutput: noYes
--stdin-file FILEFile until EOFPipe → echoNoYes
--inherit-stdioRunner stdinDirect inherited handlesIf caller has oneNo
DetachedNullPump with echo discardedNoYes

The JSONL file is independent of every mode. Events always go to --jsonl, never to stdout.

Default: closed stdin, pipe and echo

processkit-cli run --jsonl run.jsonl -- cargo test

The child cannot wait forever for input the runner does not own. Its stdout and stderr are drained through separate pipes and retransmitted to the runner's stdout and stderr.

Because the child sees pipes rather than a terminal, it may disable color, progress bars, cursor movement, and interactive prompts. This is intentional and makes the default suitable for CI and automation.

Inherit all three handles

processkit-cli run --inherit-stdio --jsonl interactive.jsonl -- cargo watch

--inherit-stdio passes the caller's stdin, stdout, and stderr handles directly to the child. If the caller is attached to a terminal, the child sees that same terminal. The runner does not create a PTY.

Direct inheritance means there is no output pump. The following features are therefore unavailable and rejected at parse time:

  • --capture-dir and --capture-max-bytes;
  • --no-echo;
  • --idle-timeout;
  • --create-no-window;
  • --inherit-stdin and --stdin-file;
  • --detach.

That list is bounded by the pump, not by observability in general. --snapshot-interval composes with every I/O mode, --inherit-stdio included: a snapshot queries the container's own member list, so it needs no pipes to observe and conflicts with nothing here. --idle-timeout is the contrast that makes the boundary concrete — it is rejected above precisely because it can only re-arm on bytes the pump observes, while --snapshot-interval never looks at the child's output at all. --jsonl likewise keeps recording the full lifecycle under inheritance; only the child's bytes are outside the runner's view in this mode.

Terminal signal behavior is platform-dependent. On Unix a child in a separate foreground process group may receive Ctrl-C directly and report a signal exit; on Windows both child and runner can observe the console event. Use the local control-plane cancel command when an orchestrator needs one deterministic runner-owned cancellation outcome.

Inherit stdin only

processkit-cli run --inherit-stdin --jsonl input.jsonl -- sort

The child reads from the runner's input handle, while stdout/stderr remain on the default pump. The runner neither records nor mediates stdin bytes. This mode does not create a terminal; redirected caller input remains redirected input.

Feed a file

processkit-cli run \
  --stdin-file requests.ndjson \
  --jsonl importer.jsonl \
  -- importer --format jsonl

The checked file is streamed to the child and stdin closes at EOF. File bytes do not enter argv or lifecycle JSONL. A missing or unreadable file is a pre-spawn SETUP (111) failure.

--stdin-file and --inherit-stdin are mutually exclusive. For small literal input, create a file or explicitly invoke a shell/pipeline outside the runner; the CLI intentionally has no string-to-stdin flag that could encourage secrets inside process listings or shell history.

Suppress live echo

processkit-cli run \
  --no-echo \
  --capture-dir ./capture \
  --jsonl run.jsonl \
  -- noisy-worker

--no-echo removes only the runner's retransmission. Pipes stay open and the pump continues to:

  • drain the child so it cannot block on a full pipe;
  • write bounded capture files;
  • re-arm --idle-timeout on observed output;
  • preserve lifecycle events.

This is the normal embedding mode for an orchestrator that owns presentation and does not want the runner's live copy of child output interleaved with its own logs.

Bounded transcript capture

--capture-dir DIR creates two files:

DIR/
├── stdout.log
└── stderr.log

Streams remain separate and are still echoed unless --no-echo is present. Each file is capped independently at 8 MiB by default:

processkit-cli run \
  --capture-dir ./capture \
  --capture-max-bytes 32m \
  --jsonl run.jsonl \
  -- compiler

The terminal output_captured event reports, per stream:

FieldMeaning
pathCapture file location.
bytes_seenTotal bytes produced, including bytes beyond the file cap.
bytes_writtenBytes retained in the file.
sha256Digest of the retained bytes.
truncatedWhether output exceeded the configured cap.
write_errorCapture write failure, if one occurred.

Do not infer completeness from file size. Use truncated; a file whose length equals the cap may still be complete when the stream ended at exactly that boundary.

Two independent bounds

--capture-max-bytes limits retained disk bytes per stream. The ProcessKit pump also has a fixed 64 MiB in-flight line-assembly ceiling for a single unterminated line. They protect different resources and neither derives from the other.

Binary output

The live echo and capture path operate on bytes. Capture files preserve the bytes written up to the cap; they are not decoded or line-normalized. Runner diagnostics remain on stderr and are not written into stdout.log or stderr.log.

When stdout itself is a binary protocol, use --no-echo plus capture, or invoke the child directly if no lifecycle control is needed. The JSONL stream never shares stdout, so it cannot corrupt a binary child stream.

Secret hygiene

Argv is redacted in events by default, but child output is not. A command may echo credentials, environment values, file contents, or tokens into live output and capture files. Apply access controls and retention policies to:

  • the JSONL file when --argv-raw is used;
  • stdout.log and stderr.log;
  • any parent process that records runner stdout/stderr.

Diagnosing a degraded terminal UI

If a tool loses color, switches to plain progress messages, or refuses an interactive prompt under the default mode, confirm that it requires a TTY. Use --inherit-stdio only when the caller already owns a real console/terminal and does not need capture or idle detection.

ProcessKit CLI does not emulate a PTY. A program that specifically requires PTY semantics cannot obtain them from this runner today.

See also

Detached runs

--detach transfers a run to a detached copy of processkit-cli and returns once that copy has provably started the child. It is intended for an orchestrator that wants to launch now and supervise later from a different process.

processkit-cli run \
  --detach \
  --run-id build-42 \
  --capture-dir ./build-42-output \
  --jsonl build-42.jsonl \
  -- cargo build

processkit-cli inspect --run-id build-42 --json
processkit-cli wait --run-id build-42

What successful return proves

The launching invocation waits until run_started is readable in the JSONL file. That event is written after the detached runner has:

  1. created the ProcessKit container;
  2. published the registry record and control endpoint;
  3. spawned the child.

After a successful return, the run is discoverable by list, addressable by inspect / cancel / kill, and waitable by wait. A timeout in the detached startup handshake kills the detached copy instead of leaving an unreported run behind.

Exit-code semantics change

Foreground run forwards the child's exact exit code. Detached launch cannot: the launching process exits while the child is still running.

Detached launch resultLauncher exit code
Run reached run_started0
Program could not spawnSPAWN (101)
Container could not be createdBACKEND (102)
JSONL or setup failedSETUP (111) or the applicable reserved code

The child's eventual result is in the terminal runner_exit event. A detached caller must treat 0 as “started,” never as “payload succeeded.” See Exit-code contract.

Output behavior

The detached runner owns none of the launching caller's standard handles. It uses the same pump as --no-echo: child output is drained, but not retransmitted to a terminal that no longer exists.

These features remain active:

  • --jsonl (still required);
  • --capture-dir and --capture-max-bytes;
  • --idle-timeout;
  • --snapshot-interval (see below);
  • overall timeout, grace, environment, cwd, and resource limits.

--inherit-stdio and --inherit-stdin are rejected because there is no caller left to provide those handles. Use --stdin-file when a detached payload needs finite input.

Recorded tree snapshots without a live watcher

--snapshot-interval <duration> works under --detach — the launcher forwards it to the detached copy like any other run flag — and this is the mode it was designed for. A detached run is precisely the one nobody is watching with inspect at the interesting moment, so the periodic members_snapshot events are the only record of how the tree evolved between spawn and teardown.

processkit-cli run --detach \
  --run-id nightly-index \
  --snapshot-interval 5m \
  --jsonl /var/lib/my-orchestrator/runs/nightly-index.jsonl \
  -- indexer

Two consequences are specific to detaching:

  • The JSONL file is the only diagnostic channel. The detached runner's stdin/stdout/stderr are null, so every warning the foreground runner would print reaches nobody. This is why a failed member read is recorded in the stream as a members_snapshot with read_error: true rather than merely warned about (see JSONL event schema). It is also why a JSONL write failure — a full disk, most plausibly — is invisible: the runner disables event logging after one unseen warning and the stream simply stops mid-run.
  • Detached runs are the longest ones, so size the cadence first. The stream grows as duration / interval; a day-scale run wants minutes, not seconds. The arithmetic and the recorded decision not to impose a ceiling are in Running commands.

Windows

The detached runner is created with DETACHED_PROCESS and owns no console. A console child may therefore receive a new visible console from Windows. Pass --create-no-window for a headless payload:

processkit-cli run --detach --create-no-window `
  --run-id worker `
  --jsonl worker.jsonl `
  -- worker.exe

A detached runner cannot escape a Job Object that already contains its caller. If the outer job is kill-on-close, its owner still controls the detached run's ultimate lifetime.

Unix

The detached copy starts a new session with setsid, so terminal hang-up and Ctrl-C from the launching session no longer target it. After the launcher exits, the system's init process adopts the detached runner.

This changes session ownership, not ProcessKit's containment mechanism. The run still reports cgroup_v2 or process_group, and the platform-specific abrupt_cleanup value still applies if the detached runner itself is killed before it can perform teardown.

Supervision pattern

Use one durable JSONL path and one stable run id per detached run:

run_id=nightly-index
events="/var/lib/my-orchestrator/runs/${run_id}.jsonl"

processkit-cli run --detach --run-id "$run_id" --jsonl "$events" -- indexer
processkit-cli wait --run-id "$run_id" --timeout 30m

The run id is an address while the runner is live. The JSONL file is the durable record after the registry entry has been removed on clean completion.

Reading the stream back — events

events closes the detach loop: it reads that JSONL stream back without an external tailer or JSON filter, and without the caller having to look the locator up first.

# Watch a detached run as it happens, until its terminal `runner_exit`.
processkit-cli events --run-id nightly-index --follow

# What happened, after the fact — the registry record is gone, the file is not.
processkit-cli events --file "$events"

# The runner's own bytes, for a machine (line-for-line, nothing re-serialized).
processkit-cli events --file "$events" --json

# Conformance-check a stream against this binary's embedded event schema.
processkit-cli events --file "$events" --validate

Naming the stream. --run-id resolves the locator through the registry — the same jsonl field list --json publishes — which works while a record exists, live or merely not yet reaped. --file reads a path directly and is the answer once the record is gone (a clean exit deletes its own record, and prune reaps what an abrupt death left), or for a stream this registry never knew about. The two are mutually exclusive and exactly one is required; passing both is a USAGE (100) error rather than a silent choice between them. When --run-id names no single readable stream — no record, several records naming different streams, or a run started without --jsonl — the refusal is the same CONTROL (103) verdict every other by-run-id command gives.

Where a follow stops. --follow polls the file for growth (there is no notification to subscribe to for either a file or a runner's death) and returns at the first of: the terminal runner_exit event, or the registry reporting the run over with the stream no longer growing. The second case is what an abruptly killed runner leaves behind — it never got to write its terminal event — and it is explained on stderr rather than passed off as a complete stream. --follow never invents a deadline of its own: it is bounded by the run's lifetime, so a caller that wants a wall-clock bound imposes one itself (wait --timeout alongside it, or a bound on the whole invocation).

Read-only, like list/wait. events opens the registry read-only, never connects to a run's control transport, and mutates nothing — so following a production run cannot disturb, end, or even be noticed by it.

Shutdown sequence

For one detached run:

processkit-cli cancel --run-id nightly-index
processkit-cli wait --run-id nightly-index --timeout 30s
processkit-cli prune

For all runs confirmed live at one instant:

processkit-cli cancel --all
processkit-cli wait --all --timeout 30s
processkit-cli prune --dry-run
processkit-cli prune

The --all target set is a snapshot. A run registered afterward is out of scope; repeat the sequence when the surrounding system allows concurrent new launches.

Recovery after orchestrator restart

  1. Run list --json to discover registry entries.
  2. Read each durable JSONL file back with events --file <path> (or events --run-id <id> --follow for one still live), instead of hand-rolling a tailer: it hands out only complete lines, so a run being appended to while it is read never yields a half-written event.
  3. Use inspect --json only for entries confirmed live.
  4. Treat stale records as evidence of abrupt runner loss, not as permission to address the recorded PID.
  5. Preview prune --dry-run --json, then prune confirmed-stale entries.

See also

Timeouts and cancellation

ProcessKit CLI has separate outcomes for a child exit, overall timeout, idle timeout, local cancellation, control-plane cancellation, and immediate kill. They share teardown machinery without collapsing their meaning.

Overall deadline

processkit-cli run --timeout 10m --jsonl run.jsonl -- cargo test

--timeout bounds wall-clock lifetime from launch. When it expires, the runner enters soft-stop → grace → hard-kill teardown and exits TIMEOUT (106). The child did not choose that code; the terminal runner_exit.child_code is therefore separate.

Idle deadline

processkit-cli run \
  --idle-timeout 90s \
  --capture-dir ./capture \
  --jsonl run.jsonl \
  -- build-worker

--idle-timeout measures silence, not total duration. Every observed stdout or stderr chunk re-arms the clock. A chatty ten-hour process can remain healthy, while a stuck worker that produces no output for 90 seconds is torn down.

Idle detection requires the output pump. It conflicts with --inherit-stdio, but composes with capture and --no-echo. Overall and idle deadlines may be used together; the first to expire wins.

Both expiries use code 106. The JSONL timeout.reason field distinguishes overall from idle.

Duration grammar

Durations are non-negative integers with an optional unit:

InputMeaning
3030 seconds
500ms500 milliseconds
5s5 seconds
2m2 minutes
1h1 hour

0 is rejected for overall, idle, and wait deadlines because it expires before the first meaningful check. --grace 0 is valid and requests no pause between the soft and hard tiers.

Grace window

processkit-cli run \
  --timeout 20m \
  --grace 5s \
  --jsonl run.jsonl \
  -- service

The runner first asks the tree to stop, waits up to the grace window, and then hard-kills survivors through the owning container. cleanup_finished reports whether a soft request was delivered, was unsupported, or failed. Its shutdown object also records the pre-attempt soft_stop_scope and ProcessKit's ShutdownReport: observed member counts, early drain, escalation, and elapsed time.

Grace is a maximum opportunity for cooperative exit, not a promise that every platform has a signal capable of reaching the child.

Local stop signals

The foreground runner catches:

PlatformSources
AllCtrl-C where delivered to the runner
UnixSIGTERM, SIGHUP
WindowsCtrl-Break, console close, logoff, shutdown

These use the local CANCELLED code (107). The cancelled.source field names the exact source (ctrl_c, sigterm, sighup, ctrl_break, ctrl_close, ctrl_logoff, or ctrl_shutdown).

An inherited Unix terminal can deliver Ctrl-C directly to the foreground child group. In that case the runner may report a child signal exit rather than a runner-owned 107. Use the control plane when a supervisor needs one stable outcome independent of terminal routing.

Control-plane cancel

processkit-cli cancel --run-id build-42

cancel reaches the live runner over local IPC and requests the same soft → grace → hard teardown. The run exits CONTROL_CANCELLED (108) and writes a cancelled event whose source identifies the control command.

The client returns only after the runner acknowledges the command. An acknowledgement means the run accepted the request, not that teardown has already completed; use wait as the barrier.

Immediate kill

processkit-cli kill --run-id build-42

kill skips soft stop and grace, hard-kills the container immediately, and produces CONTROL_KILLED (109) plus a killed event. Use it when cooperative shutdown is not wanted or after an external policy has already spent its grace budget.

Aggregate cancellation

processkit-cli cancel --all
processkit-cli wait --all --timeout 30s

--all takes one snapshot of entries confirmed live. It addresses each by record identity and remembered endpoint, so duplicate run ids do not make the aggregate ambiguous. A newly registered or initially unprobed run is outside that invocation's target set.

The command prints a JSON array with per-target accepted, already_gone, or failed status. Any unresolved target makes the aggregate exit CONTROL (103); an empty snapshot is successful.

Windows soft-stop behavior

A Job Object has no POSIX signal. ProcessKit first tries a best-effort soft close of eligible windowed members. run --windows-graceful-ctrl-break also launches the direct console child as an addressable console process-group leader, so ProcessKit can send CTRL_BREAK before the grace window. The flag is a no-op off Windows and conflicts with --create-no-window/--detach because those modes have no shared console through which to deliver the event.

The runner probes soft_stop_scope before delivery and reports none when no eligible target exists. It never labels the grace as served by the child when nothing was delivered.

Windows gives a console-close handler only a short system deadline. For that source the runner caps an excessive grace request so it can finish teardown and write terminal events before the OS ends the process.

Unix soft-stop behavior

The soft tier sends SIGTERM to the contained process group or cgroup members, then hard-kills survivors after grace. A descendant that deliberately escapes a POSIX process group is outside that weaker mechanism; the runner reports the active mechanism so an adapter can decide whether that limitation is acceptable.

Event ordering

A runner-imposed ending normally emits:

timeout | cancelled | killed
cleanup_started
cleanup_finished
runner_exit

members_snapshot is not part of that tail: one is emitted right after run_started, and — with the opt-in --snapshot-interval <duration> cadence — more of them while the child runs, but the cadence stops the instant the ending is decided, so none can appear between the reason event and runner_exit.

Exact presence and ordering rules are normative in JSONL event schema.

See also

Resource limits

Three run flags request kernel-enforced limits over the whole contained tree:

FlagScopeGrammar
--max-memory SIZETotal tree memoryBytes, or k / m / g binary units
--max-processes NLive processes in the treePositive integer
--cpu-quota CORESCPU relative to one coreFinite number greater than zero
processkit-cli run \
  --max-memory 1g \
  --max-processes 32 \
  --cpu-quota 1.5 \
  --jsonl limited.jsonl \
  -- compiler-worker

Omitting a flag leaves that resource unbounded. The runner never invents a default cap.

Fail closed, before spawn

A requested limit is a requirement, not a hint. If the active platform and containment mechanism cannot enforce it, run:

  1. does not spawn the child;
  2. emits limit_hit naming memory, processes, or cpu;
  3. emits container_failed and terminal runner_exit;
  4. exits BACKEND (102).

An adapter must inspect limit_hit; code 102 also covers unrelated backend failures.

Platform matrix

MechanismMemoryProcess countCPUNotes
Windows Job ObjectYesYesYesWhole-job enforcement.
Linux cgroup v2 with usable controllersYesYesYesRequires controller delegation at the effective root.
Linux process-group fallbackNoNoNoFails before spawn.
macOS / BSD process groupNoNoNoFails before spawn.

The run_started.mechanism field tells an observer what was actually obtained.

Linux controller requirements

The current ProcessKit implementation can apply limits when the runner is a direct member of the real cgroup-v2 root and can enable the required controllers. This is common in a minimal init environment, but not in:

  • a normal systemd user session, scope, or service;
  • ordinary Docker or Kubernetes containers;
  • typical GitHub Actions jobs;
  • any environment that delegates a nested cgroup without writable controllers.

In those environments a limit request fails rather than falling back to an unenforced process-group run.

Linux process-count caveat

The cgroup pids controller reliably bounds descendants forked inside the cgroup. It does not reject additional top-level launches into the same group in the same way Windows Job Object active-process limits do.

For ProcessKit CLI, which launches one root per run, interpret --max-processes as a cap on that tree's own growth. It protects against a contained fork explosion; it is not a general admission controller for unrelated launchers.

Size parsing

Units are binary:

InputBytes
10485761,048,576
512k524,288
256m268,435,456
2g2,147,483,648

Zero, malformed values, and overflow are usage failures (100). CPU quota also rejects negatives, NaN, and infinities.

Applied limit versus observed limit hit

limit_hit currently proves only that a requested limit could not be applied before launch. It does not prove that a successfully installed limit later fired.

The processkit version this repository resolves from crates.io (Cargo.lock, currently 3.1.0) does not yet expose portable post-spawn evidence for a cgroup OOM/pids event or a Windows Job Object notification. A child terminated by an enforced live limit may therefore be indistinguishable from another nonzero or signalled child outcome. Do not claim runtime attribution from the exit code alone.

ProcessKit-rs has since implemented such a primitive on its main branch (ProcessGroup::limit_evidence(), per-axis LimitVerdict::{Tripped, NotTripped, Unknown}), but it is not yet in a published release — it ships when a new version reaches crates.io and this project's Cargo.lock is updated to consume it, which this document does not do. The design carries constraints worth recording now, before anything is wired against it:

  • Three-valued, never a boolean. A future reader of this evidence — and any JSONL surface built on it — must represent Tripped / NotTripped / Unknown as three distinct states. Unknown must never collapse into "did not fire": that would silently misreport a platform's inability to answer as a clean run on every axis where evidence is unavailable.
  • Authoritative on Linux cgroup v2 only. There, Tripped/NotTripped come from real kernel counters (memory.events' oom, pids.events' max, cpu.stat's nr_throttled). On Windows Job Object and on a POSIX process group (macOS, the BSDs, the Linux process-group fallback), every capped axis instead reports Unknown as a measured result, not an omission — those mechanisms keep no post-mortem record that a cap fired. Windows is a first-class platform for this CLI, and runtime limit attribution will not become available there even once this primitive is wired in; only the Linux cgroup v2 gap can close.
  • Readable only while the container still exists. The evidence lives in the container itself, so it must be read before ProcessGroup is dropped or consumed by shutdown. That constrains where a future reader could sit relative to this runner's teardown and cleanup_finished/cleanup_started ordering — it has to run ahead of, not after, whatever drops or shuts down the group.

None of this changes what limit_hit means today: it stays the pre-spawn "the requested cap could not be applied" event, and a cap-dependent adapter still treats it as a hard failure signal in that scope (see docs/schema.md).

Limits and outer containers

An outer Docker/Kubernetes/systemd limit and a ProcessKit CLI limit are separate layers. The stricter layer wins, but only the outer runtime can explain its own termination reason. If the outer runtime kills the runner itself, the platform-specific abrupt_cleanup contract applies.

Use outer-runtime limits when they are the authoritative scheduler policy. Use CLI limits only where ProcessKit can install them and the adapter needs the limit request attached to this specific run.

Operational checklist

  1. Run probe --json to verify the flags exist.
  2. Launch a harmless limited command in the real deployment environment.
  3. Read run_started.mechanism rather than assuming cgroup availability.
  4. Treat pre-spawn limit_hit as a hard configuration failure.
  5. Keep a separate outer-runtime signal for runtime OOM/CPU/pids attribution.

See also

Platform support

ProcessKit CLI exposes one command surface across Windows, Linux, and macOS, but it does not pretend their kernel containment primitives are equivalent. Every run reports both the mechanism it obtained and what happens if the runner dies before it can execute teardown.

Release targets

Operating systemTarget tripleDistribution
Windows x86_64x86_64-pc-windows-msvcPrebuilt release archive
Windows Arm64aarch64-pc-windows-msvcPrebuilt release archive
Linux x86_64 glibcx86_64-unknown-linux-gnuPrebuilt release archive
Linux Arm64 glibcaarch64-unknown-linux-gnuPrebuilt release archive
Linux x86_64 muslx86_64-unknown-linux-muslStatic prebuilt archive
Linux Arm64 muslaarch64-unknown-linux-muslStatic prebuilt archive
macOS Apple Siliconaarch64-apple-darwinPrebuilt release archive

Other Rust-supported targets may build from source, but are not part of the release-artifact or CI promise unless listed here.

Mechanism and abrupt cleanup

run_started contains two independent fields:

  • mechanism: how normal teardown addresses the tree;
  • abrupt_cleanup: what the kernel guarantees when the runner never executes normal teardown.
Platform / obtained mechanismmechanismabrupt_cleanup
Windows Job Objectjob_objectwhole_tree
Linux cgroup v2cgroup_v2direct_child_only
Linux process-group fallbackprocess_groupdirect_child_only
macOS / other Unix process groupprocess_groupnone

Normal completion, timeout, caught cancellation signals, and control-plane actions still run the full owned-container teardown on every platform. The last column applies only to a crash, SIGKILL, outer Job termination, or an equivalent event that prevents the runner from reaching its cleanup code.

Windows Job Objects

A run owns a Job Object configured for kill-on-close. Child processes and their descendants are assigned to the job, and closing the last owning handle reaps the whole job.

Properties:

  • strongest abrupt-owner-death guarantee (whole_tree);
  • whole-tree memory, CPU, and active-process limits;
  • member snapshots through Job queries;
  • atomic hard kill on timeout/cancel/kill teardown;
  • best-effort graceful close before hard kill where a member exposes a window;
  • opt-in CTRL_BREAK for console children via --windows-graceful-ctrl-break.

Nested jobs

Modern Windows versions allow nested Job Objects when the outer job's policy permits it. A CI runner, service host, or container runtime may already place the CLI in a job. The E2E suite covers nested-job launch behavior, but an outer job remains authoritative and can terminate the runner plus its child job.

Console behavior

The runner does not allocate a console. A normal child inherits what the platform would ordinarily provide. --create-no-window is an explicit Windows-only request and conflicts with --inherit-stdio.

--windows-graceful-ctrl-break keeps the shared console, creates a child console process group, and lets ProcessKit address that group during graceful teardown. It therefore conflicts with --create-no-window and detached execution.

Linux cgroup v2

When cgroup v2 is available and delegated, ProcessKit creates a run cgroup and moves the child tree into it. Normal hard teardown addresses all members in the cgroup.

If controller/delegation requirements are not met, an unrestricted run may fall back to a POSIX process group and reports process_group. A run that explicitly requests resource limits fails instead, because falling back would discard a required policy.

Linux parent-death signaling kills the direct child if the runner dies abruptly. The cgroup itself persists and does not automatically kill every grandchild, hence direct_child_only rather than whole_tree.

macOS and other Unix

The runner uses a POSIX process group. Normal teardown signals and kills the group, covering ordinary descendants that remain in it.

Limitations:

  • a descendant can deliberately escape with setsid / double-fork;
  • the backend cannot provide whole-tree resource limits;
  • a just-exited member may still appear briefly during diagnostics;
  • no portable owner-death primitive reaps the group after an uncatchable runner death, so abrupt_cleanup is none.

The mechanism field exists so an adapter can reject this weaker contract when its workload requires stronger containment.

Capability matrix

CapabilityWindows JobLinux cgroup v2POSIX process group
Normal whole-tree hard teardownYesYesGroup members only
Whole-tree abrupt runner-death reapYesNoNo
Enriched member snapshotsYesYesBackend-dependent
Memory limitYesWith controller accessNo
Process-count limitYesWith controller accessNo
CPU quotaYesWith controller accessNo
Soft-stop requestWindow close plus opt-in console CTRL_BREAKSIGTERMSIGTERM
Direct inherited terminalYesYesYes
PTY emulationNoNoNo

CI coverage

The repository's GitHub Actions matrix builds and tests Windows, Linux, and macOS, including Arm runners where available. The opt-in E2E tier drives the built binary through real containment scenarios: leaked descendants, nonzero roots, abrupt runner death, nested Windows jobs, PID reuse, real console/terminal I/O, and cancellation.

That matrix proves the repository's release contract; it does not prove that a particular Linux deployment grants cgroup controllers. Test mechanism selection and limit application in the actual service/container environment.

Choosing an acceptance policy

An adapter can read the first run_started event and fail closed:

require mechanism == job_object or cgroup_v2
require abrupt_cleanup == whole_tree        # Windows-only today

These are separate policies. A Linux cgroup gives strong normal teardown but not Windows-equivalent abrupt cleanup.

See also

Running in containers

ProcessKit CLI can run inside Windows and Linux containers, but an outer container and an inner ProcessKit run solve different problems. The outer runtime controls the pod/container; ProcessKit owns one payload tree and its lifecycle contract inside that boundary.

Choosing the Linux archive

Base imageRecommended artifact
Debian / Ubuntu / glibc distrolessx86_64-unknown-linux-gnu or Arm64 glibc
Alpine / musl / minimal static imagex86_64-unknown-linux-musl or aarch64-unknown-linux-musl

The musl archives are statically linked against libc and are suitable for images without glibc, on both x86_64 and Arm64. They are a distribution option, not a different containment mechanism.

Minimal multi-stage image

FROM alpine:3.22 AS unpack
ARG PROCESSKIT_CLI_VERSION
ARG ARCHIVE=processkit-cli-v${PROCESSKIT_CLI_VERSION}-x86_64-unknown-linux-musl.tar.gz
ADD https://github.com/ZelAnton/ProcessKit-CLI/releases/download/v${PROCESSKIT_CLI_VERSION}/${ARCHIVE} /tmp/processkit-cli.tar.gz
RUN tar -xzf /tmp/processkit-cli.tar.gz -C /tmp

FROM scratch
COPY --from=unpack /tmp/processkit-cli /usr/local/bin/processkit-cli
ENTRYPOINT ["/usr/local/bin/processkit-cli"]

On an Arm64 host, override the build argument instead of the default: --build-arg ARCHIVE=processkit-cli-v${PROCESSKIT_CLI_VERSION}-aarch64-unknown-linux-musl.tar.gz.

Verify the archive checksum and attestation in the build pipeline before this copy step; the abbreviated example focuses on final-image shape.

Shell-free entrypoints

The CLI itself does not require a shell. Use JSON/exec-form container commands:

ENTRYPOINT ["/usr/local/bin/processkit-cli", "run", "--jsonl", "/run/events.jsonl", "--"]
CMD ["/usr/local/bin/worker", "--serve"]

In Kubernetes, express the same boundary with command and args. Avoid wrapping the runner in sh -c unless the payload truly needs shell semantics; the wrapper changes signal routing and adds another process to diagnose.

Persist lifecycle data

--jsonl is required. Mount or create a writable location whose lifetime matches the observer's needs:

volumeMounts:
  - name: run-state
    mountPath: /var/lib/processkit-cli
args:
  - run
  - --jsonl
  - /var/lib/processkit-cli/events.jsonl
  - --
  - /app/worker

Use a second bounded directory for --capture-dir when transcripts must survive the container process. Apply a storage quota outside the CLI in addition to its per-stream byte cap.

PID 1 and signals

When processkit-cli is PID 1, it receives the container runtime's SIGTERM directly. The runner catches it and performs ordinary cancel teardown, including terminal JSONL events and registry cleanup, before exit.

Set the orchestrator's termination grace period longer than the runner's --grace plus application shutdown overhead. If the orchestrator sends SIGKILL first, normal teardown cannot run and Linux guarantees only the abrupt_cleanup value reported in run_started.

cgroup v2 is not automatically delegated

Seeing /sys/fs/cgroup or a cgroup-v2 mount does not mean the runner may create and configure child cgroups. Ordinary Docker/Kubernetes containers usually run inside a delegated subtree without permission to enable controllers at the effective root.

Consequences:

  • an unrestricted run may report process_group fallback;
  • --max-memory, --max-processes, or --cpu-quota may fail before spawn with limit_hit / BACKEND (102);
  • mounting cgroup files read-write or running privileged changes the threat boundary and should not be done solely to silence that failure.

Prefer outer container limits when the orchestrator is the resource-policy owner.

Outer limits and inner limits

LayerTypical responsibility
Kubernetes/Docker/systemdScheduling, pod/container memory and CPU, restart policy
ProcessKit CLIOne payload tree, timeout/cancel semantics, JSONL, local IPC

If both layers install limits, the stricter limit wins. ProcessKit currently cannot attribute an outer OOM kill or every inner kernel limit event; use the orchestrator's status/events as the source of truth for outer-runtime termination.

Registry location and container users

The run registry is per-user and created with owner-only permissions. Keep the same user identity for run and its inspect / cancel / kill / wait clients. A sidecar running under a different UID should not expect access.

For detached runs, ensure the registry and JSONL locations survive for as long as the detached runner. An ephemeral container that exits immediately after launch cannot host a meaningful detached child.

Read-only root filesystems

The binary itself works from a read-only image, but it still needs writable locations for:

  • the required JSONL file;
  • the per-user registry/control socket directory;
  • optional capture files.

Mount explicit writable volumes or tmpfs paths and set the child working directory accordingly. A setup failure is reported before the payload runs.

Health and shutdown pattern

The CLI does not implement retries, pooling, or application health checks. Let the orchestrator own restart policy and use ProcessKit CLI for deterministic one-run control:

  1. start run as the container's main process;
  2. read run_started before declaring startup complete;
  3. on shutdown, send SIGTERM and allow the configured grace;
  4. collect terminal JSONL and capture files;
  5. use the outer runtime's status for OOM/eviction attribution.

See also

Live-run control plane

The control plane lets a client query and (later) steer a running processkit-cli run. It lives in the live runner process, not in named kernel objects (AGENTS.md, "The control plane lives in the live runner process"): a runner must stay alive to hold its kill-on-drop container, so the live process is exactly where clients reach it. If the runner dies, the container tears the tree down and the run becomes detectably gone — never a dangling handle a client could act on by mistake.

This document is the normative description of the local transport, the wire protocol, and the three clients — inspect (read-only) and the mutating cancel / kill — including their behavior when the runner cannot be reached. Discovery — how a client finds a live runner — is the run registry, described in docs/registry.md. The in-code source of truth is src/control/.

cancel and kill add verbs to the same transport and protocol as inspect without reshaping either: one request verb line in, one JSON line out, connection closed. They are mutating — they end the live run — and they reuse the run's own teardown path (the same one a --timeout or a Ctrl-C drives, see docs/schema.md), so a control command never invents a second way to kill a tree.

Discovery: the registry, never a PID

A client never addresses a run by PID (AGENTS.md: "Nothing is addressed by PID"). It finds one through the per-user run registry: it scans records, matches the target run_id, and acts only on a live entry (see docs/registry.md, "Staleness"). A record's endpoint field carries the address of that run's local transport — the channel this document describes.

The registry does not enforce run_id uniqueness, so more than one live entry can match. That is an ambiguous run id — a hard CONTROL (103) failure for every verb (inspect included), never a silent pick of whichever entry the scan returns first. See docs/registry.md, "Run id resolution — ambiguity is a hard failure".

Local transport

Each run stands up one local IPC endpoint, restricted to the current user, and publishes its address in the run's registry record:

  • Unix: a unix domain socket. The socket file is created in a short per-run owner-only (0700) directory under /tmp (with the platform temp directory as a fallback), and its own mode is tightened to 0600. The short path is independent of the registry location so deeply nested CI/project paths cannot exceed macOS's sun_path limit. The endpoint address is the socket's absolute path.
  • Windows: a named pipe (\\.\pipe\processkit-cli-<unique>), created with a protected DACL that grants full access to the current user alone (D:P(A;;FA;;;<current-user-SID>), built from the same SID the registry restricts to), created with FILE_FLAG_FIRST_PIPE_INSTANCE (so no other process can pre-own the name), and rejecting remote clients. The endpoint address is the pipe name.

Both are locked to the same single user as the registry, because an endpoint is a control channel — a world-reachable one would hand it to any local process.

Concurrency, and never blocking the run

The transport is served concurrently with the child's output pump, on the same runtime. It never blocks the happy path:

  • A live run that no one inspects pays only an idle accept.
  • The run's exit and teardown do not wait on any control client. When the child exits (or a --timeout / Ctrl-C ends the run), the run resolves and the control server is dropped along with it — tearing the transport down. The child's exit-code fidelity is never at the mercy of a slow or absent control client.

The transport is best-effort infrastructure: if it cannot be stood up, the runner warns on stderr, records a null endpoint, and runs the child normally — the run is simply not inspectable. Losing it never costs the child its faithfully forwarded exit code (AGENTS.md, "Exit-code fidelity").

Cleanup and leaks

On a clean teardown (a normal child exit, a --timeout, or a Ctrl-C) the transport is torn down with the run — on unix the socket file and its private directory are removed. An abrupt runner death (crash, SIGKILL, a parent's Job Object terminate) skips that removal, stranding the socket directory exactly as it strands the registry record and lock. The leak is inert while it lasts: a client detects the run as stale through the registry before it ever connects, so it never touches the orphaned socket.

It does not last, either. prune reaps all three together: reaping a confirmed-stale record now also removes the pkc-… directory and socket that record published, so an abrupt death no longer accumulates dead socket directories in /tmp — see docs/registry.md, "Reaping — prune", for the shape check that endpoint has to pass first (it is untrusted data, like everything else in a record) and for what the reaper deliberately refuses to touch. On Windows there is nothing to reap: the pipe simply vanishes with the process.

Wire protocol

Line-oriented and deliberately tiny. Over an accepted connection:

  1. The client writes one request verb line, UTF-8, terminated by \n. The verbs are inspect, cancel, and kill. (An empty line is also treated as inspect, so a bare connect-and-read probe still works.)
  2. The server writes back one JSON line — the response — and closes the connection.

The responses per verb:

VerbResponse lineEffect on the run
inspecta snapshotnone (read-only).
cancelan ack {"accepted":true,"action":"cancel","run_id":"…"}the run runs its shared soft-stop → grace → hard-kill teardown and exits with CONTROL_CANCELLED (108).
killan ack {"accepted":true,"action":"kill","run_id":"…"}the run hard-kills the whole tree immediately (no soft stop, no grace) and exits with CONTROL_KILLED (109).

An unrecognized verb yields a JSON error object ({"error":"..."}) instead, and changes nothing about the run — a foreign client cannot end a run by sending garbage.

For the mutating verbs the runner writes its ack first, then signals its own main loop to tear down. The client therefore always receives its confirmation even though the run ends the instant the signal lands; and if the ack cannot even be written (a broken client), no teardown is signaled — an unconfirmed cancel never silently ends a run.

inspect

processkit-cli inspect (--run-id <id> | --all [--label <KEY=VALUE>]...) [--json]

inspect finds the live runner for <id> through the registry, connects to its endpoint, sends the inspect verb, and prints the snapshot to stdout — as a single JSON line with --json, or, by default, as a human-readable rendering (snapshot version, run id, mechanism, root pid, start time, artifact locators, and a member table), mirroring list/prune's optional --json. --json is optional; inspect --json's output is unchanged from before --json became optional.

The aggregate form takes one snapshot of all confirmed-live registry records, optionally filters them by repeated exact --label KEY=VALUE matches (logical AND), and addresses each target by the record path and endpoint captured in that snapshot. Without --json, it prints a terminal-safe table with one row per target and the three-way status inspected / already_gone / failed, followed by a detailed snapshot block for each inspected target. Those blocks reuse the single-run renderer, including its member table and bounded handling of every untrusted string. An empty matching fleet prints no live runs to inspect.

With --json, the original output remains one byte-compatible JSON array. Each element has run_id and either a snapshot with error: null, or snapshot: null with a bounded error string. If any target fails, either output form returns CONTROL (103) after printing the complete report. This preserves partial fleet visibility without turning registry churn into silent omission.

The inspect snapshot

The snapshot is the machine-readable state of a live run. It is the control plane's own client/runner contract, versioned on its own axis (snapshot_version), distinct from the JSONL event schema_version and the registry_version.

FieldTypeNotes
snapshot_versionintegerSnapshot format version this build writes (2), and the version the runner declared when reading. The client acts on it: a version newer than it implements is refused rather than rendered, an older one down to 1 is read — see "Snapshot version: a newer runner's reply is refused, an older one is read" below.
run_idstringThe run's identifier — the key matched in the registry. Not a PID.
mechanismstringContainment mechanism: job_object, cgroup_v2, or process_group (same vocabulary as the JSONL run_started).
root_pidinteger, nullableThe root child's PID; null if the backend exposed none.
started_atstringRun start time, RFC 3339 UTC, millisecond precision.
jsonlstring, nullableAbsolute path to the JSONL lifecycle stream; null only when reading a version-1 snapshot, which had no such field. A runner of snapshot_version 2 always publishes a path — the nullability is what makes the older contract readable (below), not a caveat about this one.
capture_dirstring, nullableAbsolute capture directory, or null when capture is disabled.
membersarray of memberThe container's members, enriched with ppid/executable name/start_time wherever ProcessKit's members_info() can report them — the same member shape as the JSONL members_snapshot's own members array (docs/schema.md, "Enriched member fields"), and read through the same call, so the two views never drift. Only the member entries are shared: the JSONL event's own envelope fields (its reason, for instance) belong to that event, not to this reply. Fields stay null on platforms/members that can't report them (e.g. the "bare" BSDs). Queried at request time, so it reflects the container's composition when inspected, not at start.

Example:

{"snapshot_version":2,"run_id":"build-42","mechanism":"job_object","root_pid":4242,"started_at":"2026-07-20T21:00:00.000Z","jsonl":"C:\\runs\\build-42.jsonl","capture_dir":null,"members":[{"pid":4242,"ppid":4200,"name":"build.exe","start_time":"133456789000000000"}]}

Snapshot version: a newer runner's reply is refused, an older one is read

snapshot_version is not decoration — the client checks it and acts on it. This is the normative statement of that policy; the two inspect forms share one implementation of it (src/control/mod.rs, SnapshotReply::accept).

The rule. This build reads a snapshot declaring version 1 or 2 — the range from MIN_READABLE_SNAPSHOT_VERSION to SNAPSHOT_VERSION in src/control/mod.rs — and refuses anything outside it with the reserved CONTROL (103) code and a message naming the version that arrived, the range this build reads, and which way the runner is out of that range (newer than this client, or older than anything it still decodes). Nothing about a refused reply is printed: it never reaches the human rendering or --json, and under --all that target is reported failed (with the message in its error field), never inspected and never the successful already_gone — the runner did not end, it answered something this client cannot read. The verdict is taken from the declared number before the payload's shape is parsed, so it holds even for a newer reply this build could not deserialize at all (which is exactly the shape a breaking change produces).

Why a newer version is refused. A number above the one this build implements is the runner's statement that the shape moved on in some way this build predates, and this build cannot know which way: it holds no decoder for a contract written after it. Rendering it anyway would present a payload interpreted under semantics its sender never promised — and quietly, because the client re-serializes what it parsed, so a newer runner's added fields are dropped at deserialization and never appear in the output. The operator would see a confident rendering with no marker of what was lost. This is the mixed-binaries case a mid-upgrade user really has (an older inspect against a newer run), and the one this check exists for.

Why an older version is not. The refusal is deliberately one-sided. A lower number does not, by itself, mean "unreadable": the only bump this contract has had — 1 → 2 — was purely additive (it introduced jsonl and capture_dir, both optional with a default, and changed no existing field), so this build decodes a version-1 snapshot correctly, reporting those two as null — "not reported", which is precisely what a version-1 runner meant. That is not a tolerance policy about numbers in general; it is a checkable fact about this repository, pinned by a regression test, and it matters in practice: every binary released so far (v0.1.0 … v0.3.1) writes version 1, so refusing it would make an upgraded client unable to inspect the runs its own predecessor started. When a future bump does make the older shape undecodable or misleading — a removed, renamed, or retyped field, or an existing field whose meaning changed — the floor (MIN_READABLE_SNAPSHOT_VERSION) moves up in that same change, and that is where the judgement is recorded, rather than being inferred from the number.

This is a narrower refusal than the registry read side's, which skips a record whose registry_version is not exactly its own (docs/registry.md), and the difference is earned: that check gates destructive action — probing a lock file and reaping the record behind it — on liveness semantics an unknown version may have redefined. A snapshot is read-only output whose only failure mode is being misread.

What is printed. The snapshot_version in a rendered snapshot is the value the runner declared, unchanged — it reports which contract answered, so against an older runner it is legitimately lower than the version this binary implements. The rest of the object is this client's own re-serialization, so its field set is always this build's. fixtures/schema/cli/inspect.schema.json therefore admits the readable range on this field rather than pinning one value, and it moves when the range moves.

What to do about a refusal. Inspect that run with a processkit-cli build that implements its snapshot version — for a newer runner, one at least as new as the binary that started the run. Retrying the same command will not change the answer. probe --json reports the version (and probe_version) of the binary you run, which is how you tell two installed builds apart; it does not report a runner's snapshot version, and no preflight can — that number arrives only in the runner's own reply, which is what the refusal message quotes back to you.

Consequence for a bump. Bumping SNAPSHOT_VERSION is a real event for a mixed deployment, not just a schema edit: every older client loses the ability to inspect a runner that writes the new number — loudly, with 103, rather than by misinterpreting it. Newer clients keep reading older runners as long as the floor allows, so a bump is not automatically a fleet-wide outage; deciding whether the floor moves with it is part of making the bump, and both are announced in CHANGELOG.md. cancel/kill are unaffected (their ack carries no version and is verified by accepted/action/run_id instead), as are list, wait, and prune, which never read a snapshot.

cancel and kill

processkit-cli cancel (--run-id <id> | --all [--label <KEY=VALUE>]...)
processkit-cli kill   (--run-id <id> | --all [--label <KEY=VALUE>]...)

--run-id and --all are mutually exclusive and exactly one is required, the same clap convention docs/registry.md's wait --all (T-216) established — a bare cancel/kill with neither is a USAGE (100) form error at parse time.

--run-id <id> finds the live runner for <id> through the registry exactly as inspect does — by matching run_id, never a PID — connects to its endpoint, sends the verb, and ends the run. cancel and kill differ only in how the run is ended:

  • cancel asks the runner to run its shared soft-stop → grace → hard-kill teardown — the same path a --timeout or a Ctrl-C drives. On Unix a real SIGTERM is delivered to the tree, the --grace window (if the run was started with one) elapses, and the container's kill-on-drop then hard-tears-down whatever remains. On Windows a Job Object has no POSIX signal, so the soft tier is WM_CLOSE to windowed members plus CTRL_BREAK for a child launched with --windows-graceful-ctrl-break; a capability probe reports when neither target exists, and ProcessKit then escalates atomically. The run exits with the reserved CONTROL_CANCELLED (108).
  • kill hard-kills the whole tree immediately: no soft stop, no grace. The run exits with the reserved CONTROL_KILLED (109).

The scope of either is only the target run's container, discovered by run_id through the registry. Nothing is ever killed by executable name, and no process outside the run's own ProcessKit container is touched.

The ack

On success the runner replies with one JSON line — an ack — and the client prints it to stdout before exiting 0:

FieldTypeNotes
acceptedbooleantrue — the runner accepted the command and began tearing down.
actionstringThe action taken: cancel or kill (echoed so the client can confirm the runner answered the verb it sent).
run_idstringThe run the command targeted.
{"accepted":true,"action":"cancel","run_id":"build-42"}

The client parses the ack back and checks it names the action it asked for; a rejected or garbled reply is treated as an unreachable-runner failure (below), never a false success.

The outcome is visible to any observer, not just the client

The client's ack is not the only record of the command. The run also writes the outcome to its JSONL stream (--jsonl), so an external observer reading the event file — not the control client — still sees that the run ended by an outside command:

  • cancel writes a cancelled event with source control_cancel (told apart from the local stop signals, which are ctrl_c / sigterm / sighup (Unix) / ctrl_break / ctrl_close / ctrl_logoff / ctrl_shutdown (Windows)), the cleanup_started / cleanup_finished teardown pair, and a terminal runner_exit with source control_cancel and code 108.
  • kill writes a dedicated killed event with source control_kill, the cleanup pair (with soft_terminate null — no soft stop was attempted), and a terminal runner_exit with source control_kill and code 109.

See docs/schema.md for these events.

cancel --all / kill --all

--all is the aggregate counterpart to --run-id: instead of one named run, it acts on every registry record confirmed live in a snapshot taken the moment the invocation starts. Repeated --label KEY=VALUE filters narrow that snapshot with logical AND; only records carrying every exact pair remain, and labels are rejected with the by-id form. This is the mutating counterpart to wait --all (T-216; see docs/registry.md, "Waiting — wait", "The aggregate barrier — wait --all"), reusing its exact snapshot discipline. The target set is fixed once, before the first mutation is dispatched: a run that registers after the snapshot is out of scope for this invocation, and a run that is only unprobed (not confirmed live) at that instant is excluded from the snapshot outright, the same asymmetry wait --all documents. Each target is keyed by its unique registry-record path and the endpoint that exact record advertised at snapshot time, never by its potentially duplicated run_id. --all can therefore reach two live records sharing an id independently, while the by-run-id form remains the hard ambiguity described above. Immediately before dispatch the client re-reads and probes that exact record path, without scanning or probing unrelated entries, and requires it to remain live with the same id and endpoint; only then does it use the ordinary wire exchange.

An empty snapshot (no confirmed-live entry at all — an empty or fully-stale registry) is not an error, mirroring prune: it prints an empty report ([]) and exits 0. Opening or scanning the registry itself failing (not "found nothing", but "could not even look") is a exit::SETUP (111) failure, the same support/prerequisite failure list/prune/wait report for the identical condition — distinct from the single-run form's CONTROL (103), since there is no one target's reachability in question yet at that point.

The report. Instead of the single-run form's one ack object, --all prints one JSON array, one entry per snapshot target, to stdout:

FieldTypeNotes
run_idstringThe target record's descriptive run id; not its aggregate identity key.
acceptedbooleanWhether the runner acknowledged this invocation's mutation.
statusstringaccepted, already_gone, or failed.
errorstring, omitted unless failedPresent only for failed; names why the still-potentially-live target could not be safely reached or did not acknowledge.
[{"run_id":"build-42","accepted":true,"status":"accepted"},{"run_id":"build-43","accepted":false,"status":"already_gone"}]

A target that disappears or becomes confirmed stale between the snapshot and its dispatch is reported as already_gone: no runner acknowledged the verb, so accepted remains false, but the aggregate's terminal-state goal is already met and the outcome is non-error. An entry that becomes unprobeable, changes identity, cannot be reached while still confirmed live, or rejects/mismatches its ack is failed and does not stop fan-out to the remaining targets. --all never skips a snapshot entry silently: every one gets exactly one array entry.

The aggregate exit code. Full success — every snapshot target is either accepted or already_gone — is 0. A partial or full failure is never a silent 0: it reuses the reserved CONTROL (103) code (the same one the single-run form uses for "could not reach the target run" — there being one or more unreachable targets is the same class of fact for the aggregate), with a summary message on stderr naming how many of the snapshot targets failed; the full per-target detail is only in the JSON report on stdout, printed before that failing exit. A caller that needs --all to fail loudly on any partial failure (the typical teardown sequence — cancel --all before wait --all/prune) gets that for free from the non-zero exit; one that wants the detail parses the report.

Skipped entries — unchanged from the single-run form. A registry entry that is stale or unprobed at snapshot time is never in the target set at all (--all acts only on entries Health::Live confirms), exactly the same bar the single-run form's own resolver applies — --all only distributes that existing rule over a snapshot, it never widens or narrows it.

When the runner cannot be reached: a distinguishable result, never a hang

Every client — inspect, cancel, and kill — can lose the runner the same three ways (this applies per target under --all too, one snapshot entry at a time). All of them are reported as the reserved CONTROL exit code (103) — "could not reach the target run" (see docs/exit-codes.md) — with an explanatory message on stderr (naming the action and the run) and nothing on stdout for the single-run form (under --all, the same message text lands in that target's error field in the report instead). None is a generic error, and none hangs:

  • Stale registry entry. The runner died abruptly, leaving its record behind; the released liveness lock makes the entry stale. The client detects this before connecting and reports the run as gone (its registry entry is stale).
  • Unprobeable registry entry. The liveness probe could not be performed at all — the entry's lock file would not open (a directory in its place, a permission error, a rejected symlink/reparse point), the same case list prints as unprobed and prune refuses to reap (see docs/registry.md). The client refuses just as it does for a stale entry — it acts only on a confirmed-live match, and this is not one — but it says so differently: the message reports that liveness could not be probed and names the entry unprobed, never that the runner is gone, which is a confirmed death nothing established. So a refusal you cross-check against list will always agree with what list shows for that record.
  • Died mid-conversation. The entry read live, but the runner exited between the liveness probe and the reply — so the connect fails, or the connection closes before a complete response arrives. The client reports that the runner could not be reached or closed the connection before answering.

Every wait — connecting, and the whole request/response exchange — is bounded by a deadline, so a runner that accepts a connection but never answers cannot wedge the client either; it, too, ends as a bounded CONTROL failure. A run id that is not registered at all is likewise a CONTROL failure naming the missing run.

For the mutating verbs this matters twice over: a cancel/kill against a run that is already gone is the same bounded CONTROL (103) result — it never blocks waiting for a teardown that will not happen, and it does not mistake a dead run for a successful cancel.

One inspect-only refusal shares this code without being a lost runner. A runner that answers with a snapshot declaring a snapshot_version outside the range this client reads is reachable and perfectly healthy — the exchange completed and the reply arrived — but that reply cannot be interpreted, so inspect refuses it with the same 103 instead of rendering it (see "Snapshot version: a newer runner's reply is refused, an older one is read"). That is what sets it apart from the reasons above: they are all ways the target could not be resolved or reached, while here the target answered and its answer was rejected. (Determinism is not the distinguishing property — a confirmed-stale entry and an ambiguous run_id are just as unaffected by a retry; only "died mid-conversation" is genuinely transient.) cancel/kill cannot hit it — their ack carries no version.

This is the exit-code half of the contract: a caller distinguishes "here is the run's state" / "the command was accepted" (exit 0, JSON on stdout) from "that run is not reachable" (exit 103, message on stderr) without parsing free text.

Run registry

The run registry is the first brick of processkit-cli's control plane. The control plane lives in the live run process, not in named kernel objects (AGENTS.md, "The control plane lives in the live runner process"): a runner must stay alive to hold its kill-on-drop container, so the live process is exactly where inspect / cancel / kill reach it. The registry is how those clients find a live runner — a per-user directory holding one record per in-flight run.

This document is the normative description of the registry's location, record format, and staleness signal. The transport those clients speak over, and the inspect client itself, are described in docs/control-plane.md; here we define only the registry.

list (see "Discovery" below), prune (see "Reaping" below), and wait (see "Waiting" below) are the clients that read the registry directly, without connecting to any runner's control transport: list scans every entry and prints it, so an operator that has lost (or never had) a run_id can find one before reaching for inspect/cancel/kill; prune reaps the entries list would show as stale; and wait blocks on one entry until it is no longer live, so a supervisor that is not the runner's parent can still wait for a run to end.

Location

The registry is a per-user directory — not system-wide and not tied to any one run. It is resolved in this order:

  1. PROCESSKIT_CLI_REGISTRY_DIR — if set (and non-empty), it is used verbatim as the registry directory. This lets an orchestrator pin the location and lets the tests isolate a scratch registry.
  2. Platform default, otherwise:
    • Unix: $XDG_RUNTIME_DIR/processkit-cli/runs when XDG_RUNTIME_DIR is set — a user-private, per-session runtime directory is the natural home for live-run state — else $HOME/.local/state/processkit-cli/runs.
    • Windows: %LOCALAPPDATA%\processkit-cli\runs, falling back to the same path built from %USERPROFILE%.

Permissions

The registry directory is created restricted to its owner, and every mutating open (run's path) guarantees that restriction before a record is written into it — including on a pre-existing directory whose permissions were widened out of band, which is repaired rather than trusted. A record names a run's private control-channel endpoint, so a world-readable registry would hand that channel to any local process. The read-only open every other client takes — list, prune, wait, events, and the control clients — deliberately does neither: it does not create the directory and does not touch its permissions, since a read-only scan must not mutate registry state.

  • Unix: mode 0700. Applied at creation and re-asserted with chmod (which, unlike the creating mkdir, is not filtered by the umask) on every mutating open.
  • Windows: a protected DACL that grants full control only to the current user — the equivalent of 0700. Concretely the directory's DACL is D:P(A;OICI;FA;;;<current-user-SID>): Protected (inherited ACEs from the parent are blocked), a single allow-Full-Access ACE for the current user, inherited by child objects and containers (OICI) so the records and lock files inside are covered too. The directory is created carrying that descriptor, so it never exists momentarily reachable through permissions inherited from its parent.

The two platforms differ in how a mutating open reaches that state, because the cost of asserting it differs by three orders of magnitude. Unix simply re-applies the mode: one chmod, constant cost. Windows first verifies — one read of the directory's own security descriptor — and writes only when what it finds is not already exactly the DACL above; the write it avoids is SetNamedSecurityInfoW, which re-propagates the inheritable ACE across every record and lock file in the directory and therefore costs more the more runs the registry remembers (measured at roughly 0.15 ms per file — about 310 ms for a registry holding 1024 entries — by benches/registry_open_bench.rs).

The guarantee is identical either way, and deliberately so: the write is skipped only when the directory's DACL is already the target, compared ACE for ACE (protected bit, allow type, inheritance flags, access mask, and binary SID). Any deviation, any unreadable descriptor, and any path that is not a directory all fall through to the unconditional write. Nothing weaker — the directory merely existing, a marker file, a timestamp, a cached "already done" flag — is ever accepted as evidence, precisely because a principal who cannot defeat the DACL could still forge those and suppress the repair.

Record format

Each run writes one record file (<opaque-stem>.json) plus a sibling lock file (<opaque-stem>.lock). The record is a single JSON object:

{
  "registry_version": 1,
  "run_id": "run-1234-...",
  "endpoint": "\\\\.\\pipe\\processkit-cli-1234-...",
  "started_at": "2026-07-20T21:00:00.000Z",
  "argv_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "hint": null,
  "labels": { "batch": "42" },
  "liveness": {
    "kind": "advisory_lock",
    "lock_file": "run-000...-0000.lock"
  }
}
FieldMeaning
registry_versionRecord format version (currently 1). Independent of the JSONL event schema_version — the registry is a private per-user contract, not the public event stream, so it versions on its own axis.
run_idThe run's identifier (--run-id, or a generated one). This is the key clients match on.
endpointThe run's local control-transport connection address — a unix socket path, or a Windows named-pipe name (see docs/control-plane.md). A live runner publishes it here so a client can reach it; null only when the transport could not be stood up (best-effort degradation — the run still works, it is just not inspectable).
started_atRun start time, RFC 3339 UTC with millisecond precision.
argv_sha256The run's one-way argv fingerprint — lowercase-hex SHA-256 of the canonical argv encoding, byte-identical to the run_started event's command.argv_sha256 for the same run (docs/schema.md, "Fingerprint"). null on a record written before this field existed, or whose value failed the read-side shape check below. Never argv itself (see "Which run is which" below).
hintThe run's worker-shape category from the same classifier catalog the event stream uses (docs/schema.md, "Hint classifier") — e.g. msbuild_node_reuse — or null when the command matches no known shape (the common case) and on a record predating the field. A fixed category label, never argv content.
labelsOperator metadata from repeated run --label KEY=VALUE; an empty object on an unlabeled or older record. Used for discovery and exact-match aggregate filtering, not as a secret store.
jsonlAbsolute path to the run's JSONL lifecycle stream, or null on an older record. This is the locator events --run-id resolves — which is why it keeps working for a finished-but-not-yet-reaped record, and why events --file exists for after the record is gone.
capture_dirAbsolute output-capture directory, or null when capture is disabled or the record predates this field.
livenessHow to decide whether the record is live or stale (see below).

Registry identity, endpoint, and artifact-path strings remain byte-for-byte intact for matching and JSON output. Every human-readable renderer of one sanitizes terminal formatting and limits the field to 256 characters plus an explicit ... marker, so a corrupt record cannot create an unbounded terminal row — list, inspect, and prune for the fields each prints, and events for the single field it echoes: the jsonl locator, named in the diagnostics for a stream it could not open or read.

list --label KEY=VALUE applies the same exact-match, logical-AND label filters as the aggregate control and wait commands. list --health live|stale|unprobed restricts that result to one liveness verdict; the two filter kinds combine.

Which run is which — and what a record never carries

argv_sha256 and hint exist so an operator (or an orchestrator) staring at several live entries can tell which run is which before picking one to inspect/cancel/kill — see "Discovery" below, where both are printed. They are the redaction-safe half of the run's command: the fingerprint says whether two entries are running the same command, the hint names a recognized worker shape, and neither can disclose a command line (AGENTS.md, "Argv is redacted by default"). Both are produced by exactly the implementation the JSONL stream uses, so the same run never fingerprints differently in the two artifacts.

The artifact locators are published on every new run (capture_dir only when that feature is enabled). This is deliberate: they are operator-selected observability locations rather than argv, and the registry is already owner-only because it holds the live control endpoint. Publishing them closes the detached-run discovery loop: a supervisor with only a run_id can find the event stream and transcripts as well as inspect, wait for, or stop the run. Paths can still contain a sensitive project or user name, so consumers must treat registry JSON as private operational metadata and must not copy it into public logs. Human list/inspect output terminal-sanitizes and visibly bounds both paths; JSON preserves the exact strings.

The raw argv is never written to a record, under any flag. --argv-raw widens the run_started event only; the registry's register is handed a fingerprint-and-hint value, not the argv, so there is no code path — and no future flag — through which a command line can reach a registry record.

Two further fields were considered for the same "tell runs apart" purpose and deliberately left out:

  • root_pid would put a reused-at-any-moment number in front of an operator inside the one artifact whose entire design says a PID cannot identify a run (see "No PID addressing" below) — an invitation to kill the process that inherited it. The fingerprint distinguishes runs without that hazard.
  • cwd is raw, unredacted text with no redaction rule in this project's contract (which covers argv only), and a working directory routinely spells out a customer, ticket, branch, or user name. Persisting it verbatim into a long-lived per-user file that every local client of this binary reads is a disclosure decision distinct from the run_started event's — that stream is written only to a file the caller explicitly asked for with --jsonl. It would also add a second untrusted path to validate on read.

Reading a record: additive fields, and untrusted values

The command-identification and artifact fields are optional on read, and that is what makes adding them a non-breaking change to registry_version (still 1):

  • A record written before they existed — or by any writer that publishes none — reads back with them null. They are deserialized as optional/defaulted, so their absence is never an error.
  • A record written by a newer writer, carrying fields this binary does not know, is read as an ordinary record: unknown fields are ignored, not treated as corruption. Both directions matter in the mixed registry a mid-upgrade user actually has, where an older list/prune binary and a newer run share one directory.

A registry_version bump is reserved for the opposite kind of change: renaming, removing, or retyping an existing field, changing what a value means, or adding a field a reader must understand in order to behave correctly.

Like every other field, both are untrusted deserialized data on the read side (the same stance liveness.lock_file and endpoint are held to) and are validated by shape: argv_sha256 must be exactly 64 lowercase hex characters, and hint must be a non-empty, at-most-64-character label of ASCII lowercase letters, digits, and _ — the snake_case shape the classifier catalog requires. hint is checked by shape rather than membership in this binary's own catalog on purpose, so a label minted by a newer runner is not silently dropped.

A value failing its check is dropped to null, and the record is kept — unlike a malformed started_at or lock_file, which skip the whole record. The difference is what the value can do: those two steer an action (a path that gets opened, an ordering every client reasons about), while these two are reported and nothing more. Discarding a record over one of them would let a purely cosmetic field hide a live run from list, wait, and every control client at once — one hand-edited byte in a live entry's hint and cancel/kill could no longer find the run they are aimed at. Losing the field is strictly the smaller loss.

No PID addressing

A record is never indexed or identified by a bare PID (AGENTS.md: "Nothing is addressed by PID"). The file name (<opaque-stem>) is a PID-free time+counter token whose only job is to be unique; the authoritative identity is the run_id field. Clients find a run by scanning records and matching run_id, so PID reuse cannot alias one run onto another. Uniqueness of the file name is guaranteed by the filesystem (the lock file is created with O_EXCL / CREATE_NEW), so concurrent runs always get independent entries that neither overwrite nor block each other.

Staleness — detectable, and not by file existence

If a runner dies abruptly (crash, SIGKILL, a parent's Job Object terminate), the kernel container reaps the whole process tree, but the record file is left behind. A client must be able to tell that leftover record from a live one — and crucially, the file merely existing is not enough to conclude the run is alive.

The signal is an OS advisory lock:

  • A live runner holds an exclusive advisory lock on the record's lock file for the entire run (flock(LOCK_EX) on unix; LockFileEx with LOCKFILE_EXCLUSIVE_LOCK on Windows). The lock is tied to the open file handle, and the operating system releases it automatically when the process dies — by any means, clean or abrupt.
  • A client checks liveness by trying to take that same lock, non-blocking:
    • Denied (the lock is held) → a live runner owns it → the entry is live.
    • Acquired (no one holds it) → the runner is gone → the entry is stale.
    • Lock file missingstale by definition.

Because the verdict comes from the lock — which the OS frees on death — and not from the file's presence, an orphaned record is reliably classified as stale. A client performing a pure liveness query releases the lock immediately after acquiring it; a client that intends to reclaim a stale entry would instead keep the lock held to claim it atomically.

Run id resolution — ambiguity is a hard failure

The registry does not enforce uniqueness of run_id at register time: two concurrent runs started with the same explicit --run-id are both written as independent entries (independent opaque file stems — see "No PID addressing" above) and both read as live for as long as they run. Resolution is therefore the client's job — in resolve_live_endpoint (src/control/mod.rs) for the control-plane verbs, and in Registry::probe_run (src/registry/mod.rs) for the registry-only wait, which reaches the same verdict from its own scan — and it is deliberately conservative:

  • The client scans every entry and filters to those matching the requested run_id, then counts how many of those are live (see "Staleness" above) — deliberately before ever looking at whether they publish an endpoint. A live entry that has not (yet, or ever) published an endpoint — a disconnected or failed transport — still counts as a live duplicate; if endpoint presence narrowed the count first, such an entry would be silently skipped and a duplicate could evade detection.
  • Zero live matches → a distinguishable CONTROL (103) failure naming why (no such run registered at all, or the sole match is stale) — see docs/control-plane.md.
  • More than one live match → also a CONTROL (103) failure, "ambiguous run id", instead of silently acting on whichever entry the directory scan happens to return first. This applies to every by-run-id client the same way — the destructive cancel/kill verbs, the read-only inspect, and the registry-only wait — rather than a softer fallback for the read-only ones: guessing wrong on a mutating verb ends the other run instead of the intended one, a snapshot of the wrong run under inspect is exactly as misleading as acting on it, and a wait that silently tracked one of two duplicates would report "your run finished" on the strength of the wrong run's ending. A caller that hits this is expected to pick a --run-id that is unique among currently live runs.
  • Exactly one live match → only now does its endpoint matter: resolved normally if it published one, or a distinguishable CONTROL (103) failure ("the run is live but exposes no control endpoint") if it did not.

That single check happening once, at the start of the call, is a TOCTOU race for the mutating verbs: register never enforces uniqueness, so a duplicate can register under the same run_id in the window between the scan and the verb reaching the runner over the transport (the connect round trip in between). cancel/kill narrow that window as tightly as the registry's decentralized, no locking-across-processes design allows: immediately before writing the verb, the client re-runs the same scan+match and requires it to resolve back to the exact endpoint it already connected to — any other outcome (a fresh ambiguity, the entry having gone stale, or the resolution landing on a different entry) aborts the command without ever writing to the wire. inspect does not repeat this check: being read-only, a race that surfaces a snapshot from just before a duplicate registered is merely stale information, not a wrong-target action.

That pre-dispatch re-check is a synchronous scan, while the verb write that follows it is a separate, later .await; the two cannot be made atomic with each other, so a duplicate can in principle still register in the sub-instruction gap between the re-check returning and the write reaching the OS. Closing that residual gap completely would need a run_id-keyed lock held across process boundaries through the write — a registry redesign this resolver deliberately does not attempt (see "No PID addressing" above). It is not needed for correctness, though: by the time the re-check runs, the client has already connected to the target's specific, uniquely-tokened transport endpoint (endpoint_tokens_are_unique in src/control/tests.rs), and a later registry write cannot retarget bytes already destined for an open connection. So the guarantee the re-check actually buys is narrower than "no ambiguity can ever exist at write time" (impossible without that cross-process lock) and is instead: the verb can never be misdirected to a different run than the one already resolved and reconfirmed. A duplicate that registers in the residual gap is simply invisible to that call — it becomes visible on the next one — never a wrong-target action. See racing_duplicate_after_reconfirm_does_not_misdirect_the_dispatched_verb in src/control/tests.rs for a deterministic proof of this property.

Discovery — list

processkit-cli list [--json] opens the registry through [Registry::open_read_only] (src/registry/mod.rs) — not the mutating [Registry::open] run uses, so listing never creates the registry directory and never touches its permissions — and scans it with [Registry::entries], the same scan every other client shares, printing every entry it finds, whatever its health: run_id, health (live/stale/unprobed), started_at, hint, argv_sha256, labels, jsonl, capture_dir, and endpoint. It is deliberately read-only and never connects to any runner's control transport, so it carries none of the "could not reach the target run" failure modes inspect/cancel/kill do — it has no single target to fail to reach.

  • No --json prints a human-readable table (or no runs registered for an empty registry). Because record files are untrusted input, control characters in identity, endpoint, or artifact paths are collapsed to spaces and visibly bounded at this terminal boundary; they cannot forge another row or inject an ANSI sequence.
  • --json prints one JSON object per entry, one per line, sorted by run_id, then started_at, then the entry's registry record path (a tertiary tie-break, never itself printed) for a fully deterministic order even when two entries share both a run_id and a millisecond-precision started_at — the same "JSON Lines" shape inspect --json uses for a single snapshot.
  • An empty registry is not an error: list prints an empty result (or the no runs registered notice) and exits 0, exactly like scanning any other registry state.
  • A stale (or unprobed) entry is listed, not hidden — unlike inspect/cancel/kill, which treat anything other than a confirmed-live match as an unreachable-run failure, list's whole purpose is discovery, so a stale leftover (evidence of a runner that died abruptly without cleaning up) is exactly the kind of thing an operator wants to see, e.g. before reaping it.
  • unprobed is a distinct value, never folded into stale (T-206). A record whose liveness lock could not even be opened (permission denied, a rejected symlink/reparse point, or an unexpected non-regular file in its place) is health unprobed: the probe could not run, so nothing is confirmed — printing it as stale would assert a confirmed death the probe never established, which could lead an operator to hand-delete a record that may still belong to a live run. This is the same three-way vocabulary prune --json's unprobed tally and wait's RunStatus::Unprobed already use for the identical case (see "The reaping safety invariant" and "Liveness it cannot confirm" below) — list's health field is additive: existing --json consumers that already treat any non-"live" value as "not live" need no change, only ones that matched exhaustively on exactly "live"/"stale".
  • Telling several live runs apart. Health, run_id, and started_at cannot say what a run is running, so a registry with three live entries used to leave an operator guessing which one to act on. Each entry therefore also prints the two redaction-safe command fields the record carries (see "Which run is which" above): hint — the worker-shape label, or -/null when the command matches no known shape — and argv_sha256, the one-way argv fingerprint, which is equal for two entries exactly when they are running the same command. Neither discloses a command line, which is why they can be printed at all.
    • In the table, ARGV_SHA256 is abbreviated to its first 12 hex characters followed by ... (a full digest is six times the width of every other column put together, for a value read comparatively rather than character by character). An absent value renders as -, exactly like an absent ENDPOINT.
    • In --json both are full-precision fields — argv_sha256 carries the whole 64-character digest, so it can be compared byte-for-byte against the same run's run_started event — and both are always present, null when the record carries no value. Additive: a consumer reading the fields it knows is unaffected.
    • labels is the full key/value object in JSON and a comma-separated LABELS cell in the table. Human output passes both keys and values through the terminal sanitizer.
  • A single corrupt or unreadable record is skipped by Registry::entries itself (see "Staleness" and the per-record degradation documented there) and never blinds list to the other, healthy entries — including a record whose started_at is not the well-formed YYYY-MM-DDTHH:MM:SS.sssZ shape a runner actually writes. A malformed hint/argv_sha256 is the one case that does not skip the record: the offending field alone is dropped to null (see "Reading a record" above), so a cosmetic value can never hide an entry from this listing.

Reaping — prune

processkit-cli prune [--json] [--label KEY=VALUE]... is the cleanup counterpart to list. Where list shows a stale leftover, prune deletes it: it opens the registry through [Registry::open_read_only] (src/registry/mod.rs) — like list, so it never creates the directory or touches its permissions; a missing or empty registry simply has nothing to prune — scans it with the same shared scan list uses, and for each scanned record deletes both its files (<stem>.json then <stem>.lock, the same order [Registration::remove] uses) only when it can confirm the record is stale. On unix it deletes a third leftover of the same death — the control socket that record published, see "Reaping the control socket" below.

Repeated --label KEY=VALUE filters use the shared label parser and combine with logical AND. When filters are present, only paired records carrying every requested label enter the prune tally or liveness probe. Lone orphaned .lock files have no record from which labels or ownership can be recovered, so an explicitly filtered prune leaves them out of scope entirely. With no filter, the original registry-wide paired-record and orphan-lock passes are unchanged.

It then makes a second pass over any orphaned lock files — a .lock with no .json sibling at all. Such a .lock is invisible to the shared scan (which only ever walks .json records), so however long it has sat there the paired-record pass above can never find it. An orphan can still arise two ways: Registry::register reserves and locks the .lock file before it writes the .json record, so a fs::write failure in between would otherwise leave the fresh lock file behind forever — this is now closed at the source by a Drop-backstop guard on the reservation, armed until the record is published and disarmed right after, so a failed register deletes its own lock file instead of leaking it; or Registration::remove's best-effort .json delete can succeed while its .lock delete does not, which is not similarly guarded. The second pass reuses the exact same lock-probe safety as the first (a Live lock is never touched, a probe failure leaves the file in place, only a confirmed-stale lock is deleted) and reports its reaps under the separate orphaned_locks tally below — kept distinct from pruned because it deletes one file, not a .json/.lock pair.

A record-less lock file needs one more guard the paired-record pass does not: a freshly create_new-d .lock file that a just-starting Registry::register has not yet locked is, for a moment, indistinguishable from a genuine, long-dead orphan — both are simply an unlocked .lock with no .json next to them. The second pass therefore only ever considers a candidate whose mtime is already at least a few seconds old (ORPHAN_LOCK_MIN_AGE in src/registry/mod.rs); a genuine orphan never ages out of that check, so the extra latency costs nothing, while the two-syscall reservation window reliably falls inside it. Registry::reserve_entry closes the same window from its own side: after taking its lock it re-checks that lock_path still resolves to the identical file it is holding open (device/inode on Unix, file index + volume serial number on Windows) before trusting it enough to publish a record naming it, and retries with a fresh stem — never a hard error — both when the lock is denied and when that identity check fails. Together these close the race a lock-probe-only guarantee would otherwise leave open (see "The reaping safety invariant" below).

Reaping the control socket

An abruptly-killed runner leaves more than a .json/.lock pair behind. On unix its control transport is a socket file inside a per-run 0700 directory named pkc-<token>, created under /tmp (or the platform temp directory) and removed only by the clean-teardown Drop that an abrupt death never runs — see docs/control-plane.md, "Cleanup and leaks". The dead record publishes that socket's path in its endpoint field, and nothing else on the system knows about it, so once the record went the directory was stranded forever: over time, SIGKILLs and crashes accumulate dead pkc-* directories that no prune pass ever looked at. Reaping a confirmed-stale entry therefore also reaps the socket that entry published, closing the half of the "leftovers of runners that died abruptly" contract that used to stay open.

It is reaped before the record naming it, still under the same held lock: the record is the only thing that points at the socket directory, so a pass interrupted between the two deletions must not be the one that leaves the socket unreferenced.

A record's endpoint is untrusted deserialized data, exactly like its liveness.lock_file (whose own single-component name check the scan already applies before ever joining it onto the registry directory) — and this step deletes what it names, so it is never used as a path on trust. It is first validated by shape, and a value failing any part of that check simply contributes no deletion at all (the record and lock are still reaped as usual — the check gates only this extra step):

  • absolute, and written as plain /-separated names: a relative path, a ./.. segment, a doubled separator, or an embedded NUL/control character is refused — and refused as written, without normalizing anything away first;
  • the final component is exactly the socket file name the control server binds (c.sock), and its parent is pkc- plus a non-empty token of ASCII alphanumerics and - — the character set the transport's own token generator mints;
  • that parent sits directly inside one of the base directories the control server binds in (/tmp, or the platform temp directory). A perfectly-shaped path anywhere else — /etc/pkc-x/c.sock, $HOME/pkc-x/c.sock, one directory deeper — is not a candidate.

Shape alone cannot settle whether the path is a symlink, since that answer can change between the check and the deletion. So it is settled where it cannot be raced, at open time: the validated directory is opened with O_NOFOLLOW | O_DIRECTORY (the same discipline the liveness probe applies to a lock file), and the socket is then removed relative to that open handle. A pkc-… name that is really a symlink fails the open outright, and a swap landing after it cannot redirect the deletion. Two further refusals bound what can be deleted: only an entry that really is a socket is unlinked (a regular file, a symlink, or a device node under that name is left alone), and the directory itself is removed with rmdir, which never follows a final symlink and only ever removes an empty directory — so anything unexpected still inside keeps the directory too.

Every step is best-effort, exactly like the record/lock deletions: a socket that will not go is a leftover to retry next pass, never a reason to abort the reaping of other entries. A run whose socket was created under a different temp directory than the pruning process sees (a changed TMPDIR between the run and the prune) keeps its socket rather than having an unrecognized path deleted on its behalf. The tally is unchanged: a reaped socket is counted by its own entry's pruned, not by a counter of its own — the socket belongs to that entry and is never reaped without it.

Windows is unaffected. A Windows run publishes a named pipe, which lives in the kernel object namespace and disappears with the process that created it. There is no filesystem leftover to reap, so no endpoint is ever a candidate there and prune behaves exactly as it did before.

The reaping safety invariant

Pruning deletes files, so it is deliberately conservative: an entry is reaped only when its own liveness probe succeeds and reports stale. The three probe outcomes are kept strictly apart — and this is the load-bearing distinction:

  • Confirmed stale ⇒ reaped. The lock file is absent (stale by definition), or its exclusive lock was free and the probe took it (no live runner holds it). Only this case deletes anything.
  • Live ⇒ never touched. A live runner holds the lock, so its entry is left exactly as it is. Prune never deletes a running run's record.
  • Probe failed ⇒ left in place. The probe could not even be performed — the lock file would not open (a directory in its place, a permission error, a rejected symlink/reparse point) or the lock call itself errored. Liveness is unknown, not confirmed stale, so the entry is kept, on every repeated prune. This is the same case Registry::entries reports as [Health::Unprobed] (T-206) — never folded into Stale — so the read path list/inspect share already keeps it apart from a confirmed-dead entry too, at the Health level; inspect/cancel/ kill still act on it exactly as they do on Stale (they refuse, because they act only on [Health::Live], and a probe-failed record is not that whichever of the two non-live values it carries) — but they no longer word the refusal the same way: an unprobeable entry is reported as unprobed, liveness unknown, not as a runner confirmed gone (see docs/control-plane.md, "When the runner cannot be reached"). Prune, though, cannot simply reuse Entry::health here even now that it distinguishes the case: reaping needs the probe's acquired lock held across the two deletions below, and a pure liveness query like entries() already released it — so prune probes on its own path that keeps the failure distinct and keeps the lock, and errs toward keeping a record it is unsure about.

Two further guarantees hold, mirroring the rest of the registry:

  • Never by PID. A reaped entry is addressed only through the record path the directory scan produced (the same PID-free tokened stem — see "No PID addressing" above), never by a process id, so PID reuse can never misdirect a deletion.
  • Reclaim under the lock. A confirmed-stale entry is deleted while the probe still holds its exclusive lock — the "keep the lock to reclaim" behavior noted under "Staleness" above — so a second concurrent prune sees the entry as live and skips it instead of racing on the same files.

A .lock file with no .json sibling carries one further precondition before any of the three probe outcomes above even apply: it must already be at least ORPHAN_LOCK_MIN_AGE old (by mtime). A confirmed-stale-or-live-or-unprobed verdict on a lock-probe alone is not enough to call a record-less lock file safe to touch, because a freshly reserved-but-not-yet-locked file would otherwise read as "probe succeeded, no live holder" — indistinguishable from a genuine orphan purely by scheduling luck. A too-young candidate (or one whose age could not be confirmed at all) is simply left alone this round, to be reconsidered once it has aged.

Corrupt records the scan already skips (unreadable, unparsable JSON, a malformed started_at, or a lock_file that is not a simple in-directory name) are not prune candidates: they are never probed and never deleted, exactly as list leaves them alone — and a .lock file that does have a .json sibling, however corrupt, is likewise left to that first pass (or to neither pass, for a corrupt record), never treated as orphaned. Every deletion is best-effort and per-entry — an OS error reaping one entry never aborts the reaping of the others (the leftover just reads as stale again next time) — and pruning an already-clean, empty, or missing registry is a no-op that exits 0.

  • No --json prints a one-line summary (no stale entries to prune when there was nothing to reap).
  • --json prints a single JSON object with the tally: pruned (paired entries reaped — each including whatever control socket that entry published, see "Reaping the control socket" above), live (live entries left untouched, paired or orphaned lock alike), unprobed (entries whose probe failed and were left in place, paired or orphaned lock alike), and orphaned_locks (lone .lock files with no .json sibling that were reaped). These four fields are unchanged: reaping a socket adds no counter of its own.

Previewing a reap — prune --dry-run

processkit-cli prune --dry-run [--json] [--label KEY=VALUE]... answers "what would prune reap right now?" without reaping anything. It is [Registry::preview_prune] (src/registry/mod.rs), the non-destructive sibling of [Registry::prune]: the exact same two-pass scan (paired records via Registry::scan, then orphaned locks via Registry::orphaned_lock_paths) classified through the exact same [probe_for_prune] three-way probe described in "The reaping safety invariant" above, so a candidate here is confirmed stale by precisely the same rule a real prune would use to reap it. The only thing that differs is the action taken on a confirmed-stale (Reapable) verdict: instead of deleting the entry's files while holding the probe-acquired lock, preview_prune releases that lock immediately — there is nothing to reclaim it for, since nothing is being reaped — and records the candidate instead. Live and probe-Err verdicts are handled identically to prune. The result is that preview_prune's aggregate tally is exactly what a following, untouched prune pass over the same on-disk registry state would report, and the preview itself never calls fs::remove_file on anything, so the registry is left byte-for-byte as it was.

Label filters are applied before the same liveness probes in both paths. A filtered preview therefore lists and counts exactly what a real prune with the identical filters would reap; it also excludes ownerless orphan locks by the same rule.

Because a paired record carries run_id/started_at but an orphaned .lock file has no record to pull identifying fields from at all, each candidate is described differently:

  • a confirmed-stale paired entry is identified by its run_id and started_at, the same fields list already prints for it, plus the control-socket directory (socket_dir) a real reap would remove along with its two files;
  • a confirmed-stale orphaned lock is identified by its lock file name (there is no run_id/started_at to report).

socket_dir is classified by the very same shape check the real reap applies (see "Reaping the control socket" above), so the preview can neither promise a deletion the reap would refuse nor stay silent about one it would perform. It is null whenever that reap would remove nothing — no endpoint was published, the endpoint is not the shape a control server creates, or the record is a Windows one, whose named-pipe endpoint has no filesystem leftover. Like every other part of a preview it is read from the record alone: nothing is stat-ed, so a directory named here may already be gone (reaping it is best-effort, exactly like the record/lock deletions).

  • No --json lists each confirmed-stale candidate on its own line — a paired entry as run_id=<id> started_at=<ts>, followed by socket_dir=<path> when there is a socket directory to reap with it (omitted entirely when there is not), an orphaned lock by its file name — then the same summary-line shape prune's human-readable output uses, prefixed would throughout since nothing is actually reaped (no stale entries to prune (dry run) when there is nothing to preview). Control characters in the record's run_id or the directory entry's lock-file name are collapsed to spaces before interpolation, so neither can forge output lines.
  • --json prints a single JSON object with the exact same aggregate fields prune --json reports (pruned/live/unprobed/orphaned_locks), plus an additional candidates array: one object per confirmed-stale candidate, internally tagged "kind":"entry" (with run_id/started_at/socket_dir, the last always present and null when there is no socket to reap) or "kind":"orphaned_lock" (with lock_file_name).
  • prune without --dry-run is unchanged: its human-readable and --json output, and its exit codes, are identical to before this flag existed.
  • Like prune, --dry-run opens the registry through Registry::open_read_only, so previewing never creates the registry directory or touches its permissions, and an empty or missing registry previews an all-zero tally with an empty candidates list rather than erroring.

Waiting — wait

processkit-cli wait (--run-id <id> [--report-outcome] | --all) [--timeout <duration>] is the lifetime counterpart to list's discovery and prune's cleanup: it blocks while its target is live and returns as soon as it is not. --run-id and --all are mutually exclusive (clap rejects both together) and exactly one is required — see "The aggregate barrier — wait --all" below for the second mode. It exists for the supervisor that did not start the run — an adapter that restarted, a cleanup step, anything holding only a run_id (or nothing but "I want every run gone") — and therefore has no child process to wait on. Like list and prune it opens the registry through Registry::open_read_only (src/registry/mod.rs), so waiting never creates the registry directory or touches its permissions, and unlike inspect/cancel/kill it never connects to the run's control transport: the run is not disturbed, not ended, and not even aware of the waiter. A run whose transport never came up (a null endpoint, see "Record format" above) is still perfectly waitable — wait needs no endpoint.

A run started with run --detach is the case this was written for: that call returns once the run has started and is never the runner's parent, so wait is how its caller learns the run is over. wait --report-outcome also consumes the run's published --jsonl locator and terminal event for callers that want the outcome in the same supervision step. A detached run publishes an ordinary record here — nothing about the entry, its liveness lock, or its removal on a clean exit differs — so list, prune, and wait treat it exactly like any other.

How it waits. Liveness is the advisory lock described under "Staleness" above, and that lock offers no event, notification, or wakeup a third process could subscribe to. Waiting on it is therefore honest periodic probing — one scan plus one non-blocking lock attempt per matching record, a few times a second (POLL_INTERVAL in src/wait.rs) — not an event subscription dressed up as one. Blocking on the lock itself would be worse than merely slow-to-notice: acquiring a stale entry's lock is how a reclaimer claims it (see "Reaping" above), and it would still miss the ordinary clean exit, which deletes both files rather than handing the lock over.

Three outcomes, by exit code:

  • 0 — the run is over: no record matches the run_id any more, or every record that does probed as stale.
  • WAIT_TIMEOUT (112) — the --timeout given to wait elapsed while the run was still live. This is the waiter's deadline, not the run's: the run was left running, untouched, and will report its own ending in its own time. It is deliberately not the run's TIMEOUT (106), which means the opposite — see docs/exit-codes.md, "A waiter's deadline is not a run's deadline". Without --timeout, wait blocks indefinitely.
  • CONTROL (103) — the run_id is ambiguous: more than one live entry matches, so there is no single run whose end could be waited for (see "Run id resolution" above). Re-checked on every probe, not just the first, since a duplicate can register at any moment.

Nothing is printed on ordinary success. --report-outcome, restricted to --run-id, instead prints exactly one JSON object while leaving all exit-code semantics above untouched:

{"run_id":"build-42","status":"reported","code":7,"source":"child_exit","child_code":7}

The waiter remembers the sole confirmed-live record's absolute jsonl locator on its polling passes. Once that record disappears, it reads the terminal runner_exit; code, source, and child_code use that event's vocabulary. A short bounded retry bridges the runner's normal teardown ordering, where record removal can precede the final flushed event by a few instructions. If the waiter never observed the run live, an older record published no locator, the runner died without a terminal event, or the stream cannot be read, success remains honest data:

{"run_id":"build-42","status":"unknown","code":null,"source":null,"child_code":null}

The mode does not forward code as the wait process's exit status. Aggregate reporting is deliberately out of scope until it has an explicit per-target shape; clap therefore rejects wait --all --report-outcome. A registry that cannot be opened or read at all remains a SETUP (111) failure, exactly as for list/prune.

An unknown run_id reads as "finished"

A run that exits cleanly deletes its own registry entry (see "Lifecycle" below), and the registry keeps no history of what used to be there. So "build-42 was never registered" and "build-42 finished a moment before you asked" are the same observation: no matching record. Nothing in the registry can separate them.

wait therefore answers both the same way — exit 0, "it is not running" — rather than inventing a third outcome that could only ever be a guess. Failing on an unknown id would be worse than unhelpful: it would make the result depend on when the caller asked, turning the ordinary, expected race (the run finished while the adapter was starting up) into a hard error — precisely the failure mode a wait command exists to remove.

The consequence a caller must plan for is the mirror image: a mistyped run_id returns 0 immediately, indistinguishable from a fast, successful run. wait's 0 means "not running", never "existed and completed". A caller that needs the stronger fact must establish it separately — it launched the run itself, or it saw the id in list — and must not read a 0 as proof the run ever existed.

Liveness it cannot confirm

Scoped to wait --run-id only — see "The aggregate barrier — wait --all" below for how --all reuses this same conservative stance on every pass after its snapshot, but not on the snapshot step itself, which excludes an unconfirmed entry rather than waiting on it (a genuine, documented asymmetry with the single-run_id case below).

One case is neither live nor confirmed over: a matching record whose lock file cannot be probed at all (a directory in its place, a permission error, a rejected reparse point — the same "probe failed" case "The reaping safety invariant" above keeps apart from "confirmed stale"). Registry::entries reports that case as its own [Health::Unprobed] value (T-206), never folded into Stale — right for list, whose whole purpose is showing the operator exactly what was and was not confirmed, and behaviorally unchanged for inspect/cancel/kill, which act only on [Health::Live] and so refuse on Unprobed exactly as they refused on the old collapsed Stale (their message now tells the two apart, so no client asserts a death this case never established). Minting a positive "finished" from that same unconfirmed case would still be wrong for wait --run-id, whose 0 is a positive claim about a run's lifetime — so on this path wait probes through [Registry::probe_run] directly and does not read Entry::health at all, live or otherwise. (wait --all does read it — see below.)

So wait --run-id probes on its own path and keeps waiting on an unconfirmable entry rather than announcing a completion it never observed. A bounded caller still gets a definite answer when --timeout elapses (WAIT_TIMEOUT, honestly meaning "could not confirm completion in time"); an unbounded one keeps waiting for a real verdict. This is the same "unknown is not confirmed" stance prune takes when it refuses to reap an entry it could not probe.

The aggregate barrier — wait --all

wait --all [--label KEY=VALUE]... [--timeout <duration>] is the counterpart for a caller that does not hold one run_id but wants a barrier on every run — the typical orchestrator teardown sequence: cancel everything, wait for it all to be gone, then prune. It reuses the exact same periodic-probing mechanism (src/wait.rs::run_all), differing from --run-id only in what it tracks.

Repeated label filters combine with logical AND and are applied while forming the one initial snapshot: a record must carry every exact pair to enter the target set. Conflicting filters for one key therefore match no run. Labels are rejected with the by-id form.

Snapshot semantics, fixed rather than left ambiguous. At the moment --all starts, wait takes a single [Registry::entries] scan and fixes its target set to exactly the entries confirmed Health::Live at that instant, each identified by its record file (not by run_id — two entries can share one, since the registry never enforces uniqueness; see "Run id resolution" above). A run that registers after the snapshot is out of scope for this invocation and is never waited for — this is a deliberate, documented trade-off, the same "one clear rule beats a plausible-sounding but unbounded alternative" stance the "An unknown run_id reads as finished" section above already takes for the single-run case. The alternative — keep discovering new runs forever — would leave a caller unable to say when --all could ever return at all. A caller that wants to catch a run starting concurrently with the wait re-issues wait --all once this one returns.

Unprobed entries stay "not confirmed done" — once they are in the target set. On every later pass, a snapshot entry that re-probes Health::Live or Health::Unprobed stays outstanding — the exact same conservative stance the "Liveness it cannot confirm" section above documents for --run-id, applied per-entry instead of to a single target. An entry that re-probes Health::Stale, or that has vanished from the scan entirely (a clean exit deletes its own record), is dropped from the target set — the same "confirmed over" observation that lets an unknown run_id read as finished.

This conservative rule governs every pass after the snapshot, not the snapshot itself: because the target set is fixed to exactly the entries confirmed Health::Live at the snapshot instant (above), an entry that is only Health::Unprobed — not confirmed live — at that instant is excluded from the target set from the start, never entering it at all. This is a genuine, deliberate asymmetry with --run-id, which never excludes its one target this way (it always has exactly the id it was asked to wait for): a registry holding only unprobeable entries and no confirmed-live ones makes wait --all return 0 immediately, the same as an empty registry, even though none of those entries' liveness was actually established. A caller relying on --all as a teardown barrier must not read that 0 as proof every run in the registry was actually confirmed over — only that nothing was confirmed live at the moment the snapshot was taken.

Outcomes. Success (0) means every snapshot entry probed stale or vanished; a bounded --timeout that elapses with entries still outstanding is the same WAIT_TIMEOUT (112) --run-id uses, reporting how many snapshot entries are still outstanding and, when at least one of them was only Unprobed on the last pass, saying so rather than confidently claiming they are all still live. There is no aggregate CONTROL/ambiguity outcome: --all never resolves an id at all, so the duplicate-id question --run-id answers with CONTROL does not arise for it. Nothing is printed on success, exactly like --run-id.

Lifecycle

  • Create. run writes the record and takes the liveness lock before the child is spawned, so the entry exists for the whole run. Creating the registry is best-effort: if it fails, the runner warns on stderr and proceeds — the registry is control-plane discovery infrastructure, and losing it must never cost the child its faithfully forwarded exit code (AGENTS.md, "Exit-code fidelity").
  • Remove. On a clean exit the entry is removed from the same teardown site as the container reap (clear_registration in src/run/teardown.rs), on every decided ending — a normal child exit, a --timeout, a local stop-signal cancel (Ctrl-C, on Unix a SIGTERM/SIGHUP, or on Windows a Ctrl-Break/console close/logoff/system shutdown, all of which the runner catches), or a control-plane cancel/kill — not just the happy path.
  • Leak → stale. An abrupt death skips that removal by definition, leaving the record on disk — and, on unix, the control socket that record published (see "Reaping the control socket" above). The released lock makes the record detectably stale, per the section above. This is genuinely abrupt death only — a crash, a SIGKILL, an outer Job Object terminate — not an ordinary Unix SIGTERM/SIGHUP or a caught Windows console-control event (Ctrl-Break/console close/logoff/system shutdown), all of which the runner catches and turns into the clean removal above.
  • Stale → reaped. prune is what finally clears such a leftover, deleting the record, its lock, and the socket directory together — only once it has confirmed the entry stale.

Troubleshooting

This is an operator's guide: symptom, what to look at, and where the normative answer lives. It does not restate the normative documents — docs/schema.md, docs/exit-codes.md, docs/registry.md, and docs/control-plane.md — it points at them. For the consumer/adapter walkthrough (preflight, launching, reading the stream, supervision, housekeeping), see docs/integration.md instead; this document is organized by symptom rather than by call sequence, and each entry below is deliberately short. On any disagreement between this document and one of the normative ones, the normative document is the source of truth.

BACKEND (102) with a limit_hit event, often only on CI or under systemd

Symptom. run --max-memory <size> / --max-processes <n> / --cpu-quota <cores> exits BACKEND (102) immediately — no child output at all — even though the same command works locally without the flag, or works locally with it.

Diagnose. Read the --jsonl stream: a limit_hit event (naming which limit — memory / processes / cpu — in its limit field) precedes the container_failed (phase: "create") and terminal runner_exit (source: "container_error", code: 102). The limit_hit event, not the exit code, is what tells you this specific ending was a resource cap the platform could not apply — see docs/schema.md.

Why it happens. A whole-tree cap needs a real container. On Linux that means cgroup v2 at the real hierarchy root — a minimal, non-systemd init. It does not work under a systemd session/scope/service, inside an ordinary container (Docker/Kubernetes), or under typical hosted CI (including GitHub Actions' ubuntu-latest), because the controllers cannot be enabled there; the run fails fast rather than silently running unbounded. macOS, the BSDs, and the Linux process-group fallback have no whole-tree container at all, so any cap request fails the same way there too. See README.md, "Resource limits", for the full platform matrix, and docs/exit-codes.md for why this reuses BACKEND (102) instead of a dedicated code.

Fix. Either run somewhere the cap can actually be enforced (a Windows Job Object, or a real Linux cgroup v2 root), or drop the resource-limit flags — there is no partial/best-effort mode.

The honest fallback: cgroup_v2process_group

Symptom. On Linux you expected cgroup v2 containment (whole-tree teardown and process accounting) but observe process-group-only behavior instead — for example a descendant that left the process group via setsid/double-fork surviving an ordinary teardown, or a just-exited child still listed briefly in a post-kill member snapshot.

Diagnose. The run_started event's mechanism field (also echoed live by inspect --json's snapshot) reports which containment mechanism this specific run actually got — cgroup_v2 or process_group — never a promise based on the platform alone; that field alone tells you whether the fallback happened. Do not use abrupt_cleanup (also on run_started) to tell the two apart: it is a separate, OS-derived contract — whole_tree on Windows, direct_child_only on Linux, none on macOS/other Unix — sourced from the platform's parent-death-signal capability, not from which mechanism this run got. On Linux it reads direct_child_only whether the run got cgroup_v2 or fell back to process_group, so comparing it against mechanism tells you nothing about the fallback. See docs/schema.md and docs/control-plane.md.

Why it happens. Where cgroup v2 delegation is unavailable to the runner, it falls back to the POSIX process-group mechanism rather than claiming a cgroup it did not get — the same unavailability this document's first entry covers for resource limits, but here it is a silent, successful fallback instead of a hard failure, because plain containment (unlike a requested cap) has a working fallback. What the fallback actually costs is ordinary teardown/accounting strength, not extra abrupt-death coverage: if the runner itself dies abruptly, a cgroup does not automatically kill grandchildren either — only the direct child is covered, by the parent-death signal, under either mechanism. See README.md, "Platform matrix", for the per-mechanism guarantees.

A console window pops up for a detached run

Symptom. run --detach launches a console-based child on Windows and a new, unwanted console window appears (or flashes) even though nothing about the invocation looks interactive.

Diagnose. No JSONL event is involved — this is purely an OS behavior: Windows gives a console-allocating child a fresh console of its own whenever its parent has none. The detached runner itself has no console (it was launched with DETACHED_PROCESS), so any console-based child it starts gets one unless told not to.

Fix. Pass --create-no-window alongside --detach — it maps directly onto ProcessKit's Command::create_no_window() (the CREATE_NO_WINDOW creation flag; a no-op on non-Windows platforms). It defaults to off for an ordinary foreground run (so a bare run still behaves like a direct launch), but a detached run is exactly the case where passing it matters most. See README.md, "Windows console", and README.md, "Detached runs".

list shows an entry as unprobed

Symptom. list/list --json shows a registry entry's health as unprobed rather than live or stale, and you are not sure whether it is safe to delete by hand; or prune --json's tally keeps reporting a non-zero unprobed count across repeated runs instead of reaping those entries.

Diagnose. list's health field has three values, matching the same tri-state verdict prune/wait already use internally: "live", "stale" (confirmed dead — the liveness lock probed as released), and "unprobed" (the liveness lock genuinely could not be probed at all: the lock file would not open — a directory in its place, a permission error, a rejected reparse point — or the lock call itself errored). "unprobed" is a deliberately distinct, conservative verdict — "could not confirm liveness" is not the same claim as "confirmed dead" — and prune (and its non-destructive prune --dry-run preview) never reap an entry in this state, on every repeated run, until the probe itself can succeed. A control client (inspect/cancel/kill) aimed at such an entry refuses with CONTROL (103), since it acts only on a confirmed-live entry — but its message, too, reports that liveness could not be probed rather than that the runner is gone (see the CONTROL (103) entry below).

A non-zero unprobed count in prune --json/prune --dry-run --json is not always the same set of things list shows you as unprobed, though: the tally is shared between this per-entry probe (one .json/.lock pair, the same one list reports on) and a second, independent pass over orphaned .lock files — a .lock with no .json sibling at all, invisible to list, which only ever walks .json records. So the count can include lock files list has no entry for at all, on top of any unprobed entries list already showed you. See docs/registry.md ("Discovery" for what list reports) and docs/registry.md for exactly which of the three probe outcomes prune reaps.

Fix. Run prune --dry-run --json first to see precisely what a real prune would reap (and what it would leave as unprobed) before running the destructive form. For an unprobed entry list already shows you, or for any excess the dry-run's tally reports beyond that, investigate the registry directory and its .lock files directly (the usual cause is a permissions issue or a path collision) rather than deleting registry files by hand.

CONTROL (103): the runner could not be reached

Symptom. inspect / cancel / kill exits 103 and prints an explanatory line on stderr. For the by-run_id form this means the command did nothing to any run; cancel --all / kill --all are the exception — see "cancel --all / kill --all and a partial 103" below before assuming nothing happened.

Diagnose. stderr names which of three reasons applied — a stale registry entry (the runner died abruptly, so the entry's record is left behind but its liveness lock has been released, detected before connecting), an unprobeable registry entry (the liveness lock could not be probed at all, so the runner is not confirmed gone — the message says liveness could not be probed and calls the entry unprobed, never "the runner is gone"), or died mid-conversation (the entry read live, but the runner exited between the liveness probe and the reply, or the connection closed before a complete response arrived). All three are bounded — no client hangs waiting for a runner that is not going to answer. list is the fastest cross-check for the first two without retrying the failing command, and it reports the same verdict the refusal did: a stale entry shows as stale, an unprobeable one as unprobed (see "list shows an entry as unprobed" above for what to do with that one — in short, do not hand-delete it). See docs/control-plane.md, "When the runner cannot be reached: a distinguishable result, never a hang", and the CONTROL (103) row of the reserved-band table in docs/exit-codes.md.

inspect has a fourth reason of its own, and it is not a lost runner. If the message says the runner answered with a control-plane snapshot version outside the range this client reads, the runner is reachable and healthy: the exchange completed, and it is the answer that was rejected, because it declares a snapshot contract this binary does not read — in practice a runner newer than the client you are running. cancel/kill cannot hit this, and neither can list/wait/prune, so the run itself is still fully controllable. Retrying will not help: inspect that run with a build that speaks its version — for a newer runner, one at least as new as the binary that started the run. The message quotes the version that arrived and the range this build reads; processkit-cli probe --json reports the version of whichever binary you run, which is how you tell two installed builds apart (no preflight can report a runner's snapshot version — that number only arrives in its reply). See docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read".

wait does not share this code. The registry-only wait --run-id <id> never connects to a run's control transport, so "died mid-conversation" is not something it can hit, and a stale registry entry does not give it 103 either — only the ambiguous-run_id reason below does. A stale or missing entry makes wait exit 0, the same as a run that finished cleanly (the registry keeps no history, so "build-42 was never registered" and "build-42 finished a moment before you asked" read identically): do not take a 0 from wait as proof a stale-looking run_id was ever live. See docs/registry.md.

Not a run outcome. A 103 says nothing about how the target run itself ended (or whether it is still running) — for the by-run_id form it is purely "this client could not resolve or reach a single target run". Do not conflate it with the run-outcome codes (106109, or the child's own code), which come only from the run's own process exit. (cancel --all / kill --all's 103 is different — see below.)

cancel --all / kill --all and a partial 103

Symptom. cancel --all or kill --all exits 103, but the JSON report it printed to stdout shows at least one target with "accepted": true — so, unlike the by-run_id form's 103 above, this run was acted on.

Diagnose. --all's 103 is a different fact from the by-run_id form's: it means "one or more targets in the confirmed-live snapshot could not be reached or did not acknowledge the command", not "nothing was found or reached". A snapshot with several live runs commonly has a mix — most targets ack cleanly while one becomes unprobeable, changes identity, or does not respond in time — and the aggregate exit code reflects that any failure occurred so an automated caller never mistakes a partial teardown for a complete one. Read the JSON report on stdout (not just the stderr tally) to see exactly which records failed and why; stderr on its own only gives the failure count. A duplicate run_id is not itself an aggregate failure because --all addresses each record path and endpoint independently. Likewise status: "already_gone" is non-error: the target ended after the snapshot, so accepted is false but teardown is already complete for it. See docs/control-plane.md, "cancel --all / kill --all", and the CONTROL (103) row of the reserved-band table in docs/exit-codes.md.

Fix. Re-running cancel --all / kill --all is safe: a target already ended is absent from the next snapshot (or already_gone if it ended during this one), and a target that failed for a transient reason (e.g. it was mid-exit) is retried. If a specific run_id keeps failing, use list --json or inspect --run-id <id> to see why (a stale entry, an unprobed one, or a genuine ambiguity — the same reasons the by-run_id sections above and below cover).

An ambiguous run_id

Symptom. inspect / cancel / kill / wait --run-id <id> exits CONTROL (103) with an "ambiguous run id" message, even though you believe exactly one run with that id is alive.

Diagnose. The registry does not enforce run_id uniqueness at register time: two runs started concurrently with the same explicit --run-id are both written as independent, live entries. Every by-run-id client — including the read-only inspect and the registry-only wait — fails closed with CONTROL (103) the moment more than one live entry matches, rather than silently acting on whichever entry a directory scan happens to return first; run list --json and filter by run_id to see the duplicates directly. See docs/registry.md, "Run id resolution — ambiguity is a hard failure".

Fix. Keep run_ids unique among your own concurrently-live runs (a counter, a UUID, or any value your launcher does not reuse before the matching run has ended); there is no way to disambiguate after the fact other than avoiding the collision at launch time.

The child's terminal behavior degrades under the default pipe + echo

Symptom. Colors, progress bars, spinners, or other cursor-based rendering from the child look wrong, missing, or replaced with plain line-by-line output — even though the same command renders correctly when run directly in a terminal.

Diagnose. By default run gives the child pipe + echo, not a real inherited terminal: ProcessKit reads the child's stdout/stderr through pipes and this runner re-emits the bytes onto its own stdout/stderr. The child therefore sees no TTY on either stream, so any code path in it that checks isatty() (or equivalent) before drawing takes its non-interactive branch — this is the child's own, otherwise-correct terminal detection working as designed, not a bug in the runner's pump.

Fix. Pass --inherit-stdio for an interactive command: it hands the child the runner's own stdin, stdout, and stderr handles directly — no pump, no echo, no --capture-dir tee in this mode — so an existing terminal is preserved unmediated instead of proxied. It is mutually exclusive with --capture-dir, --create-no-window, --inherit-stdin, --stdin-file, --no-echo, --idle-timeout, and --detach (a detached run has no terminal to hand over in the first place); Ctrl-C behavior also becomes platform-dependent under this flag rather than the runner's own uniform cancelled/107 outcome. See README.md, "Standard I/O", for the full contract, including exactly how Ctrl-C is delivered in this mode on each platform.

See also

Integration guide for adapters

This is the walkthrough for a consumer of processkit-cli — an orchestrator or adapter (in particular the processkit-py CLI) that launches runs through this binary and reads its results back — rather than for a contributor to this repository (see docs/architecture.md for that audience). It ties together, in the order an adapter actually exercises them, the five normative documents that each cover one part of the compatibility surface on their own: docs/schema.md, docs/exit-codes.md, docs/control-plane.md, and docs/registry.md. This document does not restate their normative text — every concrete claim below is a pointer to, and a minimal worked example of, the contract those documents define; on any disagreement, the linked document is the source of truth.

1. Fail-closed preflight: probe

Before launching anything through a candidate processkit-cli binary, verify it is compatible. probe is side-effect-free — it spawns no child and touches no registry or container — and prints one JSON report line to stdout:

processkit-cli probe --json \
  --require-schema-version 1 \
  --require-exit-code-band 100-119 \
  --require-surface run:--jsonl \
  --require-surface run:--capture-dir \
  --require-surface inspect:--json \
  --require-surface cancel:--run-id \
  --require-surface kill:--run-id

The report (one line, shown reformatted here):

{
  "probe_version": 1,
  "binary": "processkit-cli",
  "version": "0.2.2",
  "schema_version": 1,
  "exit_code_band": { "start": 100, "end": 119 },
  "surface": ["cancel", "cancel:--run-id", "inspect", "..."],
  "compatible": true,
  "mismatches": []
}
  • Pin schema_version (--require-schema-version <N>) and the reserved exit-code band (--require-exit-code-band <start>-<end>) so a future breaking change is caught here, before a run, rather than by a JSONL parser or an exit-code table drifting silently out of sync.
  • Pin the exact CLI flags the adapter is about to use with one --require-surface <token> per token (a bare subcommand name, or <subcommand>:--<long-flag>) — this is how an adapter confirms a flag it depends on (for example run:--capture-dir) actually exists on this build before passing it.
  • An unmet expectation makes probe exit PROBE_INCOMPATIBLE (110) with compatible: false and the concrete mismatches; a malformed --require-* argument (not an incompatibility, a bad flag) is the ordinary USAGE (100). A satisfied — or unrequested — surface exits 0.

This is a fail-closed contract: an adapter that skips the preflight (or silently proceeds after a PROBE_INCOMPATIBLE) re-introduces exactly the uncontained-launch hazard this project exists to prevent. See src/probe.rs and the normative exit-code table in docs/exit-codes.md.

The report's shape is published as a JSON Schema with a golden fixture — fixtures/schema/cli/probe.schema.json and probe.jsonl — so an adapter can validate what it parsed instead of re-deriving the shape by hand. Every machine-readable output in this guide has such a pair; see fixtures/schema/cli/README.md for the full table, and docs/compatibility.md, "Machine-output schemas", for why these outputs carry no version field of their own (the probe report's probe_version and the inspect snapshot's snapshot_version are the two that do).

probe --print-schema is a separate, simpler mode on the same subcommand: it prints this binary's embedded JSONL event-schema document instead of the report above and exits 0, so an adapter that only needs the schema for its own version — no clone, no tag to match — can fetch it offline without a compatibility check. It cannot be combined with any --require-* flag: that combination is rejected as an ordinary USAGE (100) parse error, never a silent skip of the requested checks, so it can never produce a false "ok" on an invocation that also asked probe to verify expectations. See docs/schema.md, "Getting the schema without a git checkout".

2. Launching a run

The recommended invocation for an adapter:

processkit-cli run \
  --run-id build-42 \
  --jsonl .processkit/build-42.jsonl \
  --capture-dir .processkit/build-42/capture \
  --env-clear \
  --env PATH="$PATH" \
  --env-remove CI_SECRET_TOKEN \
  --timeout 10m \
  --grace 5s \
  -- dotnet build
  • --jsonl <file> is the only place lifecycle events are written — never stdout, so the child's own stdout/stderr stay pristine. Give every run a distinct path; the file is created or truncated at the start of the run.
  • --run-id <id> is the identifier inspect/cancel/kill later match on — supply one you control (rather than the generated default) so the supervision step (§4) has a stable handle. Two live runs sharing one --run-id is legal but makes every supervision command against it fail closed as ambiguous (§4, §6) — keep run ids unique across an adapter's own concurrently-live runs.
  • --capture-dir <dir> additionally tees stdout/stderr to <dir>/stdout.log / <dir>/stderr.log with a byte count, a SHA-256, and explicit truncation/write-error flags per stream (the output_captured event, §3) — use this when the adapter needs the transcript as a file rather than (or in addition to) the live echo.
  • --no-echo suppresses the runner's own live retransmission of the child's stdout/stderr — the exact "pure noise" an adapter reading results from --jsonl/--capture-dir alone does not want interleaved with its own output. The pipe, --capture-dir, and the JSONL stream are all unaffected; it conflicts with --inherit-stdio, which runs no pump to suppress in the first place.
  • --detach returns as soon as the run has provably started instead of blocking for its whole duration — the "launch and let go" shape, for an adapter that supervises out of band (§4) rather than by staying the runner's parent. It re-spawns the CLI detached (a new session on Unix, a DETACHED_PROCESS on Windows) and waits only until that copy has registered the run and written run_started to --jsonl, so on return the run is already visible to list/inspect/wait. An adapter that captures the launch command's output (subprocess.run(..., capture_output=True)) gets end-of-file when the call returns, not when the run ends: the detached runner keeps none of the caller's pipes open. The exit code changes meaning under this flag and only under it: it reports the start0 once the run started, or the same reserved code the failure would have produced in the foreground (a missing program is still SPAWN 101) — never the child's own code, which stays in the terminal runner_exit event (§3). Adapters that need the child's result must read it there, or via wait plus the event stream. It conflicts with --inherit-stdio/--inherit-stdin (nothing interactive survives detaching) and implies --no-echo's discarding sinks, while --jsonl, --capture-dir, --idle-timeout, and --snapshot-interval behave exactly as they do in the foreground. The detached runner's own stderr is null, so --jsonl is the only channel that reports anything — including a failed member read, which is why that failure is a flagged event rather than a warning (§3). On Windows, pair it with --create-no-window for a console child: the detached runner has no console to lend it, so the OS gives the child one of its own. See docs/exit-codes.md, "Detached runs".
  • --env-clear / --env-remove <KEY> / --env <KEY=VALUE> give the adapter control over the child's environment, applied in that fixed order — clear, then remove, then set — regardless of flag order on the command line, so an explicit --env always wins on a duplicated key. See README.md, "Environment", for the full precedence rule.
  • --max-memory <size> / --max-processes <n> / --cpu-quota <cores> cap the run's whole process tree. Enforcement needs a real container (Windows Job Object or Linux cgroup v2 at the real hierarchy root); where the platform or environment can't apply a cap the run fails fast with a limit_hit event (§3) and BACKEND (102) rather than running silently unbounded — so an adapter that depends on a cap must treat a limit_hit as a hard failure, not a warning. See README.md, "Resource limits", for the platform matrix (macOS/BSD and the Linux process-group fallback are unsupported; cgroup v2 is often unenforceable under systemd/containers/typical CI) and the Linux --max-processes caveat.
  • Command-line redaction. run_started's command field is redacted by default: the raw argv is not recorded, only a one-way SHA-256 fingerprint (argv_sha256) and a classified worker-shape hint (both derived from argv but unable to reveal it) — filled on every run whether or not --argv-raw is given. Pass --argv-raw only when the adapter's own storage for the resulting JSONL is at least as trusted as the command line itself; do not default to it. See docs/schema.md ("Command redaction") for the exact fingerprint encoding, which an adapter reproducing the digest independently must match byte for byte.

run is not shell-free by accident — everything after -- is the literal <program> <args...>, with no shell to expand or reinterpret it; an adapter that needs shell features passes the shell as the program explicitly.

3. Reading the JSONL stream

--jsonl accumulates one JSON object per line as the run proceeds; parse it as newline-delimited JSON, dispatching on each object's event field. A minimal reader:

import json

with open(jsonl_path, encoding="utf-8") as f:
    for line in f:
        evt = json.loads(line)
        if evt["schema_version"] != 1:
            raise IncompatibleSchema(evt["schema_version"])
        handle(evt["event"], evt)

Pin schema_version here too (or rely on the probe preflight in §1 to have already ruled out a mismatch) — never assume a fixed shape without checking it.

Or let the binary read it back for you. events is the first-party reader of the same stream, so an adapter that only needs to show a run's story — or to check one — does not have to write the loop above at all:

processkit-cli events --run-id build-42               # rendered for a human
processkit-cli events --run-id build-42 --follow      # ... as it happens
processkit-cli events --file "$jsonl" --json          # the runner's own bytes
processkit-cli events --file "$jsonl" --validate      # conformance check

It resolves the stream through the registry (--run-id, the same jsonl locator list --json publishes in §5) or reads a path directly (--file, for a stream whose registry record is already gone — a clean exit deletes its own record — or one this registry never knew about). Exactly one of the two is required and they are mutually exclusive; there is no precedence rule, so passing both is a USAGE (100) error. Like list/wait it is read-only: registry opened read-only, no control-plane round trip, nothing mutated.

Three properties matter for an adapter:

  • --json is a pass-through, not a re-serialization. Each line is emitted byte for byte as the runner wrote it, so a field a newer runner added survives the trip — the pipeline processkit-cli events --file … --json | your-parser is exactly as lossless as reading the file yourself. A line that is not JSON is reported on stderr instead of emitted, so stdout stays parseable JSONL.
  • --follow is bounded by the run, never by an invented deadline. It returns at the terminal runner_exit, or once the registry reports the run over and the stream has stopped growing — the abrupt-death case, explained on stderr rather than passed off as a complete stream. It hands out only complete lines, so a half-written event is never parsed as an event.
  • --validate is a conformance gate. It checks every line against the schema document this binary embeds — the same one probe --print-schema prints in §1 — reports each violation by line number and by what it violated, and exits EVENTS_INVALID (114) if any line fails, 0 if none does. An unreadable stream is still SETUP (111) and a --run-id naming no single stream is still CONTROL (103), so a fixture-checking CI job can tell "invalid" from "could not be checked". This is the recommended way for an adapter to keep its own recorded fixtures honest against the runner version it targets.

Ordering (normative: docs/schema.md). A normal run emits, in order:

  1. run_started — the child was spawned; carries run_id, root_pid, containment mechanism, the abrupt_cleanup tri-state, and the redacted command.
  2. members_snapshot (reason: "spawn") — the container's members at that point. Exactly one by default; a run started with --snapshot-interval <duration> emits additional members_snapshot events (reason: "interval") on that cadence, all of them after this one and all of them before step 3 — never inside the teardown pair. Route by event type and treat the count as open-ended: within a schema version an adapter must not assume an event type it knows occurs only once (the full list of what a reader must tolerate within a version is in docs/compatibility.md). Every one of these events carries read_error; when it is true the read failed and members is an empty fallback, not a confirmed-empty tree — check the flag before drawing a conclusion about the tree from an empty array.
  3. Either the natural-exit path (root_exited, cleanup_started, cleanup_finished) or a runner-imposed ending's reason event (timeout, cancelled, or killed) followed by the same cleanup_started / cleanup_finished pair.
  4. output_captured, only when --capture-dir was set.
  5. runner_exit — always the last line, the terminal event of every run, including a runner failure before the child ever started (in which case spawn_failed or container_failed precedes it instead, with no run_started).

Telling outcomes apart. Two signals distinguish how a run ended, and an adapter should use both together: the process's own exit code (fastest to check, no parsing needed) and the terminal runner_exit event's source and code fields (authoritative — see docs/exit-codes.md, "Why a band is not enough on its own"):

runner_exit.sourceExit codeMeaning
child_exitthe child's own code (child_code, echoed in code too)The child ran to completion on its own.
timeout106A runner deadline elapsed and the runner tore the tree down — the whole-run --timeout or the --idle-timeout (child silent past the idle window). The preceding timeout event's reason (overall / idle) says which; both reuse this one source and code.
cancelled107A local stop signal cancelled the run: a Ctrl-C, on Unix a SIGTERM/SIGHUP (an external kill/systemctl stop/cancelled CI job, or a hung-up terminal), or on Windows a Ctrl-Break/console close/logoff/system shutdown. The preceding cancelled event's source (ctrl_c / sigterm / sighup / ctrl_break / ctrl_close / ctrl_logoff / ctrl_shutdown) says which; all reuse this one source and code.
control_cancel108A control-plane cancel (§4) cancelled the run.
control_kill109A control-plane kill (§4) force-killed the run.
spawn_error101The child never started (spawn_failed precedes it).
container_error102The container could not be created or joined (container_failed precedes it) — including a requested resource limit (--max-memory/--max-processes/--cpu-quota) the platform could not apply, in which case a limit_hit naming the limit precedes the container_failed (see docs/schema.md).
internal104A genuine runner bug — the runner's own logic hit a state it rules out.
setup111An ordinary fail-closed setup failure (an unwritable --jsonl/--capture-dir, an unreadable --stdin-file) — distinct from internal, and the caller can usually act on it (bad path, permissions, resources).

Only source: "child_exit" carries a non-null child_code; every other source means the child's own exit code was never produced or is not what code reports, and child_code is null. See the full field reference in docs/schema.md and the exit-code contract in docs/exit-codes.md.

4. Supervising a live run: inspect / cancel / kill / wait

Once a run has started (its run_id is known — supplied at launch, per §2), an adapter can query, steer, and wait for it while it is still live. Every command resolves the target purely by run_id through the per-user registry — never by PID. This is also the whole supervision story for a run launched with --detach (§2): a detached run is an ordinary run in the registry, and these four commands are how an adapter that is no longer its parent steers it — alongside events (§3), which reads that run's stream back without contacting it at all:

processkit-cli inspect --run-id build-42 --json
processkit-cli cancel  --run-id build-42
processkit-cli kill    --run-id build-42
processkit-cli wait    --run-id build-42 --timeout 10m

The first three reach the live runner over the local control plane described normatively in docs/control-plane.md; wait does not contact the runner at all and is described in docs/registry.md, "Waiting — wait".

  • inspect is read-only: it prints a snapshot (mechanism, root_pid, started_at, the current members) to stdout and changes nothing — as JSON with --json (shown above), or a human-readable rendering by default.
  • cancel ends the run through the same soft-stop → grace → hard-kill teardown a --timeout or a local Ctrl-C drives, exiting the run with CONTROL_CANCELLED (108).
  • kill hard-kills the whole tree immediately — no soft stop, no grace — exiting the run with CONTROL_KILLED (109).
  • wait blocks until the run is no longer live and exits 0. It is the answer for an adapter that is not the runner's parent — one that restarted, or that supervises runs another process launched — and so has no child process to wait on. It prints nothing (the exit code is the answer), never touches the run, and needs no control endpoint, so it also works for a run whose transport never came up. Adding --report-outcome (single-run only) makes it print one JSON object naming how the run ended — status reported with the terminal event's code/source/child_code, or status unknown with all three null when the outcome could not be established — without changing any of the exit codes below. See docs/registry.md, "Waiting — wait".

Each of these outputs has a published JSON Schema and golden fixture under fixtures/schema/cli/: inspect.schema.json (the single snapshot and the --all array), control-ack.schema.json (the cancel/kill ack and the --all report array), and wait.schema.json (--report-outcome).

Both mutating verbs' outcomes are also written to the target run's own --jsonl stream (a cancelled/killed event with source control_cancel/control_kill, and the matching terminal runner_exit), so an adapter watching that stream sees the command take effect even without reading the cancel/kill client's own ack.

Tearing down everything at once: --all (T-217). cancel --all / kill --all (mutually exclusive with --run-id, one of the two required) are the aggregate counterpart to the by-run_id form above: instead of one named run they act on every run confirmed live in a snapshot taken when the invocation starts, applying the identical per-run mutation to each, and print a single JSON array on stdout — one {"run_id":...,"accepted":...} entry per snapshot target — instead of one ack. An adapter driving a full environment teardown (e.g. before shutting down its own process) typically issues cancel --all in place of a loop over individually-known run_ids, then wait --all / list --json / prune to confirm the fleet is actually gone. See docs/control-plane.md, "cancel --all / kill --all", for the exit-code and report contract — it differs from the by-run_id form's 103 in the note right below.

Waiting for a run an adapter did not launch. The typical shape — cancel a run, then confirm it is really gone before releasing the resources it held:

processkit-cli cancel --run-id build-42          # 0: the runner acked
processkit-cli wait   --run-id build-42 --timeout 30s
case $? in
  0)   ;;                    # the run is over; its own exit/JSONL say how it ended
  112) ;;                    # still live at the deadline — the run was NOT touched
  103) ;;                    # ambiguous run id: more than one live run uses it (§6)
esac
  • 0 means "not running". It is also what an unknown run_id returns, on purpose: a clean exit deletes its own registry entry, so "never registered" and "already finished and cleaned up" are the same observation, and failing on the second would turn the ordinary "it finished while I was starting up" race into an error. The flip side an adapter must respect: a typo'd run_id also returns 0, so never read wait's 0 as proof the run existed — establish that from the launch itself or from list (§5).
  • WAIT_TIMEOUT (112) is the waiter's deadline, not the run's: the run was left running and untouched, and is still going. Do not confuse it with the run's own TIMEOUT (106) in §3's table, which means the runner tore the tree down. Retrying the same wait is a reasonable response to a 112.
  • CONTROL (103) here means only one thing — an ambiguous run_id (§6); wait has no runner to fail to reach.
  • Without --timeout, wait blocks indefinitely. Prefer an explicit deadline in an adapter, so a supervisor never inherits an unbounded wait.

CONTROL (103) is the one exit code all four of these clients' by-run_id form can return, and for all four the usual reason is the same: the command could not be resolved to the single target run. inspect has one further reason of its own, where the target was resolved and reached and did answer — its reply declared a control-plane snapshot version this client does not read, so the answer was refused rather than rendered (see docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read"). See §6 for the concrete situations that produce a 103, that one included. cancel --all / kill --all reuse the same code for a different reason — one or more snapshot targets failed, not "no single target run" — see the --all paragraph above and docs/control-plane.md.

5. Housekeeping: list / prune

list and prune scan the registry directly rather than reaching a specific live run, and are the tools for an adapter that manages many runs or wants to clean up after abrupt failures — see the normative "Discovery" and "Reaping" sections of docs/registry.md.

processkit-cli list  --json   # every registered run, whatever its health
processkit-cli prune --json   # reap only the confirmed-stale entries
  • list --json prints one JSON object per registry entry (run_id, health, started_at, hint, argv_sha256, endpoint), sorted deterministically. argv_sha256 and hint are the same redaction-safe command identification the run_started event carries (§3) — the full 64-character digest here, so an adapter can join a registry entry to the events of the run that wrote it, or group several live entries by "same command" without ever handling a command line. Both are null on a record written before those fields existed, and hint is null for the common case of a command matching no known worker shape. Health is live, stale (confirmed dead — no live holder found), or unprobed (the liveness lock could not even be opened, e.g. permission denied — a distinct, additive value: liveness is unknown, never printed as the confirmed-dead stale). All three are listed, never hidden — a stale entry (a leftover from a runner that died abruptly) is exactly what an operator or adapter wants visible here, and an unprobed one is exactly the case where guessing would mislead.
  • prune --json deletes only entries it can confirm are stale, printing a tally: {"pruned":N,"live":N,"unprobed":N,"orphaned_locks":N}. A live run is never touched, and an entry whose liveness could not even be probed is left in place rather than guessed at — see "The reaping safety invariant" in docs/registry.md. On unix each reaped entry also takes with it the private control-socket directory that record published, so an abruptly-killed run leaves no pkc-… litter in the temp directory either; the tally fields are unchanged (that socket is counted by its own entry's pruned). Worth scheduling if your adapter starts many runs — see "Reaping the control socket" in docs/registry.md.

Both are read-only with respect to any live run's control transport; neither carries the "could not reach the target run" failure modes of §4.

Their machine-readable shapes are published too: fixtures/schema/cli/list.schema.json (one entry object per line) and fixtures/schema/cli/prune.schema.json (the plain tally and, as #/$defs/dryRunReport, the --dry-run form with its candidates list), each with a golden *.jsonl fixture beside it.

6. Typical errors

  • Stale registry entry. The runner behind a run_id died abruptly (crash, SIGKILL, a parent's Job Object terminate); its record is left behind but its liveness lock is released. inspect/cancel/kill detect this before connecting and report it as a CONTROL (103) failure with an explanatory message on stderr — never a hang, and never silently treated as live. list still shows the entry (marked stale); prune is what removes it. An ordinary Unix SIGTERM/SIGHUP, or a Windows Ctrl-Break/console close/logoff/system shutdown, is not in this class: the runner catches those signals/events and runs the full cancel teardown (a cancelled event, the cleanup pair, runner_exit cancelled/107, and removal of the registry entry), so stopping a run with kill <pid> (Unix) or a closed console (Windows) leaves neither a stale entry nor a surviving descendant.
  • Unprobeable registry entry. The entry's liveness lock could not be probed at all (permission denied, a rejected symlink/reparse point, a non-regular file in its place), so nothing about the run is confirmed either way. This is the same CONTROL (103) refusal — inspect/cancel/kill act only on a confirmed-live entry — but it is reported honestly as unprobed, not as a gone runner; list shows the same entry as unprobed and prune leaves it in place. Investigate the registry directory rather than deleting the record by hand (see docs/troubleshooting.md).
  • Died mid-conversation. The registry entry read as live, but the runner exited between the liveness check and the reply reaching the client — the connect fails, or the connection closes before a complete response. Also a bounded CONTROL (103) failure, never a wedge: every wait in the control plane (connecting, and the request/response exchange) is deadline-bounded.
  • Ambiguous run_id. The registry does not enforce run_id uniqueness; if more than one live entry matches, every by-run-id command — the read-only inspect and wait included — fails closed with CONTROL (103) rather than guessing which entry the scan happened to return first. Keep run_ids unique among an adapter's own concurrently-live runs (§2) to avoid this entirely.
  • An unreadable snapshot version (inspect only). The runner was reached and answered, but its reply declared a control-plane snapshot_version outside the range this client reads — newer than the version it implements, or older than the version it still decodes — so inspect refuses the answer instead of rendering it under semantics its sender never promised. Also a CONTROL (103), with a message naming the version that arrived and the range this build reads. Unlike the four above it says nothing about the run's liveness: the target is registered, live, reachable, and healthy, and cancel/kill/wait/list against it are unaffected (an ack carries no version). Do not treat it as a lost runner or retry it; inspect that run with a build that speaks its version — for a newer runner, one at least as new as the binary that started the run. See docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read", and docs/compatibility.md, "Machine-output schemas".
  • CONTROL-class exit codes are not run outcomes. A 103 from the by-run_id form of inspect/cancel/kill/wait describes a failure on the client's side of the exchange — it could not resolve or reach a single target, or (the inspect-only case above) could not read the answer it got — and says nothing about how the target run itself ended (or is still running). Do not conflate it with the run-outcome codes in §3's table (106109, or the child's own code); those come only from the run's own process exit and its runner_exit event. The same separation applies to WAIT_TIMEOUT (112): it is the waiting client giving up, never the run being stopped (§4). cancel --all / kill --all's own 103 is the one exception where the code can coincide with some targets having genuinely been acted on — see the --all paragraph in §4.
  • A --detach exit code is not a run outcome either. run --detach's 0 means "the run started", not "the child succeeded", and its non-zero codes mean "the run never started" — carrying the same reserved code the failure would have produced in the foreground. An adapter that branches on a detached launch's exit code as if it were the child's result will read every long-running failure as a success; the child's outcome is in the terminal runner_exit event (§3), reached after wait (§4). See docs/exit-codes.md, "Detached runs".
  • SETUP (111) vs. INTERNAL (104). A run that could not write its --jsonl/--capture-dir, or open a --stdin-file, fails closed with SETUP (111) — an ordinary, usually-actionable environment problem (bad path, permissions), not a runner bug. INTERNAL (104) is reserved for a genuine invariant violation in the runner's own logic. See "Setup failures vs internal faults" in docs/exit-codes.md.

See also

  • docs/agent-workflows.md — a policy and execution strategy for automation agents that launch external tools through the runner.
  • docs/schema.md — the normative JSONL event schema (every field, every event, versioning rules).
  • fixtures/schema/cli/README.md — the JSON Schema documents and golden fixtures for every machine-readable output in this guide (probe, list, inspect, the cancel/kill acks, prune, wait --report-outcome), and the versioning decision behind them (probe and inspect carry their own version field; the other four deliberately carry none).
  • docs/compatibility.md — the compatibility surfaces, the pinning procedure, and the upgrade/downgrade checklists.
  • docs/exit-codes.md — the normative reserved exit-code band and the child-fidelity rule.
  • docs/control-plane.md — the normative local transport, wire protocol, and inspect/cancel/kill behavior.
  • docs/registry.md — the normative registry location, record format, and staleness/reaping rules.
  • docs/architecture.md — the map of this repository's own modules, for a contributor rather than a consumer.
  • docs/troubleshooting.md — symptom-to-cause diagnosis for an operator, organized by what you observe rather than by call sequence.

Compatibility and upgrades

ProcessKit CLI has three public compatibility surfaces:

  1. command names and flags;
  2. the reserved runner exit-code band;
  3. JSONL schema_version.

The human CLI surface is guarded by through-binary golden snapshots for the root help and every public subcommand in fixtures/cli-help/. An intentional flag, value-name, default, or help change must be regenerated with UPDATE_CLI_HELP_GOLDEN=1 cargo test --test cli_help and the fixture diff reviewed. The test normalizes only the Windows .exe suffix and line endings; all contract text and ordering remain exact.

Breaking any of them requires a major release. An adapter should verify the exact pieces it uses before launching a payload rather than discovering an incompatible binary after work has started.

Fail-closed preflight

processkit-cli probe --json \
  --require-schema-version 1 \
  --require-exit-code-band 100-119 \
  --require-surface run \
  --require-surface run:--jsonl \
  --require-surface run:--capture-dir \
  --require-surface inspect:--json \
  --require-surface cancel

probe is side-effect-free. It launches no child, creates no container, and does not touch the registry. A missing requirement exits PROBE_INCOMPATIBLE (110) and reports all mismatches.

Surface tokens

A surface token is either a subcommand or subcommand:--long-flag:

run
run:--jsonl
run:--idle-timeout
inspect
inspect:--json
cancel:--all

Require only the features the adapter will actually use. This permits additive CLI releases while preventing an invocation from reaching a binary that lacks a needed flag.

Schema pinning

Every event carries schema_version. An adapter should reject an unknown value before interpreting event-specific fields. The current schema is documented in JSONL event schema and available from the installed binary:

processkit-cli probe --json --print-schema > schema.json

--print-schema is mutually exclusive with --require-*: printing the document and checking compatibility are separate operations, so neither can be silently skipped.

What a reader must tolerate within one version

Within one schema version, a reader must tolerate every one of the following. Removing a field, renaming it, or changing the meaning or type of an existing one is what requires a new schema version — nothing below does.

  1. New event types. Route by the event discriminator and ignore a type you do not know, rather than failing on it or assuming the stream is corrupt.
  2. More occurrences of an event type you already know, including a type that previously occurred at most once. Within a version you may not assume any event type is unique, or that its position in the stream is fixed relative to other types beyond what the ordering contract states. members_snapshot is the worked example: it appeared exactly once per run until run --snapshot-interval made a run emit it on a cadence.
  3. New fields on an event you already parse — including always-present ones, not only optional ones. "Additive" here means "no existing field changes", not "the new field may be absent": members_snapshot gained an always-present reason and an always-present read_error, timeout gained an always-present reason, and cleanup_started/cleanup_finished gained always-present read_error flags, all within version 1. A reader must therefore consume the fields it uses rather than pin an event's exact field set — validating with additionalProperties: false against a copy of a published document will fail on the next additive release (see "Machine-output schemas" below).
  4. New values in an open-ended descriptive string field — a new cancelled source, a new runner_exit source, a new hint label. Treat an unrecognized value as "some other trigger" and keep routing by event type.
  5. Unknown fields anywhere in the envelope or event body.

The normative source is docs/schema.md, not this page: its "Ordering" section (and specifically "Multiplicity of members_snapshot") states how many of each event a stream may carry and where, and its "Versioning" section defines exactly which changes are additive and which are breaking. This section restates those obligations for an adapter author; where the two ever disagree, docs/schema.md wins and the disagreement is a bug in this page.

Machine-output schemas

The JSONL stream is not the only machine-readable output. The discovery and control commands print machine-readable JSON to stdout too, and each of those shapes has a published JSON Schema (draft 2020-12, self-contained) plus a golden fixture under fixtures/schema/cli/:

OutputSchema documentGolden fixture
probe --jsonfixtures/schema/cli/probe.schema.jsonprobe.jsonl
list --jsonfixtures/schema/cli/list.schema.jsonlist.jsonl
inspect --json, inspect --all --jsonfixtures/schema/cli/inspect.schema.jsoninspect.jsonl
cancel/kill ack, cancel --all/kill --all reportfixtures/schema/cli/control-ack.schema.jsoncontrol-ack.jsonl
prune --json, prune --dry-run --jsonfixtures/schema/cli/prune.schema.jsonprune.jsonl
wait --report-outcomefixtures/schema/cli/wait.schema.jsonwait.jsonl

events --json is deliberately absent from that table: it passes the runner's own JSONL lines through byte for byte, so the document that describes it is the event schema above (fixtures/schema/v1/schema.json), not a second one of its own. That is also what events --validate checks a stream against.

tests/machine_output.rs validates the real binary's output for each of these against its document on every test run, so an accidental shape change fails CI instead of reaching an adapter. A document whose family has more than one output form has a root oneOf over named $defs, so a consumer can validate against the exact form it invoked — for example inspect.schema.json#/$defs/snapshot.

Two rows of that table carry a version field; the other four deliberately do not. probe --json carries probe_version and inspect --json carries snapshot_version — the same field the runner puts on the control-plane wire. probe.schema.json pins its value with const; inspect.schema.json admits the range of snapshot versions this build renders (see the snapshot_version bullet below), because that field reports the runner's contract, not the invoked binary's. Either way a bump is visible in the payload itself. Pin those two on their own version field, not on the CLI version alone: ignoring a snapshot_version bump across an upgrade is exactly the class of mistake this section exists to prevent. Both are versioned for the reason the project's other two versioned contracts (the durable JSONL stream's schema_version and the registry record's registry_version) are: each can be read by a party that did not invoke the binary — the snapshot crosses a process boundary to a runner that may be a different build, and the probe report's whole job is to be read before the binary's version is known.

The remaining four — list --json, the cancel/kill ack and --all report, prune --json, and wait --report-outcome — carry no version field, deliberately. Each is a synchronous stdout rendering read by the caller that just invoked this exact binary. That includes the printed ack, whose content does arrive over the wire but is re-serialized by the client from the three fields it parsed and verified, so its field set is the client's own (see fixtures/schema/cli/README.md, "Versioning"). Such a caller already knows the version and can pin the shape through the probe preflight above — the reported version, plus one --require-surface token per subcommand and flag it will actually use. A per-output version field would be a second, redundant pinning axis, so none was added.

Consequently:

  • The unversioned four ride on the first compatibility surface (command names and flags), not on a version integer of their own. A breaking change to any of them — removing a field, renaming it, changing its type or the meaning of a value — is a major release, announced in the changelog.
  • probe --json and inspect --json additionally bump their own field. A breaking change to either shape bumps probe_version / snapshot_version respectively, and that field is what a consumer should check. For the snapshot, this binary's own inspect client checks it too, and does so asymmetrically: a runner answering with a snapshot_version newer than this build implements is refused with CONTROL (103) rather than rendered under semantics its sender never promised, while an older one is still read for as long as this build genuinely decodes it (today: version 1, the version every release so far writes). See docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read", for the rule, the floor, and what moves it. Two consequences for an upgrade: a bump is a hard boundary for older clients in a mixed deployment, not merely a signal to read; and the snapshot_version on stdout is the runner's number, so inspect.schema.json admits the range this build renders instead of pinning a single value.
  • Every document under fixtures/schema/cli/ is updated in place. There is no vN/ directory there (unlike fixtures/schema/v1/, whose v1 is the JSONL schema_version), because a version here, where there is one, lives in the payload rather than in a path. If a future release gives one of the unversioned four a version field of its own, that is the point to revisit the layout.
  • Additive changes remain minor/patch, exactly as they are for the JSONL stream, and a reader that consumes the fields it knows is unaffected.

The published documents set additionalProperties: false so that this repository's own tests fail when a field is added without publishing it. An adapter that copies a document into its own pipeline and wants to tolerate a future additive field should relax that keyword on its copy rather than pin a field set the project treats as additive.

Exit-code compatibility

Normal foreground runs forward the child code. Runner-owned outcomes occupy 100119. Because a child may itself return a number inside that band, the terminal runner_exit.child_code / runner reason remains the authoritative machine distinction.

Verify the whole reserved band, not only the individual codes your current version knows. This protects space for additive runner outcomes without making an old adapter classify them as child exits.

Detached launch is the documented exception: launcher 0 means the run started, and the child's eventual code is in terminal JSONL.

Upgrade procedure

  1. Download and verify the new archive without replacing the active binary.
  2. Run the new binary by absolute path with the adapter's full probe requirements.
  3. Compare probe --print-schema with the adapter's pinned schema version.
  4. Exercise one harmless run through the same I/O, capture, and control-plane flags used in production.
  5. Confirm run_started.mechanism in the actual deployment environment.
  6. Atomically replace the installed binary.
  7. Keep the previous binary until terminal events from the smoke run have been consumed successfully.

Reading release notes

The project changelog uses Keep a Changelog categories. Look for:

  • Added: optional flags/events/features an adapter may adopt;
  • Changed: behavior that remains inside the current compatibility contract;
  • Fixed: corrections that may alter previously buggy observations;
  • Removed: major-version migration work.

The manifest version, v<version> tag, crates.io artifact, and GitHub Release are produced by one release workflow and should identify the same release.

Rolling upgrades with live runs

The control plane lives inside each running process. Replacing the executable on disk does not upgrade a runner already in memory. A new client must therefore remain compatible with the live run's registry/control/schema contract until that run finishes.

For a conservative rolling upgrade:

  1. stop launching new work through the old binary;
  2. let or cancel old live runs;
  3. wait for the registry to become empty;
  4. preview and prune stale entries;
  5. replace the binary and launch new work.

Do not use recorded PIDs to bridge versions.

Downgrades

Run the target older binary's probe by absolute path before replacing the newer one. An older binary may lack an additive flag even when the JSONL schema version is unchanged. Surface-token verification catches that case.

Preserve event files across a downgrade; they are durable observations from the binary that produced them and must be decoded according to their own schema_version, not the currently installed executable.

The rolling-upgrade and downgrade procedures above are not only described here. A scheduled, non-gating workflow (.github/workflows/interop.yml) downloads the latest published release and runs both directions of them against the current build every week — new clients over an old runner and the reverse, an abandoned record reaped from either side, probe pinning both ways, and each binary's JSONL stream read under the other's schema. See CONTRIBUTING.md, "Cross-version interop".

Adapter acceptance checklist

  • exact supported schema_version;
  • exact reserved exit-code band;
  • every command and long flag the adapter invokes;
  • required containment mechanism;
  • required abrupt_cleanup strength;
  • I/O mode/capture assumptions;
  • resource-limit applicability in the real environment.

See also

JSONL event schema (v1)

This is the normative description of processkit-cli's JSONL lifecycle-event contract. It is part of the project's public compatibility surface — CLI flags + exit-code ranges + schema_version (see AGENTS.md) — because adapters, in particular the processkit-py CLI, pin schema_version and reimplement these shapes. Treat every field below as public API.

  • The in-code source of truth is src/events.rs.
  • The golden sample stream is fixtures/schema/v1/events.jsonl; the golden test (events::tests::golden_stream_matches_the_fixture) keeps this document, the code, and the fixture in lockstep.
  • A machine-readable JSON Schema (draft 2020-12) is published at fixtures/schema/v1/schema.json — one variant per event type plus the shared envelope, transcribed from this document. This prose document remains the normative source of truth; the JSON Schema is a mechanical mirror of it, kept honest by tests::golden_fixture_validates_against_the_schema (tests/events.rs), which validates the golden fixture (and, in several other tests in that file, live streams emitted by the binary) against it — so a discrepancy between the schema and the fixture/code fails CI rather than drifting silently. On any disagreement between the schema and this document, trust this document and treat the schema as needing a fix. The schema's version is synchronized with schema_version: it lives under fixtures/schema/v1/ alongside the fixture, and a breaking change that bumps schema_version (see "Versioning") moves both to a new fixtures/schema/vN/ directory together, never one without the other.

Getting the schema without a git checkout

fixtures/schema/v1/schema.json lives in the repository, so a consumer who only has an installed binary (cargo install) or an unpacked release archive — no clone, no tag to check out — could otherwise only get the exact schema for their version by guessing which git tag matches. Two offline alternatives avoid that:

  • processkit-cli probe --json --print-schema prints the schema document embedded into that specific binary at build time (src/probe.rs's SCHEMA_JSON, via include_str! — the file above stays the single source of truth, this is a verbatim, byte-for-byte copy of it, never a hand-maintained second one). It replaces the usual probe report, and cannot be combined with any --require-* flag: that combination is rejected at parse time as an ordinary usage error (exit 100), not silently accepted with the requested checks skipped — a probe invocation that asks for expectations to be verified must never exit 0 without verifying them (see docs/integration.md, "Fail-closed preflight: probe"). --print-schema itself is an ordinary, additive CLI surface token (probe:--print-schema) like any other probe flag.
  • Every release archive (.github/workflows/release.yml) bundles a schema/ directory alongside the binary, completions/, and man/, containing schema.json and the golden events.jsonl fixture exactly as checked out for that release.

And to check a stream against it without a checkout — or a JSON Schema validator of your own — processkit-cli events --file <stream.jsonl> --validate validates every line against that same embedded document, reporting each violation by line number and exiting EVENTS_INVALID (114) when any line does not conform (see docs/exit-codes.md, "Checking a stream: events --validate"). It is the recommended way for an adapter to keep its own recorded fixtures honest against the runner version it targets. The checker is in-binary and adds no runtime dependency: it interprets the embedded document over the keyword subset that document uses and refuses to run on anything it does not implement, and the test tier holds its verdict against a real JSON Schema engine's, line for line, over the golden fixture and a generated mutation corpus.

Transport

  • Events are written to the file named by run's --jsonl option, never to stdout. The child's stdout and stderr pass through untouched; runner diagnostics go to the --jsonl file or to stderr, never interleaved into the child's stdout (AGENTS.md, "Streams are strictly separated").
  • The file is one event per line (JSON Lines): each line is a single, complete JSON object followed by \n. Lines are UTF-8.
  • The --jsonl file is created or truncated at the start of a run, so it holds exactly that run's stream. Each line is flushed as it is written, so the stream is durable even though a completed run forwards the child's exit code via an immediate process exit.
  • If the --jsonl file cannot be created, the run fails closed before the child is spawned (a runner-band exit code) rather than running the child with no event stream. A write failure after the child has started is best-effort: the runner warns once on stderr and continues, because the child's exit-code fidelity outranks diagnostics.

Envelope

Every line shares a common envelope, always in this order:

FieldTypeNotes
schema_versionintegerAlways 1 for this version. See "Versioning".
timestringEmission time, RFC 3339 UTC, millisecond precision (…Z).
eventstringThe event type tag (snake_case); selects the remaining fields below.

The time field is the moment the runner emitted the event. For root_exited it therefore doubles as the child's exit timestamp — the moment the runner observed the child leave.

Events

Fields marked nullable are always present; when a value is unknown or does not apply they are the JSON literal null (explicitly absent), never omitted.

run_started

The run has begun: the child is spawned into the container.

FieldTypeNotes
run_idstringThe --run-id value, or a generated run-<pid>-<unix_nanos>.
labelsobjectOperator labels from run --label; empty when none were supplied. Keys are sorted for deterministic JSON.
root_pidinteger, nullableThe root child's PID; null if the backend exposed none.
mechanismstringContainment mechanism: job_object, cgroup_v2, or process_group.
abrupt_cleanupstringCleanup surviving abrupt runner death: whole_tree, direct_child_only, or none.
cwdstring, nullableThe child's absolute working directory; null if it could not be resolved.
commandobjectThe command, redacted by default — see "Command redaction".

abrupt_cleanup is distinct from mechanism and from ordinary teardown. It is whole_tree on Windows because closing the runner's last Job Object handle kills all members; direct_child_only on Linux because the runner enables ProcessKit's parent-death signal for the root child while cgroups themselves persist; and none on macOS/other Unix because the current public API has no parent-death primitive there. Normal completion, timeout, and cancellation still invoke the reported container mechanism's regular teardown.

members_snapshot

A point-in-time snapshot of the container's members. It is a snapshot, not a census: a listed PID may exit immediately afterward, and a process spawned during the read may be missing.

FieldTypeNotes
reasonstringWhat asked for this snapshot: spawn or interval (below).
read_errorbooleantrue when the member read itself failed; see "Honest degradation on a failed sample" below.
membersarray of memberEach entry is a member (below).

A member object:

FieldTypeNotes
pidintegerThe process id.
ppidinteger, nullableParent pid — see "Enriched member fields".
namestring, nullableExecutable name — see "Enriched member fields".
start_timestring, nullableOpaque, platform-specific start-time token, as a decimal string — see "Enriched member fields".

How many, and when (reason). Every run emits one snapshot immediately after run_started, carrying reason: "spawn". A run given run --snapshot-interval <duration> additionally re-emits the same event on that cadence for as long as the child runs, each carrying reason: "interval" — recorded observability for how the tree evolved (when the worker fleet grew, whether helpers lingered, what the tree looked like just before a deadline fired), for the long, quiet, or detached runs where nobody is likely to have been watching live with inspect at the interesting moment.

  • reason and read_error are always present, including on the single spawn snapshot a run without the flag emits. reason is the only difference between a post-spawn snapshot and a periodic one: both are produced by the same code through the same members_info() enrichment, so a periodic snapshot's members cannot drift in shape from the first one's.
  • The periodic snapshots stop the moment the run's ending is decided, so they appear only between run_started and the ending's own event (root_exited, or the timeout/cancelled/killed reason event) — never interleaved into the cleanup_started/cleanup_finished teardown pair. See "Ordering".
  • Repeating an existing event is additive within schema v1 — no field is renamed, retyped, or given a new meaning, and a reader that pins a version must already tolerate additional events (see "Versioning"). The cadence is also opt-in: without --snapshot-interval a run emits exactly the one snapshot, at exactly the point, it always did. The event's wire form, however, changed for every run including the flagless one: reason and read_error are new, always-present fields, additive within v1 but not "unchanged" (see Compatibility and upgrades for the reader obligations that follow).
  • Snapshots are read from the container's own member list, not from the runner's output pump, so --snapshot-interval composes with every I/O mode — --inherit-stdio included, unlike --idle-timeout.

Honest degradation on a failed sample. When the members_info() read itself fails, the snapshot is still emitted, with read_error: true and an empty members array — the same convention cleanup_started/cleanup_finished use for their own failed reads (see "Honest degradation on a teardown read failure"). members is then a fallback, not an observation: a consumer must check read_error before reading an empty array as a confirmed-empty tree. The runner also warns on its stderr, but that warning is not the contract: a --detached runner's stdin/stdout/stderr are null (see Detached runs), so in the long, quiet, detached runs this cadence exists for, the flagged event in the JSONL file is the only report of the failure that reaches anyone. A failed sample therefore never appears as a gap in the cadence; a genuine gap means the run ended, the interval had not elapsed yet, or the stream itself stopped (see "Stream size" below).

Stream size. The cadence is the first thing in this stream whose event count scales with a run's duration, and it is deliberately unbounded — see Running commands for the recorded decision, the sizing arithmetic, and how to choose an interval.

root_exited

The root child exited on its own.

FieldTypeNotes
outcomestringexited, signalled, timed_out, or unknown.
codeinteger, nullableThe exit code for exited; null otherwise.
signalinteger, nullableThe signal number for a Unix signalled death; null otherwise.

On Windows a killed process reports exited with a platform code (there is no signal abstraction), so signalled is Unix-only.

cleanup_started

Container teardown is beginning.

FieldTypeNotes
members_beforeintegerThe tree size (member count) about to be reaped.
read_errorbooleantrue when the pre-cleanup member read itself failed; see "Honest degradation on a teardown read failure" below.

cleanup_finished

Container teardown finished (after the hard kill).

FieldTypeNotes
remainingintegerCount of remaining_pids.
remaining_pidsarray of integerPost-kill member snapshot; normally empty.
soft_terminatestring, nullableThe soft-stop tier for a runner-imposed ending (below); null on the natural-exit path.
shutdownobject, nullablePre-attempt capability plus ProcessKit ShutdownReport observations; null when no soft stop was attempted.
read_errorbooleantrue when the post-kill member read itself failed; see "Honest degradation on a teardown read failure" below.

remaining_pids is a snapshot: on the Job Object and cgroup mechanisms a process leaves membership on exit, so it is empty after the kill; on the POSIX process-group fallback an unreaped just-exited child can still be listed until it is reaped. soft_terminate is one of:

  • signalled — a soft stop really was delivered to the tree: on Unix a SIGTERM broadcast; on Windows, where a Job Object has no POSIX signal, ProcessKit's best-effort soft close — a WM_CLOSE to every top-level window owned by a live member plus a console CTRL_BREAK to a child opted in with --windows-graceful-ctrl-break.
  • unsupported — nothing in the tree could receive a soft stop, so none was sent and the runner does not pretend otherwise. Windows-only in practice, and the ordinary case there for a plain console child: no windowed member and no console-CTRL leader means the soft tier has nothing to trigger. The grace window still elapsed before the atomic Job Object kill.
  • failed — the soft stop could not be delivered; the hard kill ran regardless.

shutdown is null for natural exits and immediate kill endings. Otherwise it contains:

FieldTypeNotes
soft_stop_scopestringPre-attempt capability: whole_tree, opt_in_members, or none.
soft_signalstringObserved delivery: sent, unsupported, or failed.
members_before / members_afterinteger, nullableProcessKit's point-in-time counts; null on read failure.
drained_within_graceboolean, nullableWhether every member exited before escalation.
escalatedboolean, nullableWhether ProcessKit hard-killed survivors.
elapsed_msinteger, nullableActual stop-driver duration in milliseconds.

The last three fields are null only when ProcessGroup::stop itself failed and could not return a ShutdownReport; the owning container's hard-kill backstop still runs. This is additive to schema v1 and leaves soft_terminate in place for existing consumers.

Honest degradation on a teardown read failure. members_before/remaining/ remaining_pids are read from the live container (ProcessGroup::members()), which can itself fail (an OS-level enumeration error). Rather than let a read failure masquerade as a confirmed 0/empty observation, each event carries an explicit read_error flag: false on every successful read (the common case, unaffected by this), true when the read failed, in which case the numeric field(s) fall back to 0/an empty array — not a fabricated fact, only the absence of one. A consumer that treats cleanup_finished.remaining == 0 as "the tree is confirmed empty" must first check read_error is false; the runner also warns on stderr whenever this happens, though — as for members_snapshot's own flag above — that warning reaches nobody in a detached run, so the flag in the stream, not the warning, is the contract. This mirrors output_captured's write_error flag (both are explicit failure markers, never inferred from the accompanying data alone).

limit_hit

A requested ProcessKit resource limit (--max-memory, --max-processes, or --cpu-quota) could not be applied.

FieldTypeNotes
limitstringWhich limit could not be applied: memory, processes, or cpu.
detailstring, nullableHuman-readable detail; null if none.

When it is emitted. Enforcement of a whole-tree cap needs a real container — a Windows Job Object or a Linux cgroup v2. Where none can carry the request, the run fails fast rather than running silently unbounded, and this event records it. The emission is deliberately narrow:

  • Only the "could not be applied" branch. ProcessKit signals a limit failure only at group creation (pre-spawn — the child never started); it exposes no separate "the tree was killed mid-run for exceeding a cap" runtime signal (the OS reaps an offender itself — a Job Object/cgroup OOM or CPU throttle — without the crate translating that into an event). So limit_hit covers the unenforceable / unsupported case, not a live overrun: memory/cpu/processes where the platform has no whole-tree container at all (macOS/the BSDs, the Linux process-group fallback), or a Linux cgroup v2 whose controllers can't be enabled (not the real hierarchy root — under systemd, an ordinary container, or typical CI; see README.md, "Resource limits"). This reflects the processkit version this repository currently consumes; an additive, not-yet-released post-spawn evidence primitive and the tri-state design constraints it will impose on any future JSONL surface are tracked in docs/resource-limits.md, not in this schema.
  • Nonsense values never reach it. A degenerate value (--max-memory 0, a non-positive/non-finite --cpu-quota) is a USAGE (100) form error rejected at argument-parse time, so limit_hit never carries an "invalid value" reason.
  • Ordering. limit_hit is emitted first, then the same container_failed (phase: "create") → runner_exit (source: "container_error", code BACKEND = 102) tail every other group-creation failure takes. The dedicated limit_hit event — not the exit code — is the authoritative signal that the ending was a resource limit (docs/exit-codes.md, "Why a band is not enough on its own"). A run whose caps were applied emits no limit_hit at all and proceeds normally.

timeout

A runner deadline elapsed while the child was still running: either the whole-run --timeout, or the --idle-timeout (the child produced no output for the idle window). Both share this event, the reserved TIMEOUT (106) terminal code, and the soft-stop → grace → hard-kill teardown described by the following cleanup_started / cleanup_finished events (see docs/exit-codes.md); the always-present reason field tells them apart.

FieldTypeNotes
timeout_msintegerThe deadline that elapsed, milliseconds — the whole-run window for overall, the idle window for idle.
grace_msinteger, nullableThe --grace window, ms; null if unset.
reasonstringWhich deadline fired: overall (--timeout) or idle (--idle-timeout).

--idle-timeout re-arms its deadline on every chunk of the child's output, so a child that keeps producing output is never reaped no matter how long it runs — only one that goes silent past the window is. It reuses TIMEOUT (106) rather than minting a new exit code (the same class of ending — a deadline the runner enforced — distinguished by the more specific reason on this earlier event) and its terminal runner_exit source stays timeout, exactly as for --timeout. It requires the runner's output pump, so it conflicts with --inherit-stdio at parse time (like --capture-dir); it does compose with --capture-dir, whose tee re-arms the same one timer.

output_overflow

A capture stream exceeded its configured per-stream ceiling while --capture-overflow cancel was active. The event is emitted before the same soft-stop → grace → hard-kill teardown used by a timeout.

FieldTypeNotes
streamstringstdout or stderr, whichever crossed the ceiling first.
max_bytesintegerThe active per-stream --capture-max-bytes ceiling (or its 8 MiB default).
grace_msinteger, nullableThe --grace window, ms; null if unset.

The terminal runner_exit has source: "output_overflow", code OUTPUT_OVERFLOW (113), and child_code: null. output_captured still follows cleanup and reports the transcript's final counters and truncation flags. Without the opt-in cancel policy, crossing the same ceiling emits no output_overflow and continues the run, preserving the default truncate-only behavior.

cancelled

The run was cancelled and torn down through the shared soft-stop → grace → hard-kill path. source names the trigger, and the terminal runner_exit carries the matching reserved code:

  • ctrl_c — a local interactive Ctrl-C; terminal code CANCELLED (107).
  • sigtermUnix only: the runner received SIGTERM, the standard external stop (kill <pid>, systemctl stop, a cancelled CI job, a supervisor's shutdown timeout); terminal code CANCELLED (107).
  • sighupUnix only: the runner received SIGHUP — its controlling terminal went away (a closed terminal, a dropped SSH session); terminal code CANCELLED (107).
  • ctrl_breakWindows only: the runner caught CTRL_BREAK_EVENT; terminal code CANCELLED (107).
  • ctrl_closeWindows only: the runner caught CTRL_CLOSE_EVENT (the console window is being closed); terminal code CANCELLED (107). Windows gives the handler only a short window (about 5 seconds) before terminating the process regardless — see "Timeouts, cancel, and grace" in README.md for how the effective --grace is bounded so this event's own teardown can fit inside it.
  • ctrl_logoffWindows only: the runner caught CTRL_LOGOFF_EVENT (the user is logging off); terminal code CANCELLED (107).
  • ctrl_shutdownWindows only: the runner caught CTRL_SHUTDOWN_EVENT (the system is shutting down); terminal code CANCELLED (107).
  • control_cancel — a cancel command that reached the live runner over its control plane (see docs/control-plane.md); terminal code CONTROL_CANCELLED (108).

They all share this event because they share the teardown; the source and the terminal code tell them apart. The local signals/events deliberately share the one CANCELLED code — they are the same class of ending (a local signal stopped the run) — so a consumer that needs to know which one arrived reads this source, one event before the terminal runner_exit. The Unix signal and Windows console-control-event values are additive: a run that is never signalled emits exactly the stream it did before, and a consumer that only knows ctrl_c/control_cancel still sees a well-formed cancelled event with the same fields.

Catching SIGTERM/SIGHUP (Unix) and CTRL_BREAK/CTRL_CLOSE/CTRL_LOGOFF/ CTRL_SHUTDOWN (Windows) is what makes this teardown happen at all on those paths: their default disposition terminates the runner outright, which would skip the cancelled / cleanup_started / cleanup_finished / runner_exit events and leave the run's registry entry behind stale until prune — the ending would go unreported to any observer of the event stream or registry, even though the tree itself is not left orphaned on every platform: the abrupt-owner-death reap covers only the direct child on Linux, nothing at all on macOS/BSD, and the whole tree on Windows (closing the runner's last Job Object handle; see cleanup_finished and docs/registry.md). One exception, deliberate: a Unix signal whose disposition is already SIG_IGN when the runner starts (what nohup does to SIGHUP) is left ignored rather than un-ignored behind the operator's back, so no cancelled event is produced for it — and none is owed, since an ignored signal would not have ended the run either.

Known limitation, Windows only: a repeat Unix signal arriving mid-teardown is silently absorbed (the OS keeps the disposition installed for the process's whole lifetime, independent of listener state), but a second Windows console-control event arriving after teardown has already begun is not — it falls through to the OS's default handling and terminates the runner outright, before the terminal events above are written. See README.md, "Timeouts, cancel, and grace", and the #[cfg(windows)] arm of wait_for_cancel_signal in src/run/signals.rs for the full reasoning behind this accepted trade-off.

FieldTypeNotes
sourcestringctrl_c, sigterm (Unix), sighup (Unix), ctrl_break (Windows), ctrl_close (Windows), ctrl_logoff (Windows), ctrl_shutdown (Windows), or control_cancel.
grace_msinteger, nullableThe effective --grace window, ms; null if unset. For a Windows ctrl_close this may be less than the requested --grace (capped to fit the OS's own termination window — see README.md, "Timeouts, cancel, and grace"); every other trigger echoes the request unchanged.

killed

The run was killed by a control-plane kill command: an immediate hard kill of the whole tree, with no soft stop and no grace (unlike cancelled, which waits out the grace window first). The teardown it triggers is described by the following cleanup_started / cleanup_finished events — where soft_terminate is null, because no soft stop was attempted — and the run's terminal code is the reserved CONTROL_KILLED (109). See docs/control-plane.md.

FieldTypeNotes
sourcestringcontrol_kill.

spawn_failed

The program could not be started (not found, not executable, bad --cwd): the child never ran.

FieldTypeNotes
codeintegerThe runner-band exit code (SPAWN, 101).
messagestringHuman-readable failure reason.

container_failed

Creating the container, joining the child to it, or handing the terminal to an interactive child failed.

FieldTypeNotes
phasestringcreate (the container could not be created), attach (the launch into it failed), or foreground (handing the terminal to an interactive child failed after it had already spawned).
codeintegerThe runner-band exit code (BACKEND, 102).
messagestringHuman-readable failure reason.

runner_exit

The terminal event of every run: the exact code the runner process returns. It is always emitted, including on the runner's own failure, so a child's exit code is never lost or aliased even when the process returns a runner-band code (AGENTS.md, "Exit-code fidelity"; docs/exit-codes.md).

FieldTypeNotes
codeintegerThe exit code the runner process returns (child's code, or a runner-band code).
sourcestringWhy the runner exited: child_exit, timeout, output_overflow, cancelled, control_cancel, control_kill, spawn_error, container_error, internal, or setup.
child_codeinteger, nullableThe child's own exit code when it exited on its own; null for a runner-imposed ending or a child that never produced one.

When source is child_exit, code equals child_code. For a runner-imposed ending (timeout / cancelled / control_cancel / control_kill) or a pre-run failure (spawn_error / container_error / setup), child_code is null and code is the runner-band value. setup names a fail-closed setup failure — a required output (--jsonl / --capture-dir) or --stdin-file input that could not be opened — and carries the reserved SETUP code (111), distinct from internal (a genuine runner fault) so a consumer never reads a bad path as a runner bug (see docs/exit-codes.md).

The "runner process" here is always the process that ran the child. Under run --detach that is the detached copy, not the invocation the caller waited on: the caller's own exit code reports only whether the run started, which is exactly why this event — unchanged in shape, source, or meaning for a detached run — is where a detached caller reads the child's real result (see docs/exit-codes.md, "Detached runs").

output_captured

Bounded stdout/stderr capture finished. Emitted only when run was given --capture-dir <dir>: the child's stdout and stderr are teed into <dir>/stdout.log and <dir>/stderr.log alongside the live echo — suppressed when --no-echo is also given, which does not change what is captured — and this event records, per stream, what was captured. A run without --capture-dir does not emit it (the stream is otherwise byte-for-byte identical). --inherit-stdio conflicts with --capture-dir, because direct child output does not pass through the runner's tee, and therefore never emits this event.

FieldTypeNotes
stdoutobjectCapture result for standard output (below).
stderrobjectCapture result for standard error (below).

A capture object (one per stream):

FieldTypeNotes
pathstringThe file the stream was written to (<dir>/stdout.log or <dir>/stderr.log).
bytesintegerFull byte counter — every decoded byte the stream produced; exceeds the file size when the stream was truncated or a write failed.
sha256stringLowercase-hex SHA-256 of the bytes actually written to path — verify the file against it. Same digest primitive as argv_sha256.
truncatedbooleanExplicit flag: true when the stream outran the per-stream capture ceiling and the tail was deliberately not written. Never inferred from the file's size.
write_errorbooleanExplicit flag: true when a file write failed part-way through the stream, after which capture stopped writing to the (broken) file. Signals a disk-level problem, distinct from a ceiling clip. Never inferred from the file's size.

The two explicit flags exist so a consumer distinguishes "captured in full" from "clipped at the limit" from "cut short by a disk write error" from the flags alone — not by comparing the file's size against a ceiling it would have to know. The stream was captured in full exactly when both truncated and write_error are false; then bytes equals the file's size and sha256 covers the whole stream. When truncated is true, bytes is the full amount produced while the file holds (and sha256 covers) the first ceiling's worth. When write_error is true, a write failed mid-stream: bytes remains the full byte counter, but the file holds — and sha256 covers — only the prefix that reached disk before the failure, so bytes exceeds the file's size. The two flags are independent and may both be set (a stream that outran the ceiling and then also hit a write error). The two streams are likewise independent: one may be truncated or write-errored while the other is complete. On a runner-imposed ending (timeout / cancelled) the event reports whatever was captured before the teardown.

Ordering

A normal run emits, in order: run_started, members_snapshot (reason: "spawn"), then either

  • natural exitroot_exited, cleanup_started, cleanup_finished, runner_exit; or
  • runner-imposed ending — the reason event (timeout, cancelled, or killed), cleanup_started, cleanup_finished, runner_exit.

The reason event names which ending it was: timeout (with reason overall or idle) for a --timeout or a --idle-timeout, cancelled (with source ctrl_c, sigterm/sighup (Unix), ctrl_break/ctrl_close/ctrl_logoff/ctrl_shutdown (Windows), or control_cancel) for a local stop signal or a control-plane cancel, and killed (source control_kill) for a control-plane kill.

Multiplicity of members_snapshot. The post-spawn members_snapshot above is the only one a run emits by default, and it always appears exactly once, directly after run_started — unconditionally, because a failed member read is recorded as a read_error sample rather than omitting the event (see "members_snapshot"). A run given run --snapshot-interval <duration> emits additional members_snapshot events (reason: "interval") on that cadence, all of them after the post-spawn one and all of them before the ending's own event — root_exited for a natural exit, or the timeout/cancelled/killed reason event for a runner-imposed one. None is ever interleaved into the cleanup_started/cleanup_finished teardown pair or emitted after it: the cadence stops the instant the ending is decided. Every other event's position above is unchanged. This is an additive extension within schema v1 (see "Versioning"): a consumer that reads the first members_snapshot and routes the rest of the stream by event type is unaffected, and one that wants only the start-of-run shape can select reason == "spawn" instead of relying on position.

When --capture-dir is set, an output_captured event is inserted after cleanup_finished and before the terminal runner_exit, on every ending that ran the child (natural exit, timeout, cancel, and kill alike). Without --capture-dir it is absent.

A failure before the child is spawned emits its error event (container_failed with phase create or attach, or spawn_failed) and then runner_exit, with no run_started (and no output_captured — the child never produced output). When that pre-spawn failure is a resource limit that could not be applied, a limit_hit is emitted first, immediately before the container_failed (phase create) → runner_exit (source container_error) pair — the resource-specific record of why the container could not be created (see limit_hit). A container_failed with phase foreground comes later: the child had already spawned, so cleanup_started/cleanup_finished tear the container down before the terminal runner_exit. run_started is still never written (the handoff fails first), and the interactive mode this path only occurs in cannot set --capture-dir, so there is still no output_captured.

Command redaction

Command lines can carry secrets, so run_started's command object is redacted by default (AGENTS.md, "Argv is redacted by default"):

FieldTypeNotes
redactedbooleantrue by default; false only under --argv-raw.
argvarray of string, nullableThe raw argv, present only when redacted is false; null otherwise.
argv_sha256string, nullableLowercase-hex SHA-256 fingerprint of argv — see "Fingerprint". Filled on every run.
hintstring, nullableWorker-shape hint for a recognized argv, else null — see "Hint classifier".

The redaction is deliberately one-directional: argv_sha256 and hint are derived from argv but cannot reveal it (a one-way hash and a fixed category label), so they are filled on every run — redacted or not. --argv-raw adds the raw argv array; it never changes the fingerprint or the hint. A consumer can therefore correlate and classify a run without ever seeing its command line.

Artifact paths follow a separate disclosure rule. The public lifecycle stream does not add them as event fields: the caller already chose its --jsonl and optional --capture-dir destinations. The private, owner-only run registry does publish those locations as absolute paths so a different supervisor can discover a detached run's observability artifacts. Paths are not derived from argv, but may contain a project or user name; treat list --json and inspect --json as private operational metadata and do not copy them into public logs. See docs/registry.md.

Fingerprint (argv_sha256)

argv_sha256 is the SHA-256 of a canonical encoding of argv, rendered as lowercase hex (64 characters). The canonical encoding is the argv elements joined by a single NUL byte (0x00) — each element as its UTF-8 bytes, with no leading or trailing separator and no terminator. A NUL cannot occur inside a real argv element on any supported platform, so element boundaries are unambiguous: ["ab", "c"] and ["a", "bc"] fingerprint differently. An adapter that re-emits this schema reproduces the exact digest by hashing the same encoding. (The reference implementation is events::argv_sha256_hex.)

Hint classifier

hint names a recognized worker shape — a process form worth identifying (for example a build worker left running after a build) without disclosing its command line. It is one of a small, documented catalog of category labels, or null when the argv matches no known shape (the common case). A rule matches when all of its marker substrings appear somewhere in the argv, compared case-insensitively; the first matching rule in catalog order wins.

hintMarkers (all must be present)Shape
msbuild_node_reuseMSBuild.dll, /nodemode:1, /nodeReuse:trueAn MSBuild reusable worker node (/nodeReuse:true) — the long-lived build-node process that lingers after a build.

Adding a shape. The catalog is plain data — the HINT_RULES table in src/events.rs. Add one entry (a new hint label plus the marker substrings that identify the shape) and mirror it as a row in the table above; no control-flow change is needed. Choose a stable, snake_case hint label: consumers may match on it, so an existing label is part of this contract (changing or removing one is a breaking change — see "Versioning"). Keep the label to ASCII lowercase letters, digits, and _ — the per-user run registry publishes the same label in its records and validates that shape when reading one back (docs/registry.md, "Reading a record"); a label outside it would be dropped there. An in-tree test asserts every catalog label satisfies this, so a rule that violates it fails the build rather than surfacing as an empty column in list.

Enriched member fields

ppid, executable name, and start_time are filled from ProcessKit's ProcessGroup::members_info() — built strictly on the public processkit API rather than a local process-enumeration path (AGENTS.md, "Build strictly on the public processkit API"). Each field stays independently nullable because members_info() itself reports a field null wherever the platform can't read it: on Windows, Linux (cgroup or the process-group fallback), and macOS every field is populated; on the "bare" BSDs (no wired-up per-process reader) every enriching field is null while pid is still reported — a correct result, not an error. A member that exits between enumeration and metadata read is omitted from members entirely rather than reported with fabricated fields.

start_time is not a wall-clock timestamp — it is an opaque, platform- and unit-specific token (Windows: 100 ns since 1601-01-01 UTC; Linux: clock ticks since boot; macOS: microseconds since the Unix epoch) whose only documented purpose is telling a recycled pid apart from the process that previously held it. It is rendered as its decimal string, matching the field's string, nullable type, and must never be parsed as a timestamp or compared across platforms.

The same enrichment backs the control-plane inspect snapshot (see docs/control-plane.md): both members_snapshot and inspect's members are queried through members_info(), so the two "container member" views never drift.

Versioning

schema_version is a single integer. Any breaking change to an event's shape — renaming/removing a field, changing a field's type, or changing the meaning of a value — is a major bump of schema_version (and a matching Cargo.toml version bump; docs/exit-codes.md and AGENTS.md treat the surface as a whole). A future version lands under a new fixtures/schema/vN/ directory. Additive, backward-compatible clarifications that do not change any existing shape do not bump the version. Adding a new event type (as output_captured was added) is likewise additive: it introduces no change to any existing event's shape, and a consumer that pins the events it knows simply ignores one it does not. Adding a new field to an existing event — always present, and leaving every other field's name, type, and meaning intact — is additive in the same way: a consumer that reads the fields it knows is unaffected and simply ignores the new one. The output_captured per-stream write_error flag was added this way within v1, as was the timeout event's reason field (when --idle-timeout joined --timeout on that event) and the members_snapshot event's own reason and read_error fields (when --snapshot-interval gave that event a second trigger, and a failed sample a way to report itself). Note what that does not mean: those two fields appear on every members_snapshot, including the single one a run without the flag emits, so the default stream's members_snapshot line is not byte-identical to what an earlier version wrote. That is precisely the additive case above — a reader consuming the fields it knows is unaffected — but a reader that pinned the exact field set of an event, rather than the fields it uses, will notice. Emitting an existing event more times than a stream previously carried it is additive for the same reason a new event type is: no existing event's shape changes, and a consumer pinned to a version must already tolerate events it did not expect at that point in the stream — within a version it may not assume an event type it knows occurs at most once. members_snapshot was extended this way within v1 by the opt-in run --snapshot-interval cadence, which also leaves the default stream (no flag) emitting exactly the events, in exactly the order and the number, that it did before; the normative statement of how many such events a stream may carry, and where they may appear, lives in "Ordering", and the reader obligations these two additive shapes create are collected for adapter authors in Compatibility and upgrades. Adding a new value to an open-ended descriptive string field — a new cancelled source, for instance, as sigterm and sighup were added within v1 when the runner started catching those signals — is additive too: no existing value changes meaning, every other field keeps its name, type, and meaning, and a consumer that switches on the values it knows sees a well-formed event it can still route by event type (treat an unknown source as "some other trigger", not as a parse error). Filling a field that was reserved-as-null is not a breaking change: the field already exists and its type is unchanged. The argv_sha256 and hint fields were filled this way — they now carry values on every run instead of always null; the enriched member fields (see "Enriched member fields" above) were filled the same way once ProcessKit shipped members_info(). Adding a new hint label to the classifier catalog is likewise additive, but renaming or removing an existing hint label, or changing the fingerprint's canonical encoding, changes the meaning of a value and so is a breaking change.

Exit-code contract

The runner's exit codes are part of processkit-cli's public compatibility surface, alongside the CLI flags and the JSONL schema_version (see AGENTS.md). Consumers and adapters such as processkit-py depend on these codes, so changing them incompatibly is a major version bump.

The in-code source of truth for these values is src/exit.rs; this document is the normative description that external consumers pin against.

The core rule: child fidelity

The runner's exit code is the child's exit code.

On a completed run, processkit-cli exits with the exact code the child process returned — unchanged, unclamped, un-aliased. Nothing in the runner rewrites a child's 0, its 1, or its 137. This is what makes the CLI a faithful, transparent wrapper: a caller can branch on the child's status exactly as if it had launched the child directly.

The one invocation this does not describe is run --detach, which by definition stops being the child's parent: it reports whether the run started and leaves the child's own code to the run's runner_exit event. See "Detached runs" below.

Runner-own failures

When the runner itself fails — before, around, or instead of running the child — it exits with a code from a distinct, reserved band so that a runner failure is not mistaken for a child result.

Reserved band: 100119 inclusive.

CodeNameMeaning
100USAGEInvalid command line: unknown flag, missing required option, malformed value (including a bad --timeout/--grace duration), or bad subcommand form.
101SPAWNThe target program could not be started (not found, not executable, bad --cwd, permission denied).
102BACKENDProcessKit backend/containment failure: kernel container, job object, IPC endpoint, or run registry could not be established — including a requested resource limit (--max-memory / --max-processes / --cpu-quota) the active mechanism could not apply (the machine-readable limit_hit event names which one; see "Resource limits" below).
103CONTROLA by-run-id command could not be resolved to the single live run it names. For inspect / cancel / kill that covers every way the target cannot be reached: no such run id, a stale/dead registry entry, an entry whose liveness could not be probed at all (reported as unprobed — a refusal, not a claim that the runner died), an ambiguous run id (more than one live run registered under it), or an IPC failure. inspect adds one reason of its own that is not an unreachable runner: the target answered, and its answer was rejected — a reply declaring a snapshot_version outside the range this client reads (newer than it implements, or older than it still decodes) is refused rather than rendered under semantics its sender never promised (see docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read"). Retrying does not clear it, but that is no way to tell it apart from the others: a confirmed-stale entry and an ambiguous run id are equally unaffected by a retry. The registry-only wait shares exactly one of those reasons — an ambiguous run id — and reports it with this same code even though it contacts no runner: there is no single run to wait for. cancel --all / kill --all reuse this same code for a different fact — one or more record-addressed targets in the confirmed-live snapshot remained potentially live but could not be reached safely or did not acknowledge the command. A target confirmed gone before dispatch is instead the successful already_gone report status. The per-target reason is in the JSON report on stdout, not just the stderr tally. See docs/control-plane.md, "cancel --all / kill --all".
104INTERNALUnexpected runner fault: the runner reached a state its own logic rules out, or lost a trustworthy view of the run (a wait on the child failed and its fate is unknown; the backend returned an outcome this build cannot render). Reported with this code instead of panicking. A genuine runner bug — an ordinary setup failure is SETUP (111), not this.
105NOT_IMPLEMENTEDRetired. Formerly minted for a defined-but-not-yet-built code path; every subcommand is now implemented, so no active path mints it. The number stays permanently reserved (see "Stability" below) — it is never reused for a different meaning.
106TIMEOUTThe run exceeded a runner deadline — the whole-run --timeout, or the --idle-timeout (the child went silent past the idle window) — and the runner tore the process tree down. A runner-imposed outcome, not a child exit. The two are told apart by the timeout event's reason (overall / idle), not by the code; both reuse 106 (see "Timeout, cancel, and kill" below).
107CANCELLEDThe run was cancelled by a local stop signal — an interactive Ctrl-C, on Unix a SIGTERM / SIGHUP (a kill, a systemctl stop, a cancelled CI job, a hung-up terminal), or on Windows a Ctrl-Break / console close / logoff / system shutdown — and the runner tore the process tree down. The signals share this one code (the same class of ending); which one arrived is named by the cancelled event's source (ctrl_c / sigterm / sighup / ctrl_break / ctrl_close / ctrl_logoff / ctrl_shutdown). Distinct from TIMEOUT and from any child result.
108CONTROL_CANCELLEDThe run was cancelled by a control-plane cancel command (over the local control channel): the runner ran the same soft-stop → grace → hard-kill teardown as a Ctrl-C. Distinct from CANCELLED so "a control client cancelled it" is told from "a local signal stopped it".
109CONTROL_KILLEDThe run was killed by a control-plane kill command: the runner hard-killed the whole tree immediately (no soft stop, no grace). Distinct from every other runner-imposed ending.
110PROBE_INCOMPATIBLEThe preflight probe (processkit-cli probe) found this binary's compatibility surface does not satisfy a --require-* expectation. A pre-launch verdict, not a run outcome — no child is ever spawned by a probe. See "Preflight probe" below.
111SETUPA fail-closed setup / support failure: a prerequisite the runner needs to run — or to report a result — could not be established or produced for an ordinary reason (its async runtime would not build, a required --jsonl/--capture-dir output or --stdin-file input could not be opened, or a probe/inspect/control reply would not serialize). An environment/resource condition the caller can usually act on (a bad path, missing permissions, exhausted resources), not a runner bug — that stays INTERNAL (104). See "Setup failures vs internal faults" below.
112WAIT_TIMEOUTThe wait subcommand's own deadline (wait --run-id <id> --timeout <duration>) elapsed while the run it was waiting for was still live. The waiter gave up; the run was never touched — wait is read-only and reaches no runner — and is still going. Deliberately not TIMEOUT (106), which means the opposite (the runner enforced a deadline and tore the child's tree down), and not CONTROL (103), since the run was resolved unambiguously and found perfectly healthy. See "A waiter's deadline is not a run's deadline" below.
113OUTPUT_OVERFLOWA capture stream exceeded --capture-max-bytes while --capture-overflow cancel was active. The runner ended the tree through its graceful-stop and escalation path. Distinct from a time deadline; output_overflow identifies the stream and limit.
114EVENTS_INVALIDevents --validate found at least one line of the JSONL stream it checked that does not conform to the event schema this binary embeds (the document probe --print-schema prints). A verdict about a document, not about any run — events spawns no child, contacts no runner, and mutates nothing. Deliberately not PROBE_INCOMPATIBLE (110), whose subject is the opposite direction (this binary failing a consumer's --require-* expectations), and not SETUP (111): the stream was found, opened, and read perfectly well, and "it does not conform" is the answer, not a failure to produce one. A stream that could not be read at all is still SETUP (111) and a --run-id naming no single readable stream is still CONTROL (103), so a CI job can tell "invalid" from "could not be checked". See "Checking a stream: events --validate" below.

Codes 115119 are reserved for future runner-own conditions. --help and --version are not failures: they print to stdout and exit 0.

Timeout, cancel, and kill: runner-imposed outcomes

TIMEOUT (106), CANCELLED (107), CONTROL_CANCELLED (108), CONTROL_KILLED (109), and OUTPUT_OVERFLOW (113) are not failures of the runner and not the child's own exit — they are outcomes the runner imposes when it ends a run that did not stop on its own. The child did not choose to exit, so forwarding "its" code would be a lie; instead each takes a distinct reserved-band code so a caller can tell them apart:

  • the child exited by itself (its exact code, forwarded — possibly 0),
  • the runner ended it because a deadline elapsed — the whole-run --timeout, or the --idle-timeout (no child output for the idle window) — both 106, told apart by the timeout event's reason (overall / idle) rather than by a distinct code,
  • the runner ended it because a local stop signal reached it — the operator pressed Ctrl-C, on Unix a SIGTERM/SIGHUP arrived, or on Windows a Ctrl-Break / console close / logoff / system shutdown arrived (107 for all of them, told apart by the cancelled event's source rather than by a distinct code),
  • a control-plane cancel command ended it — the same graceful teardown as a Ctrl-C, but triggered over the network (108), and
  • a control-plane kill command force-killed it immediately, no grace (109), and
  • the opt-in capture-volume guard ended it through graceful teardown (113).

The two control-plane codes are what make a remote end-of-run distinguishable from a local one, and a graceful cancel from an immediate kill — by code alone, before even reading the event stream.

Alongside the code, the runner writes an explanatory line to stderr (never the child's stdout) that also states, truthfully, how the tree was torn down — including that on Windows a soft stop can only reach a windowed member, so for the ordinary console child no soft stop is delivered at all, the grace window elapses, and the Job Object is then killed atomically (see README.md, "Timeouts, cancel, and grace"). As with every runner-own code, the numeric value is a best-effort signal; the authoritative, machine-readable form of these outcomes is the timeout / output_overflow / cancelled / killed event (and the terminal runner_exit) in the versioned JSONL stream — see docs/schema.md.

A waiter's deadline is not a run's deadline

WAIT_TIMEOUT (112) is the one code in this table that describes the client, not the run. It is minted only by wait --timeout <duration> — targeting one run (--run-id <id>) or, in aggregate, every run confirmed live in a snapshot taken at the start (--all) — (see docs/registry.md, "Waiting — wait"), and only for one situation: the wait deadline elapsed while its target(s) were still live, so the command stopped waiting.

The distinction from TIMEOUT (106) is the whole reason it exists, and the two must never be conflated:

  • TIMEOUT (106) is reported by the run's own process: the runner enforced --timeout/--idle-timeout and tore the child's process tree down. The run is over, and it ended because of the deadline.
  • WAIT_TIMEOUT (112) is reported by a separate, read-only wait process: it gave up watching. Nothing was sent to the runner — wait never connects to the control transport — so the run is unaffected, still running, and will end (and report its own outcome, with its own exit code and runner_exit event) whenever it does.

Nor is it a CONTROL (103) failure: nothing was unreachable or ambiguous — the run was resolved to exactly one live entry and found perfectly healthy — so reporting "could not reach the run" would be false. A caller that hits 112 has learned one fact and only one: the run had not finished yet. Retrying the same wait is a perfectly reasonable response to it, unlike a 103, which will keep failing until the registry state changes.

Preflight probe: a pre-launch verdict, not a run outcome

PROBE_INCOMPATIBLE (110) is different in kind from every code above. It is not the ending of a run — the probe subcommand never spawns a child, opens the registry, or creates a container — but the verdict of a preflight a consumer runs on a candidate binary before launching anything through it. It is minted only when the probe was asked to verify an expectation (--require-schema-version, --require-exit-code-band, or --require-surface) that this binary's surface does not satisfy. A satisfied (or unrequested) surface exits 0. The launcher contract is fail-closed: an incompatible binary must be reported with this distinct, reserved code rather than silently used, so a consumer never degrades into an uncontained launch. As with the run codes, the number is a best-effort signal; the authoritative detail is the probe's JSON report (compatible

  • mismatches).

A malformed probe argument (for example a bad --require-exit-code-band value) is a USAGE (100) error like any other bad flag — distinct from PROBE_INCOMPATIBLE, which means "the arguments were well-formed, but this binary cannot meet them".

Checking a stream: events --validate

EVENTS_INVALID (114) is the second code that is a verdict rather than an ending, and it points the other way from PROBE_INCOMPATIBLE (110). 110 says this binary does not meet what a consumer requires of it; 114 says a document — a JSONL stream, this binary's own or an adapter's fixture — does not conform to the event schema this binary embeds. Neither is a run outcome: events spawns no child, contacts no runner, and mutates nothing (it is read-only in the same sense list and wait are).

The three ways events --validate can end are deliberately distinguishable by code alone, because a CI job gating a fixture needs "your file is wrong" told apart from "I could not check it":

ExitMeaning
0Every checked line conforms. The summary line on stdout says how many were checked.
114 (EVENTS_INVALID)The check ran and at least one line does not conform. Each violating line is reported on stdout by line number and by what it violated; the count is in the summary. A line that is not JSON at all counts as a violation, not as something to skip.
111 (SETUP)The stream could not be read at all (no such file, unreadable). Nothing was checked, so nothing is claimed about it.
103 (CONTROL)--run-id named no single readable stream: no registry record names that id, several records name different streams, or the run published none (it ran without --jsonl). The same "there is no single target" verdict every other by-run-id command gives.

--validate never reports 0 for a stream it could not check, and never reports 114 for one it merely could not read: that separation is the whole point of the code.

Setup failures vs internal faults

SETUP (111) and INTERNAL (104) are deliberately kept apart so the code alone tells a caller which one happened:

  • SETUP (111) is a fail-closed setup / support failure: the runner could not establish a prerequisite it needs, or produce a result it must emit, for an ordinary reason the caller can usually act on. It covers a run whose async runtime will not build; a required --jsonl events file, --capture-dir, or --stdin-file the operator asked for but that cannot be opened or created (an unwritable path, a missing parent, denied permissions); and a probe / inspect / control (cancel/kill) reply that cannot be serialized. It also covers the two failures that belong to --detach's wrapper rather than to the run — the detached runner could not be spawned, or it never reported a started run before the startup budget elapsed (see "Detached runs" below). In every case the runner's own run-tracking logic is intact — a peripheral support step just failed — so reporting it as an INTERNAL "runner bug" would mislead the consumer. A SETUP failure before the child is spawned takes the SETUP code and (where a --jsonl stream is already open) a terminal runner_exit with source: "setup" and a null child_code; no child code is ever lost, because no child ran.
  • INTERNAL (104) stays strictly for a genuine invariant violation: the runner reached a state its own logic rules out (the backend reported a TimedOut outcome when no deadline was armed on the child, or an outcome variant this build does not recognize), or lost a trustworthy view of the run it cannot recover from (a wait on the child failed and its fate is now unknown). These are runner bugs, and a consumer reading 104 can treat them as such.

The distinction is which side failed: an environment/resource condition the caller can fix (SETUP) versus the runner's own logic being wrong (INTERNAL).

Resource limits reuse BACKEND (102)

When run is given a whole-tree resource cap (--max-memory, --max-processes, or --cpu-quota) that the active containment mechanism cannot apply, the run ends with BACKEND (102) and a runner_exit source of container_error — the same code as any other container-creation failure — not a new code from the reserved band's free slots. This is a deliberate choice: the failure is genuinely that a whole-tree container capable of the requested cap could not be established here (macOS/BSD and the Linux process-group fallback have no such container at all; a Linux cgroup v2 whose controllers can't be enabled — under systemd, an ordinary container, or typical CI — can't carry it either), which is the same class as the existing container-creation error. The failure is always pre-spawn, so no child code is ever at stake.

What makes a limit ending distinguishable is not the exit code but the dedicated limit_hit JSONL event that precedes the container_failed/runner_exit tail and names which limit (memory / processes / cpu) — the authoritative, machine-readable channel, exactly as this document's core principle holds the exit code to be only a best-effort hint (see "Why a band is not enough on its own" below). A nonsensical value (--max-memory 0, a non-positive/non-finite --cpu-quota) is instead a USAGE (100) argument error, rejected at parse time before any container is touched. No reserved-band slot was spent on it.

Detached runs: the code reports the start

run --detach is the one invocation where "the runner's exit code is the child's exit code" does not apply, and it is not an exception carved out of the rule so much as a consequence of the mode: the run is handed to a detached copy of the binary, so the process the caller is waiting on has no child of its own to forward a code from. It reports the only thing it can honestly report — whether the run started:

ExitMeaning
0The run started: the detached runner registered it and wrote run_started to --jsonl. It is now discoverable (list), reachable (inspect/cancel/kill), and waitable (wait). Says nothing about how the child will finish.
a reserved-band codeThe run did not start, and the code is the same one the failure would have produced in the foreground.

No new code was minted for this. A start can fail in exactly the ways a foreground run can fail before it begins — a program that is not there (SPAWN 101), a container that cannot be created or a limit that cannot be applied (BACKEND 102), an unwritable --jsonl/--capture-dir (SETUP 111) — and the detached copy exits with precisely that code, which the caller then relays unchanged. A caller therefore reads run --detach's failures with the same table as run's, and the reserved range 115119 stays free. Two failures belong to the detach wrapper itself rather than to the run, and both take SETUP (111): the detached copy could not be spawned at all (a support step failed; blaming SPAWN would point at the caller's program, which was never reached), and the detached copy was alive but had not reported a started run before the startup budget elapsed (it is killed rather than left running unreported). An exit status that is not a reserved-band code — including a 0 — is likewise reported as SETUP and never relayed, because no run path can exit successfully without having written run_started first.

What the events file holds after a failed start. Whatever the detached copy managed to write, and nothing invented on its behalf. A copy that started and then failed records the failure itself — a spawn_failed/container_failed and a terminal runner_exit with the matching source, exactly as a foreground run would — so the stream explains the code the caller saw. The two wrapper failures above never reach that point: the caller's own stderr and exit code are the entire account, and the events file is left empty (or, if the copy was killed mid-startup, without a terminal runner_exit). A stream with no runner_exit therefore means "no run started here", never "a run whose ending was lost".

Where the child's code went. Nowhere: it is in the terminal runner_exit event of the run's own --jsonl stream, with code, source, and child_code exactly as for any other run. That is the whole trade of detaching — the caller gave up being the runner's parent, so the process-exit channel for the child's result went with it, and the event stream (which the 0 above guarantees exists and has begun) is the channel that remains. wait --run-id <id> blocks until such a run is over, but it too reports only that it ended, never with what code; the runner_exit event is the single source for that.

Why a band is not enough on its own

Exit codes are a single small integer, and a child can, in principle, exit with a number that happens to fall inside 100119 too. The reserved band is therefore a best-effort signal for shells and scripts, not the authoritative channel. The authority is the JSONL event stream: every run ends with a runner_exit event (defined by the JSONL schema — see docs/schema.md) that carries the returned code, names why the runner exited, and preserves the child's own code in a separate child_code field, so a consumer reading --jsonl can always tell a runner failure apart from a child that merely exited with the same number. A child's own code is never lost or aliased, because the runner's failures are additionally recorded out of band.

Stability

  • The band (100119) and the assigned codes above are stable; moving or repurposing an assigned code is a breaking change.
  • NOT_IMPLEMENTED (105) was the one intentionally temporary member: it has now retired, since every subcommand it once stood in for is implemented. Its retirement was not a breaking change — it only ever meant "this build cannot do that yet" — but the number is not reassigned to a new meaning; it stays reserved and unused going forward.
  • New runner-own conditions take the next free code in the reserved range rather than overloading an existing one. EVENTS_INVALID (114) is the most recent, taking the next free slot after OUTPUT_OVERFLOW (113) rather than overloading PROBE_INCOMPATIBLE (110), whose subject is this binary's own compatibility rather than a document's (see "Checking a stream: events --validate" below); WAIT_TIMEOUT (112) did the same before it, taking the slot after SETUP (111) rather than overloading TIMEOUT (106), whose meaning is the opposite one (see "A waiter's deadline is not a run's deadline" above); codes 115119 remain reserved.

Threat model

This document states, in one place, what processkit-cli treats as untrusted input, who the trusted principal is, where the project deliberately draws its security boundary, and which concrete threats within that boundary are closed — and by what mechanism. It does not restate the mechanisms' own normative text (each closed threat below links to the module or document that owns it); treat it as the map for a security reviewer or auditor, not a substitute for reading the cited code.

Untrusted inputs

Four input surfaces are treated as untrusted or semi-trusted and are handled accordingly (bounded parsing, validation before use, no blind trust in shape):

  • Registry bytes. Every record file under the per-user run registry (src/registry/mod.rs) is parsed defensively — a corrupt or malformed record is skipped, never trusted to abort a scan or to smuggle a path outside the registry directory (see "Closed threats" below for the lock_file case).

  • Control-plane wire strings. The one request-verb line a client sends, and the one JSON reply line the server sends back, over the local inspect/cancel/kill transport (src/control/mod.rs), are read as untrusted bytes from whichever local process holds the socket/pipe.

  • The child's argv and output. The command line passed to run is attacker-influenceable in the sense that it ends up in a diagnostic artifact (the JSONL stream) an operator or automated tooling later reads; the child's own stdout/stderr are unbounded, potentially adversarial or merely pathological, byte streams (src/events.rs, src/capture.rs).

  • The events file read back. The JSONL lifecycle stream is this project's own output while a run writes it, and untrusted input the moment anything reads it back: it sits at an operator-chosen path (run --jsonl), any local process can write one, and a reader will read whatever it is pointed at. Two commands read one. wait --report-outcome reads a bounded head/tail window of the file a registry record names (src/wait.rs). events is the larger of the two surfaces — it reads a whole stream, and events --file <path> reads an arbitrary caller-specified path, including a file this registry never knew about, such as an adapter's own fixture; events --run-id <id> reads the locator a registry record publishes, itself untrusted deserialized data under "Registry bytes" above. Everything on that path is hand-rolled and treats the bytes as hostile:

    • an incremental line reader that hands out only complete lines and refuses to buffer one past MAX_LINE_BYTES (1 MiB), so a file with no newline in it cannot decide this process's memory use; invalid UTF-8 is replaced and reported, never fatal (src/events_cmd/mod.rs);
    • under --validate, an interpreter of the embedded JSON Schema document run over each parsed line (src/events_cmd/schema.rs) together with the small anchored matcher that document's pattern keywords need, which runs against untrusted string content (src/events_cmd/pattern.rs);
    • and, at the terminal boundary, every operator-facing fragment — a rendered field, the notice about a line that would not parse, a schema violation, and the stream's own locator — passed through text::terminal_safe_bounded (src/text.rs) before it is printed, so neither a stream's content nor a registry-published path naming it can forge or overwrite what an operator sees. events --json is the one deliberate exception and not a terminal rendering: it passes the runner's own bytes through byte for byte (a line that is not JSON is reported instead of emitted), relying on JSON's own escaping exactly as this project's other machine-readable outputs do — the line src/text.rs draws explicitly between the two.

    See "Supply-chain compromise" below for what the fuzz tier does and does not currently exercise on this surface.

Trusted principal and boundary

The trusted principal is the same OS user that invokes processkit-cli: every security mechanism below defends that user's own runs against a different OS user (or an unprivileged remote party with no local account), never against that user's own other processes.

Explicit boundary. processkit-cli does not defend a run against a malicious process already running as the same OS user. A same-user process that can read the registry directory, connect to the control-plane transport, or otherwise act with that user's own privileges is, by definition, already inside the trust boundary this project draws — the owner-only restrictions below exist to keep other principals out, not to isolate one same-user process from another.

Closed threats

Each entry names the threat, the mechanism that closes it, and the exact code/docs it is implemented and described in.

  • A different OS user reading or connecting to the registry/transport. The per-user registry directory's permissions are guaranteed on every mutating open, not merely assumed: 0o700 re-applied via chmod on Unix (bypassing umask); on Windows the directory is created carrying its protected owner-only DACL and, on a subsequent open, that DACL is compared ACE for ACE against the target and rewritten whenever it does not match (src/registry/mod.rs, Registry::open/open_in, platform::create_owner_only_dir). A pre-existing directory whose permissions were widened out of band is repaired on both platforms. The Windows comparison is deliberately exact and fail-closed — an unreadable descriptor, an extra ACE, a missing protected bit, or a non-directory all route to the unconditional write — so the skip can only ever elide a write whose result is already in place; no weaker signal an attacker could forge (the directory existing, a marker file, a cached flag) is accepted in its stead. Neither platform touches ownership, then or now. The control-plane transport is deliberately not derived from that directory: each run atomically reserves its own short-lived 0o700 directory under /tmp (falling back to the platform temp directory) and binds the Unix socket inside it, with the socket file itself given 0o600 on a best-effort basis afterward (src/control/mod.rs, imp::ControlServer::bind, create_private_socket_dir); the path is kept independent of the registry directory specifically so a long registry path cannot push the socket path past sockaddr_un::sun_path on macOS (see docs/control-plane.md, "Local transport"). On Windows, the control-plane's named pipe is built with its own non-inheritable owner-only DACL, sharing only the FFI-glue module src/win_security.rs (SecurityDescriptor, to_wide) with the registry directory's DACL construction — that sharing is Windows-only; the Unix socket has no DACL and no relationship to src/win_security.rs.
  • The command line leaking into diagnostics. run_started's command field is redacted by default: the raw argv is not recorded, only a one-way SHA-256 fingerprint (argv_sha256) and a categorical worker-shape hint from a static classifier table, both derived from argv but unable to reveal it (src/events.rs, argv_sha256_hex, classify_hint/ HINT_RULES). Recording the raw argv requires an explicit opt-in (--argv-raw); it is never the default. The per-user registry record publishes that same one-way pair (and only it) so list can tell several live runs apart — the raw argv is not even an input to the registry's register, which takes an events::CommandFingerprint, so no flag (--argv-raw included) can put a command line into a registry record. The values are shape-checked when read back, like every other record field (see docs/registry.md, "Reading a record").
  • An unbounded or malformed control-plane wire line. Both the server's request-line read and every client's reply-line read are capped at MAX_LINE_BYTES (64 KiB) via a shared bounded-read helper (src/control/mod.rs, read_bounded_line) — an oversized or unterminated line fails deterministically rather than growing an in-memory buffer without bound. Separately, a registry record's lock_file field is validated as a simple file name before it is ever joined onto the registry directory path — control characters, NUL, path separators, Windows reserved device names (with or without an extension, including superscript-digit aliases), and symlink targets (rejected at open time via O_NOFOLLOW on Unix / a reparse-point check on Windows) are all refused (src/registry/mod.rs, is_simple_lock_file_name, is_windows_reserved_device_name, platform::open_lock_file).
  • A record steering a deletion outside its own leftovers. prune reaps the Unix control socket a confirmed-stale record published, which means a remove call driven by that record's endpoint — untrusted deserialized data like lock_file above. The value is refused unless it is exactly the form the control server publishes (absolute, no ./../empty segment as written, final component c.sock, parent pkc- plus an alphanumeric/- token, sitting directly inside one of the temp bases the server binds in), and even then no symlink is followed: the directory is opened O_NOFOLLOW | O_DIRECTORY and the socket is unlinked relative to that handle, only if it really is a socket, with the directory itself removed by an empty-only rmdir. A value failing any of that deletes nothing at all — the record and its lock are still reaped (src/registry/mod.rs, platform::control_socket_dir_to_reap, platform::reap_control_socket_dir; rationale in docs/registry.md).
  • Launching an incompatible or unusable runner binary uncontained. The side-effect-free probe subcommand is a fail-closed preflight contract: it spawns no child and touches no registry, reports this binary's version, schema_version, reserved exit-code band, and live CLI surface as one JSON line, and — given --require-* expectations — exits PROBE_INCOMPATIBLE (110) with the concrete mismatches on any unmet expectation, rather than ever letting an adapter silently proceed with an incompatible binary (src/probe.rs; consumer walkthrough in docs/integration.md, "Fail-closed preflight: probe").
  • Resource exhaustion from a pathological child output stream. The --capture-dir tee enforces a hard per-stream byte ceiling (CAPTURE_MAX_BYTES, configurable via --capture-max-bytes) with an explicit truncated flag rather than growing the capture file without bound, and --idle-timeout tears the run down if the child goes silent past a configured window (a shared IdleClock re-armed by any non-empty write on either the default echo path or the --capture-dir tee), closing the case of a child that neither exits nor produces bounded output (src/capture.rs).
  • Supply-chain compromise of the build or release pipeline. Every third-party GitHub Actions step in .github/workflows/ci.yml and .github/workflows/release.yml is pinned to a full commit SHA (not a floating tag) except the toolchain selector, dtolnay/rust-toolchain@stable/@master, which both workflows leave intentionally unpinned (each occurrence carries an explicit "intentionally unpinned" comment) so CI and releases keep tracking the rolling stable/MSRV toolchain; that one exception means trust in dtolnay/rust-toolchain's owner is accepted, not eliminated (see "What is not closed" below). Everywhere else, a compromised or re-tagged action cannot silently change what CI or a release build runs. cargo deny check advisories bans licenses sources runs on every pull request and push to main (deny.toml, .github/workflows/ci.yml), failing the build on a known RustSec advisory, a yanked crate, a wildcard version requirement, a disallowed dependency license, or a dependency sourced from outside crates.io. Released artifacts carry a SHA-256 checksum and a signed actions/attest-build-provenance attestation (.github/workflows/release.yml) a consumer can verify against the exact commit and workflow that produced them. A dedicated fuzz tier (fuzz/) exercises four of the parsers that sit closest to the untrusted inputs above, under cargo-fuzz: the registry's byte-to-record parser, the control-plane's request/reply decoders, the CLI's own value parsers, and wait --report-outcome's bounded head/tail read-back of a run's JSONL events file (a path any local process can write, so its content is untrusted the same way). It does not reach the events reader described under "Untrusted inputs" above — the larger of this project's two events-file readers, and the only one that opens an arbitrary caller-given path: neither its incremental line reader, nor the schema interpreter and anchored pattern matcher behind --validate, is fuzzed. That stack is covered by unit and through-the-binary tests, and its --validate verdict is held line for line against a real JSON Schema engine (tests/events.rs), but it is not under coverage-guided fuzzing: a fifth target over that reader is the way to close the gap, and until one exists this document claims no fuzz coverage for it.

What is not closed

The boundary above is deliberate, not an oversight; the following are explicitly out of scope for this project's own security mechanisms:

  • Confidentiality of data inside the child process. Whatever the child program reads, writes, or holds in memory is entirely its own concern; processkit-cli observes only what the child writes to its own stdout/stderr (and, if requested, the process tree's membership) — it does not attempt to protect the child's internal state from anything.
  • Isolation from another process of the same OS user. As stated under "Trusted principal and boundary" above, a same-user malicious process is inside the trust boundary, not outside it — this project provides no mechanism against it (no additional sandboxing, no cross-process capability restriction beyond the owner-only ACLs that already keep out other users).
  • Trust in the dtolnay/rust-toolchain action owner. Both workflows deliberately leave that one action unpinned (a floating @stable/@master tag rather than a commit SHA) so CI and releases keep tracking the rolling stable/MSRV toolchain; a compromise of that action's owner or repository could change what CI or a release build runs, and the project accepts that residual risk rather than freezing the toolchain version.
  • Denial of service through the operating system itself. Beyond the opt-in, best-effort --max-memory/--max-processes/--cpu-quota caps on the child's own process tree (platform-limited: real Windows Job Object or Linux cgroup v2 enforcement only, fail-fast rather than silently unenforced — see README.md, "Resource limits"), processkit-cli does not defend against exhaustion of system-wide resources (memory, file descriptors, process table slots) by other workloads on the same machine; that remains the operating system's and the operator's own concern.

See also

  • SECURITY.md — how to report a vulnerability, and the automated supply-chain scanning this document's "Supply-chain compromise" entry summarizes.
  • docs/architecture.md — the module map and data flow this document's closed-threat entries point into.
  • docs/integration.md — the consumer-facing preflight and redaction walkthrough (probe, command redaction) referenced above.
  • docs/registry.md and docs/control-plane.md — the normative registry and control-plane documents the owner-only and bounded-read mechanisms above are drawn from.

Architecture overview

This document is the map that ties the per-area normative documents together: the module layout, how one run moves data from spawn to exit, how the control-plane clients (inspect/cancel/kill) reach a live runner, where this repository's responsibility ends and the processkit crate's begins, and how the test suite is layered. It does not restate the normative contracts themselves — each links to its own document below — and it is not a substitute for reading the source; treat it as the entry point for a new contributor, sketched from the code as of this writing rather than from memory.

Module map

processkit-cli is a thin binary (src/main.rs) over an internal library (src/lib.rs): every other src/*.rs file is a mod of the library, and the binary only parses argv and dispatches into it (see "Target structure: library and binary" below for why the split exists and what stays stable). Responsibilities, in the order data flows through a run:

ModuleResponsibility
src/lib.rsThe internal library crate root: declares every src/*.rs module and re-exports nothing publicly-supported. It carries the "not a stable public API" disclaimer and marks each module #[doc(hidden)], so the library is only a foundation for the crate's own tooling — never a semver-stable Rust surface.
src/main.rsThe thin binary entry point. Parses Cli, dispatches into the library's subcommand module, and maps the result onto a process exit code: run owns its own exit path (it hard-exits with the child's code and never returns here); every other subcommand's Result<(), RunnerError> is mapped through RunnerError::code(), and a clap parse failure is mapped onto the runner's own USAGE code rather than clap's default.
src/cli/The CLI-flags half of the compatibility surface: the clap-derived Cli/Command types for run, inspect, cancel, kill, wait, events, list, prune, and probe. Parsing and shape validation only — each subcommand's behavior lives in its own module. A directory of nine files, split by subcommand family rather than by size so a new flag or subcommand lands in a focused file: mod.rs holds the top-level Cli/Command shapes and re-exports everything below it, so crate::cli::<Item> stays the single path to every CLI type and parser; run.rs holds RunArgs/CaptureOverflowPolicy; control.rs holds InspectArgs and the TargetArgs shared by cancel/kill; wait.rs, events.rs (whose implementing module is src/events_cmd/, the plain events name being taken by the emitter), list.rs, prune.rs, and probe.rs hold one argument struct each; and parse.rs owns the hand-written value parsers they share (durations, byte sizes, process counts, CPU quotas, exit-code bands, KEY=VALUE entries, run ids) — the functions the fuzz tier drives directly.
src/run/The run subcommand itself: spawns the child into a processkit::ProcessGroup this module owns, selects either default pipe-and-echo I/O or direct inherited stdio, temporarily hands a POSIX terminal to a separate child process group when required, races the child's exit against --timeout/--idle-timeout/a local stop signal (Ctrl-C, on Unix SIGTERM/SIGHUP, and on Windows Ctrl-Break/console close/logoff/system shutdown)/a control-plane command, and drives the shared teardown tiers — the graceful soft stop → grace → hard kill for timeout (whole-run or idle)/a signal cancel/cancel, and the immediate hard kill (no soft stop, no grace) for a control-plane kill. Exit-code fidelity — the child's exact code on a normal completion, a reserved-band code for every runner-imposed ending — is enforced here. A directory of four submodules under one entry point, split by responsibility rather than by size: mod.rs is the entry point (run::execute) plus the shared ending vocabulary (Ending/Termination/CancelSignal/TimeoutTrigger/SoftTerminate); launch.rs owns the run itself (run_async: container creation, spawn, I/O-mode selection, the terminal handoff, and the race); signals.rs owns the runner-imposed deadlines and the platform stop-signal listeners (wait_for_cancel_signal, effective_grace_for); teardown.rs owns the two teardown tiers, the JSONL emitters, and the ending-to-exit-code mapping; and detach.rs is the --detach wrapper (start_detached), which re-spawns this binary detached and returns once the run has provably started, without duplicating any of the above.
src/events.rsThe versioned JSONL lifecycle-event schema and its emitter — this repository's normative, golden-tested public event contract (see docs/schema.md). Also owns argv redaction: the default SHA-256 argv_sha256 fingerprint and the HINT_RULES worker-shape classifier, exposed as one shared CommandFingerprint so the registry record publishes the identical pair (see docs/registry.md, "Which run is which") instead of deriving its own.
src/capture.rs--capture-dir bounded per-stream stdout/stderr capture to files, riding the same tee run already echoes through (no second output-reading path). Records, per stream, a full byte counter, a SHA-256 of the bytes written, and independent explicit truncated/write_error flags, surfaced in the output_captured event.
src/hash.rsThe one hand-rolled incremental/one-shot SHA-256 (FIPS 180-4) both events (argv fingerprint) and capture (streamed transcript hashing) build on, so the project has a single digest primitive and rendering style.
src/text.rsShared human-output primitives: terminal-safe normalization for untrusted text and the column-aligned table renderer used by both list and inspect.
src/registry/mod.rsThe per-user run registry: one record per in-flight run in an owner-only-restricted directory, found by scanning and matching run_id (never a PID), carrying the run's redaction-safe command fingerprint/hint for discovery, with staleness detected via an OS advisory lock the live runner holds (see docs/registry.md). The first brick of the control plane.
src/control/The live-run control plane: a stable facade plus platform transports, bounded rendering, and isolated tests for its line-oriented inspect/cancel/kill protocol (see docs/control-plane.md).
src/list.rsThe list subcommand: a thin, read-only CLI wrapper over Registry::entries that renders every registry entry — whatever its health (live/stale/unprobed) — as a table or JSON Lines, for a caller that has lost (or never had) a run_id.
src/prune.rsThe prune subcommand: an equally thin wrapper over Registry::prune, which owns the whole confirm-before-delete reaping safety rule; this module only opens the registry and reports the tally.
src/wait.rsThe wait subcommand: blocks until a run is no longer live, for a supervisor that is not the runner's parent. Registry-only in both of its modes — it never contacts the runner. --run-id polls Registry::probe_run and has three outcomes (finished, its own --timeout elapsing, an ambiguous run_id); --all (T-216) instead polls a Registry::entries snapshot taken at the moment it starts and has only two — there is no aggregate ambiguity outcome, since --all never resolves an id at all. Both are decided entirely from the registry (see docs/registry.md, "Waiting — wait").
src/events_cmd/The events subcommand: the read-back counterpart to src/events.rs's emitter. Resolves a stream by --run-id through Registry::entries (or takes --file), hands out only complete lines as the file grows, and renders them (render.rs, through src/text.rs's terminal barrier), passes them through verbatim (--json), or checks them against the embedded schema document (--validate: validate.rs runs the pass and owns the exit verdict, schema.rs interprets the document over the keyword subset it uses — refusing to run on anything it does not implement rather than skipping it — and pattern.rs is the tiny anchored matcher its pattern keywords need; tests/events.rs holds that checker's verdict against the jsonschema dev-dependency's, so the binary links no JSON Schema engine of its own). Read-only in the same sense list/wait are: no control transport, no mutation.
src/probe.rsThe side-effect-free probe subcommand: reports (and, with --require-*, verifies) this binary's version/schema_version/exit-code band/CLI surface as one JSON line.
src/exit.rsThe reserved runner-own exit-code band (100119) constants — the exit-code half of the compatibility surface (see docs/exit-codes.md).

Target structure: library and binary

The crate builds two targets from one source tree:

  • an internal library (src/lib.rs, Cargo target processkit_cli) that owns every module under src/, and
  • a thin binary (src/main.rs, Cargo target processkit-cli) that does nothing but parse argv with clap and dispatch each subcommand into the library.

The library exists as a foundation for the crate's own tooling, not as a reusable dependency. Keeping the runner's internals in a [lib] target lets:

  • unit and property tests live in each module's #[cfg(test)] mod tests and run as the library's --lib tier under a plain cargo test, reaching module-private helpers directly — retiring the cargo test --bin workaround the old bin-only layout required;
  • fuzz targets (cargo-fuzz, fuzz/, T-186) link the library and drive parsers of untrusted/semi-trusted input — the registry's bytes → parse/ validate path, the control plane's request-line classifier and response-line decode, the CLI's --timeout/--grace/ --require-exit-code-band/--env/--run-id value parsers, and wait --report-outcome's terminal-outcome read-back over a run's JSONL events file (src/wait.rs, bounded head/tail scan for a terminal runner_exit, T-301); the events reader's own parsers (src/events_cmd/) are not in this tier — a boundary that CONTRIBUTING.md's "Fuzzing" section and docs/threat-model.md both state outright, rather than leaving it to be inferred from this list. A [lib] target is a hard prerequisite cargo-fuzz cannot work without (see CONTRIBUTING.md, "Fuzzing");
  • benchmarks (criterion, benches/, T-187) reach internal primitives — the hand-rolled SHA-256 hasher (src/hash.rs), bounded-capture absorb (src/capture.rs), and the argv-redaction hint classifier (src/events.rs) — directly, to measure them in isolation (see README.md, "Benchmarks").

Because the crate is published to crates.io, the library surface is deliberately not a stable public Rust API: every module is #[doc(hidden)], the crate-root rustdoc says so in as many words, and no item carries a semver guarantee. The only supported compatibility surface stays the binary's contract — the CLI flags (src/cli/), the reserved exit-code band (src/exit.rs, docs/exit-codes.md), and the JSONL schema_version (src/events.rs, docs/schema.md) — exactly as before this split. This document records only the target structure; the fuzz and benchmark tiers themselves (T-186, T-187) are documented in CONTRIBUTING.md ("Fuzzing") and README.md ("Benchmarks") respectively.

Data flow of one run

A processkit-cli run moves through the same sequence on every platform, implemented in run::execute (src/run/mod.rs) and run::launch::run_async (src/run/launch.rs):

  1. (--detach only) Hand the run to a detached copy. run::detach::start_detached re-spawns this binary on the caller's own argv with --detach removed (plus --run-id/--no-echo where the caller left them out) — in a new session on Unix (setsid), as a DETACHED_PROCESS on Windows, with null stdio either way — then waits until that copy's run_started line is readable in --jsonl and exits, reporting only whether the run started (see docs/exit-codes.md, "Detached runs"). The detached copy enters step 1 below and runs every subsequent step unchanged: detaching is a wrapper around this sequence, never a second implementation of it.
  2. Spawn. The child is built from processkit::Command and spawned into a ProcessGroup this process owns — not a shared or global one — so the group's kernel-backed kill-on-drop (Windows Job Object, Linux cgroup/POSIX-group, macOS process group) reaps the whole tree on every exit path this process lives to observe (normal completion, timeout, a local stop signal — Ctrl-C, on Unix SIGTERM/SIGHUP, or on Windows Ctrl-Break/console close/logoff/system shutdown — and a control-plane cancel/kill). That guarantee does not extend to this process's own abrupt death (crash/SIGKILL/TerminateProcess), which skips Drop entirely: reaping a leaked grandchild after the runner itself dies abruptly is a platform-derived tri-state — whole_tree on Windows (Job Object survives the owner's abrupt death), direct_child_only on Linux (kill_on_parent_death/PR_SET_PDEATHSIG, direct child only), none on macOS/BSD — surfaced per run as run_started's abrupt_cleanup field (see K-005). A run_started event (run id, root PID, containment mechanism, abrupt-cleanup tri-state, working directory) opens the JSONL stream.
  3. Select the I/O path. By default, processkit's line pump concurrently reads the child's stdout/stderr and drives two things off the same read: the live echo to this process's own stdout/stderr (src/run/launch.rs), and — when --capture-dir is set — the per-stream tee in src/capture.rs. --no-echo swaps only the echo sink for a discarding one (tokio::io::sink() in place of tokio::io::stdout()/stderr()); the pump, the --capture-dir tee, and the --idle-timeout clock's re-arming on every observed chunk are all unaffected — see src/run/launch.rs's sink-selection block and K-050. --detach reuses that same swap rather than adding a second suppression path: the detached copy is started with --no-echo (step 0). --inherit-stdio instead gives the child the runner's three handles directly; it conflicts with capture (and with --no-echo, which has no pump to act on in this mode, and with --detach, which has no terminal to hand over), so there is no pump or tee. When ProcessKit selects POSIX process-group containment and stdin is a terminal, run temporarily assigns the terminal's foreground group to the child and restores the original group during cleanup. A members_snapshot event records the container's member list — enriched with ppid/executable name/start_time via ProcessKit's members_info() wherever the platform can report them (docs/schema.md, "Enriched member fields") — in either path. With --snapshot-interval the same event is re-emitted on that cadence (reason: "interval") until the ending is decided, from an extra never-resolving arm of the run's existing select! race rather than a thread or a spawned task, and reading the container's member list rather than the pump — which is why it composes with --inherit-stdio too.
  4. Capture and hash. src/capture.rs's CaptureTee mirrors every byte the pump observes into a bounded capture file per stream — independent of whether that byte is also echoed (--no-echo does not change what is captured) — hashing what actually reached disk with src/hash.rs's incremental SHA-256 and recording an explicit ceiling-truncation flag and write-error flag — never inferred from the file's size. This stage is a no-op, with no capture files and no output_captured event, unless --capture-dir was passed; clap rejects --capture-dir together with --inherit-stdio before the child starts.
  5. Teardown. Runner-imposed endings split into two tiers, not one shared path. --timeout elapsing, a local stop signal (interactive Ctrl-C, on Unix a caught SIGTERM/SIGHUP, or on Windows a caught Ctrl-Break/ console close/logoff/system shutdown), and a control-plane cancel reaching the live runner over src/control/mod.rs all drive the same graceful path: a soft stop (SIGTERM to the tree on Unix; on Windows, which has no POSIX signal, a best-effort WM_CLOSE to any windowed member — nothing at all for the ordinary console child, in which case the grace window still elapses honestly with no soft stop delivered), a --grace wait, then the owning ProcessGroup's kernel-backed hard kill-on-drop. A control-plane kill is not part of that tier: it skips the soft stop and the grace window entirely and hard-kills the whole tree immediately via the same kill-on-drop mechanism — documented as immediate on purpose, not a shorter grace. A normal completion instead reaps via the same drop once the child's own exit is observed (root_exited, then cleanup_started/cleanup_finished).
  6. runner_exit. The terminal JSONL event closes the stream, always carrying the outcome — the child's own exit code on a normal completion, or the reserved code for whichever runner-imposed ending fired (timeout/cancelled/killed, or a control-plane cancelled/killed). The process's own exit code (see docs/exit-codes.md) mirrors that same outcome, so a shell that never reads the JSONL stream still gets a faithful, distinguishable signal.

Control-plane contour

inspect, cancel, and kill (src/control/mod.rs) never address a live run by PID; they resolve it through the run registry (src/registry/mod.rs):

  1. Registry scan. registry::Registry::entries lists every record in the per-user registry directory and classifies each by probing the record's advisory liveness lock — a dead runner's leftover record is detected this way, not by mere file existence — as [registry::Health::Live], confirmed-dead [registry::Health::Stale], or (when the probe itself could not run, e.g. permission denied) [registry::Health::Unprobed]; this control-plane contour only ever acts on Live, so Stale and Unprobed are equally unactionable here — but not equally reportable: the refusal names a stale entry as a gone runner and an unprobeable one as unprobed (liveness unknown), the same distinction list/prune/wait draw (see docs/registry.md).
  2. Endpoint resolution. control::resolve_live_endpoint matches the requested run_id against the live entries only. More than one live match is an ambiguous run id — a hard CONTROL (103) failure for every verb, never a guess at which entry the scan happened to return first. The mutating verbs (cancel/kill) additionally re-run this resolution immediately before writing the verb (mutate_async), narrowing — though not fully closing — the TOCTOU window against a duplicate registering mid-flight (see docs/control-plane.md, "Ambiguous run id").
  3. Verb over transport. The client connects to the resolved endpoint — a unix domain socket or a Windows named pipe, both owner-restricted — and speaks the shared line-oriented wire protocol: one request-verb line out, one JSON reply line in. inspect is read-only and prints a Snapshot; cancel/kill are mutating and reuse run's own teardown tiers exactly as described above — cancel the graceful soft-stop → grace → hard-kill tier, kill the immediate hard-kill tier — replying with a ControlAck before the run ends.

See docs/registry.md for the registry's location, record format, and staleness signal, and docs/control-plane.md for the full wire protocol and all three unreachable-runner cases (stale entry vs. unprobeable entry vs. died mid-conversation).

Boundary with processkit

processkit-cli is a thin, standalone wrapper: the processkit crate is the single source of truth for containment, teardown, PID-reuse discipline, and process-tree lifecycle semantics, and this repository builds strictly on its public API rather than reimplementing any of that — a genuine gap becomes an additive request in ProcessKit-rs's own backlog, never a local fork of the semantics (this is a settled, repository-wide decision the module docstrings cite verbatim). Concretely:

  • What processkit owns: the kernel-backed container (ProcessGroup) and its kill-on-drop teardown, the async child-output line pump (with a byte-capped OutputBufferPolicy), direct StdioMode::Inherit, and the environment builder (Command::env/env_remove/env_clear) run's --env* flags map onto directly.
  • What this runner owns: the CLI surface (src/cli/), the versioned JSONL event contract and argv redaction (src/events.rs), the reserved runner-own exit-code band (src/exit.rs), bounded diagnostic capture with hashing (src/capture.rs, src/hash.rs), the per-user run registry (src/registry/mod.rs), and the live-run control plane (src/control/mod.rs)/preflight probe (src/probe.rs) built on top of it, plus the temporary POSIX foreground-terminal handoff required by ProcessKit's separate process-group mechanism — none of which processkit itself provides.

README.md's introduction states the same division for a project outsider: processkit-cli "runs one program inside ProcessKit's kernel-backed containment boundary and reports the run lifecycle," while "ProcessKit-rs remains the sole owner of containment, teardown, PID-reuse discipline, and lifecycle semantics." Out of scope entirely: IPC-to-child protocols beyond the control plane above, scheduling/pooling/retries beyond what processkit::Command offers, a shell mode, and PTY support (deferred in the core crate).

Test tiers

Four tiers, increasing in weight and decreasing in how often they run:

  • Unit. Each module under src/ carries its own #[cfg(test)] mod tests (for example the SHA-256 vector tests in src/hash.rs, or the ProcessGroup/Emitter-driven helper tests in src/run/teardown.rs). The modules now live in the library (src/lib.rs), so these run as its --lib tier under a plain cargo test — no cargo test --bin processkit-cli workaround, which the earlier bin-only layout needed to reach module-private helpers. Internal helpers are tested against real processkit/Emitter objects, never a mock layer.
  • Integration. tests/ drives the built binary (env!("CARGO_BIN_EXE_processkit-cli")), not the library, because the value this crate adds over ProcessKit-rs's own suite is the binary plus its contracts: tests/run.rs, tests/events.rs, tests/registry.rs, tests/probe.rs, and tests/integration.rs cover through-the-binary scenarios, sharing fixtures/helpers from tests/common/mod.rs. This is the default cargo test tier.
  • End-to-end (e2e, feature-gated). tests/e2e.rs is heavier still: it spawns real multi-level process trees, observes liveness from outside the runner (an OS process-table probe, not the container's own member list), and stresses concurrent runs, nested Windows Job Objects, PID-reuse storms, abrupt runner death, and inherited stdio through a real Windows console or POSIX pseudo-terminal. It is gated behind the e2e Cargo feature (with its src/bin/e2e_helper.rs worker binary) so it stays off in the default cargo test and runs explicitly via cargo test --features e2e --test e2e -- --nocapture; CI runs it as a separate job. See CONTRIBUTING.md, "End-to-end tests".
  • Concurrency stress (stress, feature-gated). tests/stress.rs targets what the tiers above cannot reach by construction: the invariants that only break when many runs contend for the two resources every run shares — the per-user registry (src/registry/mod.rs) and the per-run control plane (src/control/). Where the e2e tier scripts a fixed handful of processes, this one launches dozens of simultaneous run invocations against one registry directory and drives parallel list/prune/wait/inspect/cancel/kill clients at them, asserting that prune never 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 with CONTROL (103) inside a bounded deadline rather than hanging, and that wait never misses — or invents — a completion. Every scenario carries a positive control, so none of those "never" assertions can pass vacuously. Gated behind the stress Cargo feature and run via cargo test --features stress --test stress -- --nocapture; CI runs it as a separate, non-gating scheduled workflow (.github/workflows/stress.yml), not on every PR. See CONTRIBUTING.md, "Stress tests".

The property-based (proptest), fuzz (cargo-fuzz), mutation (cargo-mutants), and benchmark (criterion) tiers cut across this ladder rather than sitting on a rung of it — each re-examines code the tiers above already cover, from a different angle. They are documented in CONTRIBUTING.md ("Fuzzing", "Mutation testing") and README.md ("Benchmarks").

Normative documents

Each area of the compatibility surface has its own normative document; this overview only sketches how they connect:

  • docs/registry.md — the per-user run registry: location, record format, staleness signal.
  • docs/control-plane.md — the local transport, the wire protocol, and the inspect/cancel/kill clients.
  • docs/exit-codes.md — the reserved runner-own exit-code band and the child-fidelity rule.
  • docs/schema.md — the versioned JSONL lifecycle-event schema.
  • docs/threat-model.md — untrusted inputs, the trusted principal and boundary, and which security threats the mechanisms sketched above (owner-only registry/transport, argv redaction, bounded control-plane reads, fail-closed probe, bounded output capture, supply-chain scanning) actually close.
  • docs/ROADMAP.md — the delivery status and the remaining ProcessKit-rs dependencies (this document describes the implementation).

Architecture decision records

Architecture decision records (ADRs) preserve why a durable project choice was made, including rejected alternatives and accepted trade-offs. They complement the current architecture description and changelog: an ADR is thematic and remains useful when the code around the decision moves.

Index

ADRDecisionStatus
0001Keep child streams and runner diagnostics separateAccepted
0002Redact argv by defaultAccepted
0003Keep control in the live runnerAccepted
0004Scope cleanup to owned containersAccepted
0005Keep command execution shell-freeAccepted
0006Poll the registry for detached waitsAccepted

Adding a decision

Use the next four-digit number and a short kebab-case title. Link the new record from this index and docs/SUMMARY.md. Record supersession in both the old and new ADR rather than rewriting history.

# NNNN: Decision title

- Status: Proposed | Accepted | Superseded by ADR-NNNN
- Date: YYYY-MM-DD

## Context

What forces a choice, including relevant constraints.

## Decision

The durable rule in imperative, testable terms.

## Alternatives considered

The credible alternatives and why they were not selected.

## Consequences

Benefits, costs, operational effects, and follow-up obligations.

0001: Keep child streams and runner diagnostics separate

  • Status: Accepted
  • Date: 2026-07-29 (retrospective)

Context

The CLI is both a transparent command runner and a structured lifecycle-event producer. Mixing either JSONL or runner diagnostics into child stdout would corrupt payload protocols and make faithful stream forwarding impossible.

Decision

Forward child stdout only to runner stdout and child stderr only to runner stderr. Write lifecycle events only to the required --jsonl file. Write runner-owned human diagnostics to stderr, never to child stdout. Keep capture files per stream.

The normative event contract remains the JSONL schema; I/O modes and capture behavior are documented in Standard I/O and capture. The implementation is split between the event writer, capture tee, and run launch path.

Alternatives considered

  • Emit JSONL on stdout. Rejected because it aliases machine events with arbitrary child output.
  • Merge child stdout and stderr into one diagnostic stream. Rejected because it destroys ordering-independent stream identity and breaks consumers.
  • Prefix relayed output. Rejected because pass-through bytes must remain unmangled.

Consequences

Consumers can parse child stdout without filtering runner records and can tail the JSONL file independently. The CLI must maintain separate pumps and capture metadata, and every new diagnostic path must be reviewed for its destination.

0002: Redact argv by default

  • Status: Accepted
  • Date: 2026-07-29 (retrospective)

Context

Command lines routinely contain tokens, connection strings, paths, and customer data. Lifecycle files and registry records outlive terminal output and are natural inputs to automation, so copying raw argv into them by default widens secret exposure.

Decision

Publish a SHA-256 argv fingerprint and a fixed, non-secret worker-shape hint by default. Publish raw argv only under explicit --argv-raw. Reuse the same fingerprint in lifecycle events and registry discovery so the two views cannot drift. Keep environment-file values out of argv and events.

See Command redaction and the registry's command identity. The shared fingerprint and hint construction lives in the event model used by both artifacts.

Alternatives considered

  • Record raw argv by default. Rejected because convenience does not justify routine secret persistence.
  • Drop all command identity. Rejected because operators still need to correlate and classify runs.
  • Apply heuristic substring redaction. Rejected because an allow/deny list cannot recognize every secret format and creates false confidence.

Consequences

Default diagnostics support equality and known-worker classification without disclosing arguments. Operators who opt into raw argv accept its storage risk. Hint rules and fingerprint canonicalization are public schema behavior and require tests when changed.

0003: Keep control in the live runner

  • Status: Accepted
  • Date: 2026-07-29 (retrospective)

Context

inspect, cancel, and kill need access to the exact ProcessKit group owned by a specific live run. A PID can be reused, and named kernel objects would expose a second lifecycle and security surface with platform-specific ownership semantics.

Decision

Host the control server inside the live run process. Resolve a run_id through an owner-only per-user registry, then connect to the recorded Unix-domain socket or Windows named pipe. Reconfirm the exact registry record around dispatch. Treat a dead runner as stale; never reconstruct control from a PID.

Protocol and snapshot details live in the control-plane guide, with lifecycle discovery in the registry guide. The stable implementation entry points are the control facade and registry facade.

Alternatives considered

  • Address the root PID directly. Rejected because PID reuse can redirect a command to an unrelated process and cannot recover the owned container handle.
  • Publish globally named Job Objects or other kernel containers. Rejected because semantics and ACLs diverge across platforms and outlive the runner differently.
  • Run a permanent daemon. Rejected because the one-command runner does not require a new service lifecycle or privileged broker.

Consequences

Only the process that owns the container can answer or mutate it. IPC and registry artifacts need owner-only permissions and bounded conversations. Abrupt runner death makes control unavailable but leaves a detectable stale record and the platform's documented abrupt-cleanup guarantee.

0004: Scope cleanup to owned containers

  • Status: Accepted
  • Date: 2026-07-29 (retrospective)

Context

Build tools commonly reuse generic executable names, and unrelated users or runs may host identical workers. Name-based cleanup and recursive PID walking are both racy and can terminate processes outside the invocation that requested containment.

Decision

End only members of the current run's ProcessKit container. Let ProcessKit remain the source of truth for membership, graceful stop, escalation, PID-reuse discipline, and kill-on-drop. Never clean up by executable name or by an externally supplied PID.

The ownership boundary is summarized in Architecture and operational teardown in Timeouts and cancellation. The CLI delegates the lifecycle to the run path and its ProcessKit-backed teardown.

Alternatives considered

  • Kill every process with a known worker name. Rejected because it cannot distinguish this run from unrelated builds.
  • Walk descendants from the root PID in the CLI. Rejected because membership races process exit/reuse and duplicates core ProcessKit semantics.
  • Reimplement missing containment behavior locally. Rejected because two sources of teardown truth would inevitably disagree.

Consequences

Cleanup is narrow, auditable, and portable through the public ProcessKit API. A core capability gap must be requested upstream rather than patched with an unsafe local fallback. Diagnostics may mention unrelated lookalike processes, but never act on them.

0005: Keep command execution shell-free

  • Status: Accepted
  • Date: 2026-07-29 (retrospective)

Context

Implicit shell parsing changes quoting across platforms, expands variables and globs, and introduces an injection boundary between an automation adapter's argv and the program that receives it.

Decision

Interpret every token after -- as <program> <args...> and pass it directly to ProcessKit. Do not provide a shell mode. A caller that needs shell language must name the shell explicitly (sh -c, cmd /c, or equivalent) and therefore owns the shell's quoting and security boundary.

See Running commands for platform examples. The boundary is declared by the CLI parser and passed to the child without a shell by the launch path.

Alternatives considered

  • Add --shell. Rejected because its quoting semantics cannot be portable and it makes unsafe interpolation deceptively convenient.
  • Join argv into one command string. Rejected because round-tripping arbitrary arguments through a shell is lossy and injection-prone.
  • Auto-detect shell metacharacters. Rejected because implicit behavior is harder to reason about than an explicit shell executable.

Consequences

Adapters can construct argv without another parser rewriting it, and Windows/Unix behavior stays aligned. Shell pipelines require a deliberate extra program token; examples must make that boundary visible rather than implying expansion by the CLI.

0006: Poll the registry for detached waits

  • Status: Accepted
  • Date: 2026-07-29 (retrospective)

Context

A detached caller is not the runner's parent and cannot wait on its process handle. The control channel is deliberately tied to a live runner and may be busy, while the registry already owns the authoritative live/stale/unprobed classification needed for discovery.

Decision

Implement wait as bounded polling of the owner-only registry, without contacting the runner or mutating registry state. For --run-id, require one unambiguous live record. For --all, snapshot the exact confirmed-live record paths once and poll only that finite set; registrations after the snapshot are outside the wait.

The full state model and timeout distinction are documented under Waiting and in the exit-code contract. The polling and snapshot rules are implemented in wait.rs.

Alternatives considered

  • Hold a control-plane connection until exit. Rejected because wait needs no live group operation and would couple observation to the sequential IPC server.
  • Wait on runner PIDs. Rejected because the caller owns no process handle and PID reuse would weaken identity.
  • Re-scan all matching run ids on every aggregate poll. Rejected because new runs could extend wait --all forever and duplicate ids would create false ambiguity.

Consequences

Wait is read-only, restartable, and independent of control-channel availability. Polling introduces bounded detection latency. Its own deadline returns WAIT_TIMEOUT without ending any run, and unprobed registry state must remain honest rather than being treated as confirmed completion.

Release process

This is the single maintainer-facing reference for how a release happens: what you click, what .github/workflows/release.yml does step by step, what repo configuration it needs, and how to recover from a failure partway through. It supersedes the old root-level release-token-bypass.md (folded in below as its own section).

Triggering a release

The workflow is workflow_dispatch-only — it has no push:/tag trigger, so it never runs on its own. In the Actions UI, run Release and pick a version bump (patch / minor / major). It always runs from main (an explicit "Require main" step rejects any other ref, even if you pick one from the dropdown). The version number is never typed by hand: the bump you pick is applied to whatever Cargo.toml currently says, and that derived version then drives the commit, the tag, and the GitHub Release, so the three can never drift apart. The very first release (no v* tag exists yet) ignores the chosen bump and ships the current Cargo.toml version as-is.

What the release job does, step by step

  1. Mint GitHub App token (conditional). If repo variable RELEASE_APP_ID is set, mints a short-lived GitHub App installation token, used further down to push as the App instead of the default GITHUB_TOKEN. See "GitHub App bypass for a protected main" below for why and how to set this up. Skipped entirely when the variable is empty.
  2. Checkout with full history (fetch-depth: 0, needed for tag-based version math and for git-cliff to walk commits) and the token from step 1 (or GITHUB_TOKEN as a fallback) so the later push carries the right identity.
  3. Require main — fails fast if the workflow was dispatched from anything other than refs/heads/main.
  4. Preflight — require CRATES_IO_TOKEN — fails fast, before any of the slower work below, if the CRATES_IO_TOKEN repo secret isn't set.
  5. Determine version — parses the current version from Cargo.toml. If a prior v* tag exists, applies the chosen bump (major/minor/patch) to it; otherwise (first release) keeps the current Cargo.toml version unchanged. Exposes version, tag (v<version>) and prev_tag as step outputs.
  6. Verify tag does not exist — refuses to proceed if v<version> is already tagged.
  7. Bump versioncargo set-version writes the computed version into Cargo.toml/Cargo.lock. A no-op on the first release.
  8. Auto-fill empty [Unreleased] from git log — manual CHANGELOG.md entries always win. Only when the ## [Unreleased] section in CHANGELOG.md has no real bullets does this step generate one via git-cliff --config cliff.toml, walking commits since prev_tag (or full history on the first release) and bucketing them by commit-message prefix per cliff.toml's rules (feat/add → Added, fix/bug → Fixed, remove/delete/drop → Removed, refactor/change/update/... → Changed, doc/chore/test/style → skipped, everything else falls back to Changed). Fails the run if there is nothing release-worthy to put there.
  9. Extract release notes — curates the (now non-empty) [Unreleased] body down to only the ### Header sections that have at least one real bullet, dropping placeholder - lines, and writes the result to $RUNNER_TEMP/release-notes.md — deliberately outside the working tree so it neither dirties it (which would abort cargo publish) nor ends up packaged into the crate.
  10. Promote [Unreleased] in CHANGELOG.md — renames the curated ## [Unreleased] heading to ## [<version>] - <date>, leaves a fresh empty [Unreleased] above it, and rewrites the Keep-a-Changelog reference links (compare link on subsequent releases, tag link on the first one).
  11. Commit version bump + changelog — commits Cargo.toml, Cargo.lock and CHANGELOG.md locally (not pushed yet, so the next step can verify against a clean tree).
  12. Verify the crate publishes (dry run)cargo publish --locked --dry-run, catching build/packaging/metadata errors before the irreversible step below.
  13. Publish to crates.iocargo publish --locked, retried up to 3 attempts on transient failures. An "already uploaded"/"already exists" response from cargo (a prior run that published but failed before tagging) is treated as success, so a re-run can still proceed to tag + Release.
  14. Tag and push — only after the crate is live: tags v<version> and pushes the commit + tag to main atomically (git push --atomic), so a rejected push can never advance the branch while dropping the tag (or vice versa).
  15. Publish GitHub Release — creates (or, on retry, edits) the GitHub Release for the tag, using the curated notes file from step 9. Retried up to 3 attempts; if it still fails, the job error tells you to finish it by hand with gh release create <tag> --notes-file <notes> and explicitly not to re-run the workflow, since a re-run would bump to the next version from the now-updated main and strand this release.

What the build-artifacts job does

Strictly downstream (needs: release) of the job above — it never bumps, publishes to crates.io, tags, or creates the Release; it only builds and attaches assets to the Release the release job already created. It fans out across a fail-fast: false matrix of seven targets:

  • x86_64-pc-windows-msvc, aarch64-pc-windows-msvc (Windows)
  • x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu (Linux glibc; the aarch64 leg cross-compiles with gcc-aarch64-linux-gnu)
  • x86_64-unknown-linux-musl (static Linux, dependency-free binary; built on ubuntu-latest)
  • aarch64-unknown-linux-musl (static Linux, dependency-free binary; built natively on the ubuntu-24.04-arm hosted runner instead of cross-compiling, since apt has no aarch64-linux-musl cross-gcc package)
  • aarch64-apple-darwin (macOS, Apple Silicon)

For each target, the job:

  1. Checks out the exact tagged commit (ref: needs.release.outputs.tag), so the binary embeds the released version.
  2. Builds cargo build --release --locked --target <triple> (installing a cross linker first for the aarch64-glibc leg; the two musl legs each install musl-tools for their own native architecture instead).
  3. Packages the binary, build.rs's generated shell completions and man pages, and a schema/ directory (schema.json + events.jsonl, copied verbatim from the tracked fixtures/schema/v1/ — not build.rs output, so the fixture stays the single source of truth) into a per-target archive named processkit-cli-v<version>-<triple> (.zip on Windows via 7z, .tar.gz elsewhere via tar).
  4. Computes a <archive>.sha256 checksum right next to the archive (sha256sum on Linux/Windows-Git-Bash; shasum -a 256 fallback on macOS, which ships BSD tools without sha256sum).
  5. Uploads the archive + checksum to the Release with gh release upload --clobber — idempotent, so a re-run of this job replaces rather than duplicates the assets.
  6. Records a signed SLSA build-provenance attestation for the archive via actions/attest-build-provenance, kept last in the leg since the archive and checksum are already on the Release before it runs; a downloader verifies it with gh attestation verify <archive> --repo ZelAnton/ProcessKit-CLI.

Because contents: write/id-token: write/attestations: write are re-declared at the job level here (a job-level permissions: block replaces, rather than merges with, the top-level contents: write), the release job above is unaffected and keeps only the top-level permission it already had.

What the package-manifests job does

This final job waits for the release and every archive-matrix leg to settle. It still runs when a leg failed only in its final provenance-attestation step, because the archive and checksum were already uploaded; a missing required checksum instead fails generation honestly. The job never publishes to an external package repository. It:

  1. Checks out the exact release tag and downloads the Release's archive .sha256 sidecars.
  2. Runs scripts/generate_package_manifests.py, which accepts only a SemVer release and verifies that every sidecar names the exact archive whose URL is being embedded.
  3. Produces the three-file ZelAnton.ProcessKitCLI winget manifest, an architecture-aware Scoop processkit-cli.json, and a Homebrew processkit-cli.rb formula for macOS Arm64 plus Linux x86_64/Arm64. The Linux formula deliberately uses the static musl archives rather than inheriting the release runner's glibc floor, for both architectures.
  4. Syntax-checks the JSON and Ruby output, packages the complete directory as processkit-cli-v<version>-package-manifests.tar.gz, and checksums that bundle.
  5. Attaches the individual manifests, bundle, and bundle checksum to the existing GitHub Release with --clobber idempotence.

Winget retains its external microsoft/winget-pkgs review. Scoop and Homebrew receive ready-to-copy files for an account-owned bucket/tap, but those separate repositories are not mutated and need no credentials in this project. This keeps all external channel availability out of the crate/tag/Release critical path.

Required repository configuration

  • CRATES_IO_TOKEN (secret) — required. Publishing to crates.io fails the preflight check immediately if it's missing.
  • RELEASE_APP_ID (variable) + RELEASE_APP_PRIVATE_KEY (secret) — optional. Only needed once main is protected by a ruleset that would otherwise reject the release commit/tag push (see the next section). Until RELEASE_APP_ID is set, the "Mint GitHub App token" step is skipped and the push falls back to the default GITHUB_TOKEN — fine while main is unprotected.
  • The GITHUB_TOKEN used to create/edit the GitHub Release and to upload build artifacts and package manifests is the default one GitHub Actions provides; no setup needed beyond the job-level permissions: blocks already in the workflow.

Recovering from a failure partway through a release

The steps are ordered specifically so that the one truly irreversible action — publishing to crates.io — happens before anything is pushed or tagged, and so build-artifacts only ever adds optional, re-buildable assets on top of an already-complete release:

  • Failure before "Publish to crates.io" (version bump, changelog generation/promotion, dry-run publish, etc.): nothing has left the runner. Just re-run the workflow from the same bump input; the earlier local commit is discarded with the job.
  • Failure during/after "Publish to crates.io" but before "Tag and push": the crate version is live on crates.io but main hasn't moved and no tag exists yet. Re-run the workflow — the "Publish to crates.io" step treats an "already uploaded"/"already exists" response as success and proceeds to tag and push, so this is safe and does not attempt a duplicate publish.
  • Failure during "Tag and push": the atomic push means either both the commit and the tag landed on main, or neither did — never a half state. If neither landed, re-run as above. If it actually did land but the step still reported failure (e.g. a flaky follow-up check), inspect main and the v* tags before re-running to avoid a wasted crates.io no-op.
  • Failure during "Publish GitHub Release": crates.io is published and the tag is pushed — cargo install processkit-cli and cargo add processkit-cli already work. Per the job's own error message: finish the Release by hand with gh release create <tag> --notes-file <notes> and do not re-run the workflow, since a re-run would bump from the already-advanced main and ship the next version, stranding this one.
  • Failure in build-artifacts (one or more matrix legs): the release itself (crate + tag + GitHub Release) is unaffected — this job never bumps/publishes/tags/creates the Release. cargo install processkit-cli and the GitHub Release page both already work; only the prebuilt archive for the failed target(s) is missing. Either re-run just that job (the upload is --clobber-idempotent and the attestation is regenerated), or build it by hand (cargo build --release --target <triple>, package it with the completions/man/schema/ trees + checksum it the same way the job does — see "What the build-artifacts job does" above for the exact contents — then gh release upload <tag> <archive> <archive>.sha256 --clobber).
  • Failure in package-manifests: the crate, tag, Release, and prebuilt archives are already published. Repair or upload any missing checksum sidecar, then re-run this job; generation is deterministic from the tagged script plus those sidecars, and every upload uses --clobber. Do not trigger a new release merely to repair these distributor inputs.

GitHub App bypass for a protected main

.github/workflows/release.yml pushes the release commit (the version bump + promoted CHANGELOG.md) and the v<version> tag straight to main. Once you protect main with a rule that requires pull requests, that direct push is rejected — for every actor except those on the rule's bypass list.

You cannot put the built-in github-actions[bot] on a bypass list (it is a system actor, not an addressable App), and a personal access token expires and ties the push to a human. The supported path is a GitHub App: the workflow mints a short-lived installation token (auto-revoked, no rotation), pushes as the App, and the App sits in the ruleset's bypass list.

When main is not protected, none of this is needed — the workflow falls back to the default GITHUB_TOKEN and the push just works. Set this up only once you turn on PR-required branch protection.

One-time setup

  1. Create a GitHub App (Settings → Developer settings → GitHub Apps → New GitHub App). Minimal config:

    • Repository permissions → Contents: Read and write (to push the commit
      • tag). Nothing else is required.
    • No webhook needed (uncheck Active).
    • It can be private to your account/org; it does not need to be public.
  2. Generate a private key for the App (App settings → Private keysGenerate a private key) and download the .pem.

  3. Install the App on the target repository (App settings → Install App → pick the repo).

  4. Add the credentials to the repo (repo Settings → Secrets and variablesActions):

    • Variable RELEASE_APP_ID = the App's numeric App ID.
    • Secret RELEASE_APP_PRIVATE_KEY = the full contents of the .pem (including the -----BEGIN/END----- lines).

    The workflow's "Mint GitHub App token" step is guarded by if: ${{ vars.RELEASE_APP_ID != '' }}, so until the variable exists the step is skipped and the push uses the default token.

  5. Add the App to the branch-protection bypass list. Use a repository ruleset (repo Settings → RulesRulesets), which — unlike the older "branch protection rules" screen — supports a bypass list:

    • Target branch main, enable Require a pull request before merging.
    • Under Bypass list, add your App (it appears once installed).

    The App can now push directly to main; everyone else still goes through a PR.

Verifying

Dispatch the release workflow (Actions → ReleaseRun workflow → pick a bump). The Mint GitHub App token step should run (not skip), and the Tag and push step should push the Release v<version> commit and tag to main without a protection error. If the push is rejected, re-check that the App is installed on the repo and is actually listed in the ruleset's bypass list, and that RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY are set on the repo (not the org, unless the App is org-owned).

This is ordinary setup documentation: it applies only if main is protected by a ruleset that would otherwise reject the release workflow's tag push.

Roadmap

Delivered in v0.2.0

  1. Runnable containment shell. processkit-cli run executes one shell-free command through the public processkit API, echoes child stdout/stderr, and preserves the child exit code. Timeouts and cancellation use a distinct, documented runner-owned exit-code band.
  2. JSONL schema v1. The normative event schema and golden fixtures cover lifecycle events, cleanup, runner failures, and terminal exit. Events are written to --jsonl, never stdout, and argv is redacted by default.
  3. Bounded diagnostic capture. --capture-dir writes separate bounded stdout/stderr transcripts with full byte counts, hashes, and truncation metadata while preserving live echoed output.
  4. Live-run control plane. The per-user registry and local IPC back inspect, cancel, kill, list, and prune; stale entries are visible and safely reaped without addressing a process by PID.
  5. End-to-end proof. Through-the-binary tests cover leaked descendants, nonzero roots, inherited pipe handles, concurrent runs, control-plane cancellation, and platform-specific teardown behavior. The heavier e2e tier additionally covers abrupt runner death, nested Windows Jobs, PID reuse, and Ctrl-C.
  6. Distribution. Releases publish six prebuilt archives: Windows x86_64 and aarch64, Linux x86_64 glibc and musl plus aarch64 glibc, and Apple Silicon macOS. Source installation remains available through cargo install.

Delivered in v0.2.1

  1. Explicit stdin sources. --inherit-stdin shares the runner's input handle with the child, while --stdin-file <file> streams a checked file through ProcessKit and closes stdin at EOF. Closed/null stdin remains the safe default.

Current development

  1. Interactive inherited stdio. --inherit-stdio passes stdin, stdout, and stderr directly to the child, preserving an existing console or terminal while retaining ProcessKit containment, lifecycle JSONL, the control plane, cleanup, and exit-code fidelity. The default remains pipe + echo; transcript capture and no-console mode intentionally conflict with direct inheritance.
  2. Cross-platform terminal proof. Through-the-binary tests cover piped I/O, a real Windows console, and a POSIX pseudo-terminal, including input, terminal detection, JSONL completion, and descendant cleanup.

Remaining ProcessKit-rs dependencies

The processkit 3 graceful-shutdown contract is now fully consumed. Windows console children can opt into CTRL_BREAK with --windows-graceful-ctrl-break; every runner-imposed graceful ending probes ProcessGroup::soft_stop_scope() before the attempt and records the resulting ShutdownReport in cleanup_finished.shutdown. ProcessGroup::members_info() is likewise consumed for members_snapshot/inspect enrichment (see docs/schema.md, "Enriched member fields").

Whole-tree cleanup after an abrupt runner death is also a core dependency on Unix. The current public primitive kills only the direct child on Linux and is a no-op on macOS/BSD; cgroups and process groups do not disappear with their owner. Until ProcessKit exposes an additive, identity-safe whole-tree owner-death primitive, the CLI reports direct_child_only or none in run_started and does not claim the Windows guarantee on those platforms. Any stronger contract requires additive, identity-safe ProcessKit-rs support and cross-platform abrupt-death proof.

Runtime resource-limit attribution is also a core dependency. limit_hit (see docs/schema.md) today covers only the pre-spawn "could not be applied" branch — a requested cap the platform has no container for, or a Linux cgroup v2 whose controllers can't be enabled. It does not, and currently cannot, cover a cap that was applied and then actually fired during the run (a Linux cgroup OOM-kill or pids fork refusal, a Windows Job Object memory/active-process limit): today's processkit 3.1.0, the version this repository resolves from crates.io (Cargo.lock), exposes no portable post-spawn evidence primitive for that case, so a live limit kill remains indistinguishable from the child failing on its own.

The cross-repo request for that primitive (msg-send-401e87d4625e22218e50a11de4a7f122) has since been answered and implemented upstream: ProcessGroup::limit_evidence() landed on ProcessKit-rs main (ProcessKit-rs task T-243) with a three-valued LimitVerdict::{Tripped, NotTripped, Unknown} per axis — never a boolean — so a future JSONL surface for this must represent "no authoritative evidence" as its own state and must never collapse it into "did not fire" (see docs/resource-limits.md for the platform-by-platform breakdown and the read-before-drop constraint on where a future reader could sit). It is not yet in a published release — the latest tag remains v3.1.0 — so nothing is consumable today and this dependency stays open in practice. This roadmap does not bump or repoint the dependency; the scheduling trigger is the upstream release notification arriving in this project's inbox, at which point wiring limit_evidence() into the JSONL stream — an additive schema change, exact shape (a limit_hit discriminator field vs. a separate event) to be decided when it is planned — gets scheduled. Note that even once wired, this closes the gap on Linux cgroup v2 only: Windows Job Object and POSIX process groups (macOS, the BSDs, the Linux process-group fallback) report Unknown as a measured result, not an unfinished one, so runtime limit attribution will not become available on Windows despite it being a first-class platform for this CLI.