@nredd/pi-dev-tools
Typed pi tools for git, ssh, Python (uv/ruff/ty/pytest) and Rust (cargo): compact structured results, explicit exit-code states, no-shell spawn, bounded output.
Package details
Install @nredd/pi-dev-tools from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@nredd/pi-dev-tools- Package
@nredd/pi-dev-tools- Version
0.7.0- Published
- Oct 29, 2026
- Downloads
- 6,773/mo · 5,142/wk
- Author
- nredd
- License
- MIT
- Types
- extension
- Size
- 202.9 KB
- Dependencies
- 1 dependency · 2 peers
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-dev-tools
Published to npm as @nredd/pi-dev-tools (public). From 0.5.1, each version is cut by the monorepo Publish workflow from a signed @nredd/pi-dev-tools@<version> tag, with npm provenance and a GitHub release carrying the tarball, checksums and manifest (docs/releases.md); earlier versions were published manually without provenance. 0.6.0 moves resource_snapshot onto the published @nredd/pi-resource-sampler 0.1.1 (breaking structuredContent, see Resource awareness). 0.5.1 adds createDevTools(options) (injectable ssh, py, cargo tool options; the default export is unchanged) and fixes process output that dropped a short line sharing a chunk with an oversized one.
Use the monorepo development commands. References below to standalone repositories,
chezmoi pins, and installed-runtime harness gates describe the legacy deployment, not verification
of this package.
Standalone use
The packed tarball is self-contained: Pi host modules (@earendil-works/pi-*, typebox) are * peers supplied by Pi, there are no runtime dependencies and no sibling plugin is required. Verified by installing the tarball alone and running it in exact stock Pi 1.3.0 (see the repository's docs/package-contracts.md). Optional fork integrations (subagents, Plan mode) are absent in that setup; the exercised behavior does not need them.
Typed pi tools for the workflows agents otherwise drive through raw Bash: git, ssh, Python
(uv, ruff, ty, pytest) and Rust (cargo). Each tool spawns the real binary with an argv array,
maps exit codes to named states, bounds output, and returns structuredContent alongside the
compact text the model reads.
Tool groups land one per PR. Current state is in the table below.
| Group | Tools | Status |
|---|---|---|
| core | run, bound, withLock, withFileLock, toolResult (shared, not model-visible) |
done |
| git | git_status, git_diff, git_log, git_show, git_commit, git_worktree_list, git_worktree_add, git_worktree_remove, git_worktree_prune, Bash guard |
done |
| ssh | ssh_exec, ssh_pull, ssh_push |
done |
| py | py_lint, py_format, py_typecheck, py_test, py_uv |
done |
| cargo | cargo_check, cargo_clippy, cargo_fmt, cargo_test, cargo_build, cargo_run, cargo_doc, cargo_bench, cargo_add |
done |
| resource | resource_snapshot |
done |
Conventions every tool follows
- No shell.
spawn(cmd, argv), stdin closed or fed a fixed script, child in its own process group, SIGTERM then SIGKILL 2s later on timeout or abort, hard resolve 1s after that so a grandchild holding the pipes cannot hang the caller. macOS and Linux only - When a command settles, including a normal leader exit, whatever is left of its process group
is SIGKILLed, so a background descendant cannot outlive the tool call. Only the owned group is
signalled (never a bare pid). SSH is the exception: the local
sshis the group, so remote processes it started can keep running after the local client exits - Output is capped at pi's
DEFAULT_MAX_BYTES/DEFAULT_MAX_LINES, truncation notice included. When cut, the full text is written to$TMPDIR/pi-dev-tools-*/<label>-N.txtand the path is named in the result runcaps captured stdout/stderr independently. Its stderr line listener drops only individual oversized lines, through their newline or EOF; valid following lines are delivered even when coalesced into the same chunk or after the capture cap is reached. Listener errors are swallowed- Exit codes become named states (
violations,no-tests,tool-error, ...), never a bare "exit 1" annotations.readOnlyHintis set honestly. Thepi-plan-modefork admits annotated read-only tools in Plan mode; everything else is denied thereoutputSchema+structuredContenton every tool,isError: trueon red with the data intact- Default transcript renderer, so every call settles as one
call · summaryrow
Resource awareness
resource_snapshot({process_limit?, interval_ms?}) is an always-read-only observer for a parent or subagent to call before it knowingly starts a large test or compile. A before_agent_start system-prompt hook carries this guidance in parent and child sessions even while the tool is deferred: discover it with the available tool-search facility first, then use the result to choose targeted scope, workers, or timing. It never forces activation, schedules work, locks commands, or changes concurrency.
- Collection is the published
@nredd/pi-resource-sampler, pinned to exactly0.1.1as a runtime dependency (installed from the registry, never the workspace copy; a test asserts the resolved module). npm would link the same-versionedpackages/resource-samplerworkspace, so the root lockfile carries an explicit nestedpackages/dev-tools/node_modules/@nredd/pi-resource-samplerregistry entry with the published integrity.npm ci/npm installkeep it;npm dedupeor regenerating the lockfile drops it, and that test then fails. Each call creates a private sampler, samples twiceinterval_msapart (default 500, 100..5000), then disposes it, which awaits any helper reaping. No background polling or state survives the call. - Reports logical CPU count, 1/5/15-minute load (
nullon Windows), aggregate host interval CPUcpu.utilization(0..100) andinterval.{requestedMs,measuredMs}, total/free memory, LinuxMemAvailable, and optional macOSmemory_pressure -Qfree percentage or Linux PSIsome avg10from the sampler. Missing optional metrics remainnull, never invented. Sampler0.1.1copiesos.cpus()counters when sampled, so Bun's lazy counters no longer produce a false 0 or 100. - Returns at most 50 process entries, 12 by default: PID, parent PID, interval CPU, RSS bytes and
executable(see privacy below). Interval CPU is a typed metric (availablewith a value where 100 means one logical core, otherwisewarmingorunavailablewith a reason), never a zero stand-in. Linux measures it; Darwin cannot (one-secondpsstart precision cannot exclude PID reuse), so it isunavailablethere.rankingisinterval-cpu(measured entries first, then RSS) orrss. Nopslifetime or decaying average is reported or relabelled. coveragepasses through the sampler's bounded scan (complete,enumerated,sampled,skipped,bytesRead, finitereasons);truncatedis!coverage.complete. The scan is not atomic: a process exiting mid-scan marks it partial (complete: false) even when nothing else was cut, and short-lived work may be missed.executableis the sampler's kernel executable basename: Darwinucomm(kinfop_comm, written at exec from the executable file name, cut to 16 bytes), Linux the basename ofreadlink /proc/PID/exe. Never argv, process title, Linux comm (node-MainThread), environment or user. Sampler0.1.0reported Darwinps -ccomm, which is argv[0] and leaked titles such asnpm exec rea-agents@6.5.0 mcp;0.1.1fixes that.""means unknown (Linux processes of other users without ptrace access, unreadable or over-budget links) and is never filled in; text output prints(unknown). Whoever names, links or copies the executable file chooses the name, so it stays untrusted display text, bounded and control-stripped. Cancellation and sampler failures reject the call rather than returning a partial snapshot, including a cancellation that lands after the second sample while the optional memory probes run. The optional Darwin pressure probe gets the same signal, and failed, aborted, timed-out or truncated pressure probes staynull. A disposal failure, rejected or thrown synchronously, never hides the sample, probe or cancellation error that caused it; after a successful snapshot it rejects the call.- Parent PIDs let a caller see real OS process trees, but OS process metadata has no proven link to a Pi parent, session, or agent. Use actual subagent/task information separately; do not claim process-to-agent attribution from this tool.
- macOS and Linux process metadata are supported. Other platforms return an explicit unavailable reason while retaining the portable memory fields.
API change from 0.5.x (structuredContent): entries drop cpuPercent (ps average) and elapsed, which the sampler does not measure, and gain parentPid plus the cpu metric. Additive: interval, cpu.utilization, processes.ranking, processes.coverage, and the interval_ms parameter. cpuSemantics, truncated, limit and the memory fields keep their names. The resourceSnapshot(limit, options, signal, intervalMs?) seams are now createSampler, readFile and run; the per-OS-primitive seams are gone.
Release: removing structuredContent fields is breaking, so this ships only as @nredd/pi-dev-tools 0.6.0, never a 0.5.x patch (0.5.2 was never published). It is cut by the signed-tag publish.yml flow in releases. The pin is the published sampler 0.1.1 (registry integrity sha512-rPwZxqqG...wisdwg==), whose process-name privacy and Bun CPU fixes this API depends on; 0.1.0 must never be pinned again.
git
Reads (git_status, git_diff, git_log, git_show) are annotated read-only and run with
--no-pager --no-color --literal-pathspecs, GIT_TERMINAL_PROMPT=0, GIT_EDITOR=true,
LC_ALL=C, timeout_s default 60. Refs (base, ref, rev) may not start with - and go
after --end-of-options. git_status parses porcelain v2 (-z, renames and copies carry the
old path, a gone upstream reads gone). git_diff leads with the stat and caps the patch;
git_log is sha date author: subject, max 200; git_show peels tags to the commit. Failures
name a state (not a git repository, unknown revision, path not tracked, no upstream,
push rejected) and return { error } as structured content.
git_commit {subject, why?, changes?, paths, push?=true}:
pathsis required; the repo root in any spelling,..segments, pathspec magic (:/) and flags are rejected. The commit isgit commit -- <paths>, so it contains exactly those files; anything else already staged stays in the index. A rename needs both the old and the new path; a deletion (unstaged or already staged) is named like any other path. Nothing to commit is its own state- The subject must be a capitalized imperative, <= 72 chars, no trailing period, no
conventional-commit prefix, no em or en dash. An
area:prefix is fine - Message is
subject, optionalwhy,Changes:bullets, thenCo-Authored-By: Claude <noreply@anthropic.com>, passed on stdin with-F - - Never
--no-verify,--amendor--no-gpg-sign. Hook output is relayed on failure, andfiles were modified by this hookis flagged as "re-stage and call again". Nothing retries - Push is
git push <remote> HEADwhere<remote>isbranch.<n>.pushRemote, elseremote.pushDefault, elsebranch.<n>.remote, else the only remote (with-u). Several remotes and none configured fails closed atstage: "push"; it never defaults toorigin. Abranch.<n>.remoteof.(local tracking) is not a push remote and counts as unconfigured and ambiguous. Only an unset config key (exit 1) is tolerated, and all three keys are read eagerly, so an unreadable one blocks the push even when a higher-precedence key is set; a broken config orgit remotefails closed with git's stderr.git push <remote> HEADpushes to the same-named branch on<remote>. A push failure returnsstage: "push"with the sha so the caller fixes the push instead of committing twice.signedis true for%G?inG,U,X,Y
Bash guard (tool_call on bash, always on): blocks --no-verify (and -n on commit, where
it means --no-verify; push -n is a dry run and passes), plain --force / -f / +ref
pushes including clustered -fu (not --force-with-lease), and --no-gpg-sign or
-c commit.gpgsign=false. Quoted strings are ignored and git must be the command word of its
segment or substitution, so echo 'git push --force' passes and $(git commit -n) does not.
Known limits, by design (Plan mode is the policy, this is a seatbelt): /usr/bin/git, sudo git,
command git, xargs git, and bash -c '...' bodies are not inspected.
git worktrees
git_worktree_list is annotated read-only (Plan mode admits it); add, remove and prune are
not. All run the real git with the same env and flags as the other git tools, take cwd and
timeout_s (default 60), and return { error } on failure with a named state in the text.
Mutations (add, remove, prune) take two locks keyed on the git COMMON dir: withLock
in-process, then withFileLock across processes, so concurrent pi panes cannot race on
.git/worktrees either. The file lock is an atomic mkdir of <common dir>/pi-dev-tools-worktree.lock
holding owner.json {pid, startedAt, token}, always released in finally (token-checked, never
throws). Only a lock whose owner pid is dead is reclaimed (plus an ownerless directory older than 2s);
there is NO age-based reclaim, a live pid is never broken. Reclaimers serialize behind a short-lived
pi-dev-tools-worktree.lock.reclaim guard directory and re-verify the same token and a dead pid inside
it. The guard records its owner with an immutable <pid>.<token> filename. Only a confirmed dead
guard owner is reclaimed, never a live owner based on age. Release/reclaim unlink only the token-owned
filename, then remove the empty guard directory, never a replacement owner's contents. Empty or
malformed guards (including a crash before owner publication) require manual inspection; bounded
lock-timeout errors name the guard path. Residual risk: PID reuse. A recycled pid makes a stale lock
look live; waiters then fail with
lock-timeout naming the pid, and a human can remove the lock directory. If owner.json cannot be
written the directory is removed and the call fails with lock-failed. Waiting is bounded at 15s,
then the call fails with lock-timeout naming the holder pid. list takes no lock. list and
prune text is capped like every other tool (truncation notice, full text spilled to a temp
file); structured content stays complete.
git_worktree_list: parsesgit worktree list --porcelain -zintopath,head,branch(short name),detached,bare,main(first entry),locked/lockReason,prunable/prunableReason. Text is one line per worktreegit_worktree_add {branch, base?, path?}: default path is<main worktree>-<slug>next to the main worktree, where slug is the branch lowercased with anything outside[a-z0-9._-]->-. A missing branch is created with-bfrombase(default HEAD); an existing branch is checked out,baseis refused for it, and a branch checked out in another worktree is refused with that path (branch checked out elsewhere). Also refused: invalid branch name, unknownbase, existing non-emptypath.branchandbasemay not start with-and go after--end-of-optionsgit_worktree_remove {path, force?}: refuses the main worktree, the session cwd (orcwd) and any parent of it (cwd is inside), a locked worktree even withforce, and a dirty one (modified or untracked) unlessforce. A worktree whose directory is gone points at prune. The branch is always keptgit_worktree_prune {dry_run?}:git worktree prune --verbose; reports{name, reason}per record,dry_runchanges nothing
Preflight checks (branch exists, base resolves, path state) separate "no" from "could not tell":
a timeout, abort, spawn failure or unexpected exit is reported as such, never as a missing
branch or bad revision. An unreadable target path is path unreadable (EACCES); a path removed
mid-check counts as free. If the final HEAD lookup fails after the worktree was created, the
error says head-unreadable and that the worktree exists.
States: branch checked out elsewhere, branch exists, path exists, path unreadable,
dirty worktree, locked worktree, main worktree, lock-timeout, lock-failed, head-unreadable, plus the shared not a git repository, unknown revision.
The Bash guard does not steer raw git worktree ...; it only blocks hook-skipping and force
pushes, as for every other git command.
ssh
ssh_exec {host, command, cwd?, timeout?=30} runs the user's own ssh with
-T -a -x -o BatchMode=yes -o ConnectTimeout=8 -o ClearAllForwardings=yes -o RequestTTY=no -- <host> 'exec sh -s', so the per-host ForwardAgent/ForwardX11 in ~/.ssh/config is
overridden and nothing can prompt.
hostmust match^[A-Za-z0-9][A-Za-z0-9._-]*$and be a concreteHostalias in~/.ssh/config(Includes followed, wildcards and!entries skipped). Option injection is impossible by construction and the--keeps it that way- The command travels on stdin: written to a remote
mktempfile through a quoted heredoc with a per-call random delimiter, run assh "$tmp" </dev/nullinside( ... )(so acatorreadin the command cannot eat the wrapper), then aprintf '\n__PI_RC=%d'sentinel. A trap removes the temp file even when the local ssh dies first. Inside the command$0is the temp path and there are no positional arguments. No remote argv quoting, no command inps, multi-line scripts and heredocs work, anexit Nends the command rather than the shell, and unbalanced quotes or parentheses stay inside the file (sh exits 2 with a syntax error) - States:
ok,command-failed(remote exit, including 255; NOT a tool error,grepexiting 1 is normal),cwd-missing(cdfailed: marker plus exit 97, so neither a command exiting 97 nor one printing the marker can fake it),ssh-failed(no sentinel: connection failure or a remote withoutmktemp),timeout,aborted. A timeout or abort kills the local ssh only; the remote command may keep running - Output and the spill file are redacted for
Bearer ...,token|secret|password|api_keystyle assignments in any case or quoting, and JWT signatures. Count-like keys (TOKEN_COUNT=) are over-redacted on purpose - No ControlMaster: measured steady-state gain was ~70ms per call, not worth stale sockets
- No
stdinparameter (the plan had one): the script already owns stdin, a heredoc incommandcovers the need, and Plan mode's gate refuses the key anyway - Plan mode: the
pi-plan-modefork admitsssh_execas annotated read-only and re-validates{host, command}againstsafeSubcommands.sshand the remote reviewed policy, sorm,cd, redirects and pipes to interpreters are blocked there. Outside Plan mode the tool runs whatever it is given, likebash
ssh_pull {host, remote_path, local_path, overwrite?} and ssh_push {host, local_path, remote_path, overwrite?} use scp -O -q with the same options and refuse to overwrite unless
asked (ssh_push probes the remote path with test -e first; a small race remains between probe
and copy). Legacy scp hands remote_path to the remote shell, so it is restricted to
[A-Za-z0-9._/+@%:=,-]: no spaces, quotes, ~ or metacharacters. ssh_pull is read-only,
ssh_push is not. Failures are named: missing, permission denied, connection failed.
~/.ssh/config parsing follows OpenSSH: relative Include paths resolve against ~/.ssh, not
the including file; Host=x, quoted names, trailing comments and CRLF are handled.
py
All five run uv run --frozen <tool> from the nearest pyproject.toml above cwd (the walk
stops at a .git boundary, so a stray ~/pyproject.toml cannot capture a repo), so the
project's pinned ruff/ty/pytest apply and uv.lock is never rewritten as a side effect (plain
uv run re-locks when pyproject.toml drifted). uv's own failures (no lockfile, tool not in
the environment) are reported as such, not as findings. Calls on one project are serialized
through the shared lock; a waiting call says so. NO_COLOR and UV_NO_PROGRESS are set.
Every positional the model supplies (paths, targets, select, packages) must be a plain
value and goes after --; -k/-m travel as -k=EXPR.
py_lint {paths?, fix?, select?, limit?=50}:ruff check --output-format concise;fixadds--fix --ignore-noqa(the Makefile gate's form) and reportsfixed. States:clean,findings(exit 1), tool error (exit 2). Onepath:line:col CODE [fixable] messageper finding; lines the parser does not recognise are kept underunparsedinstead of droppedpy_format {paths?, mode: check|diff|apply}:ruff format --diffforcheck(file list) anddiff(plus the capped diff), write mode forapply. Statesclean,would-reformat,reformattedpy_typecheck {paths?, limit?}:ty check --output-format concise, parsed asfile:line:col severity[rule] messagepy_test {targets?, k?, markers?, maxfail?=5, last_failed?, coverage?}:pytest --verbosity=-1 --tb=short -p no:cacheprovider --maxfail=N(--verbosity=-1is absolute, so a projectaddopts = "-q"cannot hide the summary;--lfre-enables the cache plugin;coverageadds--cov=src --cov-report=term-missing). Exit codes map topassed,failed,interrupted(2, collection error),usage-error(4),no-tests(5, not an error). Only failures are listed, from the short summary, with the failure block below them;stoppedEarlysays the run hit--maxfail. There is noextra_args: verbatim flags such as--basetempor-pwould let a read-only tool write, and Plan mode admits this toolpy_uv {action: sync|lock|add|remove, packages?, group?, dev?, all_extras?=true, all_groups?=true, locked?}:syncdefaults to--all-extras --all-groups(the Makefile uses--all-extras; groups are added so dev tools are present too);lockedfails loudly on a stale lock
Annotations: py_typecheck and py_test are read-only and admitted in Plan mode. py_lint
and py_format carry a rewriting mode, so they are not (known cost: no lint or format check in
Plan mode; splitting the fix modes into their own tool would lift it); py_uv reaches the
network. All five are deferred by default (found via search_tools), per the always-active
budget.
cargo
All nine run from the outermost Cargo.toml with [workspace] above cwd (else the nearest
manifest), default to --all-features, accept package (-p) and toolchain (rustup run),
set CARGO_TERM_COLOR=never, and serialize per workspace through the shared lock (keyed on
the workspace root, not the target dir); cargo's Blocking waiting for file lock line and
Compiling progress are relayed as updates. Every bare value the model supplies (package,
features, bin, ...) must be a plain identifier-ish token; crates for cargo_add may
also carry version requirements (serde@^1); filters only need to not start with -.
A relative CARGO_TARGET_DIR resolves against the workspace root. Criterion lookups honor
that environment variable, but do not consult build.target-dir from .cargo/config.toml.
Compile diagnostics (cargo_check, cargo_clippy, cargo_build, cargo_doc) come from
--message-format=json-diagnostic-short: keyed on reason, errors and warnings only, deduped
on (level, code, primary span, message) because cargo emits one copy per target, with the
~900-char code.explanation dropped and the first suggested_replacement shown. One
file:line:col: level[code]: message per finding, errors first, capped by limit. States
clean, warnings (exit 0 but counted), errors. A non-zero exit with no compiler errors
(manifest error, missing toolchain) is a tool failure with cargo's stderr.
cargo_check {all_targets?=true}mirrorsmake checkcargo_clippy {pedantic?=true, fix?, deny_warnings?}mirrorsmake lint(--all-targets --all-features -- -W clippy::pedantic);fixadds--fix --allow-dirty --allow-stagedcargo_fmt {check?=true}:cargo fmt --all -- --check --color never(rustfmt colours even when piped);Diff in <file>:<line>:blocks give the file listcargo_test {filter?, no_fail_fast?=true, doc?=true, retries?, nocapture?}:cargo nextest runwith api-dev-toolsprofile layered UNDER the repo's own nextest config via--tool-config-file(repo retries, slow timeouts and groups still apply), junit written to an absolute path in a per-call temporary directory, removed after the run. Report lookup is independent ofCARGO_TARGET_DIR, Cargo'sbuild.target-dirand nextest's[store].dir(nextest's store does not follow Cargo's target directory). A repo override of thepi-dev-toolsprofile's junit path is reported as missing output.<failure>and<error>both count, flaky retries do not; thencargo test --doc --no-fail-fast, skipped (and said so) when afilteris set. A filterset expression (any of( ) | & !or a leadingnot) goes to-E, a bare string is a substring match. nextest's exits map topassed,failed(100),no-tests(4),compile-error(101). Passing tests are never listed; a doctest failure flipspassedtofailedcargo_build {bin?, example?, release?}returns the executables fromcompiler-artifactcargo_run {bin?, example?, args?, stdin?, release?}: stdin closed unless given, output capped; a non-zero exit isnonzero-exit, not a tool error, and only cargo's owncould not compilecounts ascompile-error(a program may printerror:and exit 101)cargo_doc:--no-depswithRUSTDOCFLAGS=-D warnings, like the docs gatecargo_bench {bench?, filter?, save_baseline?, baseline?}: reads criterion'snew/estimates.json(ns) andchange/estimates.json(fraction) written since the run started; baseline flags imply--benchesbecause libtest harnesses reject themcargo_add {crates, dev?, build?, features?, optional?, dry_run?}reportsname@version
Annotations: cargo_check, cargo_test, cargo_doc are read-only; cargo_add reaches the
network; the rest write the tree or target/. All nine are deferred (found via search_tools).
Development
Wrapper coverage measurement, executable-boundary scope, fixture isolation, and Node/Bun commands: coverage evidence. External tool implementations are not part of this package's coverage denominator.
# From the monorepo root:
make install
npm run check --workspace @nredd/pi-dev-tools # biome, tsc --noEmit, vitest, bun test test/scenarios
Harness gate before any tag: HARNESS_LOCAL=pi-dev-tools make verify in ~/code/pi-harness-e2e.
Pin lives in chezmoi third_party/pi-agent-settings.base.json; see ~/.pi/agent/README.md.
Layout
src/core/shared runner, output bounding, lock, result shapingsrc/<group>/one module per tool group, registered fromsrc/index.tstest/<group>/vitest,test/fixtures/<group>/recorded real tool outputtest/scenarios/the extension inside a realAgentSession(@earendil-works/pi-coding-agent/testing): faux model tool calls, pi's validation,tool_callhooks, the realbashtool and result serialization. Run under Vitest andbun test, so they use no runner-specific API