npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rackbops/ac-agent

v2.0.0-beta.1

Published

The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.

Readme

@rackbops/ac-agent

The per-machine host agent for artifact-console: a small HTTPS server, paired to a console over a token, that lets it reach one dev machine's claude/git/gh and file system through an allowlisted verb API. Console-initiated pairing (a one-time code exchanged for a per-machine token, #48), repos list/status/pull and open (#49, 49a), exec/claude (with the job registry and its SSE log) plus exec/gh (#49, 49b), and the context-pressure / subagent-cost session-log scans (#52, 52a), and memory-footprint / toolchain-inventory (#52, 52b) are all live -- every scan/:kind kind #52 planned has landed -- as are the OneDrive mirror (publish/mirror, #53) and the tunnel-config lint (tunnel/lint, #416). Every verb also requires a per-plugin capability grant, pushed by the console and enforced here (#50) -- see Verbs and Security model below. GET /v1/preflight (#55) -- git/gh/node/claude and (#828) docker/cloudflared executed, chromium only resolved -- is control plane, not a verb; see Preflight.

Install

npx @rackbops/ac-agent install

Install with the plain command above, no dist-tag: since #564, every release -- prerelease included -- publishes with --tag latest while 2.0 is prerelease-only, so an unversioned npx @rackbops/ac-agent resolves to the newest release. Two older tags are frozen: alpha (the very first publish, 2.0.0-alpha.8, #268, was done by hand under --tag alpha) still points at 2.0.0-alpha.8, and next (what release.yml gave every prerelease from #269 through 2.0.0-alpha.40) stays at 2.0.0-alpha.40. Nothing moves either any more, and the maintainer removes or moves them by hand. latest is expected to go back to stable-only once the first stable 2.0.0 ships.

Node 26 or newer (engines.node: ">=26", #827), the fleet's one Node line and the console image's major. npx treats engines only as a warning, so the agent checks for itself before any verb: on an older Node every verb (install, status, stop and uninstall included) does nothing and exits 2, its stderr ending with the line ac-agent requires Node >=26, found <version>. On Ubuntu, the distribution's own nodejs is older; Node 26 comes from NodeSource's node_26.x apt repository (https://deb.nodesource.com/node_26.x).

Move a machine's Node to 26 before its agent updates to a release that carries this floor. The console's update (POST /v1/update, below) checks the staged bundle by running its version --json with the agent's own Node, so on an older Node the update is refused (422, naming that line) and the running agent stays as it was. A plain npx @rackbops/ac-agent install on an older Node is refused the same way, before anything is copied or registered.

  • The installed task or unit keeps the Node install ran under, as an absolute path (process.execPath). An apt or MSI upgrade in place moves that Node with it. A version manager (fnm, nvm, volta) does not: after switching it to 26, run install again under 26, or the agent keeps running the old Node and its updates are refused with that line.
  • To stop or uninstall from a machine still below 26, run a release from before the floor, e.g. npx @rackbops/[email protected] stop, or move the machine's Node first.

Upgrade with the Tooling script

For an existing install, an up-to-date Tooling checkout provides Update-AcAgent.ps1 and its sibling helpers, including Resolve-GhExe.ps1. These examples assume the PowerShell session is at the root of that checkout:

# Local Windows agent, explicit release
& .\PowerShell\Update-AcAgent.ps1 -Local -Version 2.0.0-alpha.31

# Newest release carrying its matching agent tarball, including prereleases
& .\PowerShell\Update-AcAgent.ps1 -Local -Version latest

# Linux targets reached from this PowerShell session
& .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31

# Preview the same Linux operation
& .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31 -WhatIf

The updater downloads the matching release tarball once into a fresh temporary directory. A selected target already serving that version skips installation. Other targets are read before and after the install, including the endpoint version and served-certificate fingerprint; Linux also checks that the user service is active. Target failures are independent, and any failure makes the script exit nonzero. Temporary-file cleanup is attempted at the end. If the before endpoint was stopped or unreadable, there is no fingerprint to compare, so the run cannot verify that it stayed unchanged.

-WhatIf is a non-mutating preview, not a zero-I/O mode: it still queries releases, downloads the tarball, and reads target health, but runs no install, scp, or mutating SSH command.

Run Windows -Local from a normal PowerShell window. Every Windows machine runs -Local for itself; remote Windows installation is unsupported. gh must have access to the release repository. Linux targets need working noninteractive SSH and node/npx on their PATH; the target's own Node runs the new bundle, so a release that carries the Node 26 floor needs Node 26 there too (see above). -Port changes only the health-check port (default 8788); it does not reconfigure the agent.

Linux: no special npx spelling needed (#278)

The plain npx @rackbops/ac-agent <verb> line above is the one spelling that works on Linux regardless of npm version -- confirmed on Ubuntu 26.04 with both npm 11.19 and npm 12.0.2. #278 was originally filed as an npm-11-vs-12 npx regression (alpha.9 silently printing npm notice run and exiting 0 with no verb output on a npm 12 box), but the real cause was narrower and applied to every npm version on Linux: dist/ac-agent.mjs's own entry-point guard compared import.meta.url (always the symlink-resolved real path) against a raw, unresolved process.argv[1]. npm's node_modules/.bin/<name> bin link is a real filesystem symlink on Linux (whether reached via npx or npm install -g), so the two paths never matched, main() never ran, and the process exited 0 having done nothing -- on npm 11 and npm 12 alike. Windows never showed this: npm links a bin there with a .cmd/.ps1 shim that invokes node "<real path>" ... directly, so argv[1] was already the real path. Fixed by resolving argv[1] through realpathSync before comparing (isEntryPoint in src/cli.ts) -- no --yes, --package=, or -- flag is needed or helps; none of those spellings touch the actual bug. Requires a published release built with this fix (past 2.0.0-alpha.26); any prerelease at or before that still silently no-ops on Linux the same way.

Registers a Windows Task Scheduler task (\ClaudeLocal\ac-agent, a logon trigger for the current user) or, on Linux, a systemd --user unit (ac-agent.service, enable --now), copies the running bundle into the state dir (never the npx cache — that can be garbage-collected out from under a long-running service), writes agent.toml if one doesn't already exist, and generates a self-signed cert if one doesn't already exist (also when cert.pem/key.pem exist but are not a usable pair, an empty or mismatched file, with a warning: the new fingerprint must be paired again; install prints it, serve writes it to agent.log). The certificate, key, token hash, grants, pending pairing code, agent.toml are written to a temp name (<file>.tmp-<pid>, fsynced) and renamed over; a hard kill between the write and the rename can leave a stray *.tmp-<pid> beside the file. update.json (update.json.tmp, not fsynced, a failed write is printed and swallowed) and the embedded toolchain-inventory script and (Linux) probe are renamed over from a temp name too. Still written in place: the Windows launch script, the systemd unit, the AppData-virtualization probe files, the scans cache (a torn one just re-scans) and the self-update staging directory. A file that cannot be read for any reason other than not existing is an error, not an empty answer (status instead lists it under unreadable, by file name and error code, and treats it as absent). On an existing install, an explicit --repos-root <abs> narrowly updates only the top-level repos_root string in agent.toml; comments, ordering, unknown keys, credentials, grants, and every other setting stay untouched. The old and new roots are compared as exact strings, so an equal value does not rewrite the file or change its mtime. A malformed file or a non-string repos_root is refused rather than rewritten. Prints the resolved config/state paths, the cert's fingerprint, and the firewall rule's status.

Options: --port <n> (default 8788; an integer from 1 to 65535, refused otherwise, and agent.toml's port is held to the same rule), --name <machine> (default os.hostname()), --repos-root <abs> (default <homedir>/repos; the fleet's roots differ per box — Windows and Linux checkouts don't share a layout, so every real install passes this explicitly). A relative --repos-root is rejected before installation side effects on both fresh and existing installs. On an existing install, --name and --port remain intentionally ignored and print the configured value they kept; platform registration and restart checks use that existing port. If AC_AGENT_REPOS_ROOT is non-blank and differs from an explicitly saved --repos-root, install warns that the environment still wins at runtime until it is unset.

Installing over an already-running agent restarts it (#277): once the task/unit is re-registered against the new bundle, install stops the old process and starts it again through the same stop/start machinery the ac-agent stop/ac-agent start commands use, then confirms the new process is actually answering (on the configured bind when it names one address, else on 127.0.0.1) (a health check, retried for a few seconds — the OS command that launches a process can report success before that process has actually started listening) before printing restarted: now serving <version>. Only a health answer reporting the new version counts (#659), so the old process still answering on the port is not mistaken for the new one.

Before copying the new bundle in over a running agent, install keeps the one it replaces as bin/ac-agent.prev.mjs in the state dir (#659; one backup, overwritten by the next install). Both the backup (ac-agent.prev.mjs.tmp) and the new bundle (ac-agent.mjs.tmp) are copied beside their target and renamed into place, as is the restore, so an install killed mid-copy leaves the old bundle whole. When the running agent reported its version and node <bundle> version is available, the live bundle is backed up only if it reports that same version: after an aborted install it may be a newer file than the process serving, and it must not overwrite a good backup (an existing backup is kept when it is the running version; otherwise no backup is made and install prints a warning that it cannot roll back). When the running version is not known the backup is made unchecked, as before. If the new bundle never answers health at its own version, install rolls back:

  1. It stops the task/unit again. On Windows this ends launch.js's retry loop, so it can't rerun the broken bundle in the middle of the restore.
  2. It restores ac-agent.prev.mjs and starts it.
  3. It confirms the previous version answers, then prints rolled back: now serving <previous version> (<reason>). The previous version is read from the running agent's own health answer just before it is stopped. If that read got no version (no cert.pem to read it with, no answer, an error answer, or one without a version), any healthy answer is accepted, and the line says now serving the previous bundle.

Whenever a rollback is possible, the restart confirmation waits up to a minute instead of a few seconds, because a false failure would undo a good release (the Windows cold start has never been timed). This applies to a plain install too, such as Tooling's Update-AcAgent.ps1. A new bundle that never comes up therefore takes about two minutes to report (a minute waiting for it, up to another confirming the rollback; a hung connection adds to each attempt). If the install fails before the restart (registering the task/unit, the certificate) and a backup was made, the running agent's bundle is copied back onto the disk; the error is reported as before.

The install then fails (exit 1), because it did not take. If the rollback fails too, it prints rollback failed: ... and also exits 1; ac-agent start is the fallback. Which file holds what depends on how far the rollback got: ac-agent.prev.mjs always holds the previous bundle, and the live ac-agent.mjs holds the previous bundle too once the restore copy has run (a failure while starting it or confirming it), but still the new one when the failure came before that copy. The message names ac-agent.prev.mjs and says to copy it over the live bundle if it is not already there.

When there was no bundle to go back to, a restart that fails outright still reports the install as a success, with restart failed: <reason>; run ac-agent stop && ac-agent start, and that manual sequence is the fallback. A fresh install, with nothing running yet, prints neither line.

install --update is what the agent's own update route launches; it isn't meant to be run by hand. It differs from a plain install in two ways:

  • Its restart confirmation (when an agent is running to restart) is always the minute-long one, even with no backup to roll back to.
  • It records each step in update.json in the state dir: {status: "installing" | "ok" | "rolled-back" | "failed", from, target, at, message?}, where from is null when the previous version could not be read.
    • Once installing is written, the outcome is recorded as ok, rolled-back or failed. That covers a failure before the restart, and no agent running afterwards.
    • A failure before installing is written (reading the config, the backup copy) leaves the previous record as it was.
    • It writes a temp file and renames it over, so a reader never sees a partial record.
    • A write that fails (for example a locked file) prints a warning: could not record update status ... line and never changes the install's outcome. The record then still shows the last write that landed, which may be installing, so a reader should treat an old installing as unknown.

A plain install never writes update.json. ac-agent version --json prints {"version", "apiVersion"}, which is how a staged bundle's agent API version is read without starting it.

The firewall step

install tries to add an inbound rule (New-NetFirewallRule, Windows only) so a LAN caller can reach the port. Creating a firewall rule needs administrator rights; when the process isn't elevated, install prints the exact one-liner to run in an elevated PowerShell. An existing ac-agent rule is kept only when its LocalPort is the install's port; a rule on another port (a reinstall with a different port) is removed and recreated, and when that needs elevation the printed one-liner removes it first. uninstall leaves the rule in place and prints the elevated Remove-NetFirewallRule command that removes it. Only install reports the rule's state; status has no firewall field. This is expected on a fresh install run from an ordinary shell — it is not a failure to work around.

Run install from a normal shell

The Claude desktop app is an MSIX package (Claude_<hash>, e.g. Claude_pzs8sxrjxfjjc). A Claude Code session it starts — and every process that session spawns, including a PowerShell/Bash tool shell running npx @rackbops/ac-agent install — inherits the package identity, and MSIX virtualizes AppData: a write to a subfolder of %LOCALAPPDATA%/%APPDATA% is silently redirected into the package's private ...\Packages\<pkg>\LocalCache\Local\.../ ...\LocalCache\Roaming\..., while the writing process itself believes it wrote the real path. install writes the bundle copy, launch.js, the cert and agent.toml under the state/config dirs, so run from such a session those files land somewhere the Windows logon task — launched later with the user's real, unpackaged token — can never see, and the task exits 1 at once (#263). install detects this (a nonce probe file, not just the presence of an old package copy, so a stale VFS leftover from a prior virtualized install never blocks a later normal one) and refuses with:

install ran inside a packaged app (<packageFamily>), which virtualizes AppData: the files landed
under <vfsPath> where the logon task cannot see them. Run ac-agent install from a normal PowerShell
window, not from a session started by the Claude desktop app.

Run install from a normal PowerShell window opened from the Start menu, not from a Claude Code session the desktop app started. status/serve don't refuse the same way (see Status and serve / stop / start below) — a serve launched from a virtualized session still works, since everything it reads and writes goes through the same virtualized view within that one session; it's only a task launched later, outside that session, that can't see the files.

Linux

Run install as the login user you'll actually use the agent as, never root. As root there is no user session bus for the systemd --user unit to register against — loginctl show-user/systemctl --user calls fail with Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined — and a root install would in any case put the config/state under /root, where the operator's own shell never looks.

A systemd --user unit only survives past that user's login session — logout, or a reboot — if lingering is enabled for that user:

sudo loginctl enable-linger "$USER"

This can be run before or after install; order doesn't matter, because it governs the user manager's lifetime (whether systemd --user keeps running with no session attached), not the ac-agent.service unit itself. Without it, the user manager — and the agent with it — stops the moment the last session for that user ends.

The unit's HTTPS listener isn't up the instant install returns — a curl fired immediately can race the bind and report a false failure. Wait a moment, then check:

curl -k https://127.0.0.1:8788/v1/health

A server host that also runs the console (#826)

The machine that runs the console in Docker can pair an agent too, so its own toolchain is graded in the Agents panel's Environment section like any other machine's; nucbox is set up this way and paired (#826, 2026-10-20). It installs the Linux way above, with these differences.

Node 26 from NodeSource's apt repository. The agent needs Node 26 or newer (engines.node, #827; see Install above), the fleet's one line, and Ubuntu's own nodejs is older (22 on Ubuntu 26.04). Write NodeSource's key and source by hand rather than piping its setup script to a shell. The key:

curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
  | gpg --dearmor | sudo tee /usr/share/keyrings/nodesource.gpg >/dev/null
sudo chmod 0644 /usr/share/keyrings/nodesource.gpg

Its fingerprint is 6F71 F525 2828 41EE DAF8 51B4 2F59 B5F9 9B1B E0B4. The source, in deb822 form, is /etc/apt/sources.list.d/nodesource.sources:

Types: deb
URIs: https://deb.nodesource.com/node_26.x
Suites: nodistro
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/nodesource.gpg

Two pins make NodeSource's packages win over Ubuntu's: /etc/apt/preferences.d/nodejs (below) and /etc/apt/preferences.d/nsolid (the same, with Package: nsolid):

Package: nodejs
Pin: origin deb.nodesource.com
Pin-Priority: 600

Then sudo apt-get update && sudo apt-get install -y nodejs, which brings npm with it. On nucbox (2026-10-09) that installed 26.11.1-1nodesource1 with npm 11.20.0 and removed Ubuntu's nodejs 22, its node-* libraries and nodejs-doc, so check first that nothing else on the host runs on the system Node. On nucbox nothing else did (its services run in Docker; the plan's read of the box, 2026-10-08), so the agent is the system Node's only user.

An empty repos root. After sudo loginctl enable-linger "$USER":

mkdir -p "$HOME/ac-agent-repos"
npx -y @rackbops/ac-agent install --repos-root "$HOME/ac-agent-repos"

The console's host holds no checkout the console should list or pull, so the repos root is an empty directory. curl -k https://127.0.0.1:8788/v1/health then answers {"ok":true, ..., "paired":false}.

The console reaches it at the host's LAN address. Pair it as in Pair below, giving the console's form the URL pair prints. That is the host's LAN address, not 127.0.0.1: the console's container runs on the same machine, but it calls this agent the way it calls any other, and inside the container 127.0.0.1 is the container itself. pair and the certificate leave Docker's bridge interfaces out (docker0, br-*, veth*; #330) unless they are the machine's only addresses, so the address printed is the real NIC's. A host firewall has to admit the agent's port from the console's Docker network as well as from the LAN, since the container's traffic to the host arrives from its bridge address (not verified on nucbox; pairing is the test). Give the pairing form the host's name in the fleet standard (nucbox): the Upgrade action finds the standard host by the name the console holds for the machine, and the Environment grading by the name the agent reports (its OS hostname, which an inventory sidecar's hostname maps to the host; see the agents plugin's README, "Which standard host a machine is").

The minimal grant set. Opt the machine in to upgrades (toolchain_upgrade = true and the sudoers rule, Operator setup under POST /v1/toolchain/upgrade; read what that rule grants before adding it to a production host), restart it (stop, then start), and in the Grants grid give the agents plugin agent.toolchain.upgrade and nothing else: no agent.exec.claude, no agent.git.*, no agent.scan.*. The grading needs no grant at all (GET /v1/preflight is control plane, and so is POST /v1/update, which lets the console update the agent itself). The Upgrade button needs none either: it shows on any behind row whose tool the standard gives a package for on a Linux server (among git, gh, node and claude; a docker or cloudflared row gets one too, and the agent runs that upgrade at the next 05:00 America/New_York, #978). The grant is what lets a click succeed; without it the agent refuses with agent.toolchain.upgrade is not granted to agents on <machine>. That granted verb installs a package the standard names: as root, through sudo apt-get install --only-upgrade and the sudoers rule, or for Claude Code with its own claude update as the agent's user. No other verb is granted to agents: no agent.exec.*, agent.git.* or agent.scan.*.

nucbox additionally grants agent.tunnel.lint to the admin plugin, and nothing else (the owner's Grants screenshot, 2026-10-20).

The console no longer asks this machine for what it has not been granted (#843):

  • The host's job runner skips a machine that has not granted every capability a job declares: no job_runs row, no task and no job failed notification. The four scans jobs declare agent.scan.<kind>, so each reads not granted on the machine's row instead of a failed run.
  • The repos plugin's repo-currency check and provider-artifacts' hourly publish.mirror raise no Needs-attention row for a machine that never had the grant; the card reads not granted. A grant revoked after a good result raises one row (Repo currency is no longer granted on <machine>, and its mirror-publish counterpart) for 7 days, then nothing.
  • The Admin Security card's tunnel lint reads the console's own grants and does not call this agent; it shows agent.tunnel.lint is not granted to admin on <machine> as a neutral row, not a warning.

Windows: no console window

The task's action is wscript.exe //B "<launch.js>" against a small generated JScript launcher (written into the state dir alongside the bundle copy), not a bare node invocation and not conhost.exe --headless (an earlier design that turned out to run nothing at all — verified live, conhost.exe isn't a generic headless-launch wrapper). launch.js runs WScript.CreateObject("WScript.Shell").Run("<node> <bundle> serve --config-dir <abs> --state-dir <abs>", 0, true) in a loop (the two dirs install itself resolved, baked in the same way <node>/<bundle> already are — see Config and state directories below), retrying on a crash. Window style 0 keeps node's console from ever appearing (Node has no nodew.exe, the pythonw.exe-equivalent 1.x used), and waiting (true, not fire-and-forget) keeps wscript.exe itself alive as the task's tracked process for the agent's entire lifetime, so the task reads Running while serving and a crash propagates as a non-zero exit, visible afterward as LastTaskResult. RestartCount 3 does not actually restart a crashed agent — verified live (two scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every 30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this action shape. The real crash-restart mechanism is launch.js's own retry loop (#238): a non-zero exit sleeps a minute and runs node again, up to 3 retries in a row (a run that lasted longer than five minutes starts the count over, so the budget is per crash loop, not per logon session), since the exit code is already in wscript.exe's own hand; a zero exit (a deliberate shutdown) ends the loop instead. stop/uninstall stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute after stop. Verified live: killing node by pid left the task Running with a fresh node pid listening within about a minute; stop afterward left the task Ready with LastTaskResult 267014 (0x41306, terminated by user) and nothing listening 150 s later. The launcher is JScript rather than VBScript specifically because VBScript is a Windows feature-on-demand Microsoft is phasing out (Insider builds first); JScript and Windows Script Host itself carry no such notice, so the same mechanism ships without a deprecated component in the loop. (A PowerShell hidden launcher — Start-Process -WindowStyle Hidden -PassThru -Wait — was also tried and verified working, but was dropped in favor of keeping Windows Script Host as the only script host involved.) One thing this package cannot verify by itself — no CI runner can register a real logon task — is named as unverified rather than assumed: whether any window is visible at a real logon.

Status

npx @rackbops/ac-agent status

Prints two plain lines, lan address: <ip|(none)> and repos: <n>, and then, as JSON: the config path and whether it came from a file or defaults, the state dir, the Task Scheduler/systemd service state, whether a pairing token is stored (paired: true/false), the cert's fingerprint, certExpiry ({validTo, daysLeft, expiringSoon}, null with no readable cert; expiringSoon is true within 30 days of notAfter or after it, and then a warning: the agent's certificate ... line goes to stderr), the result of a live GET /v1/health call against itself (pinned to the cert's fingerprint and nothing else: not a system CA and not the validity dates, so an expired cert still answers and certExpiry is where the expiry shows — see Security model below), the currently-held grants ({revision, plugins: <count>} | null, from grants.json — null when it is absent or malformed, the same as a freshly-purged or never-pushed-to agent), the effective reposRoot and a live reposCount computed from the same immediate-child, .git-entry, realpath-contained listing as GET /v1/repos (a missing or unreadable root is zero), unreadable ({file: error code} for any file status could not read, {} normally), and virtualized: true/false (#263) — true when this session's own AppData writes to the resolved config/state dirs are virtualized by a packaged app (see Run install from a normal shell above), which also prints a warning: this session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see README, Install line to stderr. serve logs the same warning line once at startup, in the same circumstance, without refusing to start. update (#659) is the last install --update's record from update.json, or null when there is none or it is unreadable. toolchainUpgrade: true/false (#675) is the machine owner's own opt-in to POST /v1/toolchain/upgrade (toolchain_upgrade in agent.toml, or AC_AGENT_TOOLCHAIN_UPGRADE), false by default. Like the other fields it is what status loads from agent.toml and the environment of the shell that ran status: what a start from that file and shell would load, not what the running agent holds (it read its config once, at start, and an installed task/unit does not see that shell's variables).

Pair

npx @rackbops/ac-agent pair [--force]

Generates a one-time pairing code and prints exactly three lines:

code:        <8-char code>
url:         <this machine's URL>
fingerprint: <cert fingerprint>

The code is 8 characters from an alphabet that excludes ambiguous glyphs (0/O, 1/I, easy to misread off a small screen or mishear read aloud) and expires in 10 minutes; only its hash is ever written to disk (<stateDir>/pair.json). Refuses with already paired -- pass --force to re-pair (mints a fresh code) if a token is already stored — --force starts a fresh pairing without discarding the old token until the new code is actually consumed, so a stale in-flight attempt never wins a race against a still-valid one. Requires a certificate to already exist (install always creates one); refuses with no certificate found -- run install first otherwise.

On the console side, open its Admin → Agents panel (the agents plugin, plugins/agents/src/ ui.tsx) and fill in the pairing form with the three printed values plus an optional machine name: URL, Fingerprint, Code, Name. Submitting calls POST /api/agents — success adds the machine to the paired list; the console then reaches it, per capability, once an operator grants that capability to a plugin for it in the same panel's Grants grid (see Security model below — no verb runs until a capability is both declared by the plugin and granted here; the control-plane routes, PUT /v1/grants, GET /v1/preflight, and POST and GET /v1/update, need no grant). Every other POST verb answers 413 {ok:false,error:"request body too large"} above 64 KiB; publish, toolchain-upgrade and exec/gh (512 KiB, for one 64 KiB --body) keep their own larger caps.

Unpairing (the Agents panel's Unpair, DELETE /api/agents/:id) is local to the console: it forgets the machine and its grants and makes no call to the agent, which keeps its stored token hash and keeps accepting the token. To cut that credential off, run pair --force on the machine and pair it again from the console (the old token stops working once the new code is consumed), or uninstall, which removes the token hash. A copy of the console's /state/ac.db taken before the unpair still holds the old token in clear.

Uninstall

npx @rackbops/ac-agent uninstall [--purge]

Stops and unregisters the task/unit, removes the copied bundle, cert, key, token hash, pending-pair file, grants and lock file. Keeps agent.toml unless --purge is given, so a re-install remembers the machine's chosen port/name.

serve / stop / start

serve is what the registered task/unit actually runs — it isn't meant to be invoked directly except for debugging. stop/start control the registered service (Stop-/Start-ScheduledTask or systemctl --user stop/start) without touching the registration itself.

The lock. agent.pid is taken with O_EXCL. A pid left by an unclean exit (a Windows logoff or reboot runs no shutdown handler) is replaced when it is this process's own pid, is not a valid pid, or is not running. It is also replaced when nothing answers /v1/health on the configured port with this machine's own certificate, which is pid reuse. A start is refused while that pinned answer comes from a running agent; the refusal names its pid, or pid unknown for an agent older than alpha.59. Both outcomes are written to agent.log, as replaced stale lock (pid N, <why>), already running (pid N, port P) or already running (pid unknown, port P). GET /v1/health carries the answering agent's pid for this.

stop, uninstall and an install's restart kill only the pid that pinned answer names. When nothing answers they kill nothing and remove the stale agent.pid, and they print ... nothing was killed if that pid belongs to some other live process. When the pinned answer has no pid (an agent older than alpha.59), they refuse and name the port and version instead of killing nothing. An install also refuses to start while the port still answers after the stop.

Config and state directories

| | Windows | Linux | |---|---|---| | Config (agent.toml) | %APPDATA%\ac-agent\ | ~/.config/ac-agent/ | | State (cert, key, token hash, grants, log, pid, the installed bundle copy and its one backup, update.json) | %LOCALAPPDATA%\ac-agent\ | ~/.local/state/ac-agent/ |

Both are overridable with AC_AGENT_CONFIG_DIR / AC_AGENT_STATE_DIR — absolute paths only; a relative override throws, naming the value. install resolves these once, in its own process, and bakes the resolved absolute paths into the registered task/unit as serve --config-dir <abs> --state-dir <abs>, the same way it already baked in the node/bundle paths — rather than relying on serve to re-resolve them from its own environment when the task/unit actually runs. This matters because a Task Scheduler process (and a systemd --user unit) is a separate process tree that does not see the installing shell's variables — transient or persisted — so before this, an install under the overrides silently served from the real platform defaults instead, with a different certificate and the default port (E4 finding 13, fixed in #241). A manual serve with no flags still honours AC_AGENT_CONFIG_DIR/AC_AGENT_STATE_DIR as before.

agent.toml:

port = 8788
bind = "0.0.0.0"
machine = "melody"
repos_root = "S:/Repos"
claude_projects_dir = "C:/Users/melody/.claude/projects"

Unknown keys are ignored, so a future field never breaks an older agent reading a newer file. repos_root is overridable live with AC_AGENT_REPOS_ROOT the same way port is with AC_AGENT_PORT (which, unlike the file's port, is not range-checked: it is read as an integer and a value it cannot read is ignored) — absolute paths only; a relative override throws, naming the value. claude_projects_dir (#52, 52a; default <homedir>/.claude/projects) is handled identically, with its own live override AC_AGENT_CLAUDE_PROJECTS_DIR — it names the root the context-pressure and subagent-cost scans read, overridable so a fixture tree or a non-default CLAUDE_CONFIG_DIR still resolves. Unlike repos_root, install takes no flag for it and status does not report it.

tunnel_config and tunnel_preflight (#416; both unset by default, because most machines do not run the tunnel) are nullable like publish_dir, so agent.toml simply omits them: tunnel_config is the absolute path of the cloudflared config that GET /v1/tunnel/lint reads, tunnel_preflight the absolute path of Tooling's console_tunnel_preflight.py. Live overrides AC_AGENT_TUNNEL_CONFIG and AC_AGENT_TUNNEL_PREFLIGHT. Absolute paths only, because tunnel_preflight is executed and must not resolve against whatever directory the service starts in: a relative env override throws at load, naming the value, like every other AC_AGENT_* path; a relative value in the file is loaded as written (as publish_dir's is, so a typo in an optional key can never stop serve, stop or uninstall) and the verb then refuses to read or run it, answering tunnel_config is not an absolute path (or a preflight-unusable warning naming tunnel_preflight). A blank or non-string value in the file is treated as unset. install takes no flag for either and status does not report them.

toolchain_upgrade (#675; false unless set) is the machine owner's own opt-in to POST /v1/toolchain/upgrade, the verb that installs software here (see POST /v1/toolchain/upgrade). It is a TOML boolean, written to agent.toml only when true; only a boolean true counts (a quoted string, a number or a misspelled key is false; an unquoted word that is not a TOML boolean, such as True or yes, is invalid TOML and fails the whole config load). Live override AC_AGENT_TOOLCHAIN_UPGRADE: 1/true or 0/false (case-insensitive); blank is unset; anything else throws at load, naming the value. The override only reaches the process that reads it: a foreground serve started from that shell, or a unit/task whose own environment sets it (the installed task and unit do not see the shell's variables, as the paragraph above explains). A fresh install run with it set writes toolchain_upgrade = true into the new agent.toml, where it then stays. Like every key it is read once when the agent starts, so a change needs ac-agent stop and start (or a restart of the task/unit). status prints toolchainUpgrade: true|false: what a start from agent.toml and the shell's own environment would load, not what the running agent holds, so after a change restart first and treat the agent's 403 not-opted-in as the truth. The console's grant alone never turns it on: the verb answers 403 not-opted-in until this key is set.

Verbs

Everything under /v1/* except /v1/health and /v1/pair requires pairing (see Security model). The verb registry fills GET /v1/health's capabilities field with the sorted, unique list of every registered verb's capability name — with every verb group registered, that's ["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read", "agent.publish.mirror", "agent.scan.context-pressure", "agent.scan.memory-footprint", "agent.scan.subagent-cost", "agent.scan.toolchain-inventory", "agent.toolchain.upgrade", "agent.tunnel.lint"].

Every verb also requires two headers, checked in this order (#50), after the bearer check passes: X-AC-Plugin (the calling plugin's id) missing or shaped wrong is 400 {ok: false, error: "missing X-AC-Plugin"} / "invalid X-AC-Plugin"; X-AC-Grants-Revision missing is 400 {ok: false, error: "missing X-AC-Grants-Revision"}; a value that doesn't match this agent's own current revision — including a never-pushed null — is 409 {ok: false, error: "grants out of date", revision: <this agent's own, or null>}, since a stale console must self-heal before its refusal means anything; only once the agent's own copy is known-current does an ungranted capability get a real refusal, 403 {ok: false, error: "<capability> is not granted to <plugin> on <machine>", capability, plugin}. See Security model for how a grant reaches the agent in the first place.

  • GET /v1/repos (agent.git.read) → {ok, root, repos: [{name, path, ownerName}]}: every immediate child of repos_root with a .git entry (a file for a worktree, a directory for a clone), sorted by name. A missing or unreadable root answers {ok: true, repos: [], reason: "root missing"}, never a 500. ownerName (#304) is the checkout's owner/name resolved from its origin remote (git remote get-url origin, parsed the way 1.x's init_config.py:parse_remote does), or null when there's no remote or it isn't a GitHub-shaped URL — never an error. This makes the route spawn one git remote get-url per repo (parallelized), so it is no longer git-free the way it was before #304. All three repo routes need git: they look for it first (see below), and a machine without it answers 502 {ok: false, error: "git not found on PATH"} (or, when PATH holds only a .cmd/.bat shim, git is a .cmd shim; install the native binary) before any repo work, a bad :name included.
  • GET /v1/repos/:name/status (agent.git.read) → {ok, name, path, ownerName, branch, defaultBranch, upstream, ahead, behind, dirty, inProgress} (ownerName as above), read-only (no fetch — pull is what refreshes the refs status reads).
  • git is run by absolute path (#699). Each of the three requests resolves git once, on the refreshed PATH (the registry's on Windows, the process PATH elsewhere: the search the preflight and the toolchain upgrade use), and every git spawn of that request names the path it found, made absolute if PATH gave a relative entry (exec/claude and exec/gh resolve their tool on the same refreshed PATH, #716, and exec/claude also makes its path absolute, the other spawn whose working directory a request chooses). This matters on Windows, where a bare git is looked for in the spawn's working directory first (git.com, then git.exe), and every call here has a repo checkout as its working directory: a git.exe that a clone, a branch or a fast-forward put in the repo's root would otherwise run as the agent's user, unattended (the daily repo.pull job reaches it). A bare name is looked up on PATH only elsewhere, so Linux was not exposed to that; a relative PATH entry is the one way a working directory could matter there, and the absolute path closes it too. See Security model for the second, general layer.
  • POST /v1/repos/:name/pull (agent.git.pull), body {dryRun?: boolean} → {ok: true, row} where row.status is updated | would | current | skipped | error and row.reason names which of the six 1.x-derived guards applied (dirty, branch, ahead, in-progress, no-upstream, diverged) when it skipped, or not-a-repo / fetch-failed / dry-run. Fast-forward only (git merge --ff-only) — a diverged branch is reported, never resolved. Every git call runs with GIT_TERMINAL_PROMPT=0, so a credential prompt refuses instead of hanging a request until its timeout.
  • :name is an allowlist, not a path — it must match a machine-generated repo name pattern, must not be ./.., and must actually be one of the entries GET /v1/repos would list; anything else is 404 {"error": "no such repo"} before any git call runs (on a machine with git: without it the 502 above comes first).
  • POST /v1/open (agent.desktop.open), body {url} → {ok: true}. Only a https: or claude: URL is accepted (the console's own deep-link shape, claude://code/new?q=<prompt>&folder=<path>); anything else is 400 before any process spawns. The 200 attests only that the launcher (rundll32.exe url.dll,FileProtocolHandler on Windows, xdg-open on Linux) started — never a shell string, always argv — not that a browser or Claude Desktop actually opened; that's visible to the person at the desktop, not to the caller.
  • POST /v1/scan/<kind> (agent.scan.<kind>, one capability per kind), body {} — a pure dispatch shell (#49, 49c) that mounts one verb per kind registered into it; an unregistered <kind> is 404 {"error": "no such verb"}, the same as any other unregistered route — there is no separate GET /v1/scan list, since the kinds a machine implements are already visible as agent.scan.<kind> entries in GET /v1/health's capabilities. One run per kind at a time: a second POST while one is still in flight is 409 {ok: false, error: "scan already running"} (these scans read whole ~/.claude trees and must not stack); a kind's own run() gets an AbortSignal that fires at its timeoutMs — a run that doesn't honour it promptly answers the client 504 {ok: false, error: "scan timed out after <n> ms"} while the run keeps going in the background (a later POST for that kind still gets 409, now naming "timed out, still finishing", until the run actually settles); one that never settles at all is abandoned five timeouts later, freeing the kind up rather than locking it for the agent's whole lifetime. A successful run answers 200 {ok: true, kind, generatedAt, durationMs, payload}; a rejected one, 502 {ok: false, error: "<message>"}.
    • context-pressure and subagent-cost (#52, 52a; timeoutMs: 900_000 — a cold scan can read on the order of a gigabyte of transcripts) both scan claude_projects_dir (default <homedir>/.claude/projects) and are byte-compatible with the 1.x artifacts-console scheduled tasks of the same name: same JSON shape, same key order, same incremental (mtime, size) cache, same os.linesep cache-file line ending. context-pressure walks <root>/<encoded-project>/<session>.jsonl (main session transcripts only — a subagent's own transcript has its own separate window and is never folded in) and reports each session's peak context-window occupancy (input + cache_read + cache_creation tokens, as a percentage of the model's window) plus its compaction and unmarked-drop counts. subagent-cost walks <root>/<encoded-project>/<session>/subagents/**/agent-*.jsonl (both the plain-Task layout and the nested Workflow one) and reports each subagent's token totals and an API-equivalent dollar figure at list rates (never money actually spent — this box runs Claude Code on a subscription). Both read exactly six fields per transcript record (type, subtype / attributionAgent, timestamp, cwd, message.{id, model, usage}); message.content -- prompts, tool results, file contents -- is parsed in memory with the rest of the line but never read or kept, and never leaves the process, in either scan. The cache lands at <stateDir>/scans/context-pressure.json / <stateDir>/scans/subagent-cost.json, created on first write and removed by uninstall regardless of --purge. Beside each cache sits a <kind>.tables file holding a digest of the tables its rows were computed from (the context windows; the prices and cache multipliers). A cache whose digest differs, or that has none, is dropped and every transcript re-parsed, and a row naming an unmapped (context-pressure) or unpriced (subagent-cost) model is re-parsed on every scan, so adding the model to the table fixes its rows without a manual cache delete.
    • memory-footprint (#52, 52b; no custom timeoutMs -- a cold run is kilobytes of I/O, not the transcript-scale reads above) is a producer only, computed live on every request and never cached: the fixed 8-layer set Claude Code assembles into a session's preamble (enterprise, user, user-rules -- global, loaded into every session everywhere -- plus project, project-alt, project-rules, project-local, auto-memory per repo, auto-memory truncated to the first 200 lines of MEMORY.md under claude_projects_dir, same as context-pressure/ subagent-cost). The repo set is GET /v1/repos' own listing (every immediate child of repos_root with a .git entry) -- unlike 1.x, which read the console's configured repo list, so the payload's repo and name fields are both the directory name, never an owner/name string. Tokens are a byte estimate (~4 chars/token; the payload says estimated: true); only sizes and line counts ever leave this scan -- file content is never read into the payload. Every layer file is lstated first: a layer file or the rules directory itself that is a symlink, anything that is not a regular file, or a file over 1 MiB is skipped as if absent; inside the rules directory, symlinked files and directories are not followed, and the walk goes at most 8 directories deep, so a hostile checkout cannot point a layer at /dev/zero, a FIFO or a huge file. Accepted limitation: a skipped layer is silent and reads as absent (the payload has no field for it), so a dotfiles-managed ~/.claude/CLAUDE.md that is a symlink, or any layer file over 1 MiB, is undercounted.
    • toolchain-inventory (#52, 52b; timeoutMs: 600_000 -- about seventy version probes plus a WSL probe) ships and runs 1.x's own Get-ToolchainInventory.ps1 (vendored byte for byte from 1.x main at da0848f, data/README.md) through pwsh -NoProfile -File <script> -OutFile <state>/scans/ toolchain-inventory-<hostname>.md -NoRender, with the child's cwd set to <state>/scans -- never a project directory, since the script's own pnpm --version probe rewrites pnpm-lock.yaml in whatever directory it runs in. Windows and Linux: on any other platform (darwin) this answers {skipped: true, reason: "toolchain-inventory runs on Windows and Linux only"} without spawning anything. On Linux (LX3, #1042) there is no pwsh: the agent runs bash (502 {"error": "bash not found on PATH"} before any spawn when it is missing) on the vendored probe, data/toolchain-probe.sh -- the bash New-ToolchainProbeScript builds, kept at <state>/scans/ toolchain-probe.sh (same rewrite-on-change rule as the script) -- parses its output and renders the report itself (src/scans/toolchain-linux/, a TypeScript port pinned byte for byte to the PowerShell's own output). The report is named like a Windows one, toolchain-inventory-<hostname>.{md,json}; its _Generated: line reads <hostname> (ac-agent <version>). result is unchanged when only that line differs from the previous .md and a readable sidecar exists, written otherwise (the .md is rewritten only when its text changed; a missing or unreadable sidecar is rewritten on its own). The -NoRender/cwd details above are the pwsh path's; the probe child also runs with cwd set to <state>/scans. A timed-out, aborted or failing probe is toolchain-probe.sh timed out after N ms, scan aborted or toolchain-probe.sh exited N: <last line>. Writes (and reads back) <state>/scans/ toolchain-inventory-<hostname>.md and its .json sidecar, and maintains its own copy of the script at <state>/scans/Get-ToolchainInventory.ps1 (rewritten only when the bundled text changes, via a temp file + rename so a killed agent never leaves a half-written copy). A Store-installed pwsh -- an App Execution Alias under %LOCALAPPDATA%\Microsoft\WindowsApps, which a plain Node exists check cannot see -- is found: on Windows the agent's exists check falls back to lstat (#439), the same check which and the preflight use. A missing pwsh on PATH is 502 {"error": "pwsh not found on PATH; probed: <each PATH directory's pwsh.exe, in PATH order; past 40 directories, the first 20 and the last 20 around ... (+N more) ...>"} before any spawn (the registry PATH lists the machine hive then the user hive, so the tail is the end of the user hive, where WindowsApps is; entries in the middle of a very long PATH can still be left out, and the marker counts them); the request timeout kills only the pwsh child, never a hung probe or wsl.exe process it may have spawned (1.x had the same scope). The hostname used for the file name and inside the script's own report is os.hostname() -- never the operator's configured machine/--name override -- so the agent's file name always matches 1.x's own for a byte-compare.

POST /v1/exec/claude and the job registry

  • POST /v1/exec/claude (agent.exec.claude), body {repo, prompt, permissionMode?, model?} → 202 {ok: true, jobId}. repo resolves through the same allowlist as GET /v1/repos/:name/status (a 404 before anything spawns); prompt is a non-empty string, at most 8 KiB (the Windows CreateProcess command-line limit is 32,767 characters total, and quoting can double a prompt's own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the worst case) with no control character other than \n/\t, and not starting with - (400 prompt must not start with '-': claude would read it as a flag); permissionMode is one of plan (the default) / acceptEdits / bypassPermissions; model is an optional ^[A-Za-z0-9._-]{1,64}$ string. Any other shape is 400 before anything spawns. claude runs as claude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>], cwd the resolved repo. claude is looked up on the refreshed PATH (the registry's on Windows, the process PATH elsewhere), and the job runs with that PATH, so a claude installed after the agent started is found without a restart (#716). At most 4 claude jobs run at once — a 5th concurrent request is 429 {ok: false, error: "too many running jobs"}, checked before spawning, not after. Only claude jobs count: a running toolchain upgrade (#675) neither uses up a slot nor is blocked by a claude job.
  • GET /v1/jobs (agent.exec.claude) → {ok: true, jobs: [view...]}, newest first. GET /v1/jobs/:id → {ok: true, job: view} or 404 for an unknown id. A job view is {id, kind: "claude", label, status: "running"|"done"|"failed", startedAt, endedAt?, exitCode?: number|null, repo, permissionMode}. POST /v1/jobs/clear-finished → {ok: true, cleared}, dropping every done/failed job (running ones are kept). These routes serve claude jobs only (#675): the registry also holds toolchain-upgrade jobs, which a plugin holding only agent.exec.claude must not list, read, stream or clear, so another kind's job id is 404 no such job here, exactly like an unknown one, and clear-finished leaves them alone.
  • GET /v1/jobs/:id/events (agent.exec.claude) streams a job's log as Server-Sent Events: a 404 for an unknown id (or another kind's) up front, otherwise a finished job replays its whole buffer and closes, and a running job replays its buffer then streams new lines live as they arrive, closing once the job finishes. Two frame shapes only: event: line with data the JSON-encoded line text and id the line's absolute position in the job's log (so a reconnecting client can tell whether it missed lines to eviction); event: done with data {"status": "done"|"failed", "exitCode": number|null}. These are the exact event names and shapes the host's own /api/tasks/:id/log emits (packages/host/src/tasks/route.ts) with exitCode added, since the console's shell code already knows how to consume them.

POST /v1/exec/gh

  • POST /v1/exec/gh (agent.exec.gh), body {args: string[]} → 200 {ok: true, code, stdout, stderr, truncated} (stdout/stderr each capped at 1 MiB, truncated: true when either was cut or when collection stopped at the cap, which also kills gh). A non-zero gh exit is still a 200 — the caller reads code; a gh that cannot be launched answers code: 127. At most 32 arguments, each at most 4 KiB with no control characters. Only a --body value (--body <text> or --body=<text>) may be longer, up to 64 KiB (24 KiB on Windows, where the command line is limited) and contain \n/\t (a PR/issue comment body); any other flag's value, --search=<text> included, is held to the 4 KiB and no-control-character rule.

  • The gh child gets an allowlisted environment, not the agent's (#714): PATH (the refreshed PATH gh was looked up on, #716), the per-OS profile/temp/locale keys, proxy and SSL_CERT_* keys, GH_HOST and GH_CONFIG_DIR, plus GH_PROMPT_DISABLED, GH_NO_UPDATE_NOTIFIER and NO_COLOR. GH_TOKEN, GITHUB_TOKEN, GH_ENTERPRISE_TOKEN and GITHUB_ENTERPRISE_TOKEN are deliberately left out, so gh takes its credential from gh auth login on the machine. A --jq program that names the environment (env, $ENV) is refused 400 invalid value for --jq: ... before anything spawns.

  • Every argument is checked against a fixed allowlist before anything spawns — an unlisted subcommand, flag, or malformed positional is 400 {ok: false, error: "<token and why>"}, logged as gh refused: <error>, additive-only (nothing here is ever loosened without a deliberate change to the table):

    | Subcommand | Positional | Flags | |---|---|---| | issue view | one ^[0-9]{1,9}$ | --repo, --json, --jq | | issue list | none | --repo, --json, --jq, --limit (1-200), --label, --author, --search, --state (open|closed|all) | | issue comment | one ^[0-9]{1,9}$ | --repo, --body | | pr view | one ^[0-9]{1,9}$ | --repo, --json, --jq | | pr list | none | --repo, --json, --jq, --limit (1-200), --label, --author, --search, --state (open|closed|merged|all) | | pr checks | one ^[0-9]{1,9}$ | --repo, --json, --jq | | pr comment | one ^[0-9]{1,9}$ | --repo, --body | | release view | one ^v?[0-9][A-Za-z0-9._-]{0,63}$ | --repo | | api | one ^/?(repos\|orgs\|users\|rate_limit)(/(?!\.{1,2}(?:/\|$))[A-Za-z0-9._-]+)*$, no query string | --jq, --method (GET only) |

    --repo must be ^(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+/(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+$ (a bare ./.. half is refused the same way resolveRepo already refuses a bare ./.. repo name — a real owner or repo name may still contain a dot, e.g. my.repo); --json must be ^[A-Za-z,]+$. Both this and the api row's path pattern above build the same ./..-excluding segment rule from one shared source (SAFE_SEGMENT_SOURCE in gh-allowlist.ts), not two independent copies. api is read-only by construction: no request-body flags at all (-f, -F, --input, or --method with anything but GET are refused), search is deliberately not in the path allowlist since a real search needs a q= query string this table never permits, and a bare ./.. path segment is refused too — the segment class allows a literal . for a real repo/org name (my.repo), so without this a /repos/../search/issues would otherwise satisfy the pattern and reach exactly the excluded path space. --flag=value is split before matching, and a bare -/-- is always refused. A flag or subcommand not in this table — repo delete, issue delete, --body-file, --editor, --web, --hostname, anything else — is refused the same way, before gh is ever resolved or spawned.

The .cmd/.bat shim note (claude and gh)

Both exec/claude and exec/gh resolve their binary by walking PATH themselves rather than letting a shell do it, because neither ever sets shell: true (request input reaching a shell is exactly what this agent refuses to do). Node itself cannot spawn a .cmd/.bat shim without shell: true (EINVAL, since the CVE-2024-27980 fix) — so a claude or gh install that only put a shim on PATH (common for a global npm install on Windows) answers 502 with a message naming the shim specifically ("claude is a .cmd shim; install the native Claude Code binary" / "gh is a .cmd shim; install the native GitHub CLI binary"), distinct from a bare "<name> not found on PATH" — the operator's fix differs (install the native binary vs. install it at all). Windows PATH resolution stops at the first directory with any matching extension, so a shim earlier on PATH is reported even if a native binary exists further down — that is what a real invocation would actually run, not "helpful" behavior a real shell wouldn't show either.

POST /v1/publish/mirror

POST /v1/publish/mirror (agent.publish.mirror, #53) is the 2.0 home of 1.x's hourly publish_onedrive.py: the console pushes changed report HTML to this verb rather than the agent pulling from the console (the console sits behind Cloudflare Access, which an agent cannot pass). Body {files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean} — files at most 1000 entries, each href a relative POSIX path (no empty/./.. segment, no drive/UNC anchor) ending .html, base64 a valid base64 string, decoded total at most 48 MB (a request body over ~66 MB is rejected by the route's own body-size cap (larger than the 16 KiB cap on the pair, grants and update routes) before JSON parsing even runs, 413 {ok: false, error: "request body too large"}); wanted an array of such hrefs (every html href the caller currently wants mirrored, not just the changed ones in files); pdf/prune booleans. 409 {ok: false, error: "publish target not configured"} when publish_dir is unset (most paired machines — a Linux box has no OneDrive). One run at a time: 409 {ok: false, error: "publish already running"}, which also answers while an earlier request is still sending its body (the guard is taken before the body is read, so a client that opens a request and never finishes it holds it for other callers).

  • HTML: each file's bytes are compared against what's on disk (size, then sha256) — unchanged bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to <publish_dir>/html/<href>.tmp-<pid> then renamed into place. A write/rename failure (a file OneDrive has locked mid-sync) counts as failed, is named in html.failures with its error's message, and the run continues with the next file.
  • PDF (when pdf: true): one pdf/<href>.pdf per wanted report (never for an index.html page), rendered with a local headless Chromium-family browser only when missing or older than its html — resolved via the same search GET /v1/preflight's chromium row uses (winPaths, then PATH, then a version manager's bin dir), never executed otherwise. No resolvable browser is not an error: html still mirrors, the response's pdf.browser is null. A render that produced no file counts as failed and is named in pdf.failures by its .pdf href: timed out after 90s when the browser launch timed out, exit <code>: <the last 160 characters of its stderr> when it exited non-zero (just exit <code> when it printed nothing), else no pdf produced within 30s. The poll for the file still decides success, so a browser that exits non-zero, or times out, but wrote the pdf is a success. A report whose html is not on disk is skipped, never named.
  • Ownership, not "not in my docroot" (1.x issue #230): the publish target is shared across every paired machine that mirrors to it, so pruning (prune: true) is scoped to what THIS machine's ledger — <stateDir>/published-ledger.json, machine-local, never inside the publish target itself — claims it published; a file another machine owns is never even considered for deletion. With no ledger yet, a machine adopts conservatively (only wanted ∩ mirrored). A prune is refused outright (a veto, nothing deleted) when wanted is empty, or when it would delete ≥5 files AND over half the mirror in one run — the response's html.veto/pdf.veto names the reason; re-run with the same request (or a --force-equivalent isn't exposed here — see publish-adopt below for the one-time claim that closes an adoption gap) once satisfied it's intended.
  • Response 200 {ok: true, dest, html: {written, unchanged, failed, pruned, veto, failures}, pdf: {rendered, upToDate, failed, pruned, veto, browser, failures}}. failures (added in #376) is [{href, reason}] naming each report that failed to write or render (a pdf by its .pdf href), capped at 50 per kind (failed keeps counting past it). It is always present from this agent ([] when nothing failed) and absent from an older one, so a console must treat it as optional. A reason is one line of at most 200 characters (whitespace collapsed), with this run's publish_dir replaced by <dest> and the browser's temporary profile dir by <tmp> wherever they appear as literal text: as configured, path.normalized, with either separator, or percent-encoded as in a file:// URL. The response's dest already names the former. Only an absolute path is replaced (a relative publish_dir is left alone rather than rewriting every occurrence of its name). A path that differs in case, is a Windows short (8.3) name or is the \\?\UNC\... form