Based on AgentRun release · Synced .
Changelog
Unreleased
0.1.0-beta.32 (2026-11-10)
Everything the agents make is now on the disk, apart from the data directory's own files. Each agent has one folder,
<data>/disk/agents/<agent>/(its id in lower case, a dash, and 16 hex digits of its SHA-256). That folder holds the agent's learning folder (learning/) and its runs (runs/<run>/, each withstore/run.sqliteandwork/). store.sqlite and the server's files stay at the top of the data directory, as the control plane, and only the server opens them. A run's row in store.sqlite now holds its claim:claim_epochandclaim_holder. Each launch of the run's process bumps the epoch, with that process as the holder, and hands the process its epoch. Once the process is gone, the server takes the run back: it bumps the epoch, names itself the holder, and checkpoints the run's database. A run's database (pi-durable-disk's exclusive profile) is WAL, under an exclusive lock, withwal_autocheckpoint = 0and synchronous FULL. The run's process ends with its WAL in place: its store's close keeps the connection open (keepWal), and the process ends by process.exit, which never checkpoints. A test pins this node:sqlite behaviour, and its inverse: a close does checkpoint. The server reads a run's database with a fresh read-only, lock-free open (vfs=unix-none, EXCLUSIVE) per read. It opens the WAL only once the WAL holds a commit that SQLite's recovery would take: a frame with the header's salts, a valid chained checksum and a nonzero commit size. Until then it reads the database file alone, as an immutable file. A lock-free reader of a WAL whose first transaction is still being written recovers no frame, and its close removes that WAL, which loses the writer's rows. agentrun-pi exports that check aswalHasCommit, and both readers use it. It never shows a read whose last entry is behind one it read before. It never opens the database for writing while a process holds the run. A data directory an earlier version wrote (runs under<data>/runs/, or a store of store version 1) is refused at start, in words, and nothing is migrated. pi-durable-agent's store is now store version 2. It names a run's folder by its row's agent (runDir,runStorePath), and addsclaimRunandrunClaim. A store of version 1 is refused. serve gains the local disk adapter:mount,sync,unmountandrevokefor one machine, where a runner works in the folder itself and a revoke is the server's take-back of the claim. agentrun-pi's durable readers takeunlocked(a freshvfs=unix-noneread, as the server's), which serve's reads of a run's decisions use. A run's folder is never made through a symbolic link: a link on the way to it refuses the run's start, and refuses an import that finishes a run its last import left without a transcript (pi-durable-agentrunFolders).The E2B provider takes no
publicStreamoption and has nostream(): a screen is streamed through agentrun, never through a vendor URL.publicStreamlet public traffic into the sandbox, which opened every port in the computer to anyone with its address. Making anE2BProviderwithpublicStream(either value) now throws in words, and the conformance module no longer readsCOMPUTER_E2B_PUBLIC_STREAM. Drop the option and the setting; every E2B sandbox is made with public traffic off.provider.streamstays onComputerProviderfor the browser kinds (Browserbase and Kernel).Remote computers (Daytona, E2B, Modal) cut the daemon's programs off from the vendor's own agent in the sandbox (Daytona's toolbox on 2280, E2B's envd on 49983), which runs commands as root or with sudo for whoever reaches it. Each starts
computerd init --user <uid:gid> --own-network. Init, still root, does four things: it refuses uid 0 for its programs; it refuses to start while another process holds their uid; it moves into a network namespace that holds its loopback alone; and once dropped, it refuses AF_VSOCK sockets and io_uring_setup on every thread. Without CAP_SYS_ADMIN and CAP_NET_ADMIN, init refuses to start in words and the computer fails closed.Who breaks, and what to do:
- A Daytona, E2B or Modal spec with network
openor an allowlist is refused in words bycheck()andcreate(), andnetwork()refuses a change to one. Ask fornone. A proxy-backed open network on remote computers is the next feature after this one. - The default computer user on Daytona, E2B and Modal is 2510:2510 (was 1000:1000). The
daemonUseranduseroptions still name another one. capabilities().vendorAgentPortsdeclares the ports of a vendor's agent: Daytona[2280], E2B[49983], Modal[]. The new Core conformance caseisolation.vendor-agentprobes them on both loopbacks as the computer's user, along with1.1.1.1:443andexample.com:443, and requires every target refused, with the loopback alone in view. A provider with daemonchildmust declare the ports.computerd probereportsinterfaces. The Daytona and E2B drift checks require the daemon's network to holdloalone and its seccomp mode to be 2. Modal's probe requiresinterfacesto read["lo"].
- A Daytona, E2B or Modal spec with network
The computer library reads no environment: every provider is built by its file's
create(options), and every setting, a vendor key included, is an option.ENV,fromEnv, Daytona'soptionsFromEnv, Modal'smodalServerUrland the registry'sproviderFromEnvare gone;providerFor(id, options)builds a provider by id. Daytona's API URL (default Daytona's own) and target areapiUrlandtarget; Modal's token, app and server aretokenId,tokenSecret,app(required, refused in words naming the option) andserverUrl; E2B's key isapiKey. Serve passes its own existing names at the cutover (DAYTONA_API_KEY,DAYTONA_API_URL,DAYTONA_TARGET,MODAL_TOKEN_ID,MODAL_TOKEN_SECRET,AGENTRUN_BOX_APP,AGENTRUN_DOCKER_IMAGE, andE2B_API_KEYas its new one), so an owner's settings keep working. Onlysrc/conformance/(the conformance CLI, each provider's conformance module and the sweeps) reads theCOMPUTER_*names, through each module'ssettingsFromEnv(env), so the live scripts run unchanged. A default never comes from the environment either: Docker'sdaemonCachedefaults to~/.cache/agentrun-computerand no longer readsXDG_CACHE_HOME(passdaemonCacheto keep the copy elsewhere; the conformance CLI readsCOMPUTER_DOCKER_DAEMON_CACHE). Two guards stay, each against a vendor SDK that reads the environment on its own: Modal's provider refuses an ambientMODAL_SERVER_URLand sets it only around the client's construction, now one construction at a time and put back after each; E2B's refuses an ambientE2B_SANDBOX_URL.scripts/check-env.mjsreadssrc/with TypeScript's parser and fails on any other use ofprocess.envoutsidesrc/conformance/.A Docker computer whose lifetime ran out before
docker runfinished (a Docker host that held the start; init found its deadline past and exited 0) now failscreateastimeout, "its lifetime of N s ended before docker run finished (it took M s)", where it was adockerfailure saying only that the computer was not running after its start. The conformance caseprovider.deadlinereads the run's clock. On a host slower to start a computer than the case's 45 s lifetime, it asks once more for 45 s past twice what create took. When that is still too slow it skips in words naming both times, so Core does not pass.The computer protocol extension's types and conformance cases: a computer has a kind, a daemon mode and groups, and the suite's Core depends on them.
capabilities()gainskind(linux,desktop,android,ios),groups(exec,files,processes,screen,input.pointer,input.keys,input.touch,apps,apps.install,apps.ui_tree) andmaxLifetimeMs, anddaemonbecomes a mode (pid1,childornone) instead of a list. Hello reportskindandgroupsin place ofcapabilities; the drift check and Docker'sconnectrefuse a hello whose kind or groups differ, in either direction. Docker declareslinux,pid1, exec, files and processes, and a 30-day cap. Code that readcapabilities().daemonorhello.capabilitiesreadsgroups; theDaemonCapabilitytype is removed (useGroup).protocol.tshas the screen, input and apps methods' types,KEYS,UiNode,ComputerClient(whichDaemonClientimplements) and a typeddeadline.extend;checkActions()is the batch check ofinput.actions.checkLifetime()is one lifetime rule for every provider'scheck(),kindRefusals()names an invalid kind, os and daemon, and a provider may offerextend()(an earlier deadline, or one past create +maxLifetimeMs, is refused with the deadline unchanged) andstream(). The janitor deletes a computer whose deadline is more thanmaxLifetimeMspast now.coreGroups(kind, daemon)replacesCORE_CAPABILITIES. A Core case missing what its kind requires now fails in words instead of skipping; one missing an optional group skips, is named inundeclared, and is left out of Core's count. Thirteen new cases (health.kind-consistent, and thescreen,input,touch,appsandlifetimegroups) check effects against a fixture's event log, named with--fixture; on Docker the twelve that need a group or method it lacks skip in words, and Core reads53/53.readPng()decodes a whole 8-bit, non-interlaced PNG when it reads it, bounded by its IHDR. The report names the kind and daemon mode. The conformanceGrouptype is nowCaseGroup;Groupis the protocol's.
@parcha/pi-agent-learnand@parcha/pi-git-remoteare deleted from the repository, with theexamples/agent-learndemo (npm run demo:learn) and the guide's "An agent that learns" section. No other package imports either one, so nothing else changes. Neither is published again: their last versions on npm stay installable and are deprecated there. A program that uses them keeps working on the last published version; a server's agent learning (agentrun-serve's learning folder) is unaffected.@parcha/pi-visionis gone from this repository, with the reference host it served. No package of the open platform imported it. It gets no new release, and its versions on npm are deprecated. The same change removes:@parcha/agentrun-harness, the reference host for the demos. It was held and never published.apps/viewer, the recording player, and the GitHub Pages gallery that published it.- the examples that ran on the reference host or on pi-vision:
examples/reference,hero,issue-triage,vision-agent,weband the vision question folders. - the
demo:reference,demo:kill,demo:hero,demo:issue-triage,demo:visionandtest:viewerscripts.
A program that imports
@parcha/pi-visioncan keep the last published version, which stays installable.A workflow run's graph, once the run has ended, and the server's reads at the run's end (
workflowCarryandreadWorkflowRunof its outcome) now read the run's database lock-free, as every other server read does. They make no -wal or -shm file beside it, never wait on a lock, and never remove a WAL that holds commits.GET /runs/{run_id}/graphdrops itsstalefield. The view it marked (the last whole view, or the live view, shown while the run's store was busy) can no longer happen, since a lock-free read never meets a busy store. The console no longer reads the field. pi-durable-agent'sreadWorkflowRun(run, { unlocked })andworkflowCarry(run, outcome, { unlocked })take the unlocked read. agentrun-pi exportswithDurableFile.The pi family is 1.5.0 exactly (pi-durable, pi-ai, pi-coding-agent, chord), with one typebox (1.3.27); the older-pi CI legs are gone. A pi 1.5 run store is SQLite schema 2, which pi 1.3.0 or earlier cannot open again, so there is no rollback past this release. Tool results are under
outputonly. A browser charge serve fails to count makes the run's spend unknown, and a capped run then refuses its next paid call until the spend is known again. The door'sGET /eventsand a recording's live lines read through an adapter on pi 1.5'sobserveCommits(pi-durable-disk./internalswatchEvents, with no compatibility promise) until they move toconversation.watch. A failed run store's close releases this process's live browser sessions again; a normal close keeps them for the next run. A claude-agent, codex or opencode run opens on pi 1.5, and an image it reads through pi's read tool is read. particle-1's cap hold keeps Particle's list price over pi-ai's promotional catalog price; a request is still booked at OpenRouter's billed total. The agent store reader reads pi-durable schema 2.An agent can run unattended. Its definition takes
approvals:ask, the default, is the tool gate as its settings say;neverallows each tool that would ask instead, decided by the gate at each call, so a tool granted later and an MCP tool (mcp:<server>/<tool>, which asks by default) are covered on every harness. It never lifts a deny, whether set on the server or in the agent's owntool_settings, nor anything else the gate refuses before it decides between running and asking. A workflow's call node on a tool set to ask runs undernever. The agent read servesapprovals, its tools say an allow came fromunattended(permission_from), and the run read serves the run's ownapprovals, as it started. A template apply'soverrides.approvalssets it; an apply without it takes the package's own default (approvals_default:neverfor a template whose job runs on triggers, elseask). A value other thanaskorneveris a422namingdefinition.approvals. Upgrading: nothing to do; an agent that names none asks, as before.@parcha/agentrunships an Agent Skills folder,skills/agentrun-cli, so a coding agent can drive the server through the CLI. ItsSKILL.mdcovers how the CLI finds the server and the token, five recipes as shell blocks (make an agent from a definition file, start a capped run and wait, answer a run that asks, read what it wrote and what it cost, raise a cap or approve a call), the exit codes and one line per command group.references/commands.mdhas one entry per command with its usage, summary, flags and route. Both are generated with the command table bynode scripts/generate-clients.mjs, and their header holds the table's hash.--checkfails when either differs from the generated files, or when the skill names a command or flag the CLI does not have. Recipe 5 stops a run at a cap too small for it (exit 8), raises the cap withagentrun runs raise-cap RUN_ID --max-cost-usd Nand waits for the run to complete. The generator picks that command from the table by its operation. A tool call's approval is answered by the person in the console, since the server takes that answer from no token, and the recipe says so.agentrun skill install [--claude] [--codex] [--dir DIR]copies the skill to~/.claude/skills/agentrun-cliand~/.agents/skills/agentrun-cli(the shared folder Codex reads), by default to each of the two whose folder exists, or toDIR/agentrun-cli. It prints the paths it wrote. It refuses (exit 2, nothing touched) a target that is the package's own skill folder, through a link too, lies inside it or holds it, and it replaces an older copy of the skill by copying beside it and renaming, so a failed copy leaves the old one in place.agentrun tool-keys set KEY_ID FIELD [--from-env VAR]saves one field of a tool key. The value is read from stdin or from the named environment variable, never from the command line, and appears in no output or error. AKEY_IDthat is empty,.or..is refused, and the request is sent only when its URL, the base URL's path included, is the tool key route.agentrun login [--base-url URL]reads a token from stdin and refuses one typed on a terminal. It checks the token against the server (a refused token is exit 4 and nothing is written), then writesbase_urlandapi_keyto$XDG_CONFIG_HOME/agentrun/config.json(else~/.config/agentrun/config.json) with mode 0600 in a 0700 directory.agentrun logoutremoves the file. A token that is not a valid header value is refused before anything is sent, exit 4. Every command refuses a base URL that is not http or https or has a query, a fragment, a user or a password (exit 2), from--base-url,AGENTRUN_BASE_URLor the config file: such a URL could swallow the path appended to it, so another route answered.loginsaves the token only when the check's answer is the agents list. A command that sends a secret (login,tool-keys set,tool-keys save,mcp-servers save|preview, andapito their routes) never prints the server's error body: a refusal prints its status, itserror.codeand a message of the CLI's own, with the same exit code as before. As a second line against a server that echoes what it was sent, a secret's copies in what it does print, as it is, JSON-escaped or URL-encoded, are cut out: from a JSON answer's string values and keys, so the answer printed is still JSON with its structure intact, and from the text of any other answer. Every string value of such a body is a secret except the public fieldsscripts/clients/resources.jsonnames by full path (an MCP server'surlandauth.type, a preview'sname), whichgenerate-clients --checkholds to the body's schema. A header's value is always a secret, whatever the header is called. An MCP server's OAuth sign-in hasagentrun mcp-servers oauth-start NAME, its body (with any client secret) from stdin or a file, and printing the page to open with its URL, scopes and client id intact, andmcp-servers oauth-clientreads this server's client metadata document. The callback, which only the owner's browser reaches, has no command. Theagentrun-serveskill and the server'sllms.txtpoint to the CLI skill for everything the MCP tools do not cover.npx @parcha/agentrun runs wait RUN_IDwaits until a run ends or waits on a person and prints it, andruns start ... --waitstarts a run and does the same: exit 0 completed, 1 failed or cancelled, 8 it waits on a person (a question, its spend cap, a stopped turn, or a paused workflow run), and the line on stderr says what to do next. It long-polls the run's timeline, so it answers as soon as the run changes.--timeout SECONDSstops waiting after that long, prints the run as it is and exits 1 with a line saying how to wait again; the run goes on.runs timeline RUN_ID --followstreams the timeline as it changes over the route's event stream, one page per line (NDJSON) as the server sends it: its rows, the ids it drops andresetwhen the rows held are to go first. A dropped connection resumes withLast-Event-ID, so no change comes twice, and the command ends when the run ends: 0 completed, 1 failed or cancelled.@parcha/agentrun-clientis published from this release on:npm install @parcha/agentrun-clientinstalls the typed TypeScript client for a self-hosted AgentRun server, at the release's version, with no dependencies. The release checks it as it checks every published package (its manifest, its packed files, and a clean install that imports it and calls a fake server with no network).DaytonaProvider,ModalProviderandE2BProvidermount the run's folder from an Archil disk: each declaresmount: ["archil"](MOUNT). For a spec withmount: { kind: "archil", disk, region, folder, token },createruns the shared Archil steps (preflight, the takeover of the run's lower epochs, mount or revoke with the token on the helper's stdin, serve) as root through the vendor's own exec, after the daemon is in place and before init starts, then reads the mount back from inside. The token reaches the helper's stdin alone: never an argument, a label, a tag, metadata, a read-back or an error.MountReadBack's archil arm gainsexclusiveandepoch(the computer'sagentrun.epochlabel).readBackOf(mount, epoch?)keeps the epoch optional; an Archil mount with none reads back as{ other }, in words.box/ships the root helperagentrun-archil(mount, revoke, serve, checkin; root only, never through sudo) and the image recipe: archil 0.10.1 pinned by sha256, the tool useragentrun-tool2510:2510, no sudo and no sudoers file, the image's user root. A mount gives the folder's top to the tool user right after it, whatever owner the disk's object API made it with. The image installs no firewall: init and every tool run in a network namespace of their own.- computerd gains
sync-serve(the helper's serve: a resident sync on /run/agentrun-archil/archil.sock, folder 0750 and socket 0660, root's and group 2510; one fixed request byte runsarchil sync, coalesced, and answers its exit code; it holds no token and exits when the mount goes or its socket is removed) and themount.synccall, which trusts only a root-owned 0750 folder of the tool user's group and a root-owned socket. - An Archil spec takes network
"none"and the run'sagentrun.runandagentrun.epochlabels, its folderagents/<agent>/runs/<run>/workof that run. Its tools run asagentrun-tool, 2510:2510: Daytona and E2B take an Archil spec only from a provider made withdaemonUser: "2510:2510"; Modal makes that an Archil spec's default user and refuses any other; E2B refuses a spec label namedagentrun.mount. - The vendor network stays on, for root to reach Archil: Daytona's network is open, Modal drops
blockNetwork, and E2B keeps internet access on with public traffic off. The tools' network"none"is init's own. - Daytona: each root step is a toolbox session command of its own, the token sent as its input with no echo; the label
agentrun.mountrecords the mount's kind and source. - Modal: the sandbox's command is always the entrypoint, which waits for a root-made gate (
/opt/agentrun/archil-mounted) as well as the daemon, so no tool runs before the folder is mounted. The spec tag records the mount as it reads back, under the claim's epoch.ModalTimeoutsandE2BTimeoutsgainmountMs, the bound of each root step. - E2B: each root step is a background root command with stdin (
sendStdin, thencloseStdin); the metadataagentrun.mountrecords the mount's kind and source. connect()on an Archil computer syncs the folder after every call that may change it (process.startincluded, whose log may be made in/workspace), coalesced; a failed sync fails the call, and a caller whose signal fires stops waiting for the shared sync, answeredaborted. A create never forces off another computer of the run at its own epoch: a held folder then refuses in words.releaselets the folder go (a final sync, then checkin) while the computer runs, then deletes it, and succeeds once it is gone: a folder not let go cleanly loses nothing, so it is said through the provider's newonNoteoption, never as a failed release.list()reports the recorded mount under the epoch label;attachreads it back from inside and refuses drift.- The conformance suite:
src/conformance/archil.tsgives every remote provider's module one Archil run folder (archilRunFolder,archilSettings,ARCHIL_ENV), reached from outside through the disk's object API (the host'sdiskSDK, or one given). The Daytona, Modal and E2B modules exportmountFor("archil"), readingCOMPUTER_ARCHIL_DISK,COMPUTER_ARCHIL_REGION,COMPUTER_ARCHIL_TOKEN,COMPUTER_ARCHIL_API_KEYandCOMPUTER_ARCHIL_S3_URL. New cases: mount.archil-durable, mount.archil-takeover, mount.archil-token and mount.archil-isolated-durable; a case may record what it saw without asserting it (CaseResult.observed), and the CLI puts what a provider says throughonNotein the report'sproviderNotes. An Archil computer'snetwork()reads"none"from the daemon, its own loopback alone, since its vendor network is open for root.
Browserbase and Kernel are computers of kind
browser(src/browserbase.ts,src/kernel.ts), registered inproviders.tsand held to theirPROVIDERSentries. TheirCAPABILITIESarecdpandstream.- Each is built by
create(options)fromapiKey(Browserbase also takesprojectId) or from a host's own transport (api,request). Each calls the vendor's REST API at the same endpoints pi-browser calls today. - Labels ride the vendor session's metadata.
attachfinds a session by id or by labels,listby labels.connectreads the browser first and givescdp.url, the signed CDP URL, which is a secret. A browser that has ended, or that the vendor no longer knows though its URL is held, isgone.streamgives the live view.releaseis idempotent and confirmed by a read-back.check()refuses a lifetime past the vendor's cap. Neither hasextend. - Each create also writes the label
agentrun.createwith a value of its own. A create whose reply is lost, or whose caller cancels it, finds the browser it made by that label and releases it, and never creates again.check()refuses the label in a spec, and list and attach never show it. testConnection()makes one read of the account's running sessions or browsers and never creates one.- The conformance CLI runs the browser case set (health, then
browser.*) for a provider of kind browser. The set shares one session per run, and--kind browserpasses on it. The conformance modules readCOMPUTER_BROWSERBASE_*andCOMPUTER_KERNEL_*behind a live switch. - Browserbase refuses a dot in a userMetadata key, as found live. A label's dots are written there as
_and read back as dots, andcheck()takes Browserbase label keys of letters, digits and dots only, with no'or\in a value.
- Each is built by
A conformance suite for computer providers:
npm run conformance -w @parcha/agentrun-computer -- --provider docker(or--provider ./module.js, a module that exportscreateProvider()) runs every case against the provider, prints one line per level and group with each failure's reason, and exits 0 when Core passes, 1 when it fails and 2 on a usage error;--jsonprints the report.- Each case is data: an id, a level (
coreorfull, after the Kubernetes agent-sandbox conformance levels), a group (health,exec,files,process,provider,drift) and the capabilities it needs. A case whose needs the provider or its daemon does not declare is skipped with the reason, never failed; Core passes only when every Core case passes. - Cases assume no root: a computer's programs run as the provider's non-root default with no capabilities, and the suite works under the computer's
HOME.capabilities().user(["root", "uid"]for Docker) declares whichspec.userrequests a provider takes; the Full casesexec.root-on-requestandexec.user-uidneeduser:rootanduser:uid. - Core needs network
none: the suite never asks for a wider network, so a provider without it fails Core in words. Every provider call the runner makes is bounded, so a stalled provider gives a failed report and the CLI exits; a computer a provider gives after its case ended is deleted as it arrives and named incleanup.late. runConformance(provider, { image, only?, signal?, drifted? })andformatReport(report)run it from code. The run labels every computer it makes, deletes each one, sweeps its label when the provider's lifetime is"janitor", and reports any computer left.npm run test:dockerruns the suite on Docker, one test per case, and the CLI againstalpine:3.20; the Docker live tests keep only what Docker alone shows.
- Each case is data: an id, a level (
DaytonaProvider, the second computer provider, over Daytona's v0 REST API withfetchand no new dependency. A snapshot is the computer's image;image: "default"sends none, so Daytona uses its default snapshot. Capabilities: kindlinux, daemonchild(init is a session command of Daytona's toolbox), groupsexec,filesandprocesses, lifetimevendor(the sandbox's TTL),maxLifetimeMs30 days, networknone,openor one allowlist (up to 100 domains on ports 80 and 443, or 10 IPv4 CIDRs), andrelease["stop", "delete"].createtakesautoStopMinutesandautoDeleteMinutes, pushescomputerdin through the toolbox, installs it root-owned at a path no other user can change, and startscomputerd init --user 1000:1000as root; init drops itself to 1000:1000 with no groups, no capabilities andno_new_privson every thread.connectrunscomputerd stdioin a session of its own; init and the bridge refuse a daemon whose path a user other than root could have changed. The drift check reads the sandbox and the daemon back at create and attach.releasewaits until Daytona reads the computer gone or stopped. Four live runs reached the default snapshot; the last passed Core 53/53.- The sandbox is the only boundary: a program in a Daytona computer can become root through Daytona's toolbox, which listens on port 2280 with no authentication and runs commands as a user with passwordless
sudoin the default snapshot. The daemon's user is hygiene, andnetworkBlockAlllimits egress only. dist/conformance/daytona.jsruns the conformance suite on Daytona, readingCOMPUTER_DAYTONA_API_KEYand the otherCOMPUTER_DAYTONA_*settings by name, neverDAYTONA_API_KEY; its computers stop after 30 idle minutes and a stop deletes.dist/conformance/daytona-sweep.js --before <file>and--after <file>delete the labelled sandboxes a run left behind.ComputerCapabilitiesgainsstopDeletes(a stop ends in deletion; Coreprovider.stopholds a provider to it, and Fullprovider.janitorskips) andallow;ReadBackSpecgainssnapshot; results gain adaytonaerror code. The conformance CLI takes a module'sconformanceImage()when--imagenames none and prints itsconformanceNotes.
- The sandbox is the only boundary: a program in a Daytona computer can become root through Daytona's toolbox, which listens on port 2280 with no authentication and runs commands as a user with passwordless
PROVIDERSentries carrydefaultImage: the image a computer of each setting kind starts from when its setting names none, so serve no longer keeps its own table. Each value is the constant its provider module owns:DAYTONA_DEFAULT_SNAPSHOTandBROWSER_IMAGEas before, and new named constants in the E2B, Orgo, Modal and phone modules where only a literal stood (their refusal words and conformance images now use them too). A kind with no entry has no default, and the caller refuses the setting in words. Docker has none, since serve builds its image.A Docker computer takes network
{ allow: [names or CIDRs] }besidenoneandopen:capabilities().networkis["none", "open", "allow"], and an allowlist holds exact names,*.suffixnames and CIDRs (host bits zero, over no range the address rule refuses), at most 256 of each, on ports 80 and 443. The computer itself stays--network none. Its one way out is its own egress proxy, serve's proxy and address table moved intosrc/docker/egress-proxy.tsandsrc/docker/egress-table.ts, which runs in a second container the provider makes, reads back and removes with it.createmakes a tmpfs volume (computer-<hex>-egress, mode 0700, the computer's uid:gid), starts the proxy container on Docker's default bridge alone (no port, the computer's user, a read-only root, no capability,no-new-privileges, bounded memory, pids, CPU (half of one), open files and log;EGRESS_IMAGE, node:22.23.4-bookworm-slim pinned by digest, oregress.image), and waits for its ready line (egress.readyMs, 10 s) before it runs the computer, which mounts the volume read-only at/opt/agentrun/egress. A proxy that ends first, or is not ready in time, fails create in words, and the proxy and the volume are removed. On a host that remaps user ids (userns-remap, rootless Docker)checkandcreaterefuse an allowlist in words before anything is made, as they do a bind. So they do when this provider's interfaces are not the Docker host's (it runs in a container, ordocker context inspectnames a daemon that is not a local unix socket), unlessegress.hostAddressesnames the host's addresses, which the proxy then refuses asthis-host. Before it makes anything,createmakes sure the computer's image and the proxy's are on the Docker host (docker image inspect, thendocker pullfor one that is not; a failed pull fails create in words naming the image), and both containers run with--pull never, so no pull happens while the sweep could take the new proxy for a leftover.- computerd
init --egress <socket>relays its loopback port 3128 to the proxy's socket and gives every program it startsHTTP_PROXY,HTTPS_PROXY, their lowercase forms andNO_PROXY. A program that ignores them reaches nothing. Init refuses to start when it cannot listen. - The proxy refuses, before any socket opens, a name off the list (before any lookup), an IP literal outside a listed CIDR, any port but 80 and 443, and a listed name or address that is private, loopback, link-local, metadata or this machine's own, in any form; it resolves a name once and dials the checked address, never follows a redirect, refuses SOCKS5 BIND and UDP ASSOCIATE, and logs no path, query or header. It ends itself at the computer's deadline.
network,listandattachread the allowlist back from Docker: the computer's network mode, its egress volume and relay, and the proxy container's label, deadline, state, hardening, bounds (memory, swap, pids, CPU quota, open files, IPC and cgroup namespaces, log), mounts and argv, whose--localmust hold every one of the Docker host's own addresses the provider requires now (this machine's public ones andegress.hostAddresses; a host whose addresses changed since reads as other). Anything less, a crashed proxy included, reads back{ other }in words, andattachrefuses it.network(ref, policy)returns an allowlist the computer has, in any order; every change is refused, since a Docker computer's network is fixed at create.releasestops or removes the computer's proxy and, on delete, its volume, each found by the labelagentrun.computer.egress=<computer>, so the janitor's sweep andreleaseOlderremove them too. Parts left with no computer (one removed by hand, or a provider that died between its proxy and its computer) the sweep removes through the new optionalComputerProvider.sweepLeftovers: a proxy or volume with the egress label whose deadline has passed, or whose computer is gone past a create's grace;Swept.leftoversnames them. The conformance casesprovider.network-allowandprovider.network-switch-allownow run and pass on Docker.
The Docker provider owns the image its computers start from:
ensureImage(source, { docker?, signal?, timeoutMs?, onWait? })(src/docker/image.ts) andDockerProvider'simageoption andimage()method make it ready in the provider's own process and give its tag forcreate. There is no state file between processes: Docker's own tag is the shared state.{ recipe }, a Dockerfile with no build context, is built withdocker build -(the recipe on stdin), taggedagentrun-computer:<first 12 hex digits of its sha256>and labelledagentrun.computer.recipe=<the whole sha256>, and only when Docker has no image of that recipe under the tag.{ image }, an image by name, is pulled once when Docker has none, and never built.- The default is the provider's own recipe,
DOCKER_RECIPE(DEFAULT_DOCKER_IMAGE):python:3.12-slimpinned by its index digest, with git, curl, nodejs, npm, ripgrep and socat. Another image is named only with theimageoption (tonew DockerProvideror tofromEnv's options): there is no env name for it, andfromEnvreads no image name from the environment. serve keeps readingAGENTRUN_DOCKER_IMAGEand passes it in as theimageoption at the cutover. - Concurrent callers in one process share one build; a failure is never kept, so the next call tries again. Two processes that miss the tag at once both build the same recipe; a build that then fails (Docker 29's image store refuses all but the first tag with
already exists) asks Docker once more, so both end with the tag on an image of it.onWait(making, tag)is told once before a caller waits on a build or pull, never when Docker has the image. - One bound,
timeoutMs(default 10 minutes), covers the inspect and the build or pull; past it the docker CLI is killed. A caller'ssignalends its own wait, and the last caller's abort stops the build. - Failures are in words with none of Docker's output: Docker not answering, a build that could not reach the network, pull its base, find disk space or install a package, a recipe Docker could not parse at a line or whose
RUNstep failed with an exit code, and a pull the registry refused, does not have, or could not be reached for. - The live desktop suite builds its fixture image through
ensureImage.
An E2B provider, one registry line (
e2b):E2BProviderindist/e2b.jsmakes E2B sandboxes from a template,desktop(E2B Desktop) by default, as kinddesktop, or kindlinuxwithCOMPUTER_E2B_KIND=linux. computerd is achildof E2B's envd: create uploads it as root, checks its sha256, and startscomputerd initas a background command with no command timeout,DISPLAYand--max-deadline; the X server starts through the daemon, as on a Docker desktop, and hello re-checks the display on every connect. Every call goes through the host'se2bpackage (2.63.0), of which the provider names only the slice it calls and imports nothing at load.- The key is
COMPUTER_E2B_API_KEYalone, neverE2B_API_KEY: every SDK call is given the key, the domain (COMPUTER_E2B_DOMAIN), the API URL anddebug: falseexplicitly, so no ambientE2B_*is read, and the provider refuses to start whileE2B_SANDBOX_URLis set, which no option clears. - Lifetime: E2B's timeout is the lifetime, capped at Hobby's 1 h by default and up to Pro's 24 h with
COMPUTER_E2B_MAX_LIFETIME_MS;extend()moves E2B's timer, then init's deadline, never past create's time plus the cap. - Network none is no internet access and no public traffic, read back by
getInfoand probed from inside under the shared rule (only an answer refuses it). E2B's firewall accepts each outbound TCP connection and then closes a denied one with nothing sent back, so a computer with network none is kept, and create's and attach'snotesname the accepted targets. The probe's defaults are Modal's: TCP to 443, and DNS over UDP to1.1.1.1:53/udpand[2606:4700:4700::1111]:53/udp; E2B drops non-TCP to the deny set in nftables, so a UDP target gets no reply within the 10 s window and reads refused, and a reply refuses network none.probeTargetstakeshost:port/udp. That rule holds for every provider (confirmed 11-07), and its residual risk is a one-way leak no probe from inside can see. Open is internet access, still with no public traffic.stream()exists only withCOMPUTER_E2B_PUBLIC_STREAM=1, whose computers are network open: x11vnc on loopback with an 8-character password and noVNC in front, the URL never logged. - Release kills and waits until E2B no longer has or lists the sandbox, within
goneMsand the caller's signal; a create that fails or is aborted at any step kills its sandbox.dist/conformance/e2b.js(live switchAGENTRUN_COMPUTER_LIVE_E2B=1, 30-minute computers) anddist/conformance/e2b-sweep.jscome with it. - The child daemon's install, init and stdio commands and its read-back move from
daytona.tstochild.ts, and the display start fromdocker.tstodesktop.ts, shared; Daytona's commands are unchanged. computerd probe --diagnosereports, per public address and port (1.1.1.1, 8.8.8.8, 9.9.9.9 and Fastly's 151.101.1.57 on 443/tcp, 80/tcp and 53/udp), whether the connection was refused, accepted with no answer, or answered (any byte back, judged as--connectjudges a target), every step bounded.dist/conformance/e2b-diagnose.jsruns it in one sandbox made as create makes one for network none, since E2B's firewall accepts a TCP connection before it decides, and always kills the sandbox and reads it back gone. It records the provider's own check,passedwith itsnotesor its refusal.
- The key is
Every computer of a run carries two labels,
agentrun.runandagentrun.epoch(RUN_LABEL,EPOCH_LABEL, insrc/labels.ts), and a takeover releases the run's computers of an older claim withreleaseOlder(provider, run, epoch, { signal? }). The claim epoch is the one fence for runs and computers: a new claim is epoch + 1, and its first step deletes every computer of the run labelled with a lower epoch.runLabels(run, epoch)gives the two labels; a run id is a non-empty string with no NUL byte (runProblem), an epoch a whole number from 0 to 2^53 - 1 (epochProblem).epochOf(labels)reads a computer's epoch, decimal with no sign, leading zero or space, and names anything else in words. No provider refuses the labels: they are outside its ownagentrun.computernamespace, and Docker takes them with no code of its own.releaseOlderlists byagentrun.run, holds each listed computer to the run's label again, deletes each with a lower epoch or with no epoch it can read (named inunproven), keeps this epoch's, and returns{ released, kept, unproven }. A computer of the run with a higher epoch refuses the call as fenced before anything is released. Every release is attempted, and when any fails the result is an error naming each computer left and why: a partial cleanup is never a success. An aborted call starts no further release and says which ran. It revokes no mount; there is nounmounton the provider.- The conformance suite has a Full case,
provider.release-older: computers of one run at epochs 1, 2 and 3 and of another run at epoch 1;releaseOlderat epoch 3 leaves 3 and the other run's, and at epoch 2 after it is refused as fenced.
Files move in and out of a computer through its daemon:
files.read,files.write,files.stat,files.list,files.mkdir,files.moveandfiles.removeon absolute paths (a relative one isinvalid), and tar archives both ways without holding one in memory:tar.pull.begin,tar.pull.read(at most 512 KiB a read) andtar.pull.endread an archive as it is written, andtar.push.begin,tar.push.writeandtar.push.endextract into a directory as the bytes come. Hello's capabilities gainfiles.- A pushed entry that is absolute, has a
..part, or would be written through a symlink out of the target directory is refused asdenied; modes are kept (a directory's once the archive ends) and nothing is chowned;files.movewithoutoverwritenever replaces, even under a concurrent move; a push still open when the connection ends is ended, leaving no half-written file. readFile,writeFile,pushTar,pullTarandpullTarStreamgo past one call's limit; a pull reads a chunk only as its loop asks, so it can be piped into a push on the same connection. New error codesexists,not_dirandis_dir.- The new daemon code copies from E2B envd and Kubernetes sandboxd (Apache-2.0), recorded in UPSTREAM.json.
- A pushed entry that is absolute, has a
An agent's computer is a setting kind:
none,sandbox,desktop,phoneorbrowser(src/kinds.ts).SETTING_KINDSgives each kind its machine kinds and its required and optional capability groups, and carries no tool names.serves,servingRefusalsandkindOfare computed from a provider's capabilities, so a provider that drops a group stops serving the kind.PROVIDERSis what Settings offers. Each entry holds an id, a label, the serve-side key names (required or not), mounts, whether the vendor meters, and static capabilities, and its kinds are derived from those. Browserbase and Kernel are data until their provider files land. Each provider module exportsCAPABILITIES, which must match its entry.- The protocol gains the machine kind
browser(os linux, daemon none, Core groupcdp), and the groupscdp,streamanddownloads, which belong to the provider and never appear in a computerd hello.ComputerConnection.cdp?: { url }carries a browser's CDP target as a secret.ComputerErrorCodegainsunauthorizedandunreachable. testConnection()on a provider makes one account-level read that never creates a computer. Docker, Daytona, E2B and Modal implement it. The first three count their own computers from one listing by their own label (onedocker ps, one page), with no read-back of each. Modal reads its app without creating it. It answersunauthorizedfor a key the vendor refused.- Conformance runs by kind:
--kind <kind>(kindinrunConformance) runskindCases(kind). A provider passes the kind (report.kindVerdict, exit 0) only when it serves the kind, no case fails, each required group has a passing case, and no Core case of one skips. A Full case the provider refuses in words, such as the phone harness's pinch, may skip.kindCasesandkindVerdictare exported for the serve side.
- The protocol gains the machine kind
ModalProvidermakes computers that are Modal sandboxes on the VM runtime, through the host's own Modal client: the package names the slice of the JS SDK it calls (ModalSdk) and imports nothing from it. It has not run against real Modal yet; its tests use a fake of that slice.- The daemon is uploaded at create, so nothing is published: the sandbox's command is a POSIX sh entrypoint that waits, bounded, for
/opt/agentrun/computerdand thenexecscomputerd init --deadline <iso> --user <uid:gid>; right after create the provider streamsdist/computerd-linux-amd64(daemonPath) intosh -cthroughexec's stdin in binary mode, which writes it to a part file, prints its sha256 and moves it into place only when it matches. A failed or mismatched upload terminates the sandbox and refuses.modalCommands()gives the commands as sent. WithdaemonImage(a registry image pinned by digest) the daemon instead comes in as a layer (COPY --from) and is the command itself.connect()runscomputerd stdiothroughexec, so a computer with network none is reached and needs no network for its daemon. - Capabilities: kind
linux, daemonpid1(init is the sandbox's main process, and Modal finishes the sandbox when it exits), groupsexec,filesandprocesses,maxLifetimeMsthe instance's cap (24 hours, Modal's longest timeout, or less with themaxLifetimeMsoption;check()refuses a longer lifetime with the sharedcheckLifetime()), lifetimevendor(Modal's timeout is the lifetime), networknoneoropenwithnetworkReadBack: false, isolationvm, oslinux, useruidonly. An allowlist,pids, a GPU and a change of network are refused in words.release(ref, "stop")is refused: Modal has no stop that keeps a sandbox;deleteterminates and is idempotent, and since Modal ends a sandbox asynchronously it then polls, with pauses that double fromtimeouts.releasePollMs(500 ms) to 5 s, untilpoll()reads an exit code or Modal no longer knows the sandbox, withintimeouts.releaseMs(120 s) and the caller's signal; past the bound it is atimeoutthat says whatpoll()last read.list()polls each sandbox Modal lists and leaves out one that has finished. - The drift check after
create()and inattach(): the sandbox runs, its tags read back as given, hello's version, os, arch, kind and exact groups match, andcomputerd probe, run through the daemon, reports the computer's user, a kernel that is not gVisor's, and, for network none, no connection to its public addresses. A mismatch terminates the sandbox at create, and create waits, as release does, untilpoll()reads it finished, so a refused create (drift, an abort, a failed upload) leaves nothing listed; the terminate is sent whatever the caller's signal, and the wait, bounded bytimeouts.releaseMs, ends on the caller's abort (an aborted create returns at once); past the bound or on an abort the refusal says the sandbox may still be ending and whatpoll()last read.attach()refuses and leaves it running.network(ref)reads network none back with the same probe. - The daemon's path is root's alone, every directory up to
/: the upload refuses a path another user can change (not owned by uid 0, group or other writable, or a link) with nothing installed, makes/opt/agentrunroot's 0755, and checks the installed path again; withdaemonImage, aRUNstep right after theCOPYholds the image's path to the same rule (a path another user can change fails the build). So the bridge each connection runs as root through Modal's exec is root's own. - A Modal computer never runs as root:
check()andcreate()refuse auserof"0","0:<gid>","root"or"root:<gid>"in words, before any Modal call, andconnect,attachandnetworkrefuse a computer whose spec tag records root before they run anything in it. Every connection is Modal's exec of/opt/agentrun/computerd stdio, outside the daemon, with Modal's root capabilities; root without a capability still owns/,/optand/opt/agentrun, so it could rename another program over that path. A user that is not root writes none of them (the daemon is0:0 755in a0:0 755directory). The conformance caseexec.root-on-request, which needsuser:root, skips on Modal as undeclared. - Every SDK call is bounded by a timeout and takes the caller's signal; a create that gives up has its sandbox terminated if one lands later, and a bridge whose exec lands after its connection gave up (past its bound, or on the caller's abort) has its stdin closed as it lands. One deadline,
helloMs, covers a connection's exec of the bridge (at mostcallMsof it) and the hello after it. computerd inittakes--user <uid:gid>: it starts as root, gives$HOMEand the socket's directory to that user (0700) and becomes it, with no supplementary groups.computerd initdrops its privileges itself, as any user, root included, with or without--user: on every thread it setsno_new_privs, empties the bounding set (each capability up to/proc/sys/kernel/cap_last_cap), the ambient set and the effective, permitted and inheritable sets, and reads it all back. Where a capability cannot be dropped (no CAP_SETPCAP with a bounding set), or in a binary that links cgo, init refuses to start, in words. That is policy, failing closed: a vendor that starts it as a non-root user with a bounding set it cannot empty (a KubernetesrunAsUserwithout dropping ALL capabilities) gets no computer rather than one with capabilities init cannot prove gone.- The conformance cases
exec.unprivileged(Core),exec.root-on-requestandexec.user-uid(Full) requireCapEff,CapPrm,CapInh,CapBndandCapAmball zero andNoNewPrivs1 in/proc/self/status, where they required onlyCapEffandCapPrmzero. waitUntil(read, done, { boundMs, signal, minMs, maxMs, what, describe })is a bounded wait with doubling pauses that returns the first statedoneaccepts, or a timeout or abort naming the last state read. Modal's one wait after a terminate,untilFinished, is built on it, and release, a refusing create and the Modal sweep all wait through that.computerd probe [--connect <host:port>]...prints the uid, gid, kernel release and the addresses it reached.ComputerCapabilities.networkReadBacksays whethernetwork()reads the policy back from outside the computer (Docker: true).ComputerErrorCodegains"modal".dist/conformance/modal.jsis a provider module for the conformance CLI, andnpm run test:modalthe live suite; both run only withAGENTRUN_COMPUTER_LIVE_MODAL=1,COMPUTER_MODAL_TOKEN_IDandCOMPUTER_MODAL_TOKEN_SECRETset. The module passes that pair toModalClientexplicitly and never usesMODAL_TOKEN_IDorMODAL_TOKEN_SECRET, which may hold a production token; with only those set it refuses in words. The key goes only toCOMPUTER_MODAL_SERVER_URL, else the SDK's default server: the module refuses whileMODAL_SERVER_URLis set and pins the server while it constructs the client, so noMODAL_PROFILEor.modal.tomlprofile can redirect it. It readsCOMPUTER_MODAL_APPand, optionally,COMPUTER_MODAL_DAEMON_IMAGE; without it the daemon is uploaded. The drifted provider uploads a daemon built with Go as<version>-drift. Every computer of a run lives at most 30 minutes.dist/conformance/modal-sweep.js --before <file>records the app, the server and every running sandbox in the app carryingagentrun.computer=1;--after <file>refuses a file of another app or server, or one naming neither, before it lists anything, else terminates every such sandbox not in that file, reads each back throughuntilFinisheduntil it has finished (60 s each), prints{"swept":n,"gone":m,"stillThere":[ids]}, and exits 0, 1 (one still runs) or 2 (usage, another app or server, token, refused list; nothing terminated). It reads only theCOMPUTER_MODAL_token, app and server, the way the module does.ComputerCapabilities.releasedeclares how release may end a computer: Docker["stop", "delete"], Modal["delete"]. Coreprovider.stopholds a provider to it: one that declares stop lists the computer stopped; one that does not refusesrelease(ref, "stop")asunsupported, in words, and the computer stays listed and running. The Fullprovider.janitorcase also needsrelease:stop. A provider written againstComputerProvideraddsreleaseto itscapabilities().
- The daemon is uploaded at create, so nothing is published: the sandbox's command is a POSIX sh entrypoint that waits, bounded, for
A computer mounts the run's folder at
/workspace(WORKSPACE), the one workspace path in every computer.ComputerSpec.mountis{ kind: "bind", path }(a folder on the provider's host) or{ kind: "archil", disk, region, folder, token }(the run's folder inside an Archil disk in its region), andcapabilities().mountlists the kinds a provider takes. Each provider file exportsMOUNTbesideENV, the same kinds (Docker["bind"], Modal and Daytona[]), andProviderModulehas it, so a caller tells which provider binds a local folder without building one.checkMount(mount, declared)is the one rule every provider'scheckruns, with no vendor call: a kind the provider does not declare is refused in words; a bind's path is absolute, canonical, an existing directory, with no NUL; an Archil mount's disk, region, folder and token are non-empty strings, the folder relative to the disk's root with no... The Archil token is never in a refusal, a label, a read-back (MountReadBack,readBackOf:{ kind: "archil", disk, region, folder }) or an error.DockerProviderdeclaresmount: ["bind"]and binds the folder read-write with--mount type=bind,src=<path>,dst=/workspace;HOMEand the default workdir stay/work. It never chowns or chmods the folder:createrefuses, after reading the host and beforedocker run, a folder its computer's user (the spec's, ordocker.user) cannot write and search, by the owner's, group's or others' bits as the kernel picks them. On a host that remaps user ids (docker infonamesusernsorrootless:host().remap) the folder's owner is not the computer's user, so a bind is refused there in words before anydocker run, and bycheckonce the host is read; a computer with no mount is made as before.checkrefuses an Archil mount and a path--mountcannot hold (a comma, a double quote, a line break).attachandlistread the mount back fromdocker inspect(mountOf):{ kind: "bind", path }, or{ other: "<words>" }for a read-only bind, a volume or two mounts at/workspace, whichattachrefuses. The drift check holds a computer to the bind it was asked for (that path, read-write,rprivate) and to no other mount.- The conformance suite has a
mountgroup, Full until the cutover decides:mount.run-folder-bindandmount.run-folder-archil("mounts the run's folder": a file written outside is read at/workspace, one written inside is read outside, and a program run withcwd: "/workspace"sees it; the mount reads back as asked, with no token) andmount.undeclared-kind(each kind a provider does not declare is refused bycheckandcreatein words). A provider module exportsmountFor(kind)to give each case a fresh run folder (RunFolder), andrunConformancetakesmountFor;--provider dockergives a bind of a new folder under the temp directory.
An Orgo desktop provider (
orgoin the registry, its Settings entry the desktop kind withORGO_API_KEY, no mount, metered):create({ apiKey, workspaceId })makes Linux desktops on Orgo with computerd inside, on Orgo's own X display, so a desktop speaks the same protocol as every computer. It reads no environment: serve passesapiKeyfrom itsORGO_API_KEYsetting. The module exportsCAPABILITIESandMOUNT([]), andtestConnection()is one read of the workspace's computers, which makes nothing.- It declares a Linux desktop (exec, files, processes, screen, input.pointer, input.keys), daemon child, network
"none", lifetimejanitor(Orgo ends a computer only in whole hours, soends_in_hoursis the backstop and the janitor deletes it at its exact deadline), releasedelete,extend,vendorAgentPorts(Orgo's VNC 5999, websockify 6080 and desktop API 8080), and no mount yet. - Orgo has no egress control, so the tools' isolation is init's own network namespace (
computerd init --own-network): network none is read back from inside, by a probe refused at the outside and, on both loopbacks, at the declared ports and every TCP port root listens on in Orgo's own network (read at create and named in its notes). - Before init starts,
createmakes every path unix socket of Orgo's desktop root's alone (owner root, mode 0600) but the display's own and computerd's, and names them in its notes: the tools reach the X server and nothing else. A socket it cannot lock, or a scan that fails, refuses the create and deletes the computer. Root's D-Bus buses were open to the tool user, and polkit over the system bus is a known privilege door. - Each deployment's computers live in one Orgo workspace. A computer's name,
computer-<12 hex>-r<the run's hash>-e<the epoch>-d<its deadline in base-36 seconds>, letslist, the takeover and the janitor filter by run and epoch and read the deadline with nothing run inside;extendrenames it (PATCH) with the deadline init took, a computer's extends run in turn, the name's deadline never moves back, and a ref made before still reaches it. Every label and the deadline are in the root-owned/opt/agentrun/labels.json, whichattachreads as the truth. createrefuses, and deletes, a computer whose daemon's sha256 differs, whose X server has no path socket (/tmp/.X11-unix/X99: the tools' network namespace cuts the abstract one), whose daemon drifts (its network not its loopback alone included), or whose probe reaches an address. Anextendto a deadline earlier than the current one is refused in words.- Everything inside a computer goes through an
OrgoChannel(run,start,stdio,upload); the default is Orgo's bash and upload calls and its root terminal WebSocket, made raw, so SSH can replace it. The key is only in the Authorization header, and the VNC password only in the terminal socket's URL. - The conformance module (
--provider orgo) runs behindAGENTRUN_COMPUTER_LIVE_ORGO=1, readsCOMPUTER_ORGO_API_KEY,COMPUTER_ORGO_WORKSPACE,COMPUTER_ORGO_API_URLandCOMPUTER_ORGO_TEMPLATEinsettingsFromEnv(listed in itsENV), and caps every computer at 1 h.
- It declares a Linux desktop (exec, files, processes, screen, input.pointer, input.keys), daemon child, network
The phone kind:
src/phone-harness.ts, providerphone-harnessin the registry (providerFor("phone-harness", options)), rents a temporary cloud Android from the phone harness (phone-harness.com, 5 cents a minute) and drives it over adb.create(options)takes the API key asapiKey(serve passesPHONE_HARNESS_API_KEY), andadb,fetch,ledgerand the clock as options. No daemon runs on the phone:connect()gives a client in this process that answersscreen,input.touch,input.keys,appsandapps.ui_treewith adb commands, and refuses exec, files, processes, pinch, hold and key combos in words. The phone harness keeps no labels, so the provider keeps them in itsledger({ put, get, list, remove }; in memory by default), and list, attach, release and the janitor see only the phones it names. ItstestConnectionis oneGET /sessions, which rents nothing. A cloud iPhone is refused in words for now. With no fixture app, the conformance suite's touch, keys and apps cases drive the stock Settings app and read back throughapps.ui_tree, and a new Core case,apps.launch-terminate, which needs the apps group alone, lists, launches and terminates it, and sees the launch inapps.ui_treewhen the phone has one, else in a screenshot that changed. A run's computers ask for at most the provider'smaxLifetimeMs.ComputerErrorCodegainsphone. The conformance module readsCOMPUTER_PHONE_HARNESS_*andAGENTRUN_COMPUTER_LIVE_PHONE_HARNESS.Network none, read from inside a computer, now holds when nothing answers within 10 s:
computerd probecounts a target reached only when bytes come back from it, not when a TCP connection is accepted. A target ishost:port, tried over TCP, orhost:port/udp, sent a DNS query over UDP. Each TCP target gets a request by its port (443 a TLS ClientHello, with no server name for an IP address; 80 an HTTPHEAD; 53 a DNS query; any other port an HTTPHEAD); a UDP target gets one DNS query. Each has 10 s to connect and 10 s to answer, and at most 4 KiB is read. Any byte back is an answer: a ServerHello, a TLS alert, an HTTP status line, a DNS response with the query's id, a banner, or bytes of no expected protocol. UDP has no accept, so a UDP target with no reply is refused. The probe's JSON line keepsreached(now the targets that answered) and addsaccepted(a connection accepted, then nothing back) andresults(each target's protocol, outcome, bytes and detail).- A provider refuses network none only on an answer. A connection accepted with nothing back is recorded, never refused and never hidden:
createnow returnsCreatedComputer, aComputerRefwith optionalnotes, andattach'sAttachedComputergainsnotes, such asnetwork: none; accepted with no answer (no bytes came back): 1.1.1.1:443. This is for vendors whose firewall accepts every outbound connection and then closes a denied one (E2B documents this). The conformance report gainscomputerNotes([{ computer, note }], absent when empty), every note a create returned, and the text report prints each asnote <computer>: <words>above the verdict. - What the rule can and cannot detect: it detects a TCP or UDP path that carries bytes back within 10 s on a probed host:port. It cannot detect a one-way leak, a port that is not probed, or a client-first server that silently drops what it is sent (such a TCP target reads
accepted; only an operator's ownprobeTargetscan meet one). - The Modal provider is the one that probes today. Its default targets add DNS over UDP to
1.1.1.1:53/udpand[2606:4700:4700::1111]:53/udp, itsprobeTargetsoption takeshost:port/udp, and a probe report with noacceptedlist is refused in words. Its 30 s bound on the probe covers the probe's longest run (20 s). Docker and Daytona read their network back from the vendor, and a probe there is refused outright, so their results do not change.
- A provider refuses network none only on an answer. A connection accepted with nothing back is recorded, never refused and never hidden:
computerd's process surface takes what a background shell tool needs, with no script run in the computer.
process.starttakeslog: an absolute path for a new file the daemon makes and writes the first 16 MiB of the output to, stdout and stderr as they come. It makes the missing directories and the file itself and never follows a link (each directory entered withO_NOFOLLOW|O_DIRECTORY, the file made withO_CREAT|O_EXCL|O_NOFOLLOW, mode 0644): a link at the path or at a directory on its way is refused asdenied, a file already there asexists, and nothing starts.process.readreads thelogstream from the daemon's own open file, never by path, and says when the log wascapped; every read answerssize, the stream's bytes so far. A write to the log that fails or writes short (a full disk) stops it for good, so the file stays a true prefix of the output, and the read'slogErrorsays why.process.starttakeswaitGroup(the process runs until its program and every process left in its group, on a terminal its session, have ended, and reports the program's own exit; a signal reaches each of its process groups once) andorphans: "keep"(what it started outside that runs on, unsignalled, to the computer's end).- Conformance cases
process.log,process.log-links,process.wait-groupandprocess.orphans-keep.
A process keeps running across calls and connections:
process.start(argv, with an optional pty and stdin),process.readby byte offset with an optional bounded wait,process.write,process.signal(TERM,INT,KILL,HUPto the program's group),process.resize,process.listandprocess.wait. Hello's capabilities gainprocesses.computerd initnow serves the protocol on a unix socket (/run/agentrun/computerd.sock, socket 0600) with one process table for every connection, andcomputerd stdiois a bridge to it that fails in words when no daemon listens.DockerProvider.connect()is unchanged. Init needs the socket's directory there already, the daemon's user's alone (0700) and writable, and exits 1 in words otherwise;DockerProvider.create()mounts/run/agentrunas a tmpfs owned by the computer's uid:gid, mode 0700,noexec,nosuid,nodev.- Each stream keeps its last 8 MiB; a read below the ring's start reports the bytes
dropped. All rings together hold at most 256 MiB (PROCESS_RETAINED_BYTES): past it, the rings of the processes that ended first are let go, never a running one's, and a read of one reports its bytesdropped, with the process still listed and its exit status kept. The table holds 256 processes; the one that ended first is dropped for a new start once its program runs (a refused start drops nothing), and a start is refused aslimitwhen all 256 run. exec.run'stimeoutMscounts from the call, the start included: a guard that never reports the program's start is killed with everything under it, and the run times out. Starts take turns: aprocess.startcancelled while it waits for its turn, or before its program runs, isaborted, with nothing it started left.- Writes to one process take turns; a write's 10 s bound and its cancel cover its wait for its turn.
- Every method names a signal the same way, without
SIG("TERM","KILL"):exec.run,process.waitandprocess.list. process.list {from?}answers{processes, truncated, next}in pages of at most 768 KiB, paged by start order (list againfrom: next), so a process dropped between pages skips no other; it lists at most 64 KiB of each argv (argvClippedwhen cut).listProcessespages it whole.- Init also ends an orphan whose guard died (a program can kill its own guard), with everything under it, and on SIGTERM, or at its
--deadline, ends every process and exits 0; a guard its shutdown reaps first is reported as ended byKILL.readAllpages a stream until its process ends.
The provider registry: a new computer is one provider file and one registry line, resolved through it by the conformance CLI (and by serve, once wired).
src/providers.tsmaps each id to a lazy import of its provider file (docker,modal,daytona), so loading it loads no provider and no vendor SDK;providerFromEnv(id, env?),providerModule(id),providerIdsand theProviderModuleandConformanceModuletypes are exported from the package, and an unknown id is refused naming the ones the registry holds.- Each provider file exports
ENV, the variables it reads, every oneCOMPUTER_<ID>_*, andfromEnv(env, options?), the provider built from them alone. The env reading moved out of the conformance modules into the provider files with its rules unchanged: Modal takes its key fromCOMPUTER_MODAL_TOKEN_IDandCOMPUTER_MODAL_TOKEN_SECRETonly, pins the server it goes to, and refuses whileMODAL_SERVER_URLis set; Daytona never readsDAYTONA_API_KEY; Docker readsCOMPUTER_DOCKER_DAEMON(default this package's daemon for the architecturedocker versionreports, bounded at 30 s) andCOMPUTER_DOCKER_USER. Modal's live switch, the 30-minute run cap, Daytona's run bounds, snapshot and notes, and the drifted providers stay in the conformance modules, which build throughfromEnv; the three copies of the drifted daemon's Go build are one. - Behaviour change: the Docker conformance module now honours
COMPUTER_DOCKER_USERandCOMPUTER_DOCKER_DAEMON(before, it read neither). - The conformance CLI resolves
--provider <id>through the registry for every id, Docker included, and its usage lists the registry's ids; a bare name in any case that the registry does not hold (Docker) is refused as an unknown provider, not read as a module path. A module path still works, and may be a provider file that exportsfromEnv(), run with no drifted provider.dockerConformance()and itsclose()are gone:dist/conformance/docker.jsexportscreateProvider()andcreateDriftedProvider()like the others, and the CLI'sdockerTimeoutMsoption is nowproviderOptions, given to the module's functions.
- Each provider file exports
DockerProviderfinds, checks and sweeps computers.attach({ id } | { labels })finds exactly one computer,list(labels)reads back each one's labels, deadline, state and network, andnetwork(ref, policy?)reads the network back (none,open, or anything else in words) and refuses any change, since a Docker computer's network is fixed at create.create()ends with a drift check,drift(spec, inspected, hello, expected): the network, dropped capabilities,SecurityOptexactly["no-new-privileges"], private IPC and cgroup namespaces, theruncruntime, Docker's masked and read-only paths, the pids, memory and CPU quota (with serve's defaults when the spec names none), the user it runs as, the image, workdir andHOME, exactly the/workand/run/agentruntmpfs mounts with that user's uid and gid, mode 0700 and their sizes, the read-only daemon mount and no other mount (an image'sVOLUMEincluded), the entrypoint andinit --deadline <the label's deadline>, the labels, the host's AppArmor profile or SELinux label, and hello's version, os, capabilities and architecture must match what was declared, or the computer is deleted andcreate()refuses with every mismatch in words.- The drift check is closed: every other
HostConfigandConfigfield, one a later Docker adds included, must be Docker's empty default (PidMode,UsernsMode,Devices,Sysctls,OomKillDisable,PortBindings,ExtraHostsand the rest), but for a few with a stated default (no restart policy) and a few that widen nothing (the log driver,ShmSize,Ulimits, the image's own settings).inspectDriftis its inspect half.createnow passes--ipc private --cgroupns private. host()andhostOf(info)read the Docker host once per provider: its architecture, AppArmor and SELinux, and whether it enforces memory, swap, pids and CPU limits and has seccomp, not unconfined.create()andattach()refuse a host that lacks any of these in words before any other Docker call ("this Docker host does not enforce memory limits, so a computer's memory size is only configuration"), andcheck()does too once the host is read.attach()runs the same drift check on what it finds, inspect and, when the computer runs, hello, against the spec read back from the computer and held to whatcheck()allows, and refuses a computer that drifts with every mismatch, leaving it as it is./workis a tmpfs of half the computer's memory (size=2048mby default) and/run/agentrunone of 1 MiB, so filling/workfails with "No space left on device" instead of an OOM kill.- The provider's
useroption refuses root's group as well as root ("1000:0"throws), and the default user is this process's uid:gid only when neither is 0, else1000:1000. A spec may still ask for gid 0. create(spec, { signal })honours the signal throughout: an abort duringdocker infomakes nothing, and one while inspect or hello is pending deletes the computer and returnsaborted, never ok.list()inspects 50 computers at a time and reads only the fields it reports, so a large fleet is listed (and swept) whole.connect()refuses a daemon of another version, or one missing a capability the provider declares.sweep(provider, { labels?, now? })deletes the computers of alifetime: "janitor"provider whose deadline has passed or is missing, stopped (as a computer is once its deadline ends it) or running, and keeps the rest; it refuses a provider whose vendor ends computers itself. A caller runs it on a schedule.
@parcha/agentrun-computeris no longer held: the next release publishes it (npm install @parcha/agentrun-computer@latest), in the first layer, since it depends on no other package. Its archive holdsdist, with the daemon for Linux amd64 and arm64 (dist/computerd-linux-amd64and-arm64, executable) and the Docker egress proxy's code (dist/docker/egress-main.js,egress-proxy.jsandegress-table.js, which the proxy container runs from a bind ofdist/dockerandpackage.json), the README, LICENSE and NOTICE. A new subpath,@parcha/agentrun-computer/mount, exports the mount rule alone (WORKSPACE,MOUNT_KINDS,checkMount,readBackOf, and the typesMountSpec,MountReadBackandMountKind): importing it loadsdist/mount.jsand node's built-ins, and no provider, vendor SDK or conformance code. A provider's mount kinds are read with(await providerModule(id)).MOUNT, which loads that one provider's file. The package check (scripts/verify-packages.mjs) requires both daemon binaries in the archive, each an ELF for its architecture stamped with the package's version, and the egress proxy's three files, imports the root and./mountin the clean consumer, checks in a fresh process that./mountloads nothing else, runs the installed egress proxy laid out as its container binds it, with nothing beside it, and compiles a TypeScript consumer of both entry points; the release's receipt carries acomputersmoke. CI's build job and the release workflow's publish job set up Go frompackages/computer/daemon/go.modand runnpm run build:daemon -w @parcha/agentrun-computerafternpm run build, so the archive they check holds the daemon.New private package
@parcha/agentrun-computer, the open platform's computer protocol: a daemon in every computer and one provisioner per vendor. A program runs in a Docker computer through it:DockerProvidermakes a container with every capability dropped, no new privileges, a pids limit, no network or an open one, and a deadline label, with the daemoncomputerd(a static Go binary,npm run build:daemon) mounted read-only as its first process;connect()opens the daemon overdocker exec, andexec.runruns argv, never a shell, under a guard process (a child subreaper for that one run) that ends everything the program started, even a setsid or double-forked child, when it ends, times out or is aborted, with stdout and stderr apart and capped.- A computer runs as a non-root user by default (this process's uid:gid, or 1000:1000 when that is root); root must be asked for in the spec (
user: "0"), and a provider option of root is refused.computerd init --deadlinestops the computer at its deadline, and refuses to start without one. With no sizes in the spec a computer gets 2 CPUs (a CFS quota, never--cpus), 4 GiB with swap the same, and 1024 pids./work, a memory-backed tmpfs owned by the computer's user, is its home and default workdir; it does not survive a stop. check()refuses in words, before any Docker call, a spec with no image or lifetime, a lifetime over 30 days, a network allowlist, or a field Docker does not support.- The daemon copies code, with credit in NOTICE and UPSTREAM.json, from E2B envd and Kubernetes sandboxd (both Apache-2.0).
- A computer runs as a non-root user by default (this process's uid:gid, or 1000:1000 when that is root); root must be asked for in the spec (
A computer's live view, as frames over the protocol (the user-approved decisions, 2026-11-09: proxy only, and every desktop's and phone's frames on one path).
screen.watchgives the next frame after the caller's ack, once the screen changed, andprovider.watch()is the seam the runner's viewer reads it through.- computerd's
screen.watch({ after, waitMs?, maxWidth?, quality? }):afteris the seq of the last frame the caller has whole; the answer is the next frame once the screen's pixels or the pointer's place changed, elsechanged: falsewhenwaitMs(default 2 s, at most 10 s) is over, and an olderaftergets the newest frame at once. A frame is a JPEG scaled tomaxWidth(default 1280) atquality(default 60), with its own size, the screen's size (the input space) and the cursor; one over a chunk is read on withscreen.read. A watch reads the screen once each 200 ms at most, across calls too (an animated screen acked at once is no faster), and holds the display only for each read, soinput.actionsandscreen.shotgo on meanwhile on the same connection. A frame sayseoffalse when it is over a chunk, and its rest is held apart from a shot's, so ascreen.shotnever evicts it. One watch per connection; a cancel ends it asaborted. - The Android phone answers
screen.watchitself, as PNG frames fromscreencap, read once a second at most across calls, upright as screencap gives them (no rotation of ours). A cancel or the connection's close ends a screencap and a wait in flight. Itsinput.actionsbatches run one at a time, and no phone error passes on the phone's endpoint: adb's connect answer gets fixed words, and the adb server's and the phone's words are redacted. provider.watch?(ref, { frame(frame, ack), ended(error) }, { signal?, maxWidth?, quality? })gives{ input, close }. Each frame ({ seq, format, bytes, width, height, frameWidth, frameHeight, cursor? }, width and height the screen's real pixels) waits forack()before the next is asked for. A computer that goes ends it once withended(error), its codegone,unreachable,refusedorunsupported.close()and the signal end it with noended(), andinput()then refuses in words.daemonWatchis the one implementation behind it, on Docker, Daytona, Modal, E2B, Orgo and the phone harness; a computer with no screen isunsupported. No error holds a URL or a secret (redactWords).daemonWatchadaptswatchScreen(connection, options), the internal seam: pull-pacedframes(eachnext()the ack),act()on the same connection, andclose(); its failures areScreenWatchErrors. A frame is read by the size it says (never past it, never over 64 MiB, whole at the end); 8 inputs at most are in flight on a watch (one more islimit); a failed action's words are redacted as errors are.redactWordsalso takes out logins, bearers,sk-/gh*_keys, JWTs and host or IPv4 endpoints.toAction(event, kind)maps the live view'sViewerEvents, in the screen's pixels, to input actions (a desktop's mouse, wheel and keys; a phone's taps, swipes, buttons and keys), or to aRefusalnaming the event's field. It checks every field at run time and never throws. A typed text holds 65,536 characters at most (checkActionsrefuses more aslimittoo, as computerd does); a swipe is clamped to 0 to 1000 ms and a wheel event to 20 clicks. A shortcut keeps Shift; a click with a modifier held is one click with its keys, on the up, and a double-click is two such clicks, never acount. A desktop never gets ctrl+alt+backspace or ctrl+alt+F1 to F12.createActionMapper(kind)gives{ map(event), release() }, one per take-over:mapanswers astoActiondoes, but a desktop's mouse press keeps its strategy to its release (a modifier let go before the up still clicks once, with the down's keys), andrelease()gives the ups for every button still pressed, then forgets them.- Conformance:
screen.stream(Full, a vendor's URL) is replaced byscreen.watch, Core in the screen group: the first frame decodes (a PNG whole, a JPEG walked to its end) to the size it says, of the screenscreen.shotreads, and a pointer move is a new frame with the cursor where it went.
- computerd's
computerd drives an X display:
screen.shot,screen.readandinput.actionson the displayDISPLAYnames, in pure Go with no cgo (GetImage for pixels, XTEST for input), anddeadline.extend. A Docker computer can be a desktop.- Hello declares
screen,input.pointerandinput.keys, and kinddesktop, only while the display answers with XTEST; with none, nothing is declared and each method isunsupported, saying why.input.actionschecks the whole batch first, ascheckActionsdoes (a field given as null is refused asinvalid, never read as absent or as 0), and also refuses touch actions and pixel scrolls (unsupported), points and clicks that are not whole, and text with a control character other than newline and tab; then it runs until the first failure, releasing what the batch held. It waits for the server after each action with a button, a key or a keyboard map change, so an X error stops the batch before its next action anddoneandfailedsay what ran; a cancel stops it at the next key, click or point and answersaborted.typegives exactly its text with Caps Lock on: it is turned off for the typing and back on after. Characters with no key on the keyboard map are typed through xdotool's keysym remap, which keeps its bindings instead of undoing them; a keycode another program rebinds afterwards is that program's, never moved again, and with no other keycode free thetypeis refused aslimit. Only a display in this computer is driven (the unix socket, or TCP to a loopback address); any otherDISPLAYis refused before anything is sent, so this host's X cookie never goes to another server. Neither the X server nor the Xauthority file is trusted: the cookie is read only from a regular file of at most 64 KiB (a symbolic link, a FIFO or a device there is refused in words, unread), a setup with a scanline pad other than 8, 16 or 32 or keycodes that are not a range from 8 up is refused, and each reply is bounded by what its request can get back before anything is allocated for it; a server that sends more errors than the requests it could refuse is disconnected in words, and at most 64 errors are kept for a batch to report. Ascreen.shotorinput.actionswaiting for the display waits no longer than its own request. A shot already taken is read to its end withscreen.readthough the X server exits afterscreen.shot. computerd init --deadline <time> --max-deadline <time>:deadline.extend({ at })moves init's deadline up to the maximum, refusing an earlier time or one past it in words; without--max-deadlinethe deadline cannot move. Once init has decided to end the computer (its deadline came, or SIGTERM), everydeadline.extendis refused in words (unsupported), never answered with a deadline init does not keep. Docker passes none: its deadline is a label.new DockerProvider({ daemonPath, desktop: {} })makes kinddesktopcomputers on an image with an X server:creategives the daemonDISPLAYand starts Xvfb throughprocess.start, so computerd stays PID 1 and starts nothing itself.desktop.timeoutMsbounds every call to the daemon from starting the X server on: a daemon that stops answering failscreateastimeout.dockerConformance({ desktop })runs the suite on one; withtest/fixtures/desktop/Dockerfile(Alpine and Xvfb) andcomputerd-fixture(daemon/cmd/fixture, test-only) Core passes 58/58.
- Hello declares
A template's Apply screen shows each webhook trigger it made. When the apply's first answer to your session carries its signing secret, the screen shows it once, beside the trigger's URL, with Copy and "You won't see this secret again."; it lives in that screen alone. Without the value (a replay of the same apply, or a token's), it shows the server's note and a link to the trigger ("Open to get its signing secret (Rotate)"). Schedule and GitHub triggers have no secret and get neither.
An agent's Approvals, in the console. The editor's Tools step offers Ask me or Never ask (run unattended), sent as the definition's
approvals. Never ask says what it grants: every tool the agent has runs without waiting for you, while a tool set to deny and a template's pins still hold. While it is on, a tool row whose permission is ask says it runs without asking. An agent that never asks reads Unattended on its page, and so does each run it starts (the run'sapprovals). A template's Apply screen offers the same choice, preselected from the template's ownapprovals_default(Never ask for one that runs on its triggers), sent asoverrides.approvals.The console has a design system: its colours, type sizes, control heights, layers and durations are tokens in
apps/console/src/tokens.css, its controls and messages are primitives inapps/console/src/components/ui(now with Field, Input, Textarea, Checkbox, Radio, Banner, Dialog, ConfirmDialog, Menu, Toast, Tabs, Segmented, NavItem and Spinner), and docs/console-design-system.md says which to use. The Storybook's "Design system" pages show every token and primitive in the light and the dark theme side by side. What you see change: the keyboard focus ring is 2px in the text colour (the 1px orange ring was 2.5:1 on white), secondary and error text are darker in the light theme so they pass 4.5:1 on every surface, a working run's colour is the brand's instead of purple, text on the solid orange button is the token's dark instead of a literal black, and someone whose OS asks for reduced motion gets no animation.Settings has a GitHub page (
#/settings/github) for this server's one GitHub App.- Not connected. Connect GitHub (an organization optional) posts the App's manifest to GitHub, once. GitHub then has you create the App and install it.
- Connected. The page shows the App, linked to GitHub, with its id and the accounts it is installed on.
- Disconnect. It asks first. Every GitHub trigger turns off, and the page shows the server's note on deleting the App on GitHub, with its URL as a link.
- Refusals. No public URL, no seal key, already connected, or a start from another host than the public URL show in the server's words. For the last, the page links the public URL the server serves. A disconnect that finds GitHub already disconnected, in another tab, just shows the page as not connected.
- The way back from GitHub. It reads as "Connected", or as words per reason (state_invalid, conversion_failed, installation_unverified, already_connected). The page never shows the raw value, and leaves no query on the address.
The console makes GitHub triggers. New GitHub trigger on the Triggers page picks the events it takes (a pull request opened, given new commits or reopened; a comment on an issue or pull request), then the agent each starts a capped run of, and sends its create with an Idempotency-Key. It's offered while GitHub is connected (the connection read names the App); with none, the page says "Connect GitHub first: Settings → GitHub", with the link and no form, and a 409 github_not_connected shows in the server's words. A GitHub trigger's row in the list reads GitHub on its chip, not Schedule.
The console's Settings → MCP servers: add a remote MCP server by its address and credential (none, a bearer token, headers, or Sign in with OAuth), preview the tools it lists and the protocol it speaks, then save it. A refusal reads in plain words (it asks for a token, it signs in with OAuth, it doesn't speak MCP, it redirects, it failed, no seal key). Each saved server shows its tool count and its credential's state (sealed, expired with Sign in again, can't be opened) with Refresh (what was added, removed and changed), Change and Delete; a saved credential is never shown, and a change that leaves it empty keeps it. The page says how a sign-in went when the browser comes back. The agent editor's Tools step lists each saved server's tools, a tick grants
mcp:<server>/<tool>, and a grant whose server was deleted or no longer lists the tool says so. The timeline labels such a call<server> · <tool>.- Settings lists MCP servers on every server. Capabilities'
features.mcp_clientno longer carriesroutes, which was alwaystrue: a client that read it reads the feature's presence instead.
- Settings lists MCP servers on every server. Capabilities'
A run with a browser has a Browser tab on its page: the agent's page, live, while a browser session is open (the tab reads "Watch live"), and "Waiting for the browser" while the run can still open one (queued, working, or waiting on its question). On a run that ended, it says the browser closed with it, adding that a follow-up opens a new one only where the run takes a follow-up. It reads
GET /v2/runs/{id}/browserwhile the run is live, and once after it ends; the served state decides, and a run with no browser (off) has no tab. The session's viewer is the address of an iframe sandboxed to its own scripts, origin and form (its address bar), and nothing else. Only a viewer on the console's own origin, under this run's own live-view path, is framed; anything else reads "The live view can't be shown here." with no iframe. Taking over and handing back are the viewer's own. The viewer's token stays in the address's fragment, and never reaches the console's address bar, its history, storage or a log. A new session after a release gets a new viewer, which the tab follows.The run page has one Screen tab for every kind of computer, in place of Watch live. A browser's page, a desktop's or a phone's screen is framed live through the server's own viewer (GET /v2/runs/{id}/computer), with who drives. A desktop or phone has Take over and Hand back on the tab; a browser keeps its own in its viewer. A sandbox's run has the tab too, saying it has no screen. A metered provider's line shows for a sandbox, desktop or phone, never a browser.
When the agent asks the person at its computer (its browser, a desktop or a phone), the ask shows above the run's page and in Mission Control's Needs you: "The agent asks you to: …" and "Done when: …" as plain text, the console's own line "You never need to type your AgentRun password for this.", and when it expires. The run page moves to the Screen tab. Done and Cancel answer the ask once, through POST /v2/runs/{id}/computer/hand-back. An answer that came second, or one after the ask expired, says so, and the words stay after the ask's card is gone. While the ask waits, the message box says a message steers the run and doesn't answer the ask.
The console treats secrets as write-only, the same way on every page that holds one. Each secret (a model or tool key's field, an MCP server's credential) is one row: what it is (the key's own name, or "Bearer token"; never its internal name), Set or Missing, where it comes from ("Set in the environment", greyed, with nothing to paste over it; "Pasted here"; "From sign-in"), and when it last changed. Paste, Replace and Remove appear only where the server's session read says
secrets_writable(the owner's password session; on a server with no accounts, the console session the launcher opened on loopback), never on a token alone. Otherwise the page says once why: "Sign in with your password to change secrets.", on a server with no accounts, "This server has no accounts: set secrets in its environment, or turn accounts on to change them here."; or, in a browser on the API token alone, "Open the console from the server's link to change secrets."; and the model-key card offers no key to paste, and an MCP server's form offers no bearer token, header values or OAuth client secret. No value is ever shown. An MCP server's credential is one row (its bearer token, its headers' values, or its sign-in); a sign-in's has no paste, and its Sign in is offered only where secrets may be changed.The console has a Templates page (#/templates, in the sidebar). It lists the server's templates (GET /v2/templates): each one's name, version, description, and what it needs and makes (keys by name, MCP servers, triggers, budget a run, harness, model tier). A template's Apply screen (#/templates/) takes its parameters as typed, and the owner's consent one MCP tool at a time: nothing is granted until it is ticked. Each tick says what it does, that the agent may call that tool without asking, and, apart from it, whether the template asks for it. Apply sends POST /v2/templates/{name}/apply with the ticked tools as
grants, under one Idempotency-Key that a retry reuses. A refusal shows the server's words at its field: a parameter's under its input, a grant's at its tool, and a 409'sfixat the key or MCP server it names. A made agent opens at once; with warnings, they show first, with a link to the agent.The console shows tool pins. Settings → Tools lists the tools this server pins under "Pinned tools", each as "Pinned: event must be left out or COMMENT; method must be one of create, delete_pending, submit_pending. Calls outside it are refused before they run or ask." The agent editor's tool row says the agent's own as "Pinned for this agent: …", adding "This server pins it too; both hold." where the server pins the tool as well, and the server's alone where only it does. A pin that doesn't read says "This pin doesn't read, so every call to this tool is refused. Set the pin again, or remove the tool's setting." The words follow the gate's order (arguments sorted; null first, then by value), so the screen and a refusal read alike. Pins are not edited here; a save keeps them as they are.
The console's Schedules page is now Triggers (#/triggers): it lists every trigger with its kind, and makes webhooks as well as schedules. A new webhook shows its URL and signing secret once, each with Copy and "You won't see this secret again."; a retry under the same Idempotency-Key shows the server's note in the secret's place. A webhook's page shows its URL, its signature scheme and its firings ("On a request"), and Rotate secret asks first, then shows the new secret once. A create (schedule or webhook) sends an Idempotency-Key per request, so a create sent again unchanged makes one trigger and an edited one gets a new key. Refusals show in the server's words.
A coding agent gets
agents createandruns startright on the first try, from the help and the 422s. The served OpenAPI document gives a working request body forPOST /agents,POST /agents/{agent_id}/runsandPOST /triggers, as the JSON media type'sexamples(examples/1), andnpx @parcha/agentrun agents create --help,runs start --helpandtriggers create --helpend with it as a--body '…'line to paste. A test sends every documented example to the server and requires a 2xx, so none can stop working.- Each field issue of a
422 input_invalid(error.fields[]) can gainexample(explain/1), a value the field accepts: the operation's documented body's value at that field, else the schema's example, const or option, else one made from its type (truefor a switch, the least number it takes). A value is suggested only once the route's own check of the body takes it in place, so a 422 never suggests a value the server refuses:definition.id: "bad id"is tolde.g. "due-diligence", and an invaliddefinition.modelgets no example. The message says it too:definition.learning is true or false, e.g. true,input is an object, e.g. {"content":"Research https://stripe.com and write the brief."},definition.name is required, e.g. "Due diligence". A run'sinputis told from the input this server checks: an agentic run's{content}, a workflow run's its workflow's input schema ({"question":"…"}for a workflow that requiresquestion), never the other's; a run pinned to arevisionis told from that revision's, and a pin that names no revision gets no hint. - A field sent at the wrong level, which an object inside the body keeps, is told where it belongs:
{"name": …}toPOST /agentsanswersThe body keeps no field name: name belongs under definition., withfixname belongs under definition: {"definition":{"name":"Due diligence"}}and that body as itsexample. - Upgrading: a field issue's
messagemay now end with, e.g. <value>or name where the field belongs, and the issue may carryexample; a client that compares a field issue's message whole compares itsfieldandreasoninstead.
- Each field issue of a
The release publishes the Python client,
parcha-agentrun, to PyPI after the npm packages, from the same tag, by PyPI trusted publishing (thepypijob inrelease.yml, environmentnpm-release; no token or secret). It is on a version line of its own,python/pyproject.toml's, from 0.3.0: plainx.y.zreleases above the 0.2.x the old hosted SDK left under that name, never a pre-release, which pip would pass over. Before the upload,node scripts/python-release.mjs --verifychecks the project, the sdist and wheel against this tree'spython/file for file,twine check --strictand an offline smoke in a clean venv; a version already on PyPI is not uploaded again.node scripts/changes.mjs --checkholds the version as it holds@parcha/agentrun's: a pull request leaves it alone, and a release commit that changes what the client publishes moves it.- Upgrading:
pip install parcha-agentruninstalls the self-hosted server's client from 0.3.0; code written for the old hosted SDK (0.2.x) pinsparcha-agentrun<0.3.
- Upgrading:
New Python client
parcha-agentruninpython/, on PyPI from 0.3.0 (pip install parcha-agentrun): a typed, synchronous client for a self-hosted AgentRun server on the standard library alone, Python 3.10 or newer.AgentRun(base_url, api_key), defaulting toAGENTRUN_BASE_URLandAGENTRUN_API_KEY, has a method per operation the server offers, grouped as the CLI's commands are and named in snake_case (client.runs.start(agent_id, body),client.mcp_servers.save(name, body),client.agents.list(limit=..., cursor=...)), withTypedDictandLiteraltypes from the server's OpenAPI document and apy.typedmarker.- It keeps every rule the TypeScript client keeps: one error class per outcome (
AgentRunRefused,AgentRunAuthError,AgentRunUnsupported,AgentRunUnreachable,AgentRunUsage,AgentRunTimeout), no printed form of an error or of the client holding the key or the server's message text, a redirect followed only for a GET within the base URL's origin, retries only for a GET or a request with anIdempotency-Key, a secret body's values cut from the answer, and a.or..path argument refused before any request.runs.wait(run_id, timeout=...)long-polls andruns.follow(run_id)is a generator of the timeline's pages that resumes withLast-Event-ID. A download returnsbytes, or aStreamedResponsewithstream=True; an answer with an ETag returnsTagged(body, etag). A method'stimeout=bounds one try, its answer included, and is the way to cancel a call. node scripts/generate-clients.mjswritespython/agentrun/_generated.pyfrom the same projection and resource map as the CLI's table and the TypeScript client, under the same hash, and--checkcovers it (--pythonnames another file). CI runs the client's tests on Python 3.10 and 3.13, and pyright onpython/. Theagentrun-cliskill names both clients, and the source export carriespython/.- Fix: in
@parcha/agentrun-client, aruns.waitwhose timeout passes while a refusal's body is still coming throwsAgentRunTimeoutwith the run as it is, notAgentRunRefused. - Upgrading: nothing to change; nothing breaks, and the Python client is new.
- It keeps every rule the TypeScript client keeps: one error class per outcome (
A run and an agent read over the API carry their page in the console,
console_url(consoleurl/1): every run the API answers (POST /v2/agents/{agent_id}/runs,GET /v2/runs/{run_id}, the lists' items) has<origin>/#/runs/<run_id>, and every agent<origin>/#/agents/<agent_id>(an agent whose id isnewgets its Runs tab,<origin>/#/agents/new/runs, as the console links it, since#/agents/newis the new-agent form). The origin is the address people open: the server's public URL (AGENTRUN_PUBLIC_URL, or Render'sRENDER_EXTERNAL_URL) when it has one, else the address the request reached the server at (itsHost, so a caller through a tunnel or a forwarded port gets a link on that port), else the server's own address. A retry with the sameIdempotency-Keygets the page at its own request's address. A client gives a person that link instead of guessing the server's port. MCP'sruns_getanswers the route'sconsole_urlfor the caller's ownHost.npx @parcha/agentrun runs startandruns getprint it with the run. Upgrading: nothing to do; the field is new. MCP'sruns_getlink is the same as before, except that with a public URL set it is now at the public URL rather than at the address the caller used.A secret is saved only from the owner's session, and the server never starts without a seal key. Saving, replacing or removing a tool key or a model key (
PUTandDELETE /v2/settings/tool-keys/{key_id}) takes the owner's password session (accounts on), or, on a server bound to loopback alone with accounts off, the console the launch link opened; the API token is refused (403) in one sentence on every server, loopback included, and a server bound off loopback with accounts off takes no secret at all. One check in the dispatch decides it, from a registry each kind of secret adds its write routes to; the API document marks every secret fieldx-agentrun-secret: true, and a test fails for a marked route that skips the check.GET /v2/auth/sessionnow answers with accounts off too:accounts,secrets_writable, andsecrets_reason(sign_in,no_accountsortoken) when it is false. Every secret's view answers one shape,SecretMeta(name, set, source: env, console or sign-in, changed_at), never a value: an object holding one secret carries it assecret, one holding several assecrets, each named<plane>:<id>. Each row ofGET /v2/settings/tool-keysnow hassecrets, one per field, namedtool:<FIELD>ormodel:<FIELD>, with the time that field's own value was saved. With noAGENTRUN_SEAL_KEY, the server makes one at its first start inagentrun/seal.keyunder$XDG_CONFIG_HOME(~/.config), 0600 in a 0700 directory, printing only its path, by the launcher's rules; a key or directory another user can read, a linked directory, or nowhere to keep one stops the start, in words. The runner reads the same key: it no longer falls back to an all-zero key. The CLI has no command for an operation that writes a secret:agentrun tool-keys set,tool-keys saveandtool-keys deleteare gone, and the generated CLI leaves out every operation the API document marksx-agentrun-secret(a body field, or the operation whole, as a removal is); its help says keys and other secrets are set in the console, signed in. The API keeps the routes, and the TypeScript client keepstoolKeys.saveandtoolKeys.delete. Upgrading: a script that saved keys with the token now gets a 403; save them in the console, or set them where the server starts (the environment always wins).A run's spend cap prices each model request's input from the previous request's cache (
cap_modeexpected, the default): the input the previous request of its conversation read from or wrote to the cache counts at the cache-read price when it ended less than 4 minutes before, so a cap pays for about what the run really spends instead of pausing near half of it. Output never passes the cap; a cache miss can, by at most the request's bound, served ascost.overshoot_bound_usdwhile it is in flight (and aspending.overshoot_bound_usdon a run paused at its cap). The real cost is booked, one timeline row saysWent $X over its cap on a cache miss (at most $Y)., and the run pauses at its cap instead of failing.cap_mode: "strict", on a run's body or an agent's definition, keeps the worst case: every input token uncached.A workflow run takes a spend cap (
max_cost_usd,cap_mode), on its start, from its agent's default (a workflow definition now keeps one), and from a trigger, which may now start a workflow. Each paid step is held to the cap before it is sent, as an agentic run's are: a model node's request, a call node'sllmcall and paid tool call, and a decision at its provider's price (Parallel's list price, Jev's published price, an OpenRouter Decisions model's catalog price). A step the cap has no room for waits unsent where it stands: the run isinput_requiredwithpending.kindbudget andpending.node, andPATCH /runs/{id}sends it, in the same process or, after 10 minutes paused, in one opened again from the run's journal, with no completed node sent again. A capped start whose node runs on a model with no price here, or whose decision pins an OpenRouter Decisions model the catalog does not price, is refused before it starts (409agent_not_runnable, the node and the fix inerror.fields), as an agentic run's is. A decision whose price cannot be read mid-run (OpenRouter's catalog down) is read again, then fails unsent as a connection error (DecisionProviderError("connection")) and its decision node fails, decision nodes having no attempts loop: never as the cap's stop; a decision an earlier process asked and never saw answered holds nothing and never pauses for a raise; and a decision's cost counts on the next process from its receipt when its booking was lost, or, with no receipt that names its charge, at its hold, kept in the run's store once the driver admitted the request and right before it is sent, when its process died with the request maybe out; a request never admitted keeps no hold and is never counted.@parcha/agentrun-dsl:WorkflowDeps.held(executionPath), the time a host held a call, which its deadline does not count;chargedCostis exported.@parcha/agentrun-decisions: the OpenRouter provider'spriceOf(model), the model's catalog price.@parcha/pi-durable-agent: a workflow revision keepsmaxCostUsdandcapMode.
A signing secret the server mints is shown once, as
signing_secret, and only to the owner's session. A webhook trigger's secret (POST /v2/triggers,POST /v2/triggers/{trigger_id}/secret) and the outbound webhook's (the firstPUT /v2/settings/webhook) are in the minting answer to the owner's signed-in session, or to the console the launch link opened on a loopback-bound server with accounts off. The API token gets the trigger withsecret_set: trueand, innote, "Open the console to see this trigger's signing secret once: rotate it there.", or the webhook with "Open the console to see this webhook's signing secret once: remove it and save it again there." Rotating a trigger's secret, and removing the outbound webhook (DELETE /v2/settings/webhook, its secret with it), are secret writes: the token is refused (403) in the secret-write words, the secret it would replace still signs and the webhook stays. Each answer and read carries the secret's metadata assecret, in the one shape (name, set, source, changed_at), never the value: a trigger's is namedtrigger:<id>(GET and the list too;changed_atis in the create's and rotation's answers, null on a read), the outbound webhook'swebhook:outbound, itschanged_atthe time the secret was made, kept across a URL change, and null for a webhook set before this release. A template that makes a webhook trigger says so in its apply answer, a warning undertriggers.<i>.secret. Upgrading: the shown-once value moved fromsecrettosigning_secretin those three answers, andsecretis now the metadata object; a script that created webhook triggers with the token gets no value at all: take the secret from the console's Triggers screen (rotate it there). A script that removed the outbound webhook with the token gets a 403: remove it in the console.A template applied from the owner's session shows each webhook trigger it made its signing secret once.
POST /v2/templates/{name}/applyanswerswebhook_secrets, one per webhook trigger it made (absent when it made none): itstrigger_id, its secret's metadata assecret(namedtrigger:<id>), and, in the first answer to the owner's signed-in session alone,signing_secret, asPOST /v2/triggersshows it. The Idempotency-Key's record never keeps a value: a replay under the same key answerssecret_setand a note that it was shown once, and an apply with the API token answerssecret_setand the words that send the person to the console to rotate it there. The warning undertriggers.<i>.secretstays. Upgrading: a script that readwebhook_secrets[].secretas the value now readssigning_secret, from the owner's session; the token gets none.agentrun template eval DIR(agentrun-serve eval-template DIR) runs a template package's eval cases as real runs of its agent and grades each run, so a package's quality bar is measured. By default it runs on a server of its own in the command's process over a temp data directory, so it never touches yours;--server URLruns on that one, through its v2 API alone. Per case it serves eachmcpfixture as a fake MCP server on loopback that records every call, applies the package with the case's parameters and grants (a fresh agent per run), seeds the case's learned notes, starts each run with the prompt and the case's cap, and grades the answer, the tool calls and the files:schema,regex,tool_usedandfile_existsdeterministically,llmas a run of a blind grader agent on the same server (--grader-model). A case passes when every grader passes on every run. The summary goes to stdout and the whole report, with each run's and each grade's cost, to--report FILEas JSON; the exit is 0 when every case passed, 1 when one failed, 2 when nothing ran. Every case is read and checked before anything runs, and the tests check that every shipped package's cases load and their fixtures serve.@modelcontextprotocol/server(2.4.0, already a dev dependency) is now a dependency of@parcha/agentrun-serve, for the fake MCP servers: it brings no package the server did not already install.Template packages: one folder that makes a working agent, applied by one call. A package is
agent.md(its YAML front matter is the whole manifest, its body the agent's instructions),skills/<name>/SKILL.mdin the Agent Skills format,rules/*.md(shipped beside each skill's SKILL.md) andevals/<case>/in the plugin-eval layout (prompt.md,graders/*.md). The manifest names the package (name, the agent'sdisplay_name, a SemVerversion,description,license,author,repository), the harnesses it prefers, its model as a tier with a preferred model, itseffort,tools, the MCP servers and tools it uses and those it asks the owner to allow (mcp: [{server, tools, required?, allow?}]), typedparameters, the model or tool keys it needs by name (secrets), itsbudget(required with triggers),learning,triggers(each named), itsoutputschema andchecks.GET /v2/templateslists the packages the server ships, andPOST /v2/templates/{name}/applywith{parameters, grants?, overrides?: {model}}makes the package's agent and its triggers through the same code asPOST /v2/agentsandPOST /v2/triggers: parameters that do not check are a 422, a missing secret or required MCP server is409 agent_not_runnablewith nothing made, an MCP server the package can do without is skipped with a warning, one the server has saved has the package's tools of it granted at the owner's own settings (a package sets no permission: only the apply'sgrants, the owner's answer, become allow, and never over a deny the owner set on the server), and a trigger the server refuses deletes the triggers made before it and archives the agent. The agent's model is the package's preferred one when the server runs it, else the server's model for the tier (fastAGENT_MODEL_FAST, balancedAGENT_MODEL, strongAGENT_MODEL_STRONG), andoverrides.modelwins. A package's version is neverdefinition.version, the revision number. The server checks every package it ships at its start, whole (its agent and trigger bodies compiled and checked as those routes check them, at most 256 files and 1 MiB), and a broken one stops the start. The CLI gainsagentrun template list,agentrun template applyandagentrun template validate DIR, which runs the server's own check on a folder.- The first package is
code-review: a code review agent for one GitHub repository (parameters.repo), namedCode review: <repo>, on pi, with the fast tier andopenrouter/particle-ai/particle-1preferred, low effort, each run capped at $0.50, learning off. Its review skill proves each finding against the code and answers with findings JSON (findings.schema.json), and, when its tools are a GitHub MCP server's instead of a shell, posts one review with inline comments, never an approval. It uses six tools of a GitHub MCP server namedgithubwhere the server has one saved, and none where it does not; it asks the owner to allow its four read tools, and its two review writes ask. It has no triggers yet.
- The first package is
New package
@parcha/agentrun-client: a typed TypeScript client for a self-hosted AgentRun server, with no dependencies (fetch, Node.js 22.19 or newer).new AgentRun({ baseUrl, apiKey }), defaulting toAGENTRUN_BASE_URLandAGENTRUN_API_KEY, has a method per operation the server offers, grouped as the CLI's commands are (client.agents.create(body),client.runs.start(agentId, body),client.runs.raiseCap(runId, body)), with request and response types from the server's OpenAPI document. It reads no file: finding the server started on this machine stays the CLI's.client.runs.wait(runId, { timeoutMs, signal })long-polls the timeline and resolves to the run once it ends or waits on a person; pasttimeoutMsit throwsAgentRunTimeoutand the run goes on.client.runs.follow(runId)is an async iterator of the timeline's pages over its event stream, resumed withLast-Event-ID. A download resolves to its bytes as aUint8Array, or to theResponsewith{ stream: true }. An operation whose answer carries an ETag (agents.get,agents.update,learning.read,learning.write) resolves to{ body, etag }, theetagfor a later write'sIf-Match.- One error class per outcome:
AgentRunRefused(status,code, the server'serrorobject),AgentRunAuthError,AgentRunUnsupported,AgentRunUnreachable,AgentRunUsageandAgentRunTimeout. No message holds the API key or the server's message text, and the key is cut from the error object. A key that is not a valid header value, a base URL with a query, fragment or credentials, and a path argument with a.or..segment are refused before any request. - The key goes only to the base URL's origin: a redirect is followed only for a GET within it. A GET, or a request with an
Idempotency-Key, is tried again after a 429, a 5xx or a dropped connection (a body cut midway included), never sooner thanRetry-After; anything else is sent once.runs.followwaits out a gateway'sRetry-Afterbefore it reconnects. A secret body (toolKeys.save,mcpServers.save,mcpServers.preview) has its values cut from the answer as JSON values, and a refusal of it keeps only its code. node scripts/generate-clients.mjswritespackages/client/src/operations.generated.tsfrom the same projection and resource map as the CLI's command table, and--checkcovers it. The projection now holds each request body's and JSON answer's type, so every generated file's hash moves when a type changes, and not when a description does.- Upgrading: the launcher's README names
@parcha/agentrun-clientas the TypeScript client for this server. - Upgrading:
mcp-servers oauth-startis removed; start an MCP sign-in from the console. The sign-in's callback requires the cookie its start's answer sets, so a start from the CLI or a client can never finish. A rawapicall to its route still takes its body only from stdin or a file.
A run's model requests, paid tool calls and workflow effects are durable before they are sent, ready for a run store that syncs between commits: serve syncs the run's store after a model or compaction request's start mark, after every paid call's hold (paid requests, monid.run and priced MCP calls, written by one helper), and after a workflow effect's admission. A sync that fails sends nothing. The dsl's recovery driver takes an optional
afterAdmithook, called after each effect's admission and before its dispatch; a host whose journal needs no barrier leaves it out.The root build (
npm run build) builds the workspaces in dependency order (scripts/build-workspaces.mjs): each package once, after every workspace package it names in its dependencies, devDependencies or peerDependencies, so none compiles against a dist an earlier build left behind. A cycle, or a package's build that fails, stops it loudly. A build of some workspaces goes through the same order (--only a,b,c; scripts/pi-leg.mjs builds its leg's set so), and stops on a package whose workspace dependency is outside the set with no dist. The packages that built a dependency themselves when its dist was missing (harness, decisions, learn and pi-agent-learn:test -f ../<dep>/dist/... || npm --prefix ../<dep> run build) now compile only themselves: a dist already there, stale after a merge, was taken as current. Upgrading: build one package alone (npm run build -w <package>) only after its workspace dependencies are built;npm run buildat the root builds them all in order.The CLI's edges, each said plainly:
- A body refused because the request's path, with the base URL's, ends like a secret route (
home write AGENT settings/tool-keys/xwith an inline body) now says so and names the route; pass the body on stdin or from a file. runs start --waitbounds its start as it bounds the wait: by--timeout(exit 1, with a line saying the run may have started and how to find it), else as one of the wait's requests is (exit 3). A--timeoutlonger than Node's longest timer (about 24.8 days) waits as long as it says.- Upgrading: a start that once waited without end now gives up at the bound; pass
--timeoutfor a bound of your own.
- Upgrading: a start that once waited without end now gives up at the bound; pass
runs timeline --followreports a stream event that is not a JSON object (null and a list included) as the server's bad answer, exit 1, at once, not as a server it cannot reach.- Upgrading: a script that read exit 3 for a malformed stream reads exit 1.
- A
DIR/.server.jsonwhosebase_urlis no base URL is refused as a malformed record is, exit 4; a bad--base-url,AGENTRUN_BASE_URLor configbase_urlstays exit 2.- Upgrading: a script that read exit 2 for a bad server record reads exit 4.
loginwrites its config through a temporary file with a random name, opened only when it is new, and a config it cannot write is exit 4 with a plain message and nothing left behind. It writes every byte and syncs the file to the disk before the rename, so a full disk or a quota leaves the old config as it was.- A run stopped at its spend cap prints a hint naming
agentrun runs raise-cap RUN_ID --max-cost-usd N, N the server'ssuggested_cap_usd. - An
Acceptgiven withapi --header, in any case, is the one sent, alone; without one the default (application/json, */*) goes, and the token'sAuthorizationis always set last.- Upgrading:
api ... --header 'Accept: ...', once overridden or merged with the default, is sent as given.
- Upgrading:
api --headerkeeps every header name it can send,constructorincluded, and refuses__proto__in any case, which Node's fetch would drop unsent.- Upgrading:
--header '__proto__: ...', once dropped unsaid, is exit 2.
- Upgrading:
skill install --codex, and the default install for Codex, write~/.agents/skills/agentrun-cli, the user skills folder Codex reads (Cursor reads it too). With no flag, it installs into each of~/.claudeand~/.agentsthat exists.- Upgrading: a copy installed earlier in
$CODEX_HOME/skills/agentrun-cli(default~/.codex/skills) is not touched or read by the CLI; remove it by hand.
- Upgrading: a copy installed earlier in
- A body refused because the request's path, with the base URL's, ends like a secret route (
agentrun server mcp --method tools/listis a valid JSON-RPC request: a body built from flags alone carriesjsonrpc"2.0" (the document's const) andid1, and a whole--bodyis sent as given.npx @parcha/agentrun frobnicate, or any first word that is neither a command of the CLI nor one of the server's (import, reset-password, validate-template), says there is no such command and lists the commands, exit 2, instead of printing the server's usage. A started server's refused requests (wrong or missing token, wrong password) and verified webhook deliveries its trigger does not take are said on stderr in plain words, route and reason, each once.checkActions()counts every duration toward a batch's 300 s cap (MAX_BATCH_WAIT_MS): themsof along_press, aswipeor apinchas well aswaitandhold, so[{ type: "long_press", ms: 2 ** 52 }]is refused aslimit. Akeyaction'srepeatis at mostMAX_KEY_REPEAT(100). The conformance suite's screenshot reader refuses a shot whose size is overMAX_PNG_BYTES, the largest PNG it decodes, before reading it, so a provider cannot make it hold more.Refusal-side conformance cases now pass only on the refusal they test.
drift.versionneeds the drift check's own words (hello: the daemon reports version X, expected Y, two different versions) in the create or connect refusal; a create refused for another reason (a network mismatch, a vendor error that mentions a version) fails it.provider.check-refusesand the other refused creates needcheck()'s words in create's refusal; a connect to a deleted computer or one past its deadline must answergoneornot_found; an attach by labels two computers match must answerinvalid.process.signalINTon a process started with a pty goes to its terminal's foreground process group, as ^C does at a terminal: it ends the job a shell runs in the foreground, and the shell reads on. It used to reach only the program's own group, which an interactive shell ignores while its job runs on.TERM,KILLandHUP, andINTwithout a pty, still go to the program's group.- The daemon reads the foreground group from the terminal it holds (TIOCGPGRP) and sends
INTto it through the program's guard only when that group is in the program's own session; a terminal the program handed to another session is refused asdenied, and nothing is signalled. - When the terminal has no foreground group (the program let go of it, or is ending),
INTgoes to the program's own group, and the reply, otherwise{}, says so innote. - The conformance case
process.pty-interrupt(Core, needsprocessesandexec) checks it through a provider.
- The daemon reads the foreground group from the terminal it holds (TIOCGPGRP) and sends
A Docker computer runs a daemon that npm installed under any umask. Under
umask 077npm installsdist/computerd-linux-*at 0700, andDockerProvider.createrefused it ("chmod 755 it"). It now binds its own 0755 copy, named by the sha256 of the daemon's bytes, in a directory only this user may enter (the newdaemonCacheoption; defaultagentrun-computerunderXDG_CACHE_HOME, else~/.cache). The directory is made 0700 and checked before every bind: a link, another user's directory, a looser mode, or a directory above it another user may write is refused in words, never followed or changed. A cached copy of this user's with the wrong bytes or mode, even one it cannot open, is replaced; a directory a cleaner removes partway is made again once; every other failure is a result in words naming its path. The drift check andattachcompare the copy's path. A daemon this process cannot read is still refused in words.DockerProvidertakes avolume: { name, root }option for a provider that runs in a container of the Docker it drives, given its socket (the server underdocker compose). Without it,createbind-mounted the daemon's copy and the run's folder by this process's own paths, which exist only in its container, so every Docker computer failed at create. With it, both are mounted as subpaths of the named volumename, which this process has mounted atroot:--mount type=volume,src=<name>,dst=…,volume-subpath=<path below root>,volume-nocopy, never the volume's root, and always a directory: the daemon is staged asdaemonCache/daemon-<sha256>/computerd(with an emptyegress/), and that directory is mounted read-only at/opt/agentrun, so the daemon is still/opt/agentrun/computerd. AdaemonCachethat is not at or belowrootthrows in words when the provider is made.checkandcreaterefuse, in words, a run folder that is not belowroot, and Docker Engine older than 26 (volume-subpathneeds 26 or later; the engine is read withdocker infoand ishost().engine). An allowlist's egress proxy, whose code was bound from the package's build, gets that code staged below the volume's root first (daemonCache/egress-<sha256>/:docker/with the three modules andpackage.json, 0755 directories and 0644 files, written atomically and reused when identical; a published tree is never removed or written over, since a running computer may have it mounted) and mounts that one directory whole at/codeas the volume's subpath, read-only.attachandlistread the run's folder back as the path belowroot, and the drift check and the network read-back hold every mount to the volume's subpaths. Without the option nothing changes.DockerVolumeandVOLUME_SUBPATH_ENGINEare exported.process.signalINTon a process started with a pty no longer risks signalling a process group outside the program's session. The daemon checks that the terminal's foreground group is in the program's session, then the guard signals it; in between, the group could end and its id go to a group in another session, whichkillon that id would then reach with the guard's credentials. The guard now checks again right before it signals: a group that has no process left in the program's session is not signalled, and the reply, otherwise{}, says innotethatINTwas not sent. A group whose first process has ended while the rest of it runs (a pipeline's) is still signalled. The reply to such anINTnow waits, bounded at 2 seconds, for the guard to say whether it sent the signal; a guard that does not answer in time is atimeouterror that says the signal may or may not have been sent. Every other signal is unchanged.Six fixes from a security read of the Docker provider and the conformance suite, before a vendor runs it.
- Core's
provider.network-changeasks only for networks the provider does not declare and expects each refused, so a provider that can switch networks never puts a Core computer on a wider one. The applied change moves to two new Full cases on a computer of their own,provider.network-switch(network:open) andprovider.network-switch-allow(network:allow). --provider dockerbounds itsdocker versioncall (30 s, then killed): a hung docker CLI fails the run in words instead of hanging it.- Every create a case starts goes through the runner (
t.attempt(spec, by?)), so a computer fromprovider.check-refusesordrift.versionthat arrives after its case ended is deleted, and named incleanup.late, before the report returns. A call still pending when the cleanup bound runs out is named incleanup.errors, and Core fails. --onlythat leaves Core cases out reportscore: "partial"withcoreNotRun, printscore PARTIALand never PASS, and exits 3 when every case that ran passed. A script that checks for exit 0 no longer takes a subset for a Core pass.- The daemon's 256 MiB retained-output budget now covers running processes' rings: once no ended ring is left to let go, each live ring is limited to an equal share (at least 64 KiB) and drops its oldest bytes, which reads report in
dropped. attachreturns the computer's ref withspec, read back from the computer:image,user(uid:gid,"0:0"for root),network, sizes,workdiranddeadline. Code that compared an attach result to a ref with deep equality comparesidandnameinstead.
- Core's
A run the daemon has to end with SIGKILL (its guard stalled or outlived its grace) returns only once every process under the guard has ended. SIGKILL lands asynchronously, so
exec.runcould report a timeout or a cancel while a process it had just killed still ran for a moment. The daemon now waits, bounded at 10 seconds, until each such process is gone or a zombie, telling a reused pid apart by its start time, and fails with an error naming any process still running after that.A remote computer starts on a kernel with the tunnel modules loaded (Modal's VM), where every new network namespace gets the kernel's fallback tunnels (tunl0, gre0, sit0 and the rest), down with no address. Init's own network now holds the loopback alone up or addressed, every other interface down with no address, in place of the loopback alone; no sysctl is written.
computerd probereportsinterfacesas the live ones (up or addressed) alone, plusquiet(down, no address) andraised(any quiet one it could bring up as its user, put back after). No program of a computer's holds CAP_NET_ADMIN, soraisedis empty.- Daytona's, E2B's and Modal's read-backs of a computer's own network, and the conformance case
isolation.vendor-agent, read that report through computerd's probe instead of/proc/self/net/dev. A raised interface is drift; quiet ones are recorded, never drift. computerd probe --socketsalso lists the unix sockets with a path the probe can connect to (sockets: {sockets: [{path, type}], incomplete}: each by real path and the type that connected, stream, dgram or seqpacket; found by a walk of /run, /tmp and the like, each root within bounds of its own, and /proc/net/unix's paths;incompletenames each root the walk could not cover whole).isolation.vendor-agentholds them to the computer's own (computerd's, the Archil sync socket, and on a desktop the one X socket of its display); a probe with no list, or an incomplete search, fails it; no image needs python3.- A desktop's
hellosays the display it drives (display, such as":99"). The isolation check reads it there: the daemon's/proc/self/environis root's once init drops to its programs' uid. - Under
--own-network, init, still root, takes others' access off every world-writable unix socket under /run (a vendor's base leaves rpcbind's, sshd's unix-local one, systemd's varlink sockets and the journal's there), so the programs reach none of them; root and the services keep theirs. - E2B's root channel opens stdin only for a step given one (the Archil mount's token):
serve, which detaches at once, no longer fails on a pid E2B no longer knows.
phone-harness, from its first live run and the second read of #2000:
- It takes the phone harness's own unlock code (
ph_and base32 letters), which it refused, failing every connect. - No spawned adb's argv holds the phone's endpoint or unlock code, which
/procshows to every user of the host. The endpoint goes to the local adb server over its socket protocol, and the unlock (the vendor's shell commandunlock <code>) goes as the shell serviceshell:unlock <code>on a socket the adb server bound to the phone. Every spawned adb names the phone by its transport id. Theadboption is now a transport,{ run(args, { input }), server(service) }, or the CLI's path. - A phone made while both a create's answer and its recovery were lost is found: create puts the Idempotency-Key in the ledger before its POST, and
list()takes the account session whoserequest_keyis exactly that key. - The conformance module's file ledger writes one at a time, across processes too (a lock file beside it), so no entry is lost.
- A ledger that fails is a result in words from every call, never a throw, and create rents no phone it cannot record.
- A connect that fails after adb reached the phone lets the transport go.
- release ends only a phone its ledger names. Another session on the account is refused
not_foundand sent no DELETE; an unnamed id that is already gone is ok. The service ends a session asynchronously, so a released phone stays in the ledger, markedreleased, until its own GET is a 404: a second release of it is ok, and it is not listed or attached. - A create cancelled during its POST asks the service by its Idempotency-Key, not under the cancelled signal, and ends the phone the service made. It answers
abortedand names that phone. - Every ledger write create makes once the phone's id is known is guarded, the last one once the phone is ready included. If one fails, the phone is ended and create answers in words; no throw leaves a phone running.
- A cancelled
input.actionsbatch stops at the action it is on: awaitends when the signal fires, no later command runs, and the batch answers failed, kindaborted, at that action. Each adb command runs under the signal. - Each API request has a bound of its own,
requestMs(30 s by default), beside the caller's signal. A readiness read takes no longer thanreadyMshas left, and one cut off byrequestMswhilereadyMshas time left is asked again: onlyreadyMsitself ends a phone that is slow to start. A request cut off by its bound is a timeout in words, and a POST cut off is recovered by its Idempotency-Key. The timer is an option,timeout. - A swipe of more than two points takes its
ms:input motioneventhas no duration, so the time goes between the moves as toybox sleeps, shared out by each segment's length. A timed command's adb call (a swipe, a long press) gets itsmson top of the usual 30 s bound. - A literal
%sin typed text is typed as%s.input textreads%thensas a space, so the provider splits the text there, across two calls. list()reads the ledger beforeGET /sessions, so a phone a create records while it runs is not forgotten. It forgets a phone the listing leaves out only once that phone's own GET is a 404.- Conformance's
touch.tapthrough Settings takes the page's title as a text or a label: a live Android 15 page shows it only as its toolbar's content-desc. The next row must still be gone, so a tap that lands nowhere fails. When the page does not come, the words say what the case saw: the screen in px and dp, what is in front, the tap and the row, and whether the title shows outside the list. adbCli(path, { port })passes-P <port>to every adb it spawns, sostart-serverand every command reach the server its socket requests go to, even whenANDROID_ADB_SERVER_PORTnames another.- A create answered 5xx (other than
phones_busy), or 2xx with no session in it, is uncertain like a lost answer. It is recovered by its Idempotency-Key before the pending key is forgotten, and the phone found is recorded, so none bills unrecorded. list()names a phone once while create swaps its pending key for the session's entry: a pending key whose session already has its own entry is dropped, not adopted, and no phone is listed twice.
- It takes the phone harness's own unlock code (
phone-harness, from the third read of #2000:
- The vendor's unlock reply is redacted of every occurrence of the unlock code, not only the first.
- A connect and the last holder's release no longer race the phone's adb transport. A connect waits for a disconnect still on its way, and takes its hold before
host:connect, so a last release meanwhile keeps the transport. adbCli(path, { env })spawns adb from the environment it is given, with everyADB_*,ANDROID_ADB_*andANDROID_SERIALvariable dropped, so nothing in it (ADB_SERVER_SOCKET,ANDROID_ADB_SERVER_SOCKET, …) points the CLI at another server than its socket requests reach. The library reads no environment itself. The conformance module passes its own, without itsCOMPUTER_PHONE_HARNESS_*names, so the API key never reaches adb, whether the adb's path comes from its setting or from itsadboption. A transport given as the option is used as it is.- The conformance module's file ledger judges its lock by its holder, not its age. The lock names the writer's pid and is linked into place whole. A live writer's lock is never taken, however old: a write waits up to
waitMs(10 s), then fails in words. A crashed writer's lock is broken at once, by one waiter at a time, so two writers never run together. A writer that meets a live breaker at work waits for it under the same deadline, and never blocks its process.waitMsbounds one holder that never lets go: a lock that keeps passing between live writers is progress, and is waited for, so busy writers under load no longer give up at 10 s. Before, a lock was taken once it was 30 s old, from a live writer too, while a writer gave up after 10 s.
Console: agents, pickers and models read the served agent, as one picker.
- Every dropdown is the console's picker (it opens right under its field, on a phone too): the runs' state filter, the run bar's effort, Mission Control's agent, a trigger's agent and its every-unit, Settings → Tools' permissions and providers, and a template's choice parameters. No native select is left.
- Every agent picker lists agents by name, A to Z, ignoring case and accents, a number in a name read as a number.
- A model reads one way everywhere: without the gateway's routing prefix the server names, the full id in its title; an agent that names no model shows its harness's default only as the server names it (
harnesses[].default_model). - An agent's page lists its tools with the provider and permission in force; after a save on the page it reads the agent again, and says so with a retry when that read fails.
- The Learning tab's "Learned in" names the run by its short id (its title on hover), and what the next run reads renders as safe Markdown.
- Learned items waiting for the owner's OK are counted from the agent's
learning.pending: on its page's Learning tab, its card in the Agents list, and Mission Control's Needs you, one row per agent.
A template's Apply screen offers a parameter with an
enumas a choice of those values, not a box to type one into (a number's choice is sent as a number), and a parameter whose pattern is a plain list of words (^(crm|slack|none)$) the same way. A value outside the set is refused by the server asnot_an_option, shown under its field in the server's words. No parameter's help shows its raw pattern any more: it says the parameter's own description.Console: Mission Control says when the Docker box's image is still coming ("Preparing the Docker box: building the box image (…). A sandbox run waits for it before its first tool call."), in the server's words from capabilities'
places.boxes, as a run's page already did on its timeline. While a box prepares, the console reads the capabilities again on its own, so the line goes once the box is ready.A run paused at its spend cap says why once, in its banner. On the timeline, the pause is the server's own row (a
phaserow withpaused: true, "Paused at the run's cap"): it reads as any server row, AgentRun with the served words, muted, with a pause icon, where a red "Error" row repeated the banner's sentence. The same row stays after the cap is raised, and it never counts as a failure on the page. The run's waiting row says "Paused at cap" for a cap pause, as its status does, where it said "Input required". A request that failed at the cap is still an error, in red. A timeline row whose detail is its own line's words has nothing to open, so it shows no chevron (the pause row among them); a row with more to say opens as before.A schedule's cron line reads in words on the Triggers list and on its page: "0 8 * * 1" in UTC reads "At 08:00, only on Monday · UTC", by cronstrue on a 24-hour clock, with the raw line as the words' tooltip and in the editor. One cronstrue can't read stays as it is ("Cron "). Its next time beside it is unchanged.
A GitHub trigger's page opens in the console: the events it takes (each with its actions and conditions), its firings ("On a GitHub event"), Run now, Pause and Resume (so one a Disconnect turned off can be turned back on) and Delete. It has no URL, signing secret or Edit, which are a webhook's or a schedule's.
A run says the notes it started with ("Started with: …") and the note files it opened ("Read: …"), and a note's card how many runs it was given to and read in, as the server counts them; the console no longer counts runs itself.
A run paused at its spend cap now shows its two cap figures apart and labelled, both on the pause banner's raise and in the run header's: "Least to continue" (the served
pending.least_cap_usd, the "at least" figure the server's words name) and "Suggested" (the servedpending.suggested_cap_usd, which the input is prefilled with). Shown side by side without labels, the server's "at least $0.62" and a prefilled $4 read as a contradiction. A pause that names no least shows the Suggested line only. For an upgrader: nothing to do; the console ships with the server that serves these figures.A new agent's form opens with instructions to edit ("You are a helpful agent. Do the task you're given and report what you found."), so Create works as soon as the agent has a name. The field stays required: emptied, Create is blocked again. An agent being edited keeps its own instructions.
A one-time schedule that has run (a one time with no next time, whose last firing is for that time and started a run, as served) reads "Done · ran " on the Triggers list and its page, with no Pause or Resume (Delete, Edit and Run now stay). A paused recurring schedule still reads Paused and offers Resume. A one time whose time passed with no run known to have started reads "Its time passed · " and keeps Resume (only the server's started firing hides it, never the browser's clock alone); one run by hand after it was done still reads Done on its page.
The console's timeline shows no duration on a tool call parked for the owner's approval: its result row has no status yet (
tool_status: null), and the time to that row is no run of the tool. A finished call, succeeded or failed, keeps its duration.On a phone (narrower than 640 px), the console's agent and run pages give their content more of the screen. The header, run box and tabs took 68% of a 390x844 screen on an agent's page, and 42% on a run's.
- An agent's page. The back button joins the name row, its word read but not shown. Edit shows its icon. The revision's facts fold into one line, which opens them; a description is cut at two lines until that line is opened, which shows it whole. The message box is one line high (a workflow's JSON input keeps its height). The effort's second line is hidden. Attach files is an icon in the row with Effort and Run, its limits in its title, with no drop zone.
- A run's page. The header is tighter. The title is smaller and cut at two lines (the whole of it in its title). The facts and the actions share one wrapping row.
- Tabs. They keep one row, with less padding.
640 px and up are unchanged.
Code and mono text in the console draw each character: ligatures are off for code, pre, kbd, samp, mono text, the caps labels and the workflow graph (
font-variant-ligatures: none,font-feature-settings: "liga" 0, "calt" 0).httpx==0.28.1showed its==as one long bar.An agent whose revision names no place says where it runs on its page, the server's default place for it marked "(server default)", and its Learning tab warns that bash on the host runs as you only where that place is the server and the agent has bash, never for one in a box. The sign-in page's "Forgot it?" names
npx @parcha/agentrun reset-passwordfirst, then the server's ownagentrun-serve reset-password --data DIR.The console's flows are tested in a real browser against the built server (apps/console/e2e,
npm run test:e2e, CI'sconsole-e2ejob). Each flow starts the server's own CLI on loopback with a fresh data dir and home, then drives headless Chrome through playwright-core, so the page loads with the server's real headers, CSP, cookies and redirects. The first flows are setup without a public URL and sign-in. The others are listed as pending with what they wait on. Nothing leaves loopback, and every wait is on a selector, a response or the server's output, never a clock.The run page shows the agent's question after you hand the browser back. A hand-back in the Screen tab's viewer answers the agent's browser ask, and the agent often asks its next question at once. The page re-read the run only when its status or state changed, and both waits read "input required, waiting", so it could keep showing the hand-over's card. While the run waits, any new timeline page now reads the run again. The served Timeline (
GET /v2/runs/{id}/timeline) also carries a waiting row for a question the agent asked again, as the run's view does. The page's "The agent asks" box reads the question from that row, and without it the box showed an earlier wait's words, or nothing.On an agent's Learning tab, a retired note, skill or workflow opens to its text, read-only, as one in use does: when it applies, what it says, the run that learned it, and who retired it when. It was only a crossed-out name. No Edit, Approve or Retire on it: no later run reads it. Restore makes it active again, after a confirm, on the version its row shows; a version changed since, a token's call, and a refusal by the checks a new one passes each say so in plain words, and it stays retired. A card says its newest change as the server names it (approved, restored, retired, edited or created by you, with when), and a retired one who retired it and when; with neither served, a card names no change. The "Waiting for your OK" box says a run's bash on the host runs as you only for an agent that runs on the host: one whose definition places it in the sandbox runs no bash on the host, and is not told so.
A working run's header shows its cost as it stands: it follows each timeline page's
cost, never the run's first read, which can be older than every priced request ("$0.0000 est." beside a Timeline that says otherwise). An ended run's cost is still the run's own read at its end.A run a webhook or GitHub trigger started is titled by the trigger's input line in the console (the runs list, the run's header, Mission Control, an agent's runs and its latest run, every row of the needs-you strip, and Learning), never the
<trigger-payload … untrusted>wrapper and the request's body after it, which stay on the run's Timeline. A trigger with no input of its own reads by its source ("A webhook request", or "A GitHub event" for a GitHub delivery). A title the server clipped inside the wrapper reads the same, and a run no trigger started keeps its title as it is.A decision's sources and the images a provider saw are links and images only when their URL is http or https: a
javascript:,data:orvbscript:URL from a fetched page, however it is spelled, shows as text, never as a link that runs script.The New schedule, webhook and GitHub trigger forms' cost cap field shows the picked agent's own default cap as its placeholder ("$5.00, the agent's default"), which an empty field takes, and no placeholder when the agent has none, where it showed a made-up example ("2"). A schedule's one time and an interval's start are picked to the minute (no seconds); the time sent keeps its shape.
The console's server-deny line reads the newest word on each tool. An MCP tool picked from its server's read, before the agent is saved, is held at deny with the line when that read says this server denies it, and the MCP picker doesn't offer it to tick. A deny lifted since the editor read capabilities no longer holds a tool the agent's read says is allowed.
The console says a server's deny is absolute. In Settings → Tools a tool set to deny reads "Refused on this server for every agent, whatever the agent's own setting or Never ask.", and the page no longer says an agent's own permission keeps it over this server's. In the agent editor a tool the server denies keeps its permission at deny with the same line, and can't be ticked in the Tools step; the console reads it from the agent's tools (permission deny from the server) and the server's table.
Console: a setup code the server refuses (a link from an older log line whose code has since been replaced, or a mistyped code) now says what to do: "That setup code was refused: open the newest setup link in the server's log, or paste the server's token (AGENTRUN_TOKEN) here." A setup link's code is read as the base64url code the server printed, so a character a chat app took into the link after it (a "." or ")", an invisible space) is no part of it; a typed code or token is only trimmed. The Setup code field is marked as a one-time code (
autocomplete="one-time-code").The console has words for every kind of newest change the server serves for a learned item,
supersededincluded ("Replaced by you"), so a card never says "undefined by you".A template's title on the Templates page and its Apply screen never shows a raw
${parameters.<name>}. Before apply, the card reads "…" in its place ("Code review: …"), and the Apply screen fills it live with what you type ("Code review: acme/web"), as the server fills the made agent's name.A run a trigger started names the trigger by its kind on its chip, in the runs list and on the run's page: "Webhook: inbound-lead" for a webhook's run, "Schedule: nightly" for a schedule's, where it read "Schedule:" for every trigger. A trigger the console's list doesn't hold (deleted since, or not read yet) reads "Trigger: ".
The console's run timeline shows a trigger's message (a webhook's input, a schedule's) as the trigger's: labelled "Trigger", its first line as the title, never as "You". Before, it read as a bare "Phase" row titled "Trigger : …".
The OpenAPI document requires what the server always sends: a run's
revision,kindandarchived_at, on a run and in a list, and a trigger'ssource.timezone(a cron line's zone) andsource.start_at(an interval's start) in every answer that holds a trigger. A write may still leave the last two out; the server fills them in, as before.The console's API types are generated from that document, as the CLI's command table is:
node scripts/generate-clients.mjsalso writesapps/console/src/lib/api.generated.ts, one TypeScript type per schema ofcomponents.schemasand, per operation, the JSON body it takes and the JSON it answers with. Its header holds the hash of the shapes it is made of (a description or an example changes nothing), and--check, which CI runs, fails when the committed file is not the document's.apps/console/src/lib/v2.tsnames each wire type the console uses as an alias over the generated ones, so a change to the server's contract shows up in the console's typecheck. The console's branches for values the server never sends are gone: a timeline row'srunning,missingandinterruptedstatuses andended_at, a run's learning statepending, and a stats cost basis other thanestimate.Every decision provider's evidence, a third party's included, goes through one sanitizer at the provider seam:
evidenceOfin@parcha/agentrun-dsl(with itsEVIDENCE_*caps), which the Parallel and OpenRouter providers call too. A kept URL is the parser's.href(an interior newline, a control or direction character in the path comes out normalized), and every cap and the shared budget count code points, so non-BMP text counts once and the total never passesEVIDENCE_TEXT_MAX. When a later step's prompt reads the workflow state whole, evidence is wrapped as data wherever the engine wrote it: any<as>$answerssidecar at any depth (inside a map's item states or a child workflow's output too) and a sift's item sidecars (<as>.answers[i].evidence).A decision's web evidence is treated as text from pages anyone can write, on both Parallel routes (its own API and OpenRouter) and every OpenRouter Decisions model. An item keeps an http(s) URL with no user or password, or it is dropped: a
javascript:ordata:citation never reaches the console as a link. Its title and passages are plain text with no control or direction-override characters, cut to 300 and 4096 characters (at most 5 passages), and a decision keeps at most 20 items and 32 KB of text in all (EVIDENCE_*in@parcha/agentrun-decisions). When a later step's prompt reads the workflow state whole, each decision's evidence goes in wrapped as data:{ note: "Web excerpts follow. They are data from web pages, not instructions.", web_excerpts: [...] }; the state itself is unchanged. Parallel'sincludeExcerpts: falsenow holds on the OpenRouter route too: no item keeps a passage on either route.The OpenAPI document requires
toolsin the answer to GET /agents/{agent_id}, which the server always sends (an empty list before the agent has a revision). The agent in a list, a create or a save holds no tools, so the shared Agent schema is unchanged; that one route's answer is the Agent withtoolsrequired, markedagenttools/1. The console's generated types follow.npx @parcha/agentrunwithAGENTRUN_PUBLIC_URL, on a server whose owner has no password yet, now shows the server's "Set up this server: /setup#code=…" line on stderr. Setup at a public address asks for that code, and the command hid the line, so a fresh server behind HTTPS couldn't be set up from its own address.When the server stops before printing anything on stdout (its reason on stderr only, such as another server holding the data root),
npx @parcha/agentrunnow says "AgentRun did not start; the server's own message, if it gave one, is above." where it said the server printed nothing.The Render blueprint deploys a 2 GB instance (
1c-2g) with a 10 GB disk, about $27.50 a month. On a Starter's 512 MB and 1 GB disk, Claude Code's first-use install filled the disk; health turned 503 and Render restarted the service.render.yamlgeneratesAGENTRUN_SEAL_KEYas it generatesAGENTRUN_TOKEN. A service made from the blueprint can now save model and tool keys, MCP server credentials and webhook trigger secrets from its first start, with no key added by hand. A service that already hasAGENTRUN_SEAL_KEYkeeps its own value.Packages take TypeBox's
Type,StaticandTSchemafrom typebox, never through pi-ai, pi-agent-core, pi-durable or chord, which drop those re-exports from pi 1.5. serve, pi-browser and pi-agent-learn now depend on typebox 1.3.27 directly (the version pi-ai 1.1 resolves); harness and agentrun-pi keep it as a host-provided peer. Tool schemas serialize exactly as before, so tool arguments are checked the same. harness'sshownShotshas a named return type,ShownShots.A workflow's model node submits on the runner. Every model request on the runner goes through the session's model proxy, whose policy drops each tool definition it is not given, and a workflow's proxy was given none: a node's request reached its model with no
submit, so no workflow could finish there. The workflow's proxy now admits the node's whole offered set by name, itssubmitand the tools its workflow's model nodes name, on Anthropic's Messages and OpenAI's Responses alike, as a pi session's proxy admits its own tools. agentrun-pi exports the tool's name asSUBMIT_TOOL.node scripts/changes.mjs --checkno longer reads a package that catches up to the workspace version as a release commit: a package whose manifest moves to the version the rootpackage.jsonalready holds at the merge base (one added behind the others, as@parcha/agentrun-computerwas at 0.1.0-beta.30) is an ordinary pull request, and a fragment beside it passes. A release moves the root version too, so its bumps still count, and an unfolded fragment on one is still refused.actions/setup-gois pinned by commit (v5) in the release's publish job, which holds the npm OIDC token, and in CI's build and computer-docker jobs. The@parcha/agentrun-computerREADME states what every install carries (both daemons and the conformance code) and the archive's size.The held package
@parcha/agentrun-learn(packages/learn) is removed. Nothing imported it, and it was never published to npm. The server's learning code lives in@parcha/agentrun-serve(src/learn/) and is unchanged. The release lists,verify-packages, the source export, the build and test orders and the documentation no longer name it, and the release preflight's held-package tests use@parcha/agentrun-computer, which is still held.Imports follow the layer direction.
npm run check:direction(scripts/check-direction.mjs, run in CI's checks shard) gives each workspace package one layer: protocol, kernel, plug-in, subsystem, server or surface. A protocol reaches nothing, the kernel reaches protocols, a plug-in reaches protocols and the kernel, a subsystem also reaches other subsystems, the server reaches anything and a surface reaches no workspace package. An edge is a manifest dependency, an import, export,import(),requireorimport.meta.resolveof a workspace package, a relative path into another package, or a surface naming one of the server's data files. Today's 23 wrong edges are listed as exceptions, each with its count, reason and fix. A new wrong edge fails, and so does an exception that no longer occurs, so the list shrinks as the fixes land.A test covers
npx @parcha/agentrunwhose reader closes its pipes once it has the address (| head -n 1). A box image line said after that goes nowhere, and the command and its server keep running until they are stopped.serve's daytona-boxes test that run A's token reaches nothing of run B's box no longer fails when run B makes a call of its own at the same moment (a late call at its turn's end). Run A's calls now name a file only they name, and the test looks for that name in what reached run B's box. Run A's own box, asked the same way, shows the name is seen when a call does get through.
serve's test cleanup no longer fails when part of a scratch tree is removed while it walks it. A fake box's command, or a server still closing, removes its own spool directories, and the walk that makes the tree removable now passes over a directory that is already gone. That fixes daytona-boxes' "a run found waiting at a start is pulled then" failing in its after hook with ENOENT on a box's
tmp/pi-transport/<id>/in.npm run test:affected(node scripts/test-affected.mjs) runs only the test files a change reaches: a changed test file, the tests that import a changed source file directly or transitively (imports read from the parse tree,dist/mapped back tosrc/, workspace names resolved throughexports), a package's every test when itspackage.json, tsconfig or build script changes, and the tests that name a changed file where an import can't say. A shared root (root manifests,scripts/,.github/) adds only its importers, and a fast smoke file per package always runs. It prints a command per suite, or the files (--list), or why each file was picked (--json). CI still runs every suite. pi-browser's test runner (node scripts/run-tests.mjs) now runs only the test files it is given.Tests only: the workspace runner's Ctrl-C test no longer loses a workspace's SIGTERM line. Its fixture step installed its SIGTERM handler after announcing its pid, so a stop landing in between ended the step unlogged.
Tests that start a server with an environment of their own (the server-record test, the npx command's
cliandfollowtests) setAGENTRUN_BOXES=noneinstead of dropping it, and so do the decision demos' runner and the smokes' faux server unless the caller names boxes. Since the Docker box is on by default, these made real Docker calls on CI runners, built the box image and raced their own cleanup.Tests no longer remove a temporary directory while a server or child they started in it still writes there. node:test runs a test's after-hooks in the order they were added, so a removal added as the directory was made ran before the stop added after it (ENOTEMPTY on CI). The serve suite's
scratch()and the npx command's tests now remove their directories once the file's tests are done, and the helpers that make and stop their own processes add the stop before the removal.Groundwork for a canary from main, published under npm's
canarydist-tag. No workflow publishes one yet.scripts/canary.mjs versions <run> <sha>sets every listed package, held ones too, to its canary version, and every exact pin between them (dependencies, peers, optional and dev).x.y.zbecomesx.y.(z+1)-canary.<run>.g<sha7>andx.y.z-beta.Nbecomesx.y.z-canary.<run>.g<sha7>. It refuses a tree that is already a canary.scripts/canary.mjs plan <sha>writes.release/release-plan.jsonfrom the archivesverify:packagesverified. It checks the bytes, the commit the versions name, and that the verification passed.scripts/verify-published.mjstakes--canary. With it, every package of the plan must be<x.y.z>-canary.<run>.g<sha>, all on one suffix, and a release plan is refused. Without it, nothing changes and a canary plan is refused. An unknown or repeated flag stops at the usage line before any registry read.The docs match the beta's code. The server README describes an agent's computer (
computer: {kind, provider}, Settings → Computers, Mode 1 and Mode 2 on Archil) in place of the boxes, says wherebashruns (in a computer that mounts the run's folder; on Archil's serverless exec in Mode 2, with its 295 s bound, no stop once sent, no kept file mode and no link followed; nowhere in Mode 1 without such a computer), that an escalated workflow run endscompletedwith areview, the run'spendingkinds and the webhook's, that a trigger's run is titled from its trigger, that a GitHub App token is minted again 5 minutes before it expires, that an agent keeps its own tool pins (the template READMEs too), and that a server in a container can't start a Docker sandbox yet. The contributing guides build the computer daemon beforeverify:packages.The root README is rewritten for a first-time reader: what AgentRun does, a three-step start (
npx @parcha/agentrunor Deploy to Render), a CLI example that makes an agent, starts a capped run, answers its question and reads its file, three diagrams (the server, store, runners and hands; the computer kinds; the learning loop), short sections on templates, computers, workflows, learning, security and deploying, and a short list of what comes next. The package inventory moved out: each package's own README is its reference. The support example and the research demo moved todocs/examples.md, and the docs that linked to them in the README link there.
0.1.0-beta.31 (2026-11-06)
npx @parcha/agentrunmanages agents and runs from the terminal:agents list|get|create|update,runs start|get|list|message|stop|cancel|raise-cap,triggers ...,usage,statsand a command for every other operation the server's OpenAPI document offers, plusapi METHOD PATH [--header 'NAME: VALUE']for any route. An operation the server turns off has no command, and neither haslearning approve, which takes the owner's password session in the console and refuses every token.npx @parcha/agentrun helplists the commands and<command> --helpgives a command's arguments. Path parameters are positional, each encoded whole (learning read AGENT skills/find-policies/SKILL.md) except a file's path, which keeps its slashes (home read AGENT notes/a.md); query parameters, header parameters (--idempotency-key,--if-match) and a JSON body's top-level scalar fields are flags (--max-cost-usd 2);--body JSON|@FILE|-gives the whole body, its fields under the flags, and--body-type TYPEsends a body of another type the route takes as it is (files create --path notes.txt --body @notes.txt --body-type text/plain). A JSON body with no flags goes byte for byte. The response's JSON goes to stdout (indented on a terminal), a file's bytes exactly as the server sent them, and an error's JSON to stderr; exit codes are 0 ok, 1 the server refused, 2 bad usage, 3 the server cannot be reached, 4 no token or the token refused. A command whose body carries a secret (tool-keys save,mcp-servers save|preview, andapion any path that leads to one) takes its body only from stdin or a file. The server and token come from--base-url, elseAGENTRUN_BASE_URLandAGENTRUN_API_KEY, elsebase_urlandapi_keyin$XDG_CONFIG_HOME/agentrun/config.json, else the server started on this machine with the same--data(default~/.agentrun). The token is never a flag and never printed, a key that is not a valid header value is refused before any request, and the local server's token goes to that server only. The local token is read fromDIR/.tokenalone, and the config file,DIR/.server.jsonandDIR/.tokenare used only when they are regular files of the user's own with no group or other permission (exit 4 otherwise).apirefuses a PATH with a.or..segment, plain or encoded, or an empty one. A reader that closes stdout early (| head) ends the command quietly. With no positional argument the command still starts the server, andimportandreset-passwordare still the server's.agentrun-servewritesDIR/.server.json({base_url, pid, token_file}, mode 0600, never the token;token_fileis absent when the token came fromAGENTRUN_TOKEN) once it listens and removes it on a clean exit; a file whose server is gone is reported as such, exit 3. The command table is generated from the server's document bynode scripts/generate-clients.mjs, with names inscripts/clients/resources.json; its header hashes only what the table is made of, and--checkfails when the committed table differs.The Docker box is on by default. A server started with
AGENTRUN_BOXESnot set at all (a plainnpx @parcha/agentrun) probes Docker at its start, as ifdockerwere listed, and offers it when Docker answers, so a new agent lands in the Docker box with a shell. Where Docker does not answer, new agents run on the runner, and the banner (Boxes: none, so new agents run on the runner. Docker isn't running on this machine (no docker command on PATH): install or start Docker, then restart AgentRun.),capabilities.places.default_reason, docker'sreasonand the console's new-agent form say why. With Docker on, the banner saysBoxes: docker (on by default; set AGENTRUN_BOXES to change).AnAGENTRUN_BOXESyou set still wins: a list probes only what it names, and an empty value ornonesets up no box (nonestands alone). Daytona, the VM box and bwrap stay yours to list. Docker's reasons say what is wrong in plain words first (Docker isn't running on this machine), then what to do. The box image's first build is said in the terminal as it starts (Preparing the Docker box image (first start only, a few minutes); agents can be made now, and their first run waits for it.) and as it ends (The Docker box image is ready.).npx @parcha/agentrunnow prints theBoxes:line and these two on stderr, after itsAgentRun is running atline, so stdout stays that one line.@parcha/pi-transport, where the boxes' providers live, is now a dependency of@parcha/agentrun-serverather than an optional peer, so a plain install (npx @parcha/agentrunincluded) has the Docker box.npx @parcha/agentrunnow tells the person at the terminal how to stop it and where its data is kept: "Press Ctrl-C to stop it. Its data is in ~/.agentrun." (or the directory--datanames), on stderr, so the one line on stdout that scripts read is unchanged. The command also reads--data=<dir>as it reads--data <dir>, and the last one given wins, as on the server, which is handed exactly one: before,--data=<dir>sent the server two and the command named the wrong one. It also says where the API's bearer token is (<data>/.token, or theAGENTRUN_TOKENit was started with): the server's own line saying so stays out of this terminal.Started with
AGENTRUN_PUBLIC_URL(a server behind HTTPS on its own name), the command now starts: it takes the server's "Open https:///" line, where before it waited 60 seconds for a 127.0.0.1 one and stopped the server. A server that does not start is shown with its own last lines ("AgentRun did not start. The server's last lines: …"), where before the message pointed at lines this terminal never showed. A stop the command passes on (Ctrl-C, a process manager's SIGTERM) now ends it with 0, sosystemctl stopno longer logs a failure.A run whose learning kept nothing for a reason (the grader turned it away, a check refused it, it was skipped or stopped) says so on its page: a "Kept nothing" chip with the server's own sentence, linking to the agent's Learning tab. Before, only the run's Timeline said so. A run with nothing new to remember stays silent. The run's look-back line and the Learning tab's panel of runs that kept nothing (now titled "Kept nothing") show the server's sentence as it is, so one that names a lost skill says "Not kept" once.
Tests only: the CLI test that starts a server with accounts off and a public URL lets the server pick its own port (
--port 0) and reads it from the start line, so another process can no longer take the port between the test choosing it and the server listening.The source check gives the downloaded source tree's install and whole build 600 seconds each, where every command had 180: a slow CI runner ran the build past 180 seconds once. Every other command keeps 180.
0.1.0-beta.30 (2026-11-05)
- The console's agent editor sets an agent's default spend cap. Settings has "Default spend cap per run, $" on an agentic agent (not a workflow one): it shows the revision's
max_cost_usd, and a save sends the number typed. Emptying the field clears a saved cap with an explicitmax_cost_usd: 0, the server's "no default"; an edit never clears it by leaving the field out. Text that is no number is refused under the field and not sent; the server's own answer on the number shows under it too. A new agent's Create carries the cap typed. An edit keeps every definition field the form does not show. - The console raises a run's spend cap. On a run paused at its cap, beside the cap's banner, the new cap is prefilled with the server's suggestion (
pending.suggested_cap_usd), editable, and "Raise the cap and continue" sends it once (PATCH /v2/runs/{id}); the run is read again and goes on. The run header offers "Raise the cap" for any capped agentic run that has not ended, so a working run can be raised before it pauses; its answer's note (the live process keeps its old cap until its next one) is shown. A refusal is said under the input in the server's words (422), and an ended run (409) or a run the server no longer has (404) in plain words. The Report tab of a paused run reads "Paused at its spend cap", with what it spent of its cap. - The console's Learning tab retires a learned skill or a workflow draft as it retires a note. An active or waiting skill and a draft workflow have Retire, behind the notes' confirm in place ("Retire it? Later runs stop getting it. It stays under Earlier skills."), made on the listing's version of its
SKILL.mdor its draft'sREADME.md(never itsworkflow.json). A retired one moves under Earlier skills or Earlier workflows, closed until asked, with no Approve and no Retire, and is no longer counted as in use. A version changed since the listing (412) says "It changed since you opened it. Reload the tab to see the newest version.", as a note does. - The console's runs list names the schedule that started a run, as the run's page does, with a link to the schedule: "Schedule: ", or its id when the schedule is not in the list.
- The console has a Schedules page, where the server has schedules (
features.triggers). It lists each schedule: its name, its agent, when it fires (a cron line and its zone, one time, or an interval), its next time, whether it is paused, and how its last firing went. A schedule is made or edited in a form: when (cron in an IANA zone, one time, or every N seconds, minutes or hours from an optional start), the agent, what each run is asked, and its required cost cap. A save the server refuses shows each issue under the field it names, a workflow agent's refusal under the agent. An edit sends only what changed. A schedule's page pauses and resumes it, edits it, deletes it after a confirm, and runs it now, one firing per click under its own Idempotency-Key. It lists its firings, newest first: started (with its run), skipped, missed (with the count), failed (with why), or due. A run a schedule started names the schedule on its page, and links to it. - The console answers a tool call that waits for your approval, and sets each tool's permission
- A run waiting on a tool call set to ask (
pending.kindapproval) shows an approval card on its page where a question would be. The card names the tool and shows its arguments in a monospace block that scrolls, and never runs wider than a phone. It says what a call costs ("Costs $0.02 per call", "Not metered", or nothing when the tool names no price) and takes an optional note, sent with Deny. Allow and Deny both disable on the first click, so a double click is one answer. The run is read again after the answer. A refused answer is said in words, and the words stay after the card goes: another answer came first, the run was stopped (the call withdrawn) or cancelled, the page has no owner sign-in, or the sign-in ended. While the call waits, the message box stays and says what a send does: a message declines the call, and the agent reads it as the reason ("or type what to do instead; sending it declines this call"). The card goes once the run is read again, as after a Deny, and the timeline's row reads "Declined by your message". An answered question that is still the newest on the timeline is no longer shown as the question. - Mission Control's Needs you strip answers the same call in place, with a compact card under the agent, the run and when it asked.
- The timeline's
tool_approval_requestedrow reads "Your approval", marked while the call waits, and points to the card above. - Settings → Tools lists every tool with its permission (
allow,ask,deny) beside its provider, with the owner's default named. The agent editor does the same for each tool the agent grants, the server's permission its default. Where accounts are off (ask_needs: "password"), ask is offered disabled with why, and a tool already on ask says each call is refused. Saving a provider or a permission keeps every other setting the server holds, a catalog tool's or an MCP server's wildcard included:PUT /v2/settings/toolsreplaces the whole map. - A workflow refused at its start because a call node calls a tool set to deny, or to ask (a call node can't wait for an approval), says the server's words and links to the fix: "Set for this agent" opens the agent's Tools on that tool's permission (
#/agents/<id>/edit?tool=<tool>, a legacyfetch_pageatweb_fetch's), or Settings → Tools for every agent. It readsdetails.deniedanddetails.askson the agent page and in the Playground. A workflow agent's editor now shows the permission of each tool its call nodes call (the server's built-in call tools, no catalog tool, no provider), saved in itstool_settingswith the workflow kept whole.
- A run waiting on a tool call set to ask (
- Console: the file viewer shows a file Rendered or Raw, with a toggle in its header. Rendered is the Markdown rendering it showed before; Raw is the file's exact text, each line on its own line, in monospace. A Markdown file opens Rendered and any other text file Raw, so
Hello, world!andGoodbye, world!on two lines of a.txtread as two lines, and a.md's are one click from it. A file the viewer doesn't show (an image, a binary) is still offered as a download, with no toggle. - The console's timeline shows a message the server wrote into a run (an approval's resume, a raised cap's follow-up: a
phaserow withorigin: "server") as AgentRun's: labelled "AgentRun", its first line as the title without the "AgentRun:" prefix, muted. It never reads as the person's. A plain phase row keeps "Phase". - The agent's Analytics tab no longer shows a run that just ended as unknown (no effort, 0 tokens, cost Unknown) until a reload. It reads its figures again when it opens, when a run of the agent that was going changes status (followed on its timeline, as the Runs tab follows its latest run), and a few times more, briefly, while the server's
runs.status_unknownsays a run's end is not in its store yet. Mission Control's agent picker now scopes everything below it: with an agent picked, "Runs per agent" shows that agent's row alone and "Recent runs" that agent's runs alone; with All agents both list every agent, as before. Its Needs input tile now counts exactly the runs Needs you lists, those with a pending ask; a run the person stopped is no longer counted there, and shows in Recent runs as Stopped with "send a message to continue". - The console shows what an agent wrote as it wrote it. A single line break in an answer, a note or a file is a line break: an answer asked for in three lines reads as three lines, where it read as one. Lists, code blocks and paragraphs are unchanged. The timeline's Answer row, which shows the answer's first line, ends it with "…" when the answer has more. Text in angle brackets that is no HTML the console keeps reads literally: "Customer: " and "Amount: $" in an answer, a learned note or a skill, where the tag was dropped and they read "Customer: " and "Amount: $". A
<script>an agent quotes reads as its text and never runs. HTML the console keeps (<b>,<br>,<img>, a table and its cells) is still HTML, and a template whose first line is<name>keeps its lines, and the sanitizer still checks everything. - A link in a run's answer no longer takes the console's tab away. A relative link that names one of the run's files (an agent that wrote
hello.mdand answered with[hello.md](hello.md)) opens that file in the run's file viewer, and a Markdown file in the viewer opens the run files it links, read from its own folder. Any other link with no scheme (a path that names no run file, a path from the root, a fragment,//host) reads as its text: before, the browser followed it against the console's address, onto the server's JSON 404 or, for a fragment, onto another console page. A link no URL parser takes reads as its text too, and a folder name with#,?or%in it is read as a name. A link to a page (http:,https:) opens in a new tab withrel="noopener noreferrer"; before, it replaced the console. The same holds wherever the console renders Markdown: a learning note, the agent editor's preview. Amailto:ortel:link is unchanged. - The browser's provider choice, in Settings → Tools and in an agent's editor, names and explains the browser's providers: "Auto (Browserbase where its key is saved, else Kernel)", "Browserbase", "Kernel" and "Local Chrome", each with a line on what it needs. It read them through the names web_search and web_fetch share, so Kernel read "kernel fetch" with the guarded fetch's help, auto took search's rule, and Browserbase and chrome showed their ids. What the browser resolves to reads by the same names. Search's and fetch's names and help are unchanged.
- Console: a banner above a page (no model yet, an import under way, a failed sign-out) now takes its own space above the page instead of sitting inside it, so a full-height page keeps its foot in view: the agent editor's Previous, Next and Create no longer drop below the window while "No model yet" shows. A harness card this server can't run says "Not available here", and the server's whole reason, with its install command, opens in a "Why, and what to do" line that wraps inside the card, on a phone as on a desktop, where it ran out of the narrow card before.
- The console's first-time approval path reads plainer. The approval card's button says "Allow once", on the run page and in Mission Control, since the next call asks again. A call's arguments show as one "key: value" line per argument (a string as it is, a nested value as JSON), with the whole arguments on hover. A preview the server cut shows as it came. In the agent editor, a tool's description stays at two lines with "more" for the rest, where it had shown whole because the clamp was overridden. Settings → Tools puts each tool's permission right after its name: Tool, Permission, Provider, Resolves to.
- Console on a phone on its side (844 by 390) and other short or narrow screens: the sidebar scrolls, so Sign out at its foot is always reachable; the agent editor's tool cards size their columns by a minimum width, one card a row in a narrow pane instead of two tall strips; and the harness cards wrap onto more rows instead of squeezing five into one, so their names no longer run into each other.
- Console: every time it shows (a run's header, the Runs list, the Timeline, a run's files, learned notes, Mission Control, an agent's runs and revisions, tool keys' checks and the webhook's deliveries) reads in the viewer's own time zone, the browser's, instead of UTC. A run started at 20:50 UTC now reads 4:50 PM to a viewer in UTC-4. All of them go through one formatter, so the webhook's deliveries now read in the same short shape as the rest. Relative times and the analytics' days, which are the server's UTC days, are unchanged.
- The console on a phone. On a narrow portrait screen the Agents search box shows its magnifier without the placeholder (only its first letter fitted); landscape and desktop keep it. The Playground's Agent and Revision fields are a picker that opens its list right under the field (Headless UI's Listbox,
@headlessui/react2.2.10), in place of native selects, which a phone opened detached from the field. In a short viewport (a phone in landscape) the Playground's code and response panel folds behind Show code so the form gets the height; the agent header stays put and Send request sits in its own row. Sending opens the panel so the response shows. - Console: an agent's Learning tab, under "What the next run reads", counts and names the agent's active skills beside the notes in use: "1 skill", "2 notes · 1 skill", and, opened, "Active skills: find-policies". An agent whose only learning was an approved skill read "nothing" there, though later runs get that skill. A skill that waits for its owner's OK is still left out, and with learning off (for the agent, or on the server) the panel says the next run gets no notes or skills. A long skill name wraps inside the panel.
- The console's Report tab never says "Writing the answer" for a run that isn't working on one. A run waiting on a question reads "Waiting for your answer: the run continues when you reply below." (or "once the question is answered" where the page takes no answer); a stopped run reads "Stopped before it wrote an answer. Send a message below to continue it."; a paused workflow run reads "Paused: no new step starts until it's resumed." A run that waits on a kind of thing the console doesn't know yet (its
pending.kind, from a newer server) reads "Waiting for you: the run continues once you give it what it waits on." Only a queued or working run reads "Writing the answer". A failed, cancelled or answerless completed run already says how it ended, and still does. - The back arrow before a run page's breadcrumb goes back: to the console page the run was opened from (the runs list, the agent's page), or to the run's agent page when the run was opened at its own address, from outside the console. It was an icon that did nothing. The breadcrumb's agent name links to the agent's page.
- A workflow agent's Tool settings in the console say what ask means for it: a call node can't wait for an approval, so a tool set to ask stops the run from starting, and the fix is to set it to allow. The line had said a tool on ask makes the run wait for your approval at each call, which holds for an agentic run only, and still says so there.
- A decision receipt keeps its request's error whole up to 64 KB (it was cut at 2000 characters), marking a longer one
error_truncated. A workflow run's graph redacts a decision's error before it cuts it, and holds back the tail of one stored cut (marked, or exactly 2000 characters from before), as it does a call's, so a held key across the cut shows no part. - A decision's row in a run's store keeps its receipt's
error_truncated, so a completed run's decision error stored cut at 64 KB is still held back at its tail on the graph, where a held value the cut split would sit. - The test shards' weights (
scripts/ci-timings.json) are refreshed from 168 job logs of three green pull-request runs; the old ones dated from the day sharding landed. The root scripts suite had grown (release-preflight.test.mjsalone went from 152 s to 257 s), and all of it sat onany-2, which then ran 7.8 min against the otheranyshards' 5.4 to 6.1 and held upleanandverifyin 36 of 40 runs. Each shard now printsci-shard: <shard> ran <s> s, modelled <s> sand, when it ran more than a quarter and more than a minute over its model, a::warningthat the weights are stale, with the command that refreshes them. - A flaky test can be quarantined: skipped on CI only, under its flake id and owner, for at most 24 hours, with at most three at once (
node scripts/quarantine.mjs --add,scripts/quarantine.json). Once an entry expires,scripts/quarantine.test.mjsfails every pull request until the test is fixed and its entry removed. CONTRIBUTING.md says how. - Greptile reviews every push to a pull request, not only its opening (
greptile.json,"triggerOnUpdates": true). A push that adds commits is reviewed as it lands, so nobody has to ask for another review. Every other review setting stays as the Greptile dashboard has it. - serve's test of OpenCode on one port across twenty drives no longer fails now and then on a busy CI machine. It took its port from the range the kernel hands to any listen(0) or outbound connection, so another test could be handed that port in the moment between one drive and the next, and the next OpenCode could not bind it ("OpenCode exited before it listened", ServeError). The test now takes a free port below that range, and a drive that does not complete names its error. This ejected a merge-queue run and failed a PR on 11-05 (F36); its quarantine is lifted.
scripts/release-preflight.test.mjspacks its fixture packages side by side, at most one per CPU, instead of one after another: 18npm packruns, 13 times in the file, which took most of its 257 s on CI and held up the test shard it sat on. On the shared box the file went from 330 s to 220 s; the receipt it writes is unchanged, in the release map's order.- The release sets
@parcha/agentrun's version; a pull request no longer bumps it. A change to what thenpx @parcha/agentruncommand publishes now adds only a fragment for@parcha/agentrun, andnode scripts/changes.mjs --checkrefuses a pull request that moves its version. On the release pull request, which moves the command's pin on the server every time, the check still refuses a change to its published files that keeps the version, so each release gives it the nextx.y.z. Pull requests stop colliding on that number, and the changelog stops naming versions that were never published (0.3.1 to 0.3.3 in beta.29).docs/releasing.mdsays so. - serve's dwf test of a run's graph read while it is paused mid-map bounds each read at 10 s, not 1 s. A read that waited on the paused run's store would wait until the resume, and one that met a busy store answers at once marked stale (still asserted), so the bound only clears a slow runner: one answered in 2.6 s and ejected a merge-queue run on 11-05 (F30).
- serve's tests read the fake docker's logs by whole lines only: a log just made and still empty, or a last line still being written, is no longer parsed, so the box default-image test no longer fails with "Unexpected end of JSON input" on a busy runner (F35). Its CI quarantine is lifted.
- serve's secret-custody test reads a live run's files while the run goes on, and a file that goes between its listing and its read (the run's empty launch mark, removed once its process serves) no longer fails the test with ENOENT. Such a file holds nothing; any other read error still fails, and a file that holds a key is still found. This ejected a merge-queue run on 11-04 (F31).
- serve's store-busy test of a message to a working run bounds each answer at half the revision read's retry (15 s of 30 s), not at 6 s: CI's slowest runners answered at 6.0 to 6.5 s and failed it on four pull requests and a merge-queue run on 11-05 (F34), while a message that waited on the read would answer at 30 s. A server whose message path makes that read still fails it, at 30 s.
- The dependency inventory (docs/dependencies.md) and the release notices read OpenCode's twelve platform binaries (opencode-darwin-arm64@1.19.5 to opencode-windows-x64-baseline@1.19.5) as MIT, from OpenCode's own LICENSE at the pinned release, instead of "UNKNOWN — Missing license metadata": their package manifests name no license. The reading is pinned by name and version, so a bumped binary is flagged again until it is checked. The release review's flags drop from 21 to 9.
0.1.0-beta.29 (2026-11-04)
Known issue: with a Jev key set (
TYPESAFE_API_KEY), a note that records a preference you stated can be dropped; fixed in the next beta.The DSL's command line is
agentrun-dsl, no longeragentrun:agentrun-dsl demo,agentrun-dsl inspect workflow.json,agentrun-dsl dry-run, and so on, with the same commands and options. A script or shell that callsagentrunfrom@parcha/agentrun-dslcallsagentrun-dslinstead. Theagentrunname is left for the AgentRun server.A host with no decision receipts now escalates instead of re-asking. A host that runs decisions under
withRecoverywithoutrecordDecisionandrecovery.decision, and whose process died after a decision's answer and before its node committed, used to ask the decision again and pay for it twice. It now ends the run escalated, kinddecision_interrupted(exported asDECISION_INTERRUPTED), stagedecision, for review. To keep the run going instead, keep decision receipts (recordDecisionplusrecovery.decision): an answered decision then replays from its receipt at no cost.Under
withRecovery, a decision is never paid for twice across a crash. Each request to the host'srunJudgeis committed as adecision.requestbefore the judge is asked, and ends with one outcome:answered, orfailedin its own process (asked again by the next as its next attempt). A later process that finds a request asked and never answered (its process died, or a cancel cut the call) does not ask again: it books the requestinterruptedat an unknown cost (cost_usd: null, never 0) and ends the run escalated as above. A pause now waits for a decision in flight to finish, as it waits for an effect.- The driver keeps the requests in its frame by node path (
decisions, optional in older checkpoints), so a map item's requests live in its own record in the keyed layout.driver.decisionRequests()reads them back.
- The driver keeps the requests in its frame by node path (
createJevRunnernever re-sends a paid request unless you ask it to- A public default that could re-send a paid request was the bug. A request that was sent may have been answered and billed, so the runner no longer retries a 408, a 5xx or a connection lost after sending. It sends the request once and fails with the new
JevErrorcodeoutcome_unknown(with the HTTPstatuswhen there is one), at an unknown cost. - It still retries a request that provably never reached Jev (no DNS answer, no connection, no TLS handshake), and a 429 with
Retry-After. A 429 withoutRetry-Afteris now anhttperror at once. - Who breaks: a host that counted on the runner retrying 5xx responses, lost connections or 429s with no
Retry-After. What to do: hosts that want the old retries opt in withcreateJevRunner({ retryAfterSend: true }), knowing such a retry can re-send a request Jev answered and billed. A host that switches onJevError.codehandlesoutcome_unknown, for example by sending the case to a person rather than asking again.
- A public default that could re-send a paid request was the bug. A request that was sent may have been answered and billed, so the runner no longer retries a 408, a 5xx or a connection lost after sending. It sends the request once and fails with the new
npx @parcha/agentruninstalls the server it was released with:@parcha/agentrun-serveis its one dependency, at the release's exact version, so npx installs the two together and nothing is fetched at run time (no@latest). The command passes its arguments through, with its store (--data ~/.agentrununless--datanames another), and the server's CLI picks what it used to: with no--port,agentrun-servetakes the first free port from 7340, found by listening, so a port another process takes at the same moment is passed over (--port Nis still exact and fails at once when taken,0any);--openopens the console's page in the browser once the server listens (never the banner's sign-in link, so the token stays out of the opener's arguments and the browser's history), except in CI or on Linux with no display (the command passes it unless--no-open). The CLI sets no model: with noAGENT_MODEL, the server's default is the first provider key it has, set where it starts or saved in the console's Settings → Model keys, so a key saved later moves the default with no restart. The release publishes the command after the server, andrelease-preflightrefuses a command whose server pin is not the release version, so every release bumps the command too. This release publishes@parcha/agentrun0.3.4.npx @parcha/agentrunstarts AgentRun on this machine with one command: it checks Node, takes the first free port from 7340 on the loopback address, keeps its data in~/.agentrun, starts the server, waits until it answers, prints on one line the address the server says to open (the URL of itsOpenline) and opens it (not with--no-open, in CI, or with no display). WithANTHROPIC_API_KEY,OPENAI_API_KEYorOPENROUTER_API_KEYset and noAGENT_MODEL, the server runs a current model of that provider. Other commands go to the server's CLI:npx @parcha/agentrun import. The TypeScript SDK for the hosted API stays@parcha/agentrun0.2.x; a dependency on^0.2never gets 0.3.0. The release publishes@parcha/agentrunat its own version, first, andchanges --checkfails a change to the files it ships that keeps its version. The README's first run is two lines, Docker and Render move under "Run it on a server", anddocs/SKILL.mdis the setup skill for a coding agent.The console's agent page opens on the agent's latest run, above its runs: its status, its title, when it started, its timeline as the run page shows it, and a link to the whole run. While the run goes, its timeline follows it from the cursor, and when the run's status moves, the run is read again, so its badge and its row in the list move together. A read that fails is tried again, even after the timeline's last page; one that keeps failing says the run could not be refreshed, with a Retry, rather than show the old status. An agent with no runs says so, and that its first run will show there. The page reads what it read before (the agent's runs, the run's timeline and the run), with no new server route.
The console gives each agent its own page at
#/agents/{id}. The header shows the current revision's harness, model (the server's default, named as such, when the revision sets none), thinking and revision number, and says when the agent is archived. Each tab has its own address (#/agents/{id}/{tab}). The Runs tab, the default, lists the agent's runs newest first, an archived agent's included. Each row links to its run and shows its status, when it started, how long it ran once ended, and its cost once known. A workflow agent also has a Workflow tab that draws the current revision's graph read-only. The editor moves to#/agents/{id}/edit, reached from the page's Edit button and the card's Edit icon. A card and a newly created agent open the page, and the card's History icon and Mission Control's runs per agent open its Runs tab.#/runs?agent={id}still lists one agent's runs.An agent's page in the console runs the agent and shows its analytics. A run bar under the header takes an agentic agent's first message, or a workflow agent's input as JSON (prefilled with its example, else its fields empty), and an effort from the server's
features.efforts("default" sends none). A started run opens on its page; a refusal (a409 agent_not_runnable, a422with its fields) is shown in the bar, and sending again is the same request under the sameIdempotency-Key. An archived agent has no run bar. The Runs tab shows each run's effort, and an Analytics tab, on a server whoseGET /v2/statsanswersanalytics, shows the agent's tokens, time, success, cost, success by harness and effort, top tools, and runs and tokens per day over the last 7 or 30 UTC days or every run, drawn with Mission Control's analytics components: a token sum says how many runs it holds, unmetered requests are named, and a cost not known is never $0.- serve's runs index (
GET /v2/runs,GET /v2/agents/{id}/runs) now carries a run'seffortasGET /v2/runs/{id}does: optional, absent for a run started with none, markedeffort/1in the served OpenAPI document. The document also lists every other field a run answer carries beyond the contract, markedserve/1:revision,kindandarchived_atonRunandRunSummary, anderroronRunSummary. Before, a run or a runs-index answer did not meet the document's closed schemas.
- serve's runs index (
The console's agents: the list (and the archived ones) and an editor for a new agent or the next revision of one. The editor has context, tools and skills, settings (the harness from the server's
harnesses, a harness it does not run shown disabled with its reason; thinking; model) and history (make an earlier revision current), plus archive and unarchive. A save is made on the revision the editor loaded (If-Match). When another save landed first, the editor says so and keeps nothing over it. A 422 shows its fields where they are. An edit keeps every definition field the form does not show, and the name and description the agent was created with.apps/consoleis the start of AgentRun Server's console: a React app that builds to static files, with the shell and the run page (status, live timeline, input required, stop versus cancel, files and the file viewer, typed errors) and a Storybook of every component on responses recorded from the server. It offers a message only in a mode the server'sfeatures.messagestakes. The source export now reviews.tsxfiles, and CI'sverifyneeds the console's typecheck, tests and builds.The console says how much a run whose requests were cut can have cost. Where a run's cost is unknown but the server bounds it (
at_most_usd, set only when every cut request has a recorded figure), the run header and the run rows read "≤ $x" with "some requests were cut", in place of "Unknown". A bound is rounded up, so it never reads below itself, and is never a bare "$x". Mission Control's total cost and the analytics' cost keep the known sum and add "up to $y more for N cut runs" from/v2/stats'bounded_unknown(the analytics' under their range and agent). A server without these fields reads exactly as before.The console shows what a run has cost so far when its whole cost is unknown. The run header, the runs list, an agent's runs, Mission Control's latest runs and the live runs under Needs you read "so far $0.0123 · 1 not priced", from the server's
so_far_usdandunpriced, in place of "Unknown". It says "est." for a local estimate, and the run header's hover says it is not the whole cost. "Unknown" stays for a run whose server says neither. The console's reads of a cost bound, which no server sends, are removed:at_most_usd,/v2/stats'bounded_unknown, and the "≤ $x" and "up to $y more for N cut runs" lines.The console's agent builder can create a decision agent: on a new agent, the harness picker lists Decision after Pi, Claude and Codex. Picking it makes the agent a workflow revision built from a starter decision workflow (one typed question, and a person for an unclear or low-confidence answer, adapted from the repository's jury demo; it has no code node, so a server started without
--allow-code-nodesruns it), whose question goes to the server's decision provider: Jev, the default, or Parallel where Jev does not run, with no instructions, tools, thinking or model to fill in; after Create the agent opens on its Workflow step, where its nodes are edited. Decision is offered when the server's capabilities list therunJudgeworkflow adapter and Jev or Parallel among the decision providers available, and shown disabled with why otherwise, so the console never makes a decision agent whose run the server would refuse. After Decision the picker lists Workflow, offered on every server: it makes a workflow revision from a general starter, one agent step that does what its input'staskasks and answers inanswer(no code node, no decision node, an example input), again with no instructions, tools, thinking or model to fill in, and opens on the Workflow step, where its JSON is edited or another workflow's pasted over it. So a server with no decision provider can still create a workflow agent from the console. The harness picker shows one column per choice on a wide screen. Both are choices in the console only: the revision names no harness, and the server is unchanged.The console's start screens take files. The Playground and the agent page's run bar each have a file picker, a button and a drop zone, that sends each picked file at once (
POST /v2/files?path=<its name>), all in parallel, with its size and how much is sent. The run names them (file_ids) and finds them ininputs/; it starts only once each is sent. The server's limits from capabilities (limits.max_upload_bytes,limits.max_files_per_run) are said under the picker and kept before anything is sent, and a refusal the server gives is said there in its words. Removing a file stops its send, or deletes it from the server when it was sent.An agent whose harness the server can't run says why on its page, in place of a run that would fail: "claude-agent doesn't run on this server: claude-agent needs ANTHROPIC_API_KEY.", from capabilities'
harnesses[].reason, or the harness by name when the server gives no reason. Its Run is off and sends nothing.The console shows what an agent learns. The agent page has a Learning tab: each note the agent wrote for itself, with its type, its body, the run it was learned in and, on a server that says what runs read, how many runs used it. A person edits a note in place (name, description and type as fields, the body as markdown; Ctrl or ⌘+Enter saves, Esc cancels), adds one, or retires one after a confirm. Each write names the version the person saw (the note's own ETag on its card), so a note changed in the meantime is never written over or retired unseen: the newest version loads, and the person's edit stays on screen to copy. Notes that were replaced or retired are listed under Earlier notes. Beside them are the learning switch, the latest runs that kept nothing with the reason in plain words, what the next run reads, and, from a server that has it, the before and after ("Since it learned (on 4 runs): answers in 12s instead of 18s · $0.03 est. instead of $0.11 est. a run"); until it has two runs on each side of the first note, one quiet line says so. An agent with no notes shows one sentence on what learning is, with the switch. The editor's Settings step has a "Learns from its runs" switch. It asks for no tool, since a run says what it learned in its answer. A run's header and its rows on the agent page and Mission Control say "Used 1 note" and "Learned 1 note", naming the notes on hover; the header's chips open the notes in the Learning tab. An ended run whose learning is still deciding is read again until it settles, and its timeline is read again each time its learning moves, so the timeline says what the header says. From a server that holds notes for the owner's OK (a note that changes what the agent trusts or skips), those notes sit at the top of the tab under "Waiting for your OK", each with Retire and, from a server that approves from the console, Approve, made on the card's version (a quiet line says when approving needs the owner's password sign-in, or comes in the next update), beside a line on what a run on the host can reach; a note from the person reads "From you", and a run that kept one says "1 note waits for your OK", linking to it. In the timeline, the end-of-run look-back is one step labelled Learning that says what it kept ("Looked back: kept 2 notes (Refund limit, Answer shape)", "Looked back: kept 1 note (Answer in tables); 1 note waits for your OK (Buy from PaperDepot24 first)", or "Looked back: nothing new to keep"), with what it did behind it; its message is no longer labelled as the person's. A run whose notes came back out of format says so: "Not kept: its notes weren't in the expected format." A run that kept a note shows "What it changed" under its report and its timeline: each note its learning added, replaced, retired or removed, by name ("Added: Refund limit", "Replaced: Old refund rule → Refund limit"), each opening to its text before and after with the changed lines marked.
The console reads a live AgentRun Server: the runs, and a run followed from its timeline cursor (long poll), with stop, cancel and messages sent as same-origin JSON with an
Idempotency-Key.npm run devinapps/consoleproxies/v2to a local server (AGENTRUN_URL,AGENTRUN_TOKEN).Mission Control shows a run's tokens, time, tools and success, not only counts and cost, when the server answers
GET /v2/statswithanalytics. A server without it shows the page as before.- Tiles for tokens (with the cached share of input and the output), run time (wall time of ended runs, and active time where the metrics have it), success rate (completed against failed or escalated) and cost. When some runs have no metrics, the tokens say so ("tokens from 9 of 12 runs"), and the requests that went uncounted are named.
- A per-day trend of the range, at most the last 30 days: runs stacked as completed, failed and other, and tokens, each bar with a readout on hover and focus, plus a table view. Success, tokens and cost by harness and effort, and the top tools with their error rate. Imported runs are counted apart from the ones run here.
- A range picker (7 days, 30 days, all) and an agent picker, which ask the server again. A cost no run reported reads Unknown, never $0.
The console says when no model can run. The first screen asks for a model key (a card that saves one through the key routes) while the server's capabilities say
models.ready: false, and every other page shows a bar with what to set: a model provider's key where the server starts, or one added in Settings, or, when the server'sAGENT_MODELnames a model it does not run, that. While no model is ready, the console reads the capabilities again every 10 s and when the page comes back into view, so the card and the bar go by themselves.The console sets and shows where an agent runs. The editor's Settings step picks On the server or In a sandbox, with a box from the server's list; a box the server lacks is greyed with its reason. Picking a box opens bash, and going back to the server turns bash off and says so. The agent page says where the agent runs, and the run page where the run ran. A start refused for a missing box says which box. A tool the server can't run shows the server's reason, and a refusal at one tool shows on the Tools field.
The console waits out a server that goes away (a restart, a laptop waking up) instead of showing the password page or "TypeError: Failed to fetch". Reads wait and carry on once the server answers, so a page keeps what it shows and a run's timeline picks up where it was. One banner says it is reconnecting, with the next try and Retry now, then "Resumed". Errors say what failed, why and what to do next, a missing run or agent links to its list, and pages show their shape while loading.
The console's agent History has View on each of a workflow agent's past revisions. It opens that revision's workflow as its graph, read-only and marked against the current revision (new, edited, removed, moved), with Make current beside it and Back to History. A read-only view takes no edit, asks the server for no check, and has no save. An agentic agent's History is unchanged.
The console archives runs and filters the runs list, as the server's own page does. A run's page has Archive (or Unarchive) in its header, and an archived run shows an Archived chip there. The runs list has a state filter and an Archived toggle that lists only archived runs. The address keeps both (
#/runs?status=failed&archived=true, besideagent=), so a reload keeps the view. The client'sarchiveRunandunarchiveRunsend the empty JSON body a cookie write needs, andlistRunsandlistAgentRunstake{ status, archived }.The console's setup offers to import this machine's Claude Code, Codex and pi sessions. On a server on the same machine, the first-visit card shows the box at once, never ticked for you. It reads "Counting sessions…" until the server's numbers arrive, and the count isn't known if counting failed. Ticked, setup asks the server to import them once the password is set. The console then shows the import running and then done, and it reads the agents' names again once the import ends. Where the server offers no import, there is no box.
The console signs in through AgentRun Server's own routes (
/v2/auth/session,/v2/auth/sign-in,/v2/setup,/v2/auth/sign-out): the first visit sets the owner's password, later visits sign in with it, and Sign out ends the session. The server's HttpOnly cookie is the session; the console keeps no credential. A server without accounts sends the person to the link it printed.The console starts runs. The Operator is one composer: pick an agent, give it its first message, and the run starts and opens. The playground is
POST /v2/agents/{id}/runsas a request form (input, revision, effort, spend cap) or its JSON. It shows the same request as cURL and JavaScript, and the server's answer with a way into the run. A retry of the same request reuses itsIdempotency-Key, so the server answers with the run it already started.The console follows the OS's light or dark scheme instead of always being dark, and follows it as it changes. A switch at the foot of the sidebar picks System, Light or Dark, and this browser remembers the pick. A dark OS's page is dark before the script runs, so it never flashes white.
Settings → Tool keys says, on each key's row, how its provider took the saved key: " accepted this key ·
The console's Settings → Tool keys page, over
GET,PUTandDELETE /v2/settings/tool-keys: each key (Parallel, Monid, Browserbase) with what it unlocks and the harnesses that run that, and each of its fields set or not set, from the server or saved here. Add key and Change open an inline form with one password input per field the page may set; it sends only the fields typed in, then reads the keys again, and Remove asks first. A key the server is started with is read-only and says so. No value is ever shown, pre-filled or left in the page: the inputs are uncontrolled (so no value reaches avalueattribute) and cleared after a save or a cancel. Saved keys that no longer open raise a banner naming why and which to enter again. A server with no seal key (at_rest: "unavailable", or a409 no_sealing_key) says it can't store keys and offers no save. A 422 shows at its field. Settings is in the nav only when the server's capabilities havefeatures.tool_keys.routes.The console sets which provider serves each tool that has a choice, such as web search and web fetch. Settings → Tools is the server's own choice: each tool's providers in the server's order, the default first, what each resolves to on each harness, and a change saved at once. A pick unlike the default is marked, and can be reset. The agent editor's Tools step sets an agent's own provider over the server's, saved with the agent's definition. It says what the provider resolves to for that agent once the change is saved.
The console has a webhook page, Settings → Webhook (
#/settings/webhook), on a server with the webhook routes. It shows the URL run notices are POSTed to, whether its secret signs here, and why it is off when a receiver's 410 turned it off. The signing secret is shown on the save that made it, and never again. A test send says how it went. The last attempts are listed newest first, each with the receiver's answer or why it failed and when it is tried again, and any message of the log can be redelivered under its ownwebhook-id. Settings gains a tab for each of its pages.The console edits a workflow agent. A workflow agent's editor opens on a Workflow step that draws its graph, with provider and model chips, the questions, and image and web badges on each decision node. Its JSON redraws the graph as you type, marks what changed against the current revision, and shows the server's validation (
POST /v2/workflows/validate, or the console's own lint on a server without the route) at each issue's line. A refused save lists every field at its line. The Playground prefills a workflow agent's input with its schema's example (else its fields, empty), and the agents list describes a workflow agent by its shape.The console draws a workflow run live. A workflow run's page has a Graph view that polls
GET /v2/runs/{id}/grapheach second while the run goes: each node's state, the branch a route took, a map as a grid of item cells (named by document, toned by outcome, with the deciding probability), and the run's cost, decisions and elapsed time. Pause and Resume hold and release the run. Clicking a node or an item opens a drawer with the verdict (a probability bar per question, the answer chosen, the rule and the action), Parallel's cited sources, the state the model saw, the page images it was shown, the model, cost and latency, and for a call node its input and output. Written reports read as Markdown, and an ended run's header cost comes from its record. Idempotency keys no longer needcrypto.randomUUID, so a run starts from a plain-http address too. The graph renderer is a TypeScript port of pi-durable-agent'sgraph.js, and it reads the DSL'sworkflowGraphthrough an@dslalias.Decision nodes name their provider, pin their model, ask for evidence and bound their own time
judge,pick,sift, a judgedrouteand anaskpredicate take optionalprovider,model,evidence("off","if_supported"or"required") andtimeoutMs(1 to 120000). A route by value takes none of them.judgeFromProviders({ providers, default, deadlineMs })is the interpreter'srunJudgeover several providers: each request goes to its node's provider, or the default, with its pinned model, within the shorter of the node'stimeoutMsand the provider's deadline. A node only ever shortens it. A call its node's bound cuts isoutcome_unknown: never asked again, and the run escalates asdecision_interrupted.- With
decisionProviders: { providers, default }in its deps, a run checks every decision node before any node runs: a provider the host lacks,"required"evidence from a provider that gives none, a model it does not serve, or a question type it does not answer stops the run naming the node. A provider is looked up by own property only, so a name such as"constructor"is a provider the host lacks, in the check and injudgeFromProviders. - Without
decisionProviders, a run refuses before any node runs a node that names a provider or setstimeoutMs: the host's judge could honour neither.dryRunWorkflowholds a node to no host's providers. DecisionCapabilitiestakes an optionalmodels, the names a node may pin (absent or null: any). A pin outside them is refused before any node runs, andcheckCapabilitiesrefuses it asinvalid_requestbefore a provider sends anything.parallelProvider()declaresparallel-fast, the one mode it asks.- Pi's Jev judge is
judgeFromProvidersover a Jev-only registry, which the run's checks get too: a node naming another provider is refused before the run, with nothing sent to Jev, and a node'stimeoutMsbounds its request. A Jev failure behind it keeps pi's fixed report (jev_httpwith its status,jev_invalid_responsewith its reason):jevProviderkeeps theJevErroras thecauseof theDecisionProviderErrorit throws. - The provider's evidence (
{ url, title, excerpts, scope }) travels with the answer to the<as>$answerssidecar, the decision receipt and the node's answered event;"off"drops it, and"required"with none fails the decision. Receipts name the provider that answered. Asiftgives each item's sidecar the evidence of the request that answered it, and its answered event names the provider and every request's evidence. - A pinned
providerormodelis part of the decision id, so changing it asks the decision again. A node that pins neither keeps the id it had. - A decision whose outcome was lost (
outcome_unknown) is booked with the provider'scost_basisbeside its cost: the exchangerecordDecisiongets carries both. dryRunWorkflowpasses a node whoseevidenceis"required": its synthetic answer carries a placeholder source.
Decision nodes send images, receipts keep what the judge saw, and a map collects its items' failed decisions
judge,pick,sift, a judgedrouteand anaskpredicate take an optionalimages: { "<token>": <reference> }. The token must appear literally in the node'sstateor in a question's text; the reference is written as a state value is ("{item.pages.0.image}") and resolves to an image path the host reads. The schema,inspectWorkflow,workflowGraphand the author contract know the field.judgeFromProvider(provider, { deadlineMs, loadImage })andjudgeFromProviders({ ..., loadImage })read a request's images through the host'sloadImage(ref, { signal })before anything is sent. A provider withoutimagein its inputs, a host with noloadImage, or an image that cannot be read is refused, and no request is made.DecisionRequest.imagescarries{ bytes, mime, ref }by token.- A decision receipt names each image as
{ token, ref, sha256, mime, bytes }, never its bytes, and keeps the state the judge saw (state, past 64 KB of JSON its first 64 KB withstate_truncated: true) and itscost_basis. imageDimensions(bytes)reads a PNG's or JPEG's width and height from its header.jevProvidertakes image input on TypeSafe's own API (as Jev assets, with theTypeSafe-Danger-Zone-Imagesheader) and refuses an image over 1568 px on a side before the paid call, the largest Jev answers.jevProvidersends a request again only on a 429 withRetry-Afteror a connect error before sending (up tomaxAttempts, default 3). A 408, a 5xx or a connection lost after sending staysoutcome_unknown, never sent twice.- Inside a
mapwithonItemFailure: "collect", a decision whose provider fails (rate limit, HTTP error, invalid answer, timeout) fails that item, and one whose outcome was lost, or that the provider refused, ends the itemneeds_review. The other items go on; before, any of these failed the run.
Decision models plug in through one seam, and Parallel Decisions is the second one after Jev
@parcha/agentrun-dslhasDecisionProvider: a fast, non-agentic decision model that answers typed questions and declares what it can (question types, inputs, evidence, where its probabilities come from, web grounding, replay, limits) and the keys it reads by name.judgeFromProvider(provider)is arunJudge, so every decision node runs on it unchanged. A request beyond what the provider declares is refused before it is sent; a choice or score with probabilities but no confidence gets one derived with the formulas TypeSafe documents for Jev, and the decision receipt's newconfidence_sourcenames each one's source. The result also carries the provider's evidence, its request id andcost_basis(reported,estimateorunknown, which goes with a null cost).- A provider may refuse a question:
{ "type": "refusal" }is a valid answer. It never decodes to a value (not false, not an option, not a score). The run endsescalatedfor a person, with kindDECISION_REFUSED("decision_refused") at stage"decision"and the workflow state at the node, so the case is kept instead of lost to a failed run. A route with anunsurebranch takes that branch instead, as it would an answer of no confidence. The receipt lists the refused questions inrefused. @parcha/agentrun-dsl/testinghasregisterDecisionProviderConformance, the suite a provider's tests register with a fake transport: exact answers, a null cost never read as 0, typed errors (auth,invalid_request,rate_limited,invalid_response,aborted, andoutcome_unknownfor a 5xx or a deadline after sending from a provider that cannot replay;httpandtimeoutfor one that dedupes), no retry after sending for a provider that cannot replay, no key in what it returns or throws, declared limits refused before sending, and a refusal kept as one.@parcha/agentrun-jevhasjevProvider(options), Jev as aDecisionProviderthat passes the suite. It sends each decision once (maxAttempts: 1), and a 408, a 5xx or a connection error isoutcome_unknownat an unknown cost.- Behaviour change under recovery (
withRecovery): a decision whose provider throwsoutcome_unknownnow endsinterrupted, notfailed. A failed decision is asked again by the next process; for a request sent whose outcome was lost, that would be a second paid ask. Now the next process escalates it asdecision_interruptedwithout asking. Its estimate (error.costUsd), or an unknown cost that makes the run's spend read incomplete, is booked once, in the same commit as the outcome. - An answer rejected after it was paid for is booked. When
judgeFromProviderrejects a provider's answer (invalid_response, for example a distribution that does not sum to 1), the error now carries the provider's cost and basis, and a provider's owninvalid_responsefor a response it could not read carries its charge too: Parallel at list price, Jev at an unknown cost. The decision endsfailedand is asked again, and its cost is booked, in the receipt and under recovery in the run's spend, with the outcome. An unknown cost counts as an unpriced request, never $0. A request refused before it was sent books nothing. - A provider's
outcome_unknownerror (a request sent whose outcome was lost, which may have been answered and billed) ends the runescalatedas an interrupted decision: kind"decision_interrupted", stage"decision", booked at the cost the error carries. It is never asked again. The suite checks that a provider declaringreplay: "never"never retries a failure after sending. - New package
@parcha/agentrun-decisions, held (not on npm yet), withparallelProvider(): Parallel Decisions (POST /v1/decisions) with probabilities and web citations as call-level evidence. The answered request is booked at list price as an estimate. The API takes no idempotency key, so a paid request is never re-sent unasked: it is sent again only when it provably never reached Parallel, or on a 429 withRetry-After. A 408, a 5xx or a connection lost after sending failsoutcome_unknown, booked at list price, and the run escalates the decision as interrupted (decision_interrupted, stagedecision) for a person. Its key isPARALLEL_API_KEY. Its tests run on a fake fetch over 28 recorded responses; the paid REST path is tested only bytest/parallel.live.test.mjswith a key, and is skipped without one.
A decision row can name the admission its request was sent under (
DecisionRow.admission)- A host that admits each typed-question request before sending it (so a request the run stopped before the answer came back is still counted) records the admission's id on the request's row. The row settles that admission in the row's own commit: a cost fold counts an admission no row names, and never one a row names, so a process stopped between the two counts the request once.
DecisionProviderErrortakesunsent: truefrom a provider that knows its request was never sent, for example a check before the request that a deadline or a cancel cut. Under a node'stimeoutMs,judgeFromProvidersturns a cutabortedortimeoutintooutcome_unknown, because the request may have been sent and billed, and the decision then goes to review. Anunsenterror keeps its own code instead, so the decision fails astimeout(oraborted) and asking it again costs nothing.attemptsalone never says a request was unsent. An error without the field is handled as before.Two mechanical predicates for
loop.until,escalate.whenandpoll.field_type{path, type} holds when the value atpathis of a JSON type (number,string,boolean,array,objectornull); a number only when finite, and an absent value is of no type.not{of} holds whenofdoes not, and takes only a mechanical predicate, so a judgedasknever sits inside a negation. Together they let a guard fail closed on a value that is present but of the wrong type:escalatewith{"predicate": "not", "of": {"predicate": "field_type", "path": "score", "type": "number"}}fires on"abc",null, NaN or an object, wheregteandlton"abc"never fire. Validation names a badtypeor a judgedofby its path (when.of).FIELD_TYPESlists the types; the author contract, the workflow schema and the Lean model carry both predicates.GET /v2/capabilitiesnames each harness's default model:harnesses[].default_model, the model this server runs for that harness when a definition names none (provider/model-id), or null where it runs none (the harness does not run here, or no default model here runs on it; claude-agent never takes a gateway's model). It is the model a save and a run resolve, through one function (harnessModel, whichblockersOfnow calls too), so capabilities and saves cannot disagree. The console reads it: the editor's Model field and the agent page offer each harness the default the server names for it, so a codex agent behind a gateway is offered the gateway's default, and a claude-agent agent is told to choose a model. A server that does not name them keeps the console's earlier rule, pi alone. Capabilities also name the routing prefix this server puts before a gateway's model ids,models.route_prefix(agent-model; null with no gateway), and the console takes exactly that off a model's label, never a first segment read off the id, so a gateway id with no routing prefix keeps its provider. The exportedcapabilities()helper resolves each harness's offer itself and returns a promise of the document the route serves,default_modelincluded, so a direct caller's document passes the published schema. The published document'sCapabilities.models.sourcetakes ongatewayandproviders(markedmodels/2): it held only the contract'smanaged,chatgpt,envandfaux, so every answer of a server without--fauxfailed the schema the server publishes.A decision Jev answers is booked at TypeSafe's published price, as an estimate
jevProviderprices the counted tokens of a TypeSafe call atJEV_PUBLISHED_PRICING($0.042 per million input tokens, output tokens free) unlesspricingsays otherwise, and books it withcost_basis: "estimate". A cost the response reports still wins, bookedreported. With no token counts, on the OpenRouter route, or for a call that may have been billed, the cost staysnull, never 0.- agentrun-serve: a decision workflow run's
cost.usdis its decisions' estimated sum, where it wasnullbefore. - agentrun-serve: a run's usage row keeps a decision's cost to the millionth of a cent, so it agrees with the run's
cost.usd, and a cost above zero but under a hundredth of a cent reads<$0.0001, never$0.0000.
OpenRouter's Decisions models answer decision nodes, and four-place probabilities are accepted
openrouterProvider()in@parcha/agentrun-decisionssends a decision to any OpenRouter Decisions model (POST https://openrouter.ai/api/v1/systemone) named by the node'smodel, such asliquid/d1,perplexity/pplx-decider-v1.2,cloudflare/clefortypesafe/jev-1.14. A node that names no model is refused before sending. The response'susage.costis booked as reported, else the cost is unknown. A request is sent again only when it never reached OpenRouter or on a 429 withRetry-After; one lost after sending isoutcome_unknown. Its key isOPENROUTER_API_KEY.- Images to OpenRouter (experimental) go in the JSON body as base64
assets, only to a model the public catalog lists with image input. Jev takes an image token anywhere in the state or a question, at most 1568 px on a side; every other model only as a whole top-level state value, sent as an item of an array state. A token placed anywhere else is refused before sending. @parcha/agentrun-decisions/testing/faux-system-oneis a fake System One endpoint for a host's tests and rehearsals.parallelProvider({ modes })lists the Parallel modes a node may pin asparallel-<mode>(defaultPARALLEL_MODES,["fast"]); a node that pins none asks the first.validateAnswersaccepts probabilities rounded to two, three or four places: a value may be off by half its grid's step. Cloudflare'sclefreports four places (a distribution summing to 0.9999), which was refused asprobability_mass.jevProvider's refusal of images on its OpenRouter route names the way that works: theopenrouterprovider withtypesafe/jev-1.14.
An agent whose place is
{"mode": "sandbox", "box": "docker"}runs itsread,write,editandbashin a Docker container, on pi, claude-agent and codex alike, while its loop, its log, its model calls and every key stay on the server. The container is made on the run's first such call and labelled with the run's id. It binds the run'swork/alone at/run/agentrun/run/work(a mount source through a link is refused), has no network, getsLANGandHOMEand nothing of the server's environment, and runs as the server's user. It is found again by its label after a crash, the commands the dead process left killed first, and destroyed when the run ends, when it is archived, and by the next start once its run has ended.bashtakes pi's arguments, with a 300 s default timeout and its output's tail.read,writeandedittake a path in the box's work directory only, and their errors name the box's paths, never the server's. This carries the coding-tool source that was proposed on its own (no shell on this machine).bashis refused on a runner place, and so is a sandbox place on a workflow, andbrowserin a sandbox place. What the server files in a run'swork/(monid's long answers, the browser's evidence) is written through held directory handles and never follows a link a box's command planted there. On a host without/proc/self/fd(macOS), where a directory is reached by its path, the server files none of these in a sandbox run'swork/: monid's long answers there say they could not be saved, and the browser's evidence is not filed. A file tool's absolute path under the run's own work directory on this machine names the same file in the box.AGENTRUN_DOCKER_IMAGEnames the image (defaultdebian:bookworm-slim). The console leavesbashunticked on a new agent and offers it only on an agent whose place is a sandbox. In pi-transport,dockerBoxes({ user })runs a box's programs as that uid:gid, andendLeftCommands(box), from@parcha/pi-transport/hands, kills the command groups a gone host left in a box, leaving a tombstone for one not written down yet.An agent whose place is
{"mode": "sandbox", "box": "daytona"}runs itsread,write,editandbashin a Daytona sandbox, on pi, claude-agent and codex alike, as a Docker place does. The server holds Daytona's key (DAYTONA_API_KEY, withAGENTRUN_BOXES=daytona) and gives it to nothing else: it makes the run's sandbox on the run's first such call, with its network blocked (networkBlockAll; a sandbox Daytona does not report blocked is deleted and the call fails), the fixed environment and the run's labels, and the run's process reaches that sandbox only through the server's box relay on loopback, which takes a run's call only with that run's own door token, adds the key to the calls a run's tools make of their own sandbox (a read of it, and its toolbox's programs and files), refuses every other call, and never follows a redirect (nor does pi-transport's Daytona client): a redirect would carry the key. Each run's door now takes a token of its own,runs/<id>/door-token, made at its first launch, and a run's end-of-run notes that hold it are refused like any other secret. A sandbox cannot bind the run'swork/, so the server copieswork/into it at/run/agentrun/run/workwhen it makes it, copies what changed back at each turn's end and once more before the run's end is recorded (with pi-transport's safe extractor: nothing outsidework/, no link, a file the server changed meanwhile kept), and destroys it only once that last copy is whole: a copy that fails is tried again before the end is recorded, and one that stops for good (an archive the server cannot read, a limit it cannot pass) keeps the sandbox, said in the log, its auto-delete turned off and set to stop after 15 idle minutes (its disk kept, its compute stopped), woken and copied back by the next start. A run's sandbox that Daytona stopped, paused or archived (or is stopping, pausing or archiving) is started again, an archived one restored, when the server needs it, never replaced. A read of the run's files waits for a copy its turn's end began, and a start pulls a run it finds waiting. What the server files inwork/meanwhile (monid's long answers) is copied into the sandbox too, and an edit the sandbox makes to such a file comes back. A start pulls, then destroys, the sandbox of a run that ended while its sandbox was left. In pi-transport, a Daytona box takesnetwork: "none"(networkBlockAll, andfacts().networkisnoneonly when Daytona reports it), a workspace the sandbox's user may not create is made withsudo -n, and a paused sandbox counts as not running (a host starts it again).Workflows on AgentRun Server read documents, call one completion, judge images, and run code nodes when allowed
pdf_pages, a workflow call tool:{ path, maxPages?, dpi? }draws a document's first pages as PNGs under the run'swork/pages/<stem>/<dpi>/(long side at most 1568 px, Jev's largest) with its text. A PNG or JPEG passes through, scaled down if larger.pathis confined to--documents DIR(orAGENTRUN_SERVE_DOCUMENTS) and the run'swork/. Needs poppler'spdftoppmandpdftotext.llm, a workflow call tool: one completion on the run's model of the call's tier, no tools and no loop:{ text, model, usage, cost_usd }, the cost null when the model has no price.- Both are call-only: an agent or a model node naming them is refused (
422), andGET /capabilitiesmarks themcall_only. - Decision nodes'
imagesare loaded from the run'swork/. Nodes namingprovider: "openrouter"run on OpenRouter's Decisions models, on the OpenRouter key Jev's OpenRouter route reads (OPENROUTER_API_KEY, else the gateway'sAGENT_MODEL_API_KEYon openrouter.ai), handed on the decision descriptor; capabilities listopenrouteramong the providers (underfaux, startServe'sfauxOpenrouterfakes it). - A call tool that fails on its own fails that item in a collecting
mapinstead of the run. codenodes run only on a server started with--allow-code-nodes(orAGENTRUN_SERVE_CODE_NODES=1): they run in the run's process throughnew Function, which is no sandbox. Off (the default), such a workflow saves with anot_set_up_herewarning and its runs are409 agent_not_runnablewithdetails.code: true. Calls via a shell or the executor stay refused.- Pi's decision rows keep a receipt's images (by digest), the state the judge saw and its cost basis, and a replay returns them.
A workflow run on AgentRun Server is drawn live, pauses and resumes, and a workflow is checked before it is saved
GET /v2/runs/{id}/graph: the run's workflow and graph, each node's state (map items labelled from the run's input, with the branch each route took for them; a route's branch), the decisions with their receipts (state seen, images, evidence, cost basis, error), the calls with their input and output, and totals. While the run goes it reads a live view the run's process writes (live.json), so a poll never waits on the run's store or answershome_busy.POST /v2/runs/{id}/pauseand/resume: what is in flight finishes, and nothing new starts until the resume; a paused run stays paused across a restart. Run status gainspaused(markedpause/1),GET /runs?status=pausedlists them, andGET /statscounts a paused run as active.POST /v2/workflows/validate { workflow, tools? }answers{ ok, issues: [{ path, code, message }], warnings }and writes nothing. A refused save's422lists every issue as a field (definition.workflow/<path>) with itscode.- A workflow run's title is its agent's name and a few words of its input, never its JSON. A run's
llmcalls count in its cost. - pi-durable-agent:
workflowIssues(workflow, tools?)lists every issue a save would refuse;checkWorkflowBody's refusal carries them, each with its message. - dsl: the recovery driver's
pinJson()gives its frame as it stands, for a host's live view.
npx @parcha/agentrunstops its server when the npm that runs it ends: a SIGTERM or SIGKILL to npm alone (a process manager's) used to leave the command and the server running with nobody to stop them. Only a command that npm exec started watches its npm; started any other way (an installedagentrun, in the background of a shell that then exits, under nohup), it keeps running as before. A SIGINT to npm alone still reaches nothing, since npm leaves SIGINT to the terminal's process group: stop it with Ctrl-C in its terminal, a signal to its process group, or a SIGTERM to npm. A SIGHUP to the command alone now stops the server as a SIGTERM does, and the server drains each run once.The console names an agent made since the page loaded, here or elsewhere, wherever its runs show: the runs list and a run's page read the agents' names again when they open, so a new agent no longer shows as its id until a reload. An archived agent's runs are named too; only a run whose agent is gone shows the agent's id, and a name being read shows as nothing yet, never as the id. A read that partly fails keeps the names it got.
A browser step on a run's Timeline is labelled with what its tool does, where it read the tool's wire name ("run", "snapshot"): "Open or act on a page in the browser", "Look at the page structure", "Read the browser's page", and so on, the names the timeline library gives pi-browser's tools and a run's tool counts already show. A browser tool the library names nothing keeps its own name, and every other tool's row is unchanged.
The console's
npm run devproxies/v2to an AgentRun Server on this machine at serve's default port whenAGENTRUN_URLis not set, as it did before;AGENTRUN_URLstill names any other server, andAGENTRUN_TOKENstill goes as a bearer header from the dev server only.The console's agent editor ties each label to its input: Name, Description, Instructions and Model are read by name by a screen reader, and a click on the label puts the cursor in the field.
The console's agent editor fits a 375 px phone: the agent's name keeps the row, with its kind, revision count and Archive in a row under it, and the step rail shows step numbers (each step's name heads the step, and stays for screen readers), and the tool provider table (the Tools step, and Settings → Tools) scrolls in its own box instead of widening the page. An agent with no web tool, such as one that only reads and writes files, is no longer told on every step that it can't reach the web: the Tools step says so, quietly, in the same words.
The console's effort picker says what it sets: how hard the model thinks. The agent page's run bar and the Playground say so beside the choice, and "default" (or empty) still leaves it to the agent.
The console no longer hides two things the server says. A run that ended escalated (
failed, its error's codeescalated) reads Escalated in the runs list, Mission Control and the run's header, never Failed, and its error card says the run escalated. An agent save'swarnings(what this host lacks to run the revision, such as a provider's key) now show after the save: every one is listed above the form, and each also appears beside the field it names. A create's warnings show on the agent's page that opens after it.The console tells a workflow run that escalated from one that was accepted. A run that completed with
review(it waits for a person) reads "Needs review" in its badge, never a green "Completed". The run header and the Runs list say its reason, and its Report says the reason and the node it stopped at in place of "This run gave no answer". Mission Control's recent runs and an agent's runs follow the badge. Where/v2/statscounts such runs apart (needs_review), Mission Control says them under Failed and the analytics' outcomes read "completed · failed · need review", with a run waiting for review counted as no success. An older server's "failed or escalated" reads as before. A run graph's judge says its verdict with that verdict's own probability, as the timeline does ("no 0.99", not "no 0.01").The console's page declares its own icon, an orange square with a run mark, so the browser never asks for /favicon.ico and no page load logs a 404 for it.
The console's agent editor shows the fetch provider row for an agent that grants
fetch_page, web_fetch's older name, which the server runs as web_fetch. Before, such an agent had no row, so its fetch provider could be neither seen nor changed in the editor.Settings → Tool keys: a key that no harness reads (TypeSafe's, which only the decisions provider uses) says what it unlocks and nothing about harnesses, where it read "Unlocks decisions.jev · Harnesses" with nothing after the label. Mission Control's model step, where the server keeps no keys, has its spaces back: "in its environment, AGENT_MODEL=provider/model-id", where it read "environment,AGENT_MODEL=".
The console shows a model without the gateway's internal routing prefix: on a server whose models come through a gateway (
capabilities.models.sourceisgateway), the prefix every tier's model carries is read frommodels.defaultand taken off on the agent page and in the editor, with the full id in a tooltip. The editor's Model field no longer offers a harness a default it may not run: the server's default is offered for pi, and claude-agent and codex say "No default for this harness: choose a model", as the agent page says for one that names none. Until capabilities say which model each harness takes, a codex agent behind a gateway names its model by hand.Mission Control's no-model step points at the key card above it. Where the server keeps keys sealed, the first screen shows a card that saves a model key with no restart, yet the start-here panel under it still said to start the server with
AGENT_MODELand a provider's key in its environment, then reload. Now it says "Add a model key in the card above, and the templates here run in one click." Where the server keeps no keys, has no seal key to keep them with, names a default model inAGENT_MODEL(whose provider's key the card may not offer), runs on a gateway that names no model (AGENT_MODEL_BASE_URLwith noAGENT_MODEL, which a provider's key doesn't fix), or itsAGENT_MODELis the problem (which no key fixes), the panel says what to set where the server starts, as before.The console's agent editor no longer offers or ticks a workflow's call-only tools (
pdf_pages,llm:call_onlyincapabilities.tools) for an agentic agent, so a new agent created with its default tools saves instead of being refused with a 422. The console keeps each tool'scall_onlyandnodesfrom capabilities; no harness runs a call-only tool, so an agentic agent's Tools step leaves them out, new and existing alike, and an existing agentic agent that names one shows it as workflow-only, ticked, so it can be unticked rather than dropped silently. A workflow agent still picks them: its call nodes need them listed in its revision's tools.When no default model is set, the console's no-model bar leads with saving a key in Settings where the server keeps keys (its key routes, with a seal key): "Add a key in Settings → Model keys (one OpenRouter key gives every harness its model), and it takes effect at once. Or set OPENROUTER_API_KEY, ANTHROPIC_API_KEY or OPENAI_API_KEY where AgentRun starts, and restart." Where the server keeps no keys, or has no seal key to keep one (
tool_keys.at_rest: "unavailable"), it reads "Set OPENROUTER_API_KEY (one key gives every harness its model), or ANTHROPIC_API_KEY or OPENAI_API_KEY, and restart." When the server'sAGENT_MODELis set without its provider's key, an OpenRouter key does not run it, so the bar names that model instead: "This server's default model is : set its provider's key where AgentRun starts, and restart.", with no Settings link, as Settings holds only some providers' keys. On a server that sends its models to a gateway (models.source: "gateway") with no model named, it says what the server's own message does: "This server sends its models to a gateway: name the gateway's model in AGENT_MODEL where AgentRun starts, and restart, or add a model key in Settings → Model keys." (the Settings part only where the server can keep a key), with no OpenRouter promise. The bar for anAGENT_MODELthe server does not run is unchanged.The console's agent editor keeps a tool provider change made while a save is under way, a reset included. Before, picking a provider, saving, and resetting it before the save came back lost the reset: the form showed the saved provider again, and another save couldn't undo it.
Console: an agent made here and run straight away in the Playground shows its name in the run panel's header on its first run, not its id. A run's short label (the run page's header, the Playground's run panel, a learned note's run) is now the last 8 characters of its id, so two runs started in the same minute no longer share a label. The file viewer also closes on a click on the dimmed backdrop around the file, as well as on its X and Escape; a click inside it, or a selection dragged out of it, keeps it open.
The console's agent editor no longer tells an agent with
web_fetch(orweb_search) that it can't reach the web: any offetch_page,web_fetchandweb_searchreaches it, one named list (WEB_TOOLS), since the server's capabilities mark no tool as web-reaching. An agent with none of them is told which of them its harness runs on this server, to tick, or that its harness runs none. The tools step shows what each tool does, from each tool'ssummaryin capabilities.The console keeps everything the server says about a refused request:
ClientErrorcarries the error'sdetails, andtoApiError()passes them on. A run start refused with409 agent_not_runnablefor a sandbox agent on a box the server does not have now shows in the playground as "The run did not start: (box: )" above the whole answer,details.boxincluded. The Files tab's comment now says that files list in the server's path order.The console's run timeline marks a failed tool call failed: it read a
tool_statusof"error", which the open platform never writes, so a call that failed (or was interrupted) showed like one that succeeded. A workflow run'sdecisionrows now read Decision with their own icon, and open to their cited sources and refused questions.Settings → Tools never shows a stale result beside a provider changed elsewhere: it reads the capabilities again as it opens, and a row whose provider the capabilities were not read for says "reading…" until they are, then offers Retry if a read failed or the provider moved again. A save is never undone by an older read.
Settings → Tools says when reading the tool settings again fails, beside the table it keeps (the last settings it read), with Try again. Before, a Retry whose settings read failed showed nothing, and the row kept saying the provider had changed.
A decision's deadline now counts the time its images take to load.
judgeFromProviderandjudgeFromProvidersload a node's images within the provider'sdeadlineMs(or the node's shortertimeoutMs), and the provider gets what is left of it. Before, the clock started only once the images had loaded, so aloadImagethat never returned held the decision forever. A load the deadline cuts failstimeout, and a load the run's cancel cuts failsaborted. So does a load that finishes past the deadline before its timer fires, for example while a loader's synchronous work holds the thread: the provider is not called with an expired budget. Nothing was sent in either case, so neither isoutcome_unknown, and in a collecting map atimeoutfails only its item. AloadImagethat ignores its signal is cut too.In a map with
onItemFailure: "collect", a judge that fails inside aparallelfails its item again, not the run. The parallel cancels the sibling judge still in flight, and Jev and OpenRouter report that cancel asDecisionProviderError("aborted"), not as anAbortError. The parallel reported the sibling'sabortedover the item's own failure, so the map stopped. It now reports the first failure, and the map writes that item'sfailedAsrow and finishes the others. A run that is cancelled still stops the map.submitParametersleavescriteriaout of the submit tool's schemacriteriais System One's question keyword: DSL vocabulary, not JSON Schema. A node's output schema could carry it into the submit tool the model sees, on every harness, and a harness that refuses unknown keywords refused the tool (the issue-triage example'sJudgmentschema as an agent output, for one). The envelope now drops it from every schema object it walks.- A property,
$defsordefinitionsentry NAMEDcriteriakeeps its name and its schema. Annotations (title,description,examples) and any other keyword stay as written. - Validation does not change: a runner and the interpreter check a submission against the node's schema as written, and
criteriahas no validation meaning, so no verdict differs.
On a server started with
npx @parcha/agentrun, a harness that is not set up (claude-agent without the Claude Agent SDK, codex without Codex) now says the one command that starts the same AgentRun again with it, on the same data:npx -p @parcha/agentrun@<version> -p @parcha/agentrun-serve@<version> -p @anthropic-ai/claude-agent-sdk@0.3.286 agentrun --data ~/.agentrun(with the store's absolute path). It keeps any other harness package already there, so setting up one never takes the other away. A server started withnpx @parcha/agentrun-servesays the same withagentrun-serve, and adds--dataonly when its data is not in its default place.npx @parcha/agentrunkeeps its data in~/.agentrunand hands it to the server,importandreset-passwordas--data, and its hint names that store as an absolute path, so the pasted line opens the same store from any directory;agentrun-serve's own default,~/.local/share/agentrun/agents, does not change. When the server can't be downloaded, the command says so ("Couldn't download the AgentRun server (@parcha/agentrun-serve: not on npm). Your network or the release is the problem; nothing was started.") and exits 1, instead of saying the server did not start on a port.A decision row (
agentrun.decisions) keeps what its receipt says beside the answers:status,provider,evidence,refusedandconfidence_source, each when present. A run resumed from its rows gets them back, so a replayed decision gives the same provider and sources its first run had.A record's scalar repair reads a field's
$refthrough the root schema's$defsas well as itsdefinitions, at the top and in nested objects, so a string the referenced schema lists (aconst: "null") is kept as the value, never turned into JSON null.parseStringifiedContainers's fourth argument is now the root schema (it was the root'sdefinitions).A node's review that rejects a candidate without a message now rejects it on Pi.
createPiRunner'ssubmitread the review'smessageas the rejection, so a review answering{ accepted: false }with none let the candidate through as the node's result and told the model it was accepted; a null, empty or blank message rejected with no words. Each now rejects, and the model reads that the review rejected it without saying why, as on the kernel's runner.A frozen run now records how many question answers came back with no price, so its recorded spend is read as incomplete rather than as a bare total that silently omits them. Before, an answer whose cost was null (or any non-finite or negative number) added nothing to
questionSpendUsdand left no other trace, so the run's question spend read as a complete number that dropped the unpriced answer. An unknown cost is never zero.- The driver keeps
unpricedQuestionCountbesidequestionSpendUsd, committed in the same save, and a resume that skips a finished unpriced question still reports it. A reader of the run's spend that finds the count non-zero has the known sum and the fact that it is incomplete. - A refused
recordQuestionSpend(an answer for a node that is not a route, or a route already decided) now leaves the snapshot untouched, so a later save never carries a half-recorded answer forward. - The frozen checkpoint gains an optional
unpricedQuestionCount(a non-negative integer). The checkpoint schema is unchanged, and a checkpoint written before this change has no count and opens as before.
- The driver keeps
The repository's
npm run buildbuilds the console (apps/console) before the packages, so a fresh checkout'snpm ci && npm run buildgives a server that serves its console, not the "built without its console" page, and so does a release's build. It installs the console's dependencies when they are missing or its lockfile changed, and skips the build whenapps/console/distwas built from the same sources (a hash of them, written beside the build, never file times). The console's ownnpm run buildrecords that hash, so building it first, as CI and the image do, is not built twice.A script in
scripts/run on a Node withoutimport.meta.main(20, 23, 24.0 to 24.1) now exits 1 with one line naming the script, the Node it ran on and the Node the repository needs. Before, it exited 0 having done nothing, sonode scripts/changes.mjs --checkon Node 20 passed without checking anything. The scripts decide they are the command withisMain(import.meta)fromscripts/is-main.mjs, which isimport.meta.mainwhere Node sets it; where Node does not, it compares the realpaths ofprocess.argv[1]and the script, refuses the command, and leaves an imported module alone.scripts/entry-points.test.mjsfails on any other file that readsimport.meta.mainitself. The examples (copied into consumer and scratch projects), the package test fixtures (kept to their packages) andapps/viewer/build.mjs(deployed on its own) keepimport.meta.main.A run runs where its agent's definition puts it: a run request names no place, and
POST /agents/{id}/runswithplaceis422 input_invalidatplace, as with any field the body does not keep. The served OpenAPI document has noRunCreate.place, and the console's run bar and playground offer no "Run on the server instead".GET /v2/setup/importanswers200 { "offer": false }where it answered404: to the signed-in owner when no import was asked for at setup, and to the setup page of a server that cannot import (a public address, or open setup off loopback). The console reads that answer as no offer, so its first page no longer logs a failed request in the browser; it still reads a404from an older server the same way. An offer, its count and an import's state answer as before, and a browser that is not signed in still gets404once the owner is set.The source archive keeps source named
review: a modulereview.ts, itsreview.test.mjs, and a skill directory namedreviewwere left out, so a package importing such a module did not build from the archive. Only review files (a file named*.review.*, such as the author's<n>.review.json) and the.reviewand.reviewsdirectories are left out; a directory named like a review file is not.A fetch that a tool answered as refused or failed reads as that on the timeline, never as "ok".
web_fetchandfetch_pageanswer a refusal ({ok: false, refused}, the public-host guard's) or a failure ({ok: false, error}) as their result rather than as an error, and the timeline read any returned call as "Fetched …: ok". Now such a call reads "Tried to fetch …", its result row says "refused: …" or "failed: …" withtool_statusfailed, and the text render lists it as failed, outside a fold of fetched pages.fetch_pagegets the same label asweb_fetchin place of "Using tool: fetch_page". A host registers how its own tools' results tell their outcome withregisterOutcome, besideregisterLabel. The console labels atext_blockrow "Text", with its own icon, in place of its kind's name.A run's tool counts read a call's outcome as its timeline does, and analytics name each tool in plain words.
runMetricsOfcounted a tool error only for an error result, so a refused or failed fetch (a result withok: false) counted as a success while its row read failed. One rule,callFailed(tool, result)in the timeline's labels, now decides both. The label registry also keeps a tool's display name (registerLabel(tool, label, fold?, { name }), read withnameOf), and serve'sGET /v2/statssends it aslabelbeside eachtoolsrow'stool(stats/3). The console shows the label, with the name the tool was called by as its title, somonid_discover, the browser'srun,browser_releaseandsubmitread "Search monid's catalog", "Open or act on a page in the browser", "Release the browser session" and "Submit the answer". monid's tools also get timeline labels of their own.The test that damages a run's box ids directory before the supervisor's final pass now damages it when the run's process has exited, through a local place of the test's own, instead of from a file watcher. The watcher wrote the damaged file once
box-2.jsonreadended, but nothing ordered its callback before the supervisor's reads: a supervisor pass could mark that receiptwrittenfirst, so the watcher never wrote the file, or the file could land after the final pass. Either way the run succeeded and the test failed with "Missing expected rejection". The supervisor waits for the place's exit before it reads the directory again, so the damage now always lands after the process's last write and before the final pass. The reference demo's testrun()takes demo options of a test's own as a fifth argument. Test-only: the harness is unchanged.CI builds once per Node version and shards the tests by time. A
build (<node>)job runsnpm run buildand packs its output (every workspace's dist and the console's, about 10 MB) for the jobs that test it; each of those still runsnpm ci, then unpacks the output and refuses one built from another tree or on another Node (scripts/ci-build-output.mjs). The test shards and the pi legs' parts get their files fromscripts/ci-plan.mjs: each suite's test files, weighted byscripts/ci-timings.json(seconds from CI's own logs, refreshed withnode scripts/ci-timings.mjs <job logs>), assigned longest first to the shards of their pool (scripts/ci-shards.json). Every shard job plans every shard and fails unless they cover each test file once, so a new package or test file runs without an edit.checksis split intochecks,packages(verify-packages) andsource(the downloaded source's consumer), which ran one after the other. The pi legs run as a consumer job and seven parts per pi version (pi-leg.mjs --shard), which build what they test themselves and do not wait for the build job. The required checksverify (22.19.0),verify (22.23.2)andleankeep their names and what they gate.CI's advisory real-Docker job now also builds serve's default box image (python 3.12, git and curl, from its recipe) on the runner's Docker, checks its label, and runs
python3,gitandcurlin a box made from it, which has no network. The step's per-file bound goes from 300 s to 900 s, inside the job's 30 minutes, for the build. It stays advisory: a failure shows and blocks nothing.A registry connection that fails during
npm cino longer fails a CI job at once- A registry request that fails before its response arrives (a connection reset, refused or timed out, or a 5xx) is retried 5 times, 20 seconds and then 2 minutes apart, where npm's default gave up after 2 retries in about 70 seconds. This holds for every
npmandnpxin the package checks, including the ones a script runs inside a job, except the stranger and source-consumer checks, which start npm with a clean environment on purpose. - npm never retries a download that drops midway.
node scripts/npm-ci-retry.mjs <flags>runsnpm ci <flags>and retries once on a network error only: when npm's error code is ECONNRESET, ETIMEDOUT, ECONNREFUSED, EAI_AGAIN, ENOTFOUND, EPIPE or ERR_SOCKET_TIMEOUT, or it reports a network error, it prints a::warning::naming the code, waits 15 seconds (NPM_CI_RETRY_WAIT_MS) and runs once more. Any other failure, a lockfile out of step withpackage.jsonor an engine npm refuses, and a second failure exit at once with npm's own code. Everynpm cistep in the package checks runs through it, and so does the console'snpm ciinscripts/build-console.mjs. - What it buys: a short registry outage costs a job up to about 8 minutes, or one extra install, instead of failing it and sending a merge-queue entry back to the end of the queue. The risk: a real registry outage fails a job a few minutes later than it did.
- A registry request that fails before its response arrives (a connection reset, refused or timed out, or a 5xx) is retried 5 times, 20 seconds and then 2 minutes apart, where npm's default gave up after 2 retries in about 70 seconds. This holds for every
The merge queue runs the test shards on Node 22.23.2 only; every pull request and every push to
mainstill runs them on 22.19.0, 22.23.2 and 24.21.0. A queue run has 10 test jobs in place of 30, and a flaky test on the other two Node versions no longer ejects a change that already passed all three on its pull request. The TypeScript 5.4.5 floor check keeps running in the queue, on 22.23.2. The required checksverify (22.19.0)andverify (22.23.2)keep their names and run on every event. The risk: a change that breaks only on Node 22.19 or 24 after its pull request ran (a newer base) is caught onmain's push run, not in the queue.A CI test that hangs fails by name, and a suite that never exits is killed, both before the test job's 30-minute limit
scripts/ci-shard.mjsadds--test-timeout(10 minutes) to each test script of the shapenode --test [--test-concurrency=<n>] <files>, and to the examples shards. On Node 24 a test still running then fails, named in the log; Node 22 applies the flag to each test file instead, and names the file. A test's owntimeoutholds on Node 24.- The scripts that run without it are pinned in
ci-shard.mjsby their text: pi-durable-disk's (its own--test-timeout), pi-transport's vitest, pi-browser's and pi-vision's own runners, andtest:viewer. A test script of any other shape fails its shard, naming the script, before anything runs. - A shard's suites get 24 minutes in all. The suite still running then is killed with every process it started, the suites after it are not run, and an
::error::line names each. The job ends failed with its log, where a stalled shard used to be cancelled at 30 minutes, its log sometimes lost. CI_SHARD_TEST_TIMEOUT_MSandCI_SHARD_TIMEOUT_MSset the two limits, in milliseconds.
The Codex pin check (
verifyCodexBinary) importssha256OfFileandcheckedOncefrom@parcha/agentrun-kernel/host, the code the Claude adapter's check already uses, in place of a line-for-line copy inside the codex package. The copy and the test that held the two copies equal are gone. The check behaves as before. The image check's test now lays the kernel next to the codex adapter in its scratch tree, as npm does, since the adapter depends on it; without it, the test's Codex cases fail on the missing package.A demo test file that leaves a child, a server or a held pipe open fails by name in seconds, instead of hanging its CI shard
node --testwaits for each file's process to exit before it reports the next file. One reference demo file whose tests all passed but whose process stayed alive held examples-2 until the 30-minute limit, with no name in the log.- The shared demo-test helpers now check each file that is still alive 10 seconds after its hooks start, after the file's own
after()hooks have run. What is still open is named, closed, and the file fails: a child (by pid and argv) with the processes it started, a server (by address), or a pipe that a child's own child still holds.
dsl's test of a poll wait with a fraction of a millisecond now fakes the clock and the timers the recovery driver waits on: after 200 ms of a 200.5 ms wait the next check has not started, and after 201 ms it has. Before, it measured the gap between two checks on
performance.now()and wanted at least 199 ms, but Node can fire a timer a millisecond or two early on a loaded machine, so the test failed although the wait was right. pi-durable-disk's scripted/procandpstables now use pids from 9,004,200 up, past any pid Linux or macOS hands out. Before, they used pids such as 4300 and 4301, and when the test process itself had one of them, the reaper rightly refused to signal what it took for its own process or group, and the tests that expected those kills failed.agentrun-dsl drops eight tests that repeat a shared Lean conformance case, now that the
leancheck is required to merge: each case holds the Lean model and the TypeScript interpreter to the same verdict, state, error and events. Fromlean-findings.test.mjs: F1a (requires-after-code), F1b (requires-empty-submission), F2a (parallel-code-collision), F2b (parallel-label-collision), F3 (code-writes-dollar-key,code-writes-host), F7a (parallel-host-scalar-then-array,parallel-host-array-then-scalar) and F9 (predicate-array-index); F5 keeps only its typed-output half, sincechild-undefined-outputholds the permissive one. Frompredicate-not-field-type.test.mjs: the single "abc" escalation (escalate-not-field-type-fires). Tests that check an error's wording, its position, or behaviour no case covers stay.The ten-kill test covers every class of crossing with planted kills, not the seeds' draws
- A seeded kill lands at the nth crossing a process takes, and the order of those crossings shifts with timing. The seeds therefore did not always reach every class: a CI run missed
judge. killAnywherenow takesplantedkills, each planned by point name on either side. A new test plants one kill at a point of each class, in the run's order, and asserts that each one lands there and that every class is covered.- The seeded runs keep every other check, and the draws are extras.
- A seeded kill lands at the nth crossing a process takes, and the order of those crossings shifts with timing. The seeds therefore did not always reach every class: a CI run missed
The
leanjob takes about 2 minutes, down from about 16. It no longer runs the DSL, pi and example test suites a second time to collect the workflows for the validator sweep: on Node 22.23.2 thecoreandexamples-<n>test shards record them while their tests run and keep them as an artifact, andleanwaits for the test shards, reads their captures (node spec/lean/sweep/collect.mjs --captures <dir>), builds only the DSL and runs the proofs, the conformance cases and the sweep. That saves about 13 runner-minutes on every pull request, merge-queue andmainrun. The capture hook now runs in the test's own thread, which keeps the capturing shards at their usual speed (the off-thread hook made a DSL test file about a third slower), and each capture is written to a temporary file and renamed into place, so two test files recording the same workflow at once no longer leave a torn file that failed the sweep. The risks:leannow reports after the slowest test shard instead of on its own, and it fails when a capturing shard failed or its captures are missing, since the corpus would be incomplete.leanis not a required check, andverifydoes not need it.The
--opendemo test waits for the stub opener's whole line, not just its file: the stub's>>creates the file before itsprintfwrites, so a read in between got an empty string and failed the test on a loaded CI runner. Its wait is also 15 s instead of 5 s.Two pi-durable-disk local-run tests that need a process the reap cannot kill (one refused open, one boxed open after an unboxed one) now use kthreadd on Linux. They borrowed the lowest-pid root process, which can end while the test runs. When it did, the reap found nothing left to wait for, the open went through, and the test failed with
Missing expected rejection. kthreadd lives as long as the kernel and no unprivileged user can signal it. On macOS, or in a container where pid 2 is not a kernel thread, the tests still borrow the lowest-pid root process. Test-only: the reap and the open are unchanged.A step's next attempt on a fenced durable-disk lease is refused at the recovery driver's flush
- A workflow run on a durable disk, wired as a host wires it (pi's workflow extension over
openDurableRun), whose lease is fenced while a step's first attempt is out. The recovery driver may admit the next attempt in that process, since a fence is not final; the flush before it commits through the run's Harness over the lease's observed storage, so it is refused with the fence and the node runner is not called again. Two ways in: a failed heartbeat write, and a lease that lapsed while the process was stuck (the watchdog thread's lapse, which the lease finds at its next check or at that commit).
- A workflow run on a durable disk, wired as a host wires it (pi's workflow extension over
Each pi leg runs as five jobs, so a merge queue run takes about 8 minutes less
verify (<node>)waits on every pi leg, and the leg that ran the agentrun-serve, pi-transport and pi-git-remote suites one after another took about 21 minutes: the longest job in the queue.- Each leg now runs as
pi-1-2 (<pi>, consumer): the packed packages installed in an empty directory, and the reference example;first: agentrun-serve, and the mid-stream kill and resume;transport: pi-transport and pi-git-remote;browser: pi-browser, pi-durable-disk and pi-durable-agent;rest: every other pi workspace, and any new one. Each suite still runs once on each pi version. - Estimated from the last queue runs: the longest leg drops to about 13 minutes, and a queue run from about 22 to about 14 minutes. The cost is six more jobs per run on standard runners, about 27 more runner-minutes, since each repeats the install and the build, and six more installs from the npm registry.
- Risk: the same suites run on the same runners with the same installs; only the grouping changes. The required checks,
verify (22.19.0)andverify (22.23.2), keep their names.
Each pi leg runs as parallel jobs, and verify now fails unless every pi leg passed
- A pi leg ran every suite one after another, and took 25 to 30 minutes against its 30-minute limit: one 1.3.0 leg was cancelled while its suites were still passing.
scripts/pi-leg.mjsgains--tests <names>and--tests-except <names>. CI splits each leg intopi-1-2 (<pi>, <suites>)jobs with them. Each suite runs once, a new pi workspace runs inrest, and every job that runs suites installs and checks the whole family.verify (<node>), the required check, now needs the pi legs too. A leg that failed, was cancelled or was skipped fails it, so the merge queue waits on the pi legs. Every other side job stays advisory.
pi-transport's launch tests now wait for a file's text, not only for the file, when the program writes that file through a shell redirect. The shell creates the file empty before the command writes it, so a test that polled until the file existed could read it empty. It then failed (
expected '' to be '<hash>'), or checked an empty environment and passed on nothing. The waits for the hash of the program's stdin and secrets, its environment, its uid and its pid, and for the watchdog's pid, now wait until the file has text. The frame tests wait for the last file the second incarnation writes before they read the others. Test-only: the launcher and the box code are unchanged.scripts/port-durable-harness.mjsregenerates the code copied from vercel-labs/durable-harness into@parcha/agentrun-kernelfrom four inputs: the pristine upstream files at the pin, named patches (vendor/durable-harness/port/patches/<upstream package>/NNN-<slug>.patch, each with aReason:, anUpstream:ofofferorours, and the tests it isPins:-ed by), one import table (port/imports.json) and generated headers. A module or symbol the table lacks, or maps to a gap, fails the port by name; a patch applies with exact context or fails by name.--check(run bynpm run test:release) fails on any byte of the committedsrc/processthat the port does not make, and on a patch that changes nothing;--drop <patch>regenerates without one patch, for mutants;--pin <sha> --upstream <clone>reports an upgrade as the pristine diff, the table against the new text and the patches that fail. The copies' Changed-by line is now generated from the patch list, and the shared process layer's files are otherwise unchanged. The Codex harness files (harness.ts,app-server.ts,rollout.ts,config.ts,binary.ts,test/fake-app-server.mjs) regenerate the same way from six named patches and the one import table, whose entries name the package they apply to. The Claude Agent adapter's copied files (src/{binary,env,harness,ids,inbox,store,transcript,versions}.tsand the two scripted fakes undersrc/testing/) regenerate the same way from fourteen named patches; a relative import that lands in a file AgentRun wrote in place of upstream's is named in the one import table.serve's box tests plant the four keys a tester runs everything with (OpenRouter's, Monid's, Parallel's and Browserbase's), as well as an unnamed key and OpenAI's, each as its own sentinel value, and search the box's environment and
/procfor every value. A key handed to a harness under another name, such as the OpenRouter key passed on as a model key, now fails the tests if it reaches a box. The faux-box, fake-Docker and real-Docker tests all change. Test-only: the server is unchanged.serve's harness-run tests wait for a run as long as they allow themselves, and still report where it stopped
- Seven of the file's 60 s tests followed their run with
follow()'s default 30 s deadline. Under load, a run that needed more than 30 s failed "the run did not reach completed" with half the test's budget unused. - Every
follow()in the file now waits 55 s, under its test's budget, so a run that never finishes fails with follow's own "last " line rather than the test's timeout. - The three tests that follow a run twice get 120 s, and the one that follows three runs in turn gets 180 s.
- Seven of the file's 60 s tests followed their run with
serve's run-box tests wait for a destroyed box's container file to be gone as well as for its
rmcall. The fakedockerwrites a call's arguments before it acts on it, so under load a test could see thermand then read the container still there, and the create-failure test's polling of the containers could read a file anrmin flight was removing. Test-only: the fake and the server are unchanged.The serve test for setup's count of a big home now checks that the count answers, then stops the server, instead of racing the server's exit against a 3.5 s bound while the count runs. The old test failed on loaded CI runners with correct code (an exit 3.5 s after SIGTERM), because the time a correct count takes to stop and the time a count that reads the sessions for keys takes overlapped. Its result also depended on when SIGTERM landed: a stop before the count's listing ended it before any scan. The home now holds one subagent file of about 20 MB under 5,000 hard-linked names. A count that lists the files answers in about a second. A count that reads them would read about 100 GB and does not answer within the test's 30 s, so the test fails on every run. The test also no longer holds its process open for the full bound after it passes. Test-only: the server is unchanged.
npm testruns the workspaces' tests several at once, andnpm run test:changedruns only what a change reachesnpm testruns up to 6 workspaces at once (a quarter of the cores), the slowest first, where it ran all 16 one after another. On a 48-core machine a full run goes from 17 minutes to about 4.5, the length of the slowest workspace (@parcha/agentrun-serve). Each workspace's output is printed whole when it ends, under a line with its result and time, and the run ends with a table of every workspace's result and time and exits nonzero when one failed.AGENTRUN_TEST_JOBS=<n>sets how many run at once;AGENTRUN_TEST_JOBS=1runs them one by one, in the old order, with their output live.- The script owns the machine's budget: at most one test file per core runs at once across all the workspaces (
AGENTRUN_TEST_TOTAL=<n>sets another total, for a shared machine). Each workspace running gets its share, as--test-concurrencyfor anode --testscript (one that sets a lower value keeps it), and asTEST_CONCURRENCYandVITEST_MAX_WORKERSotherwise. The default number of workspaces at once is a quarter of the total. - Ctrl-C stops every workspace's processes. A process a workspace's tests leave running when they end is killed, and the workspace's result line says so.
npm run test:changedruns the workspaces with a file changed since the merge base withorigin/main(committed, staged, unstaged or new), plus every workspace that depends on one of them. A changed file outside the workspaces, such aspackage-lock.json,tsconfig.base.jsonor a script underscripts/, runs them all; Markdown files,changes/anddocs/run none. It prints each workspace it picked and why.npm run test:changed -- --base <ref>compares with another base.
A test that reads a pid from a file can no longer signal its own process group by reading the file mid-write
- A test that waited for a pid file to exist and then read it could read it in the moment between the file being made and the pid landing in it. The empty read was taken as pid 0, and
process.kill(0, "SIGKILL")killed the test's whole process group: npm, the test runner and every test file still running. The shard then ended with no summary and no failing test named. - serve's crash-mid-request cap tests and ci-shard's cancel test now wait until the file holds a whole pid, and their fixtures write the pid to a file beside it and rename it into place. pi-browser's ffmpeg-timeout test reads its stub's pid file the same way in its cleanup.
- Before any signal, each of these tests refuses a pid that is not an integer above 1, or that is the test's own: the test fails with a message naming the pid, and nothing is signalled.
- A test that waited for a pid file to exist and then read it could read it in the moment between the file being made and the pid landing in it. The empty read was taken as pid 0, and
A GitHub workflow with a key twice in one map (two
env:under a job, say), or one that doesn't parse, now fails the release tests, naming the file, line and column. YAML loaders differ on a duplicate key: GitHub's keeps one of the two without a word.node scripts/check-workflows.mjs [directory]runs the same check by hand.docs/AUTOQA.md's "Subsystems" section gains the tool plane's seam, how to run it alone with the model and every third party faked, and the checks a QA pass runs when the tool plane changes.examples/decision-demosholds five decision workflows that run on AgentRun Server with no agent loop: one document's validity from its page images (Jev), a batch of 20 documents through a map with a written report, a KYB check that crosses Parallel's web-grounded registry answer with Jev's reading of the documents, a merchant's category and prohibited-goods check from its web page, and one question to a jury of Jev and three OpenRouter Decisions models. The documents are for invented companies, labelled for scoring.run.mjsstarts the server from the checkout (with--allow-code-nodes), makes the five agents and runs a use case on fakes of every provider, or live with--live;validate.mjsandrehearse.mjscheck and rehearse them offline.docs/AUTOQA.mdis the repository's QA baseline: how to build and start AgentRun Server for a QA pass, how to authenticate, the core catalog of checks (health, auth, agents, runs per harness and effort, spend cap, cancel, follow-up, crash and restart durability, web_fetch, workflows, stats, archive, tool keys, session import and the console), what each one proves, the model and host caveats, and the open findings to re-check each pass.docs/AUTOQA.md's kernel subsection is filled in: its seam (the run routes and fields, the journal's request marks, the run directory, the adapter interface it calls, and what it takes from the tool plane and places), how to run it alone with no key on the faux model and the faux harness, and the six checks a QA pass runs when the kernel changed.docs/AUTOQA.md: the kernel's multi-tool check runs alone (node --test test/harness-multi-tool.test.mjsfrompackages/serve) now that #1363 is on main, the kernel's journal row namesREQUEST_CONTINUED, and the known open findings mark three items fixed, each with the check that re-checks it: the claude-agent and codex multi-tool hang (#1363, kernel check 6), the console's New agent refused with 422 for call-only tools (#1357, console check 2), and tool keys listing unbound tools as unlocked (#1356, tool plane check 1).docs/AUTOQA.md's Places subsection is filled in: the seam between boxes, the agent's home and files and the kernel (pi-transport'sBoxandBoxProviderwithmounts, the safe extractor and sync layer, the checkpoint vault, box-made stores, bwrap, and serve's run files, start sweep, agent place and box registry), a recipe that runs it alone on fake boxes and the faux model, and the six checks a QA pass runs when it changed.docs/AUTOQA.mdgains a Subsystems section: one subsection per subsystem (kernel, tool plane, places, console with import and analytics, learning loop), each naming its seam, a recipe that runs it alone with the others faked, and the checks a QA pass runs when it changed. The console, import and analytics subsection is filled in; the others are left to the teams that own them.Install and
npxcommands in the READMEs and docs name@latestinstead of@beta. Releases publish under npm'slatesttag, andbetawas not moved with them: it still pointed at 0.1.0-beta.21 whilelatestwas 0.1.0-beta.28, and@parcha/pi-durable-agent,@parcha/pi-git-remoteand@parcha/pi-agent-learnhad nobetatag at all, so their install lines failed. The harness demo's hint for a missing@parcha/pi-transportnames@latesttoo.@parcha/agentrun-harnessis not on npm yet (itslatestis a placeholder), so its README says it is held and installs it from a packed tarball. The README's Render option gives measured memory: a Starter's 512 MB runs about 3 pi agents at once, and Claude Code or Codex runs want a 2 GB instance. The stranger check reads the install line as@latest, installs@latestwith--registry, and walks the keyless examples against the registry'slatest. The release guide drops the step that movedbetaafter a release;betais kept only for older links, with the one-time command to bring it up to a release.The README and the setup skill (
docs/SKILL.md) lead with one model key:OPENROUTER_API_KEY, set where AgentRun starts, gives pi, Claude Code and Codex agents each a current model, with noAGENT_MODEL, and the console's Settings → Tool keys → Model keys is where to add it, with no restart. The Docker and Render instructions dropAGENT_MODELfor the same reason; a provider's own key (ANTHROPIC_API_KEYorOPENAI_API_KEY) still works in its place.The README says what runs today: an agent runs on pi by default, or on Claude (the Claude Agent SDK) or Codex, and
GET /v2/capabilitiessays which the server runs. Comments and fixture notes no longer use internal planning names or ticket numbers.The README's Render option gives the runs each instance admits now that a run is admitted by the memory its harness takes: a Starter's 512 MB runs 3 pi agents or 2 Codex runs at once, and a 2 GB instance 10 Codex runs. It says the image leaves Claude Code out for now (the Dockerfile removes the Claude Agent SDK and CLI), so Claude Code agents run with
npx @parcha/agentrun. The old figures (650 MB a Claude Code run, 400 MB a Codex run) predate the chunked check of a harness binary.
0.1.0-beta.28 (2026-10-28)
- The chat view draws a run's answer that is a list of flat records, and a pipe table in the agent's words, as tables
- An answer field that is a list of flat records (every value a scalar or a list of scalars) is a table: columns in the order the records first name them, a list of scalars as a comma list, a missing field as an empty cell. Its cells are plain text, never markdown. Any other answer keeps its JSON.
- A markdown pipe table in the agent's words (thinking, said text, Muse bubbles) is a table whose cells keep the page's inline markdown. The first line that is not a row ends the table and stays words, so nothing the agent wrote is dropped. A table wider than its box scrolls inside the box; the page never scrolls sideways.
submitParameters(schema)is exported: the input schema of a node runner'ssubmittool, the node's output schema undervaluewith its local$refs moved with it. The Pi runner uses it, and so does any other runner, so a node's schema behaves the same on every harness.validateWorkflow(workflow, { tools }): an opt-in list of the host's tool names. Given, a node that names a tool outside it is refused at validation withNODE_FIELD_INVALID, at that node's path, naming the tool: anagent,decide,extract,reportorartifactnode'stools, acallnode'stoolwithvia: "tool", and the same inside a child workflow. Absent, validation is unchanged, and an unknown tool is still refused when its node runs. The options' type is exported asValidateWorkflowOptions.- When Pi starts with an
--toolsallowlist that leaves outagentrun, the session now shows a warning with thepi --toolsline that enables the AgentRun tool. Before, Pi disabled the tool without a word. A workflow whose agent node needs a tool that is not active is now refused with the names of those tools,Not registered: grep, find., instead of a refusal that names no tool. The README and the guide give Pi's stock default tools (read,bash,edit,write), the working--tools read,grep,find,ls,agentrunline, and--approveforpi install ... -lin a project that is not trusted yet. - A Pi node's turn limit can be seen and raised. A first-run tester's read-only research node failed three times at the fixed 8 turns, with no way to raise it and no word of how far it got.
- An
agentnode may carrymax_turns, a positive integer: the most turns it asks its host for, per attempt. The engine checks its shape and hands it torunNodeasmaxTurns; it enforces nothing, and naming it changes the workflow's digest. - In the Pi extension,
runtimeLimits.nodeMaxTurnskeeps its meaning (the most turns any node gets; a host lowers it or setsnull) and is the cap on a node'smax_turns; its default goes from 8 to 40. A node that names nomax_turnsgets 20 turns when it can only read (nobash,editorwrite, and no host tools), and 8 otherwise. Inspection refuses a node that asks for more than the cap, naming it.describereports the two defaults asnodeTurnDefaults. - A node that reaches its turn limit is not tried again:
PiRunErrorfor a turn limit isfinal, so the recovery driver gives it no second attempt. Before, each failure ran a second fresh session with the same prompt and limit; the tester's three runs spent 2 sessions of 8 turns each, and the second never helped. pi_turn_limitsays how many turns the node used of its limit and its last tool call, and how to raisemax_turns.PiRunErrorcarriesmaxTurnsandlastTool(the tool's name, never its arguments)./agentrun limitsshows the limits in force, and the turn limit of each node that runs in a Pi session (agent, decide, extract, report) in the current workflow and where it comes from.
- An
- A script run through a symlinked path now runs as the command
apps/viewer/build.mjsand the other repository scripts, examples and test fixtures that run as commands decided they were the command by comparingprocess.argv[1]with their own URL. Node resolves a module's URL through symlinks and leavesargv[1]as given, so through a symlinked directory (macOS's temp directory is one) each one exited 0 having done nothing: a site build on a Mac built no viewer. They now checkimport.meta.main, which Node sets from 22.18 on 22 and from 24.2.scripts/entry-points.test.mjsfails on any script that compares the two again.- Node 23 and Node 24.0 to 24.1 have no
import.meta.main, so there these scripts would exit 0 having done nothing.scripts/check-node.mjs, whichnpm run buildand the demos run first, now refuses them: the repository needs Node 22.19 or newer on 22, or 24.2 or newer.
- A string that a field's own
enumorconstlists exactly is kept as a value. Before, the null-spelling repair (nullSpellings,healNullSpellings,repairRecordand the DSL'snormalizeStringNullsForSchema) turned "None", "null" or "N/A" into JSON null whenever the field also admitted null, even when the schema named that string as one of the field's values. The same holds in anyanyOforoneOfbranch at any depth and through a local$ref. The scalar repair (parseStringifiedContainers) also leaves a listed string alone: aconst: "null"stays the string. A spelling the field does not list exactly ("none" where the enum says "None") is still repaired to null. - The issue-triage example's
--max-usdis checked before every lead request, not only on a timer- The cap was watched by a 250 ms timer alone, so a lead that answers quickly could start and finish many paid requests between two ticks, and a busy machine stretched the gap. The spend is now also read, as the spool stands, before each lead request (the lead's own and a research node's, including a retry), and no request is made once the known spend has reached the cap.
- The timer stays as the backstop that ends a request already in flight. The overshoot is at most that one request, which the stop message already says.
- The example's test bills its stub lead once per call instead of on a clock, and checks that the run ends within one call's cost of the cap.
- The dependency inventory leaves out a third-party package's optional peer that nothing requires at runtime
- The lockfile records an optional peer when the repository installs it for its own development (a test runner an auth library names), and the inventory followed it and everything under it as if consumers shipped it, which put a dev-only bundler's MPL-2.0 CSS engine under release review. A third-party package's optional peer now ships only where a runtime dependency requires it. A workspace package's own optional peers (the host it runs in, an adapter's SDK) are still followed.
- On this lock no package leaves the inventory:
undiciandzoddrop the packages that reached them only throughopenai's and@anthropic-ai/sdk's optional peers from their "Used by" lists.
- The
/agentrunextension takes Jev's provider from the environment, as the Jev adapter'sjevProviderFromEnvdoes: with onlyOPENROUTER_API_KEYset, a workflow's Jev decisions (including/agentrun demo live) go to Jev through OpenRouter instead of being refused for a missingTYPESAFE_API_KEY. With both keys set, TypeSafe is used as before. The configuration refusal names both keys, and/agentrun statussays whether Jev goes via TypeSafe or OpenRouter. - The outer model can no longer pass off its own work as an AgentRun run. A first-run tester twice saw it do the research itself and answer "Complete" and "Supported, High confidence" with no workflow run and no Jev call.
- Every
agentrunrun result carries areceipt, the run id that/agentrun historylists (the durable tool'sreceiptis its run key). The results ofinspect,dry-runandloadsayno workflow ran. - In a session where the author skill loaded (through its command, or because the model read its SKILL.md itself) or the
agentruntool was called, the extension adds its own entry under each final reply, labeled as a host notice and not written by the model: the receipt with the run's model steps, Jev decisions and direct tool calls (for a resumed run, this attempt only; a scripted run says no model or Jev call was made), orno workflow ran in this turn. The model's reply is left as it wrote it. The workflow's name, which the model writes, is printed only when it is a plain name (letters, digits and a few marks, up to 80 characters), and inside quotes asworkflow "name"; any other prints asa workflow, here and in/agentrun history. A reopened session starts without the notice until the skill loads or the tool is called again. - The host addendum tells the model to quote the receipt or say no workflow ran, and never to report its own reading or searching as a workflow result.
- The author skill now forbids passing research done while authoring in as the workflow's input, calling a result verified or supported without a judge call in the run, giving a judge file names instead of the material it judges, and adding a code node only to format output (code forces
--trusted).
- Every
- A
callnode's poll that runs out of time fails one way:call node "<label>" poll exceeded its <n>s deadline after <k> check(s), atimeout, with oneeffect.pollevent settled"deadline"for the last check started.<k>counts the checks started, including a last check that the deadline stops before its effect begins. Before, a wait that woke past the poll deadline (a slow timer, or a retry's backoff) and a check the poll deadline cut failed with the same text but no count, and sent no"deadline"event; which one a run got depended on how late its timers fired.- Each check sends exactly one
effect.pollevent. A check that does not settle now sendssettled: nullafter its interval wait, just before the next check starts, instead of before the wait; itselapsed_msis unchanged (the time when the check ended). A wait that ends at or past the poll deadline sendssettled: "deadline"for that check instead. A cancel during the wait still reports the finished check asnull. - A check the poll deadline cuts is counted, and its
effect.failedmessage carries the same counted text. - A poll run under a recovery driver does not change, including the text without a count when the poll deadline cuts a check.
- Each check sends exactly one
- This repository's install resolves undici 8.12.2 under the disk SDK and pi-coding-agent
- Both pin undici 8.10.2 exactly. A root override for
undici@8.10.2moves those copies to 8.12.2. pi-browser keeps its own undici 6, which a blanketundicioverride would also have moved. CI now checks that no undici in the install is below 8.12.2. - Overrides apply only in the project that runs the install, so a project that installs these packages gets 8.10.2 under them until those releases move their pins. To install 8.12.2 there, add the same override to your own
package.json:"overrides": { "undici@8.10.2": "8.12.2" }.
- Both pin undici 8.10.2 exactly. A root override for
- The release check's registry consumer runs
npm installwith--loglevel=error, so a caller that silenced npm (npm run -s, which setsnpm_config_loglevel=silentfor its children) still sees why the install refused. Before, the refusal still failed the check, but with an empty npm error. - The merge queue runs the transport test shard on GitHub's runners, as it does the viewer shard. Blacksmith's kernels have no Yama ptrace_scope, and pi-transport's launcher refuses a launch with stdin or secrets on such a host, so the shard's launch tests failed there while the code was right.
- The hero example's cost check allows the fold's 1e-8 rounding
- The fold rounds a run's cost to 1e-8, so a priced run's total can land a hair under the raw sum of the eyes' charges (0.009632 for 0.009632000000000002). Beat 3 compared them exactly and failed on such runs; it now allows the 1e-9 the look cost's check already allows, and a test plants those charges.
- pi-durable-disk's live-prepare test no longer fails when another test changes the package directory
- The Daytona live helper packed the package by having tar walk its directory. Other tests write there while the suite runs (package.test.ts creates
.tmp/), and tar fails a directory whose entries change while it reads them ("file changed as we read it"). The helper now packs a list instead: the files git tracks or would track, plus the builtdist/. That is the same set of files as before, and a test packs it while another process churns an entry in the directory.
- The Daytona live helper packed the package by having tar walk its directory. Other tests write there while the suite runs (package.test.ts creates
- The package check accepts five more undici advisories on the copies the disk SDK and pi-coding-agent pin, until 2026-11-06
- The disk SDK (1.10.0) and @earendil-works/pi-coding-agent (1.4.0) still pin undici 8.10.2 exactly. Five advisories published by 2026-10-27 are fixed only in 8.12.2: GHSA-4w7w-5xrc-9h26, GHSA-2m5v-pch6-74hx and GHSA-qrxm-fqhp-jwvm (high), and GHSA-9fm3-97px-rjf6 and GHSA-55wc-3pg8-jfw6 (moderate). The existing undici exception now names them. It keeps the same two parents and the same 2026-11-06 expiry, and any other advisory, any copy elsewhere, or a date past the expiry still fails the check.
- The release script test where the registry consumer cannot meet its pins now says what npm did when it fails another way: npm's own error (code, signal, message and stderr), or that the install succeeded, and the pi-codemode version the consumer resolved.
- The checkout's docs name the Node range its scripts accept
- The contributor guides, the tutorials, the demos' setup prompts and the examples run from a checkout now say Node 22.19 or newer on 22, or 24.2 or newer. That is the range
scripts/check-node.mjsaccepts: Node 23 and 24.0 to 24.1 have noimport.meta.main. The published packages still need Node 22.19 or newer.
- The contributor guides, the tutorials, the demos' setup prompts and the examples run from a checkout now say Node 22.19 or newer on 22, or 24.2 or newer. That is the range
- The README credits the projects AgentRun builds on, each with its license and a link: Pi, Stagehand and the Browserbase SDK, the TypeSafe SDK, Archil's disk SDK, pi-vision's document readers, PDF.js, and the smaller libraries.
- The README says agents run on pi today, and that bringing your own harness comes next, starting with Codex.
- The README's plan for bringing your own agent now says Codex first, with more harnesses later.
0.1.0-beta.27 (2026-10-25)
- New subpath
@parcha/agentrun-dsl/author-references: the author skill's five documents (SKILL.md,language.md,workflow-format.md,jev-decisions.md,sdk-authoring.md) as static strings inAUTHOR_REFERENCES. It imports nothing, so a browser or a Worker can carry the references without reading files or loading Node's builtins. rederiveDecisionreturnsnullfor a route receipt with no branch answer and for a pick receipt with no pick answer, as its documentation says it does for a receipt that decided nothing. A route receipt without its branch answer and with anunsurerule made it throw aTypeError; without anunsurerule it returned a decision with an undefined branch. A pick receipt without its answer returned{ index: -1, none: false }. A receipt the interpreter records always carries its answer, so only a damaged or hand-built receipt changes.rederiveDecisionreturnsnullfor averifyreceipt whose rule has a field with a threshold but no yes/no answer for it, as it already does for a receipt that decided nothing. Before, a missing answer read asNaN, which is not below the threshold, so the field was skipped and the receipt re-derived as{ accepted: true }— accepting where the live run, which scores a missing answer as 0, would have doubted or unmet it. A field whose threshold isnull(held empty and not judged) needs no answer and is unchanged. A receipt the interpreter recorded carries every answer, so only a damaged or hand-built receipt changes.- The
/agentruncommand shows an error's own text only when the extension wrote that text for the operator: its own messages,WorkflowStoreErrorandExtensionServiceError. Any other error shows the generic message. Before, it showed any error whose text began like one of the extension's messages (Workflow,Code,SOP, ...), so a host callback's own error starting that way reached the operator as written. - The web example's server reads the agent home again whenever the store's head commit moves. It keyed its cache on the store file's mtime and size, which a commit can leave as they were (the size does not move, and a write inside one tick of the filesystem's clock keeps the mtime), so a run started right after the server's last read could answer 404 until the next write.
verify (<node>)runs its suites as ten shards at once (scripts/ci-shard.mjs) and passes when every shard passed; the required check names are unchanged. Pushes run CI onmainonly (a pull request's branch runs onpull_request), and a new push to a pull request cancels its running checks.- The release workflow builds AgentRun Server's container image from the reviewed tag once publication passes, and pushes
ghcr.io/parcha-ai/agentrun-server:<tag>and:betafor linux/amd64 and linux/arm64, with an SBOM and max provenance stored with the image. - The release workflow no longer runs
npm run checkon the tagged commit: it requires theverify (<node>)gates ofci.yml's push run onmainfor that commit to have passed (scripts/release-ci-gate.mjs) and stops on anything else, then builds and runs the checks that make the published archives (generated files, author contract, package and source verification, preflight). It publishes in dependency layers (releaseLayersinscripts/release-preflight.mjs): each layer is published, then every package of it is verified against the registry at once, and a failure stops the release before the next layer.
0.1.0-beta.26 (2026-10-24)
- Use npm 11.21.0 for CI, source and release installs. Install vision CI's native dependencies from Ubuntu's HTTPS archive. Pin the workspace and Docker runtime installs to Zod 4.6.6, and document the application-root override for browser hosts. The reference kill ledger accepts a scan-only result with no browser session while rejecting duplicate creates, unreleased sessions and page operations without a session.
- Validation refuses parallel branches that write the same label. An unaliased agent, decide or extract node writes its result under its label, but the parallel disjointness check read only
as, so two such branches with one label passed validation and then failed every run at the merge withparallel_write_conflict. They are now refused withPARALLEL_WRITE_CONFLICTbefore anything runs.declaredWrites, which hosts can call, now includes that label. A code node's patch keys are still known only when it runs, and the merge still refuses a collision there. - An inspection that
WorkflowExtensionServicerefuses now reaches the caller as anExtensionServiceErrorwith the service'scode(admission,invalid_workflow, ...), its message prefixedCannot inspect workflow:as before, and the refusal as itscause. Before, inspection rewrapped it as a plainErrorwith the message only, so a host could branch on the code of a refused run but not of a refused inspection. Any other inspection failure keeps its original error as itscause. - The
agentruntool's description no longer tells the model that a run always restarts with no checkpoint resume. It says what the extension does: a run the session left interrupted or failed resumes from its journal when its draft, input, SOP and tools are unchanged, and any other run starts new. - The package gate accepts the undici copies that the
diskSDK and@earendil-works/pi-coding-agentpin at 8.10.2 for GHSA-xjj6-f33q-929c (high) and two moderate advisories on the same versions, until 2026-11-06. Neither upstream has a release with the fixed undici 8.12.2 yet. An audit exception can now name more than one parent. - examples/web:
AGENT_BROWSERislocal,browserbaseorkernel, with no default. A run of an agent with a browser tool and no setting is refused withset AGENT_BROWSER (or --browser): local (no key; a Chrome on this machine), browserbase (BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID) or kernel (KERNEL_API_KEY); Browserbase is no longer picked just because its key is set.localis the run's own Chrome (CHROME_PATH),browserbaseneedsBROWSERBASE_API_KEY(the project id stays optional), andkerneldrives pi-browser's Kernel provider (KERNEL_API_KEY, optionalKERNEL_BASE_URL) and keeps no sign-in between runs.chromeis accepted aslocalfor one more release, with a notice. The README's first run setsAGENT_BROWSER=local, and a table lists the env and the network each value needs. examples/web/agents/company-research: an agent, Desk, whose research prompt depends on the kind of organisation. A base prompt and an overlay for each kind (a public company, a non-profit, a small retailer, an online store) and a generic one are kept asagent.promptsin the agent's own home, each overlay with the rule that loads it (company_kindis its option at 0.8 or more, from a Jev choice question).edit-prompts.mjschecksprompts.json, then has the home check the set as it would save it (its own size limits included), and saves it into a home withsetPrompts, or prints what the home holds.config-page.mjswrites one static, script-free page of the base, the overlays with their rules, the generic, and a table of runs with Jev's choice and probabilities (both passes when the host looked the company up), the lookup, the prompt loaded, the outcome and a link to the brief. Eight companies, four clear and four ambiguous, are incompanies.json. The tests run offline.- The web example's server picks each run's prompt before the run starts, when its home keeps prompt variants (
--agent <dir>makes a new home from that agent'sagent.jsonand skills):choosePromptof @parcha/pi-durable-agent asks Jev the home's question about the person's first message, through the model gateway's TypeSafe pass-through (/typesafe) or TypeSafe itself onTYPESAFE_API_KEY, and when Jev is unsure looks the name up once (a Browserbase web search withBROWSERBASE_API_KEY, and Wikipedia's keyless search and summary, both at once) and asks again, else the generic prompt.startRunrecords the choice, andprompt.jsonbeside the run holds each pass, what the lookup read, the reason and the guide: the home's base prompt and the chosen variant as they were then, which the run's instructions get on every launch, however the home's variants change after. With a Browserbase key the agent can listweb_fetchandweb_search, through Browserbase's own Fetch and Search APIs, which open no browser session; an agent that lists no browser tool gets no browser, and start-run.mjs starts no Chrome for it. - A new
@parcha/pi-durable-agent/workflowsubpath runs an agent's workflow revision:checkWorkflowBodychecks a revision's body before a store commits it,runWorkflowRevisionruns its workflow with agentrun-pi's durable workflow task on the run's own store and returns the outcome and thecarryBackinput ({ state, summary }),readWorkflowRunreads the run back, read-only, andrevisionGraphdraws a revision's graph and its diff from a workflow parent. The package now depends on@parcha/agentrun-dsland@parcha/agentrun-pi; only these functions load them, so an agent with no workflow revision never does. agentrun-pi's readersreadJournalanddurableDoctake a new option,{ immutable: true }, which opens a store no process writes any more (an ended run's, with no write-ahead log beside it) as an immutable file, so the read makes no file beside it. - The web example records a run: with
RECORD_DIRset, the agent app writes an agentrun.recording/1 directory with the harness recorder from inside the run (every document's lines, none dropped, pi's live events and the frames the frame store keeps), and start-run.mjs finishes it once the run has exited, with the agent'sreport/index.htmlasmanifest.artifact. The Browserbase key and project id, the browser profile's id, live views and any URL parameter that carries a credential (its name with its value:token,key,apiKey, a signature) are[redacted], email addresses a page printed are[email], a session's id is a label of its hash, a document'sliveViewis null, and the machine is scrubbed; the run and the finish both scan for every id. A replay (writeReplay) takes the same browser ids, credential parameters, email addresses and live views out of its rows, text files and records, by the one set of rules both use (agent/redact.ts). The agent answers in the chat; the site-report skill writes report files (report/index.html, one script-free page) only when the person asks for a report, and the host writesreport/remember.html, the recording's artifact entry (what the agent keeps: its signed-in sites, its memory files and its runs, never a ref). The agent keeps its memory as files inwork/memory/(a memory skill: an index, the person's preferences, the accounts it works on); the host seeds the folder into each run and takes it back into the home from the run that owns the sign-in (a plain folder that does not travel in a pack yet), and GET /agent returns the files. With a local Chrome, each run's downloads land in its ownwork/downloads/. The harness entry exportsredactorOf,runScrub,scanSecretValues,scanPlacedand theSecrettype, so an example that records a run of its own uses the harness's redaction and scans.AGENT_MODEL_API=anthropic-messagesruns an Anthropic model over the Messages API at the gateway's root, with pi-ai's own entry, so its thinking is recorded. - The chat view puts its dark palette only over an agent's page that declares it supports dark
- An agent's page whose own color-scheme names
dark(ameta name="color-scheme", the root element's style, or a:rootorhtmlrule in its style elements, read with the browser's CSS parser) is drawn in the site's dark palette, as before; the report skill's pages declarelight dark, so they look as they did. - Any other page is drawn as its author wrote it: no forced background, colour, color-scheme or palette variables. A page that sets a light background and leaves its text at the default (a table header
th{background:#eee}) was white on light grey; it now keeps its own contrast. Square corners and the site's type still go over every page.
- An agent's page whose own color-scheme names
- Source-comment corrections in the DSL, no behaviour change: the
effectFilesJSDoc now nameswriteHandoffFiles(the stalewriteFrozenEvidenceno longer exists), theexecuteCode: falsenote sits onvalidateWorkflowwhere it belongs (not onRETRY_ON_NAMES), an orphanedsameValuecomment is dropped, andapplyHostOutputTypesstates why it does not descend into child workflows. The compiled output differs only in these comments.
0.1.0-beta.23 (2026-10-22)
agentrun.decisionsis now a keyed document family: one document per typed-question receipt, written once, instead of one session document whosereceiptslist was rewritten at every commit.- A recording of a run grew with the square of its questions (a 200-item scripted run: 17 MB of decisions in a 22 MB recording); it now grows with the questions (the same run: 0.4 MB of decisions in a 4.9 MB recording; 400 items: 9.8 MB, down from 77 MB).
- Breaking for a host that wraps a commit-only session:
createDecisionWriter's session (DecisionSession.harness) andappendDecision's first argument now requirecommitandsnapshot(aHarnesshas both), because the form is the store's and a writer that could not read it would have to guess. A host that passes a realHarnesschanges nothing; one that wraps{ commit }alone addssnapshot(a TypeScript error today, a refused first row in JavaScript). - The row is the same, byte for byte: each keyed document's value is exactly one receipt as before, so
readDecisions,appendDecision,createDecisionWriteranddecisionRecoverykeep their behaviour: the same receipts in the same order, and a resumed run answers a question from its receipt without asking again. - One form per store: a store that already holds the single document keeps writing it to the end of its run, with no migration, so no recording holds both. A fresh store writes the keyed form, with a small counting document,
agentrun.decisions.count, beside it. - Readers read both forms in the commit before this one, and the harness pins
@parcha/agentrun-piexactly, so the two release together: a harness never meets a writer whose rows its cost fold cannot read (a cap that counted from the single document only would let a run spend past it).
Under the recovery driver, a value an effect adapter returns is the effect's result, whatever its fields. The driver used to read four returned fields as "outcome unknown" (
outcome,effect_status,details.outcomeordetails.effect_statusequal to"unknown") and stop the run. A tool whose ordinary result had such a field, an inconclusive classifier for example, stopped every run under recovery.An adapter that cannot know whether the outside call happened now throws a
RecoveryErrorwith codeFROZEN_EFFECT_UNKNOWN, with what it observed as itscause. The driver keeps that cause in the checkpoint'sunknownResponsesfor the operator. The effect stays unknown and is never dispatched again until the host reconciles it. AnEffectOutcomeUnknownErrorworks the same way, as any thrown call does.Who breaks: a host whose
runEffectreturned one of those fields to mean "unknown". It now throws instead:throw new RecoveryError(message, "FROZEN_EFFECT_UNKNOWN", { cause: observed })pi does this at its own boundary: a call that
reuseReceiptsrefused (details.effect_status"unknown") is turned into that throw byeffectResultOf, new in@parcha/agentrun-pi/durable. The extension's workflow calls use it.A durable task runs the host's own
runEffect, so a durable host that wraps tools withreuseReceiptscallseffectResultOfon each wrapped tool's result in thatrunEffect. Without it, a refusal is no longer read as unknown and becomes the call's result.The recording validator now refuses a live host line that carries an event-level
gen("a supervisor line (kind) has no event-level gen", as the viewer says); it accepted one before. A hand-built recording, or one made by a recorder older than this change (a tool box'sbox.receiptwritten by a process), is refused until the line'sgenis removed and the process is named indata.gen.The durable node runner's configuration document,
agentrun.node, carries the node's input.value.inputis the node's prompt state: the JSON object its user text was stringified from. A reader can show what a node was given without parsing its prompt.- It is optional. It is absent when a node's user text is not a JSON object, and from a document an older build wrote. A run an older build started resumes on that document as it is, and the document is not written again.
NodeDoc's value type gainsinput?. Nothing else in the document changes. The attempt's binding digest and the node index do not read the document.- Size: on one recorded run of 20 nodes it added about 44 KB, 0.14% of the recording. Each document row grew by 0.5 to 2.2 KB.
The chat view marks a planned move between hosts. Where the recording holds a host
handoffline (a run released to its disk on one host and opened on another), the conversation's timeline holds one card there, between the last line of the generation that ended and the first of the next: "after the 5th charge on host-1 (daytona)" over "Moved to host-2 (daytona) from the same disk; the conversation goes on", or, where the holder's recorded label is the one it left, "Reopened on host-1 (local) from the same disk; the conversation goes on". It is read from fields only: the handoff'spointandn(aneffect.kept <tool>point reads "after the th ", any other point drops that clause), itsfrom, and the holder of the generations'run.jsonlines (a host named only by its recordedhost-Nlabel, a driver only by a kind: local, daytona or modal). The handoff's prose, a process id and a boot id are never read, so none can appear; ahandoff.failedline draws no card, and a run with no handoff line draws none. A move that happened while a folded conversation was the active one is drawn after that fold, in the run's own timeline. The process lane stays behind?debug=durability, and the Muse layout is unchanged.The cost fold (
cost, which--max-usdand a demo's spend cap read) and the recording validator readagentrun.decisionsin both of its forms: the single document (areceiptslist) and the keyed documents (one row each,keyits place). A keyed row counts once, however often a snapshot lists it, and a row with no cost makes the cost unknown, never 0.durableDecisions(file)(@parcha/agentrun-pi/durable) reads a store file's decision rows in commit order in either form;durableDoc(file, "agentrun.decisions").receiptsis nothing in the keyed form.- A recording that holds both forms is refused by the validator: one form per recording.
- It is the first commit of the change that makes the keyed form, released with it (the harness pins
@parcha/agentrun-piexactly).
@parcha/agentrun-dsl/graphexportsgraphDiff(prev, next): what changed between two graphs of a workflow, such as two saved revisions of one, as{ added, removed, changed, moved, edges: { added, removed } }. A node is the same in both when its(kind, label)is unique in each graph, so a step inserted before others reads as one added node and the steps after it moved, never as a run of changes; a node whose(kind, label)repeats, or is in one graph only, is matched by its path, and the same path with a new kind or label is changed. A matched node whose kind and label stayed but whose own fields changed (new instructions, a new budget) is rewritten, so a revision that only rewrites steps no longer reads as no change. For that, every nodeworkflowGraphreturns carries achangeCheck: 8 hex digits, the FNV-1a hash of the node's own fields as canonical JSON, its children left out. It detects a change; it is not an identity. Added nodes and edges carry their paths innext, removed ones their paths inprev. The module still imports nothing.nodeStatustakes the run's terminal result,{ pin, items, result }, and marks where an escalation stopped the run: the escalate node that fired and every container on its path areescalated. The driver's frame holds no mark for a fired escalate node, so from the frame alone that node readnot-runand its containersworkingafter the run had ended. A map or loop on that path has ended, so what finished inside it is done. The escalation's node is read fromresult.escalation.executionPath, asrunWorkflowreturns it.NodeStatewidens withescalated: a reader that switches on every state adds the case. Without a result, the status is byte for byte what it was.A browser run's replay can show what its browser showed:
chat.html?r=<id>&muse&visualadds a visual report under the answer in the Muse layout. The recording's own browser video leads it (muted, looped, its long holds skipped, scrubbable), with a chapter strip from the pages the run read that seeks it; the run's screenshots follow as a strip of screenshots under each page's title (a blank frame, and a picture shown twice, are no tile); the PDFs an artifact holds are first-page thumbnails; a run that skipped its sign-in because it was remembered says so in a tile; and each fact of the answer is a crop of the frame where it was read, boxed by a look that recorded the box (manifest.facts, anagentrun.facts/1blob: a frame, a box in the frame's own pixels or none, the look's label and receipt), never by the viewer. At a wide window the report breaks out of the chat column, the video large and the fact cards three across; a phone stays one column. A session that had a credential on its page (frame.gap, reasoncredential_on_page) has every frame from its first to the gap's end blurred as pixels, never enlarged, in the strip of screenshots, the crops and the chat's browser card, and the video's playback skips that span; a run with no such gap blurs nothing. Everything is read from the recording's own lines and blobs. Without&visualthe Muse layout and the default view are as they were.@parcha/pi-agent-learnis published from this release, under thebetatag, with the other packages: its manifest no longer says"private": true. It depends on@parcha/agentrun-dsl,@parcha/agentrun-piand@parcha/pi-git-remoteat this release's exact version, and on pi-durable and chord 1.1 to 1.3.x as peers; it needs git 2.40 or later. Its README's "Quickstart (no key, no network)" runs from the packed tarballs in an empty directory with an empty environment in thestrangerCI job (scripts/stranger/pi-agent-learn.json), and fails when its output differs from the README's by a byte. Its README andpackages/pi-agent-learn/CHANGELOG.mdhave the details.recordCommits,snapshotLinesandrecordingLines(@parcha/agentrun-pi/durable) write a durablepi.conversationline for each conversation record (recording draft 54):datais{ id, parent?, owner? }as pi records them (parent { conversationId, at }for a fork,owner { conversationId, taskId }for the creator), integer ids and nothing else, placed before its entries in its commit and in the snapshot at open. A bare store read dates a conversation at the first commit that wrote an entry or a document of it, or at the last commit when it has neither. A consumer that counted every line without adocas a task must now skipkind === "pi.conversation".@parcha/pi-git-remoteis published from this release, under thebetatag, with the other packages: its manifest no longer says"private": true. It depends on none of the other packages and needs git 2.40 or later;@earendil-works/pi-coding-agentstays an optional peer at*, for the./coding-agentextension only. Its README's "Quickstart (no key, no network)" runs from the packed tarball in an empty directory with an empty environment in thestrangerCI job (scripts/stranger/pi-git-remote.json), and fails when its output differs from the README's by a byte. Its README andpackages/pi-git-remote/CHANGELOG.mdhave the details.A recording keeps each conversation's record, and the validator checks it (draft 54)
apps/viewer/RECORDING.mdadds thepi.conversationline, one per conversation record pi writes:{ id, parent?, owner? }, withparent { conversationId, at }(the conversation it was forked from and the entry it inherits through) andowner { conversationId, taskId }(the task that created it), and no content. A panel places a forked conversation under its parent atparent.atand ties it to the call that made it throughowner.taskId. In a conversation with an owner, api.userentry is its creator's message, never "You", unless a person's submission names it. A copiedreference.labelis never read as a link.scripts/recording/validate.mjsrefuses api.conversationthat holds anything beyondid,parentandowner, or beyond their two fields each, or whoseparent.conversationId,parent.at(an entry of that conversation) orowner.taskId(a task record ofowner.conversationId, of any kind, found by pi's task record shape) is not in the recording.
The chat view draws a conversation a call made under that call, and never draws its maker's words as "You"
- A conversation whose record (
pi.conversation, draft 54) has aparentis drawn in its parent conversation at the entry it was forked at (parent.at): under the call's card when its owner's task names a tool call, else right after that entry's item, as a fold. A fork is named by its ownreference.label, else by its parent's label and its place among the parent's conversations (threads · 1). It is no longer a top-level "conversation N" row, and a call that made conversations is never folded into the commands line. - In a conversation that has an owner, a user entry is its maker's message, drawn with the tool's name (
fork_threads), never as "You". The one exception is an input a person submitted: a recording that keeps submissions (manifest.recorded.submissions, draft 45) names it with api.submissionwhose request is not the node runner's. - The header names the agent by its own label, never a fork's, even a fork that has no message yet.
- A jump from the timeline or a citation to anything inside a fork opens every fold around it: the fork's and each conversation's it sits in.
- A fold inside an open fold keeps its own arrow and preview: the open rule applies to its own summary only.
- A conversation whose record (
authorWorkflowno longer evaluates a candidate's code during pre-acceptance validation. Before, when a host allowed executable candidates (allowExecutableCandidates: true), the structural validation of each candidate compiled and ran itscodenodes against probe states in the host process, so an author-written candidate's code ran before the host's acceptance hook decided on it. Validation now checks a candidate's code for syntax only; the code runs only through a later run or dry run, after the host accepts the candidate. Hosts that leave executable candidates off (the default) were never affected. The documented note stands: acodenode runs in the host process and is not a sandbox, so hosts isolate untrusted code themselves.A recovery driver waits out an effect's
deadline_sand a poll'sinterval_swhatever their size. Before, a deadline with a fraction of a millisecond (deadline_s: 1.0005) threwERR_OUT_OF_RANGEonce the effect was admitted, so the effect was never dispatched and was left unknown. A deadline past Node's timer limit of 2^31-1 ms, about 24.8 days (deadline_s: 3000000), fired at once with aTimeoutOverflowWarning, aborting the effect as it started and leaving it unknown. A poll wait past that limit checked again with no wait at all. The effect's deadline still aborts with theTimeoutErrora host reads as a deadline.Validation refuses a declared write to any state key that holds a
$, not only one that starts with it. A typed question's sidecars,<as>$answersand<as>$verify, were documented as engine-owned, yet a node could writegate$answersbyas, by an unaliased label, or by a collecting map'sfailedAs, and overwrite the answers a code gate reads. The label message now readslabel must not contain "$", and the author contract says keys that hold a$are engine-owned. Reading a sidecar is unchanged. A code node's patch may still write a$key at run time, as the Lean model documents (finding F3).inspectWorkflowand the shape diagnostics name what a judged route lacks. The workflow schema has two route variants, a judged route (instructions,state) and a route by value (valuePath), and the diagnostics kept only the last schema per node kind. So a judged route missing itsstatewas told it must have avaluePath. Every variant of a kind is now kept, and a node is diagnosed against the variant that accepts it, or else the one it is closest to. What the full validator accepts is unchanged.A recovery driver whose pause or cancel commit the store refuses now says so:
flush()throws the refused commit, andclose()throws it after releasing the journal. Before, both resolved, the journal still readrunning, and the next open resumed a run the host had been told was cancelled. The run reports the stop (FROZEN_CANCELLEDorFROZEN_PAUSED), or the refusal itself when the refusal reaches one of the run's own commit waits first; a host that awaitsclose()and sees it throw must not take the run as stopped, and can open it and stop it again. A run's own failed commit is unchanged: the run andflush()throw it, andclose()does not throw it again. A durable pi run whose journal refuses a cancel taken when its next process starts hands its waiters that refusal, where they got a poisoned-Session error before; the task stays pending, and a later process takes the cancel.Errors from
createJevRunnernow say what happened, and its checks match what was sent. A host that branches oncodeorrequestDiagnostic.reasongets the right branch.- A deadline that the SDK's own per-attempt timer reaches first is
timeout, and is not retried, as when the runner's own deadline ends a call. When the SDK's timer ended the last attempt, it wasconnection: in 300 runs of a request that never answered, with one attempt, 8 to 18 came backconnection. With attempts left, the runner's deadline ended the retry wait, so the call wastimeoutand no second request went out. A timed-out request may have been billed, and the code is what tells a host so. WithtimeoutMs: nullthere is no deadline: a host client's own timeout stays a connection failure and is retried. - A
nullor missingstateis refused before sending, asinvalid_requestwithattempts0 andrequestDiagnostic.reasoninvalid_state. Before, it passed the runner's own check and the SDK refused it after the attempt began:invalid_requestwithattempts1 and no diagnostic. A custom client that took anullstate is refused it too. client: nullfrom a JavaScript caller is no client. Before, it lifted the rule that the SDK path needs a finitetimeoutMs, and let an image or a Box question go to OpenRouter.- A blank per-call
modelis refused withrequestDiagnostic.reasonunsupported_model, the reason a model refusal already had. It wasinvalid_questions, which named the question map. - Answers are checked against the questions as they were sent. A criterion whose value is
undefinedis not carried by JSON, so Jev is never asked about it; it is no longer required in the answer, and a right answer to the questions sent is no longer refused asinvalid_response. The DSL's interpreter still checks a workflow's answers against its own questions. - An
apiKeythat is empty or only spaces is a configuration error when the runner is created, on the TypeSafe path as it already was on OpenRouter. Before, it was sent as an empty bearer token and every call failed ashttp401. @parcha/agentrun-pi's message for an invalid Jev state no longer listsnullas accepted.
- A deadline that the SDK's own per-attempt timer reaches first is
createWorkflowToolfrom@parcha/agentrun-pi/durablenow checks its host'stoolsas the coding-agent extension checkshostTools, and throws when it is created with a list that fails: more than 100 tools, a name that is blank, longer than 200 characters, repeated, or reserved (the workflow tool's own name,agentrunby default, and the node runner'ssubmit), orparametersthat are not an object schema. Before, such a list was taken as given. Both hosts use one check.A fork of a workflow node's conversation no longer hangs. The node's configuration document (
agentrun.node) was forked with the conversation, so the fork's first request waited for the node runner, which never reaches a fork. A fork now starts without it, as it already did without the node's record: it is no node, it answers on its own, and asubmitin it is refused as in any conversation that is no workflow node. A host that reset the document in the fork by hand can drop that step.A limit given as
undefinednow keeps its default, as an omitted one does, increateAgentRunExtension'sruntimeLimitsand inWorkflowExtensionService'slimits. Before, a field present with the valueundefined(as an options object built from optional configuration has) was refused as an invalid limit. An explicitnullstill lifts a limit, and any other invalid value is still refused.A recording opened from a dropped folder (or the folder picker) now reads each blob as the gallery publishes it: an extension outside the recording format's allowlist (draft 50), such as an
.svg, a page or a script, is text, and itsblob:URL istext/plain. Before, a dropped<sha>.svgkept the browser's ownimage/svg+xml, so itsblob:URL, which has the viewer's origin, would run a script it carried if it was opened in a tab. Every dropped blob's URL now carries the viewer's type, never the file's own, and a type that would run in a tab is opaque bytes, as before. A dropped artifact page (.html) is read as text, as a files build already reads it.The pi-transport test that checks a checkpoint counts its held time from when the session came to rest, not from the pull's ask, no longer fails on a loaded runner: it compares the held time with the measured total (held is at most the total minus the 400 ms drain) instead of an absolute 300 ms bound.
The release workflow publishes each package with npm's
latestdist-tag, so npm's package pages show the README. npm takes a package page's README from the publish that carrieslatest; publishing with--tag betaand movinglatestlater withnpm dist-tag addleft every@parchapage without one. A release now moveslatestwhen it publishes, and a maintainer movesbetato the same version afterwards.The
strangerCI job runs the "Quickstart (no key, no network)" of@parcha/pi-git-remoteand of@parcha/pi-agent-learnfrom their packed tarballs in an empty directory, with an empty environment and home (scripts/stranger/pi-git-remote.json,scripts/stranger/pi-agent-learn.json). pi-agent-learn's spec packs the packages it depends on too (dsl,jev,piandpi-git-remote) and installs pi-durable, chord and pi-ai beside it. Each spec requires the output its README shows under the quickstart, anchored at both ends, and the learn spec reads<sha>as the 12 hex digits of the seeded commit. A test inscripts/stranger.test.mjsholds each pattern to its README block, so the two cannot drift.The README no longer says every node shows on a graph in a viewer, which the public viewer does not draw today. It says that
@parcha/agentrun-dsl/graphturns a workflow into its graph. The issue-triage example's README and author prompt drop the same claim. Pi's/agentrungraph is unchanged and still documented.The README has a "Durable runs" section (
openRecovery,fileStore,withRecovery: a run opened again with the same key answers committed steps from the journal and runs the rest), with a sample that dies and resumes with no key, and an "A judge on any model" sample whoserunJudgeis a plain function. It no longer says that hosts own recovery: the host chooses the store and the package ships the driver.The README pins every peer install line to
@1.3.0(typebox to1.3.7) and says that Pi 1.4 is not supported yet: an unpinned install now resolves Pi 1.4.0, and an unpinned pi-durable or chord fails withERESOLVEagainst the peer range. It no longer says a run "is not checkpoint resume" next to the paragraph that says an interrupted run continues, and it has a new section on running a workflow as a durable pi-durable task (createWorkflowTool,createWorkflowExtension): what a resume answers from the journal and what it refuses.The recovery models state the host's cancel.
spec/receipts/Receipts.tlaadds a stop whose commit may fail and the host letting go of the driver withclose, with three properties: a stop the host let go of is on the journal or the host heard that it is not (I8_StopOnJournalOrHeard), nothing is admitted or dispatched after a stop (I9_NoAdmissionAfterStop), and a cancelled run never resumes (I10_CancelledNeverResumes), each with a guard mutant TLC must catch.spec/lean/AgentRunRecovery/Frozen.leanproves the same for every number of steps and crashes (stop_admits_nothing,stop_dispatches_nothing,cancelled_never_resumes), with a witness that a refused stop commit is reached and heard.
0.1.0-beta.21 (2026-10-21)
A gallery lock entry (
apps/viewer/recordings.lock.json) may now name the words its list shows for a run (title: a trimmed string of 1 to 120 characters, no control character) and the view its link opens (view: a non-empty list of distinct flags the viewer knows, todaymuse, the phone-chat layout). The recordings are not touched: the lock is read by the build, which carries each named id'stitleandviewinto its gallery entry (gallery.js, orrecordings/index.jsonin a files build), the list showstitlein place of the recording's own words and linkschat.html?r=<id>with each flag (&muse), and an entry without them is listed and linked exactly as before.readLockrefuses a wrong type, an empty or untrimmed or over-long title, an unknown or repeated flag, an empty list, and any other key; the gate refuses a built index whose titles or views differ from the lock's. The three utility-account replays in the lock open in the Muse layout under their own titles. The inspection header and the default chat header still use the recording's own title.Validation with input keys now refuses a state read that a run would refuse, inside a route's branches and past route and parallel nodes. Before, the state check stood down there: a
{url}that nothing produced passedvalidateWorkflow(and so inspection) and failed the run withinterpolation path "url" is not present in the state, while the same read at the top level was refused. The check now holds what each point of the run holds:- inside a route branch, the state before the route plus the route's
as(and<as>$answersfor a route Jev chooses); - after a route, only the keys every branch writes, since one of them runs;
- after a parallel, what each branch writes, since all of them run.
It still stands down after a code node, a map or a loop, as before.
A workflow that read, after a route, a key only some branches write is now refused at validation, where before it failed at run time through a branch that does not write it. Write the key on every branch, or read it inside the branch that writes it.
- inside a route branch, the state before the route plus the route's
The gallery's list now follows the lock's order.
build()lists the recordings in the order of the lock's ids when it is given a lock (the ids the lock does not name come after, by name), the gate checks that the built index lists them in that order, and the lock's ids are in the order the gallery should show: the three earlier recordings as they stand today, then the three utility-account replays as a story told in parts. A lock entry'stitlealso refuses the C1 control characters (U+0080 to U+009F), as it already refused the C0 ones and DEL. The recordings and the asset are untouched; the tar, written with its members sorted by path and read by id, does not depend on the lock's order.examples/issue-triage: the host counts the agreement whatever shape the agent's workflow returns- The session counted the type agreement only when the workflow's result was an object with
rows; a workflow that returned the list itself got no agreement and a result blob with numbered keys. The rows are now the result itself when it is a list, else itsrows, and the recording's result holds{ rows, agreement }either way. - The summary line reads the spend before the recording is finished (the spool is removed then), so
spentUsdis the run's spend and not 0.
- The session counted the type agreement only when the workflow's result was an object with
The DSL package's build and type-check reject an unused local, import or parameter (
noUnusedLocals,noUnusedParameters), and the two unused imports they found in the recovery handoff block are gone. The emitted JavaScript and declarations are byte for byte what they were.The README now shows a
runWorkflowcall a reader can run with no key (a scriptedrunNodeanswers each model step), a CLIrunline that works, how to import@parcha/agentrun-dsl/schema(as JSON), and a link to the host integration guide.The README now starts with the offline sample, which needs no key, then the live call. It says that a missing key fails at
createJevRunner, before any request, that the TypeScript samples need a TypeScript runner, and it lists the whole public API. It also describes theRetry-Afterwait and the 429 give-up, therequestDiagnosticon a refused request, and thatcost_usdis the reportedusage.costfirst.The README now says that
pi --offlineneeds an interactive terminal, that its two startup warnings (fd not found,No models available) are expected, and what the trust prompt offers. It marks the preview, input, run, save and load steps as needing a model, links the example that lives in the repository, adds/agentrun triage, labels the host samples as sketches, and documents theagentrun-picommand line from npm with an inputs example.
0.1.0-beta.20 (2026-10-20)
- The gallery takes a recording that names no example under an id of its own, and shows three utility-account browser replays
- A recording whose manifest names an example (its
example, else ataskthat is an example folder's name) is still that example's: its id is the folder's name, one recording per example, so a re-take replaces it. A recording that names none (itsexampleabsent or null, its task free text, as in a browser session's replay) may take any id of the lock's form except an example folder's name, so an example's id only ever holds that example's own run.scripts/recording/gallery.mjsrefuses<id>: is the example folder examples/<id>, but its manifest names no example; without the folder list, the check is as before. - The gallery's lock names a new asset,
recordings-6b.tar: the three recordings it held, byte for byte, and three replays of a browser agent at a utility account (utility-bills,utility-amount-owed,utility-last-payment).
- A recording whose manifest names an example (its
- A recording can be opened as a phone chat of the agent's work:
chat.html?r=<id>&museis a different layout over the same events, in the viewer's own agentrun.ai look (its tokens, square corners, hairlines, flat cards, JetBrains Mono for labels and titles, orange the one accent), and without&musea recording reads as before (a test pins the default view byte for byte). A square avatar at the top, one picture file the build ships beside the page (muse-avatar.webp), floats over the conversation with the agent's name (the host'sreference.label, else the recording's title) and its state: "Waiting for you" when a hold is open, "Done" once the run delivered and the end of the conversation has been scrolled into view, else "is working". The header and the composer are transparent: the chat scrolls under them behind a soft blur and fade. The person's words are a hairline box under a "You" label, the agent's replies sit under its name with the files it names as chips, and its thinking and the steps between are folded and quiet. The browser is a card inline in the chat: the frame the steps ended on and a status line, "Your agent is driving the browser" (no button: the shipped browser viewer has none); a hold, read from thebrowser.sessionsandbrowser.handoffrows alone, reads "Handed the browser over: " and then "Handed back · ". Where the run waited for a person to sign in, a "Secure Store" card marks it, from the browser's ownbrowser.profilesdocument (the address it gains, then its verification; "Signed in · remembered" when it was already there). The host's artifact is a "What I remember" card (times as a fixed date, no file names or web addresses), read by decoding the recording's embeddeddata:page in the viewer itself, so it reads under a host whose policy allowsconnect-src 'self'only. No report page beside it, and every web address is shortened to its host.
0.1.0-beta.19 (2026-10-20)
examples/issue-triage: an agent writes the workflow that triages a repository's issues with Jevsession.tsgives a lead model theagentruntool and one sentence: it drafts a typed workflow, checks it (inspect), dry-runs it for free and runs it as one durable task, over a repository's newest labelled issues. For each issue a research agent with read-only tools reads the issue, the pull requests that mention it and the code it names; Jev gives it one of four types and judges good-first-issue and actionable in one request; the result is a table of rows, and the host counts how often Jev's type agrees with the maintainers' labels, which the classifier never sees.--recordkeeps the run as a recording whose graph the viewer replays.--max-usdis a hard spend stop, and the host (not the agent) counts how often the type matches the maintainers' labels.- The run needs no GitHub token: two API requests per issue, the rest from
github.comandraw.githubusercontent.comat the one commit the run was picked at (pick-issues.mjsprints it with the issues). A rate limit stops the run with that reason and is not retried. npm run demo:issue-triageruns the same workflow on scripted fakes ($0, no key, no network) over twenty fictional issues, through the harness's demo entry (--record).live.tstakes the lead and Jev from your keys or from an OpenAI-compatible gateway (/typesafefor Jev).workflow.jsonis the reference workflow (it validates and dry-runs clean);AUTHOR-PROMPT.mdis what a user says to the drafting agent;SETUP-PROMPT.mdanddemo.jsonare the setup prompt and its spec, held to each other byscripts/check-setup-prompts.mjs. Recovery from a killed process is tested (--kill,analyze.mjs), under "For developers" in the README.
@parcha/agentrun-dsl/graphexportsgraphNodeOf(graph, executionPath): the graph node an execution path ran at,{ path, at }, withatthe map items and loop iterations it ran in, outermost first ({ kind: "item" | "iteration", index }). An execution path is the node's JSON Pointer withitems/<i>before a map'sbodyanditerations/<i>before a loop's;graphNodeOfreads it by walking the graph by node kind, so a route branch nameditemsoriterationsstays a branch, and a path the graph does not have, or one that is not an execution path, isundefined. The module still imports nothing.A map can collect its items' failures:
onItemFailure: "collect"withfailedAs: "<key>". An item that ends on an item failure becomes a row{ item_index, label, code, message }offailedAs, in item order, the other items finish, andasstays aligned with the items:as[i]is item i's result, ornullwhen item i failed. The default,onItemFailure: "stop", is the behaviour before: the first failing item stops the map and the run. The map never escalates by itself; gate onfailedAswithescalateor a code node.- An item failure is one of the interpreter's own typed node failures (an
EffectFailureafter its retries,WorkflowOutputInvalidError,WorkflowStateError,WorkflowCodeError,WorkflowVerificationError) or a host error that declares itself one, with a stringcodeanditemFailure: true.isItemFailure(error)is the rule, exported from@parcha/agentrun-dsl. Never collected: an effect whose outcome is unknown, anyRecoveryError, anescalateinside the item, a stop or cancel, several failures at once, and anything else. Each collected row emitsmap.item_failed{ item_index, label, code }. When several branches of a parallel (or items of a map that stops) fail at once, the failure reported is the first one, unless that is an item failure and another is not: then it is the other, so an item failure never hides a failure a collecting map must stop on. A row'slabelis the node that failed: the innermost step, or a structural node (a nested map whose list is not one) when the failure is its own. - The validator:
onItemFailureisstoporcollect;failedAsis required withcollect, refused without it, may not begin with$and differs fromas; parallel branches may not both write it. Each refusal is an issue at the map's pointer: a badonItemFailure, a missingfailedAsor one equal toasNODE_FIELD_INVALID, a$failedAsSTATE_KEY_RESERVED,failedAswithoutcollectNODE_KEY_UNKNOWN(a key of the other form), two branches writing itPARALLEL_WRITE_CONFLICT. The schema, the author contract and the guide say so. - Recovery:
WorkflowDeps.recoverygainsfailItemanditemFailure. A collected failure commits with its item before the map goes on, and every later process ends the item on it without running its body. A store without both hooks cannot hold a collecting map: it is refused before any step runs. The recovery driver keeps the row in its frame'sfailures(the whole layout) or the item'sagentrun.itemsrecord (status: "failed",outcome, in the keyed layout), and a step that failed on an item failure keeps it (failure), so a process that resumes between the step's commit and the item's ends the item the same way. An item whose call it left with an unknown outcome is refused asFROZEN_EFFECT_UNKNOWN, never collected. @parcha/agentrun-pi:NodeFailure.itemFailureis true when the node's delivery attempts are spent or its model will not answer (model_error, as a provider's content filter), never for an abort, a stale or faulted run, a missing model or a schema no record can satisfy.- Deploy rule: run a collecting workflow only once every process that can open the run is on this release or later. A process on an earlier release refuses the workflow at validation, so its open fails its task, and that ends every agent node of the run then in flight. With the node runner's new attempt for a replaced task (which this release needs under it) those nodes start again from their beginning in new conversations, so the run still completes, but each such open costs those agent turns again: in the reference kill storm (30 kills a seed), 4.27 to 4.30 model requests per committed turn with old processes against 3.04 and 3.40 without (the medians of four 50-seed storms), about 33% more.
- An item failure is one of the interpreter's own typed node failures (an
A workflow node that names no
thinkinglevel thinks at medium (it was low), on every Pi path: the runner,/agentrunand the durable node runner. Thinking is never off: a level ofofffrom any source runs at medium. A node that names medium or high costs the same as before. A node that names no level and ran at low now costs more per request, and so does one that ran at off.createPiRunner: a node's ownthinking, else the runner'sthinkingLeveloption (the caller's override), else medium.createPiHostRunnerand/agentrun: the host session's thinking level is no longer a node's default. A node that names none thinks at medium whatever level the session runs at,offincluded. PassthinkingLeveltocreatePiHostRunnerto choose another default.- The durable node runner (
@parcha/agentrun-pi/durable): a node's ownthinking, else the level the host'sagent(node)sets, else medium. Before, the runner left the level to the host's agent alone, so a node's ownthinkingcounted only when the host applied it. A node a task owns copied its owner conversation's level, and a node no task owns ran with thinking off. A node conversation the runner created before this change keeps its level when it resumes; one an older build opened, which the runner gives its agent when it reaches the node, takes the new level. - The author reference (
language.md) states the default: a node that names nothinkingthinks at medium, unless its host sets another level.
A rejected workflow says where and which rule:
validateWorkflowreturnsissuesbesideerrors, one{ path, code, message }per error in the same order, withmessagethe same text, andWorkflowInvalidErrorcarries the sameissues.errorsis unchanged.pathis a JSON Pointer into the workflow as written naming what the rule is about (a node, at the same pathinspectWorkflowgives it; a schema; or""for the document's own fields and a supplied input), never a missing key.codeis one of 16 rules, the exportedValidationCode; the guide lists each with an example. The set may grow, so keep a default branch.A dry run says whether every check ran:
dryRunWorkflowresults gainstatus,passed,not_run(okstays true andskippedgives the reasons, as before) orfailed.okand the CLI's exit codes are unchanged;agentrun dry-run --strictexits 3 when the status isnot_run.The chat view reads as a conversation: a run of steps that only ran a command folds into one quiet "ran N commands" line, and a step that made looks leads with them (each look's picture, what it asked, and the first sentence of its answer that says something), with the code behind a click. The person's question is a plain bubble with the files as chips (four, then "+ N files") and any other fields behind "details"; the first screenful of pictures loads at once. The chat view and the inspection view take agentrun.ai's look: square corners, no accent bars, the site's dark palette and its type. The agent's page is drawn in that look too, for presentation only: the recording keeps its own page byte for byte. A thought shows a title only when its model wrote one (a headline in bold); any other thought folds whole under "Thought". A look's card shows its first picture that is not near-black, so a film's dark opening frame never leads.
apps/viewer: the hosted gallery's recordings come from a release asset, and a public replay draws no dollar figure- The gallery's recordings leave git.
apps/viewer/recordings.lock.jsonnames one tar on the release taggedgallery, its SHA-256 and the gallery's ids in order, each{ id, poster?, looks? }(looks: the keys of looks the run filed on one image, which its card shows with their boxes). The Pages job fetches it before the build (node scripts/recording/gallery.mjs fetch, with the job's own token) and unpacks nothing unless the tar hashes to the lock, holds only those ids' manifests, lines and blobs as plain files, no recording is over 80 MB and the gallery not over 300 MB, raw, each poster is an image its recording holds, and each id, listed once, is the name of the example folder whose run it is (examples/<id>), as the run's own manifest says: one recording per example, so a re-take replaces its id.gallery.mjs packwrites the tar and the lock's SHA-256. - The gallery's gate reads a card on an index entry (
{ title, line?, poster?, rank? }) and refuses a poster that is not an image the recording holds. - A part of the inspection view that draws a dollar figure is marked
costand drawn only with?debug=costor?debug=durability, under Receipts too: the strip's cost, the vision panel's spend and a receipt's price, and the transport panel's cost. - The vision panel no longer says the eyes have not looked when live lines tell of a look no store document filed: it lists the look (a native one as the lead's own) under what live lines told, as never filed once the run delivered, and counts nothing from it.
- The gallery shows agent runs only: the scripted reference job leaves
apps/viewer/recordings/for the test fixtures (apps/viewer/test/fixtures/reference-job), where every test that read it now finds it. In git,recordings/holds only its README. - A page, an SVG or a script in a recording (an agent's report, draft 41) is published as
<name>.txt: opened straight on the gallery's site it is plain text and runs nothing, while the viewer reads it into its sandboxed frame. The gate refuses a published blob that would open as a page under its own name. - The viewer's page carries a Content-Security-Policy (only its own files run or load; data: and blob: pictures and
videos), its styles in
viewer.css; PDF.js opens every PDF withisEvalSupported: false.
- The gallery's recordings leave git.
The release workflow's publish job may run 90 minutes (it was 45): enough for its
npm run check(about 27 minutes) and one registry wait per package (up to 20 minutes each).docs/guide.mdhas a section "An agent that learns", after the Pi workflow section: notes proposed on the run's own branch and curated into commits onmain, a bad lesson reverted or retired, and saved workflows judged on their own document, admitted as trial skills, promoted on clean uses and demoted on escalations. Its part "Its own git repository" has a short program for the repository the agent keeps.docs/examples.mdlistsexamples/agent-learn(npm run demo:learn).The
@parcha/agentrun-dslREADME and the guide's "Let Pi build and run a workflow" tell the story in five steps: the agent drafts a typed workflow, checks it, dry-runs it for free, runs it, and every node shows on its graph. Each step names the call or the/agentruncommand that does it. Install andnpxlines use thebetadist-tag (npx @parcha/agentrun-dsl@beta, since npm has no unscopedagentrun), the starter's README names no version (it installs at the version itspackage.jsonpins), and the READMEs state one Pi range (1.0.4 to below 1.4, with the versions CI tests). The@parcha/agentrun-dslREADME says that a durable run's store keeps each effect's arguments as given, so secrets are redacted before they reach an effect. The author skill and the guide say that a dry run gives a boolean whose schema leaves it open (noconstorenum) the valuefalse, so anescalateon such a boolean beingfalsealways fires there and the dry run reportsnot_run. The Jev README, its types and its Box refusal no longer say thatjev-latestis 1.13: a Box question needsjev-1.14.0(or later) orjev-previewby name, and the runner refuses it onjev-latestwhatever version that alias points at.
0.1.0-beta.18 (2026-10-19)
- The gallery opens a recorded run in a chat + report view (
apps/viewer/chat.html?r=<id>): the run's recorded conversations on the left, the agent's report on the right. The inspection replay is one link away, "How this ran" (./?r=<id>). The left side is made from the recording's generic events, the same for every run: the run's input as given as the user's message, then each conversation as a thread (its messages as recorded without the prompt the host composed to open it, the assistant's text as bubbles between the cards, the run's own thinking folded, one card per tool call titled by the tool and its arguments, consecutive calls of one tool showing the first three, and under each tool card every model call it made as its own card with its scores, the boxes it drew and its raw output, a judge's calls marked with the judge's accent), and a session timeline of workflow nodes, LLM calls, tool calls and the model calls a tool makes, nested under it and labelled as their cards. The right side is the agent's own artifact when the recording carries one (manifest.artifact), shown in a frame that runs no script, its citations opening the cited page or frame with its region boxed; otherwise the run's cited answer, or the result record it submitted, labelled as the answer; otherwise its final assistant text. The view shows no counters, costs, process or kill markers, and no thinking the run did not record. - The Jev runner honours
Retry-Afteron HTTP 429 and 503. It waits the larger of the server's time and its own backoff, never past the request's timeout or its abort signal, and gives up at once when the server asks for longer than is left. A request still rate-limited after its retries ends as before:JevErrorwith codehttpand status 429. apps/viewer: the gallery and a replay's header name no process or kill- The killed and resumed reference job (
ten-kills-seed21) leaves the gallery and is kept with the test fixtures (apps/viewer/test/fixtures/), where the tests that read a killed run beside the clean one find it. - The gallery's line reads "Replays a run recorded as agentrun.recording/1.", each run in its list is followed by its
model alone, and a replay's header no longer counts processes and kills. The counters stay under
?debug=durability.
- The killed and resumed reference job (
- The vision agent example (
examples/vision-agent) with a gateway set (VIDEO_MODEL_BASE_URL) now reaches an Anthropic lead or eyes model over Anthropic's own Messages API at the gateway's root (/v1/messages), under the gateway's key, instead of over chat completions. Through chat completions the gateway answered with thinking off (0 reasoning tokens, no reasoning field); over the Messages API the agent node'sthinkinglevel (medium) is Anthropic's adaptive thinking at that effort, and the model's thinking comes back as thinking blocks when it thinks (adaptive thinking is the model's choice at that effort: on a live probe Sonnet 5.5 returned a thinking block athighand none atmedium). A gateway that serves an Anthropic model only on its OpenAI-compatible endpoints must also serve/v1/messagesfor it.--thinking low|medium|highsets the agent node's thinking for a run (the default staysmedium; any other value, or an input it cannot be put in (a file, a capture name, none), is refused before anything starts), which is how a Claude lead is asked to think athigh. OpenAI and Google models, and runs on a provider's own key, are unchanged. - The vision agent example (
examples/vision-agent) finds its example folder as the one argument that is neither a flag nor the value of a flag that takes one, sonpm run demo:vision -- --live examples/document-checkasks that folder's question over its files instead of silently running the default question over none. The flags that take a value are the host's string options and--thinking; a test holds the list to the host's. A second folder, a stray token, or a folder that is not an example is refused before anything starts, with one line and nothing written, where it used to be skipped or to end in a stack trace. - On fakes, the vision agent's scripted lead looks at a first file that is a still image (PNG, JPEG, WebP or GIF) as it is, and cites the image itself. Before, it read any file that was not a PDF as a film and asked for its frame at second 1, which a still image does not have, so a free
examples/lake-meadrun stopped with "the model did not answer". PDFs and films are read as before. - The vision agent example (
examples/vision-agent) with a gateway set (VIDEO_MODEL_BASE_URL) no longer asks forTYPESAFE_API_KEY: Jev goes through the gateway's/typesafe, so its key isTYPESAFE_API_KEYwhen given and the gateway'sVIDEO_MODEL_API_KEYotherwise; without a gatewayTYPESAFE_API_KEYis still required. A live run now checks every key before it starts anything, naming each one that is missing and what it is for (--live is missing TYPESAFE_API_KEY (typesafe.ai: ...); OPENAI_API_KEY (the lead openai/gpt-6-luna)), and stops with a failing code, a message and nothing written: no recording, no run directory and nocost: unknownline, where it used to start the recorder first and leave a recording of the refusal behind.demo.json, the README and the setup prompt say the same. The scripted lead of a run on fakes now writes its report asreport/index.html, the name the report skill gives a live run's, and the instructions of a package without the skill name it too. - The vision agent example reads the report page its answer names only from under
work/report/. A path that leavesreport/, by its parts or through a link (report/itself, a folder in it, or the page), gives the record it gives for a missing page, with a note on the answer's row that says why, and nothing it names is read or kept. A file inreport/that is a link is not kept with the report. The Answer schema refuses a report path with a.or..part. - Two more questions for the vision agent, each a folder with no code:
examples/lake-mead(thirteen Landsat images of Lake Mead, 1984 to 2024, committed; when the water fell most and every shoreline it left, boxed and dated) andexamples/market-street(the 1906 film down Market Street from the Library of Congress, fetched by URL; every vehicle that crosses in front of the camera, to the second, boxed and counted).ASSETS.mdrecords each source and its licence as the source page states it.
0.1.0-beta.17 (2026-10-18)
- The hero demo moves onto the showcase template:
demo.json(title, targets, claim, models, keys, kills, panels, tools, expected cost),job.tsas data (a workflow and its default input, which now names the operators to read),fakes.ts, a shortrun.tson the reference entry,SETUP-PROMPT.mdand a README in the template's shape.npm run demo:herotakes the standard flags;--killreplaces--kill1, and--inputtakes a subset of operators. The hero's own looks filing is gone: the entry's vision option keeps every look. The reference entry gainsFakes.backend(a demo shapes what its fake browser serves). - The reference entry takes
--provider,--modeland--eyes-model(with--live; handed to the demo'sliveasmodels),--see-also <recording>(linked in the manifest'ssee_alsoby name and title), and a demo'smanifest(context)hook for its own manifest fields (a name the entry writes is refused).source.runis now the demo's name plus the input file's name, not a fixedreference. - The reference example's child is also an app module for
pi-durable-disk run --app(examples/reference/child-app.ts), so a run has one open path with the disk package's claim, fence, stdin mount token, run.json holder and restarts. Ondonethe run's final status carries the record and the store's hash.run()is now built fromcompose()(the registry, models and the steps after the open) and the entry's child fromprepareChild(), which the app module shares. A run is keyed by its demo's name (the place'spaths.id, passed to the child asRUN_ID), not a fixedreference, so two demos never share a run;pathsAtandplaceAttake the id. Underpi-durable-disk run --appthe id is the instance's--id: pass the demo's name. The supervisor putsDEMO_ENTRY(the demo's entry module) andDEMO_NAMEinto every order's env, so a place that starts the child through the app module needs no wrapper. A process run bypi-durable-disk run --appwrites nothing under the run's directory before its claim is open: the recorder, the fetched sources, the box, the ledger and event logs and the judge's count start inbeforeResume, and a recording line produced earlier is kept in memory and written in order when the recorder starts. - The reference entry has an opt-in code mode (
DemoOptions.codeMode, orcodeModein demo.json): acodetool whose script runs in pi-codemode's sandbox withtools.bash(through the call's environment, inwork/) and the vision handle'svision.see. Off by default; the sandbox has no network, process or file access of its own, and a command it runs can do what the environment's shell can. The repository now declares@earendil-works/pi-codemodeas a root devDependency, the one extra install code mode needs. - The supervisor writes
delegationhost lines fromplace.status()'sdelegationswhen one appears or its orphaned or pending state changes, and arevokeline fromrecover()'s{ revoked, ms }; a place without them writes none. The supervisor writes abox.receipthost line for each boxplace.status().boxesreports once itstoappears (or at the final read): the box as abox-Nlabel, seconds, the published per-second price and the cost (null with no price), the source and whether it was priced; never the provider's id. A place that reports none writes none. The example's cost (examples/reference/fold.ts) counts eachbox.receipt'scost_usdonce per box label; a receipt with none (an unpriced box, or one still open at the final read) makes the cost unknown, never 0. - The reference entry takes
Fakes.input(the no-key default input, a smaller slice than the job's own;--inputwins), reports the models the live parts chose (LiveParts.models, handed tomanifest()asmodels, so a demo needs no file handshake between its two hooks), and prints why a child stopped in the summary. A demo's vision lane,fakes.vision(host, running, { input })and the live parts'vision(host, running, { input }), is handed the input the run was given (--input, else the mode's default), so scripted eyes need not read the entry's environment. The same third argument carries the run'splace({ root, work }, as a tool's context has it). models.judge.capsin demo.json ({ requests, tokens }, each a positive integer at most 4 times the default of 50 and 40,000) sets the demo's own Jev caps; a value past the ceiling is refused at load, the run still refuses past the caps and--max-usdstill applies. A demo that namesmodels.judge(Jev) without akeysgroup forTYPESAFE_API_KEYorOPENROUTER_API_KEYis refused at load, naming the group to add.- A
--killwithncounts every crossing of its point and lands at the first eligible one at or after the nth (not while a tool call is between its dispatch and its kept answer), so it always lands when the run reaches the point n times or more; the kill line'scrossing.nis the ordinal it landed on,DemoResult.killscarriesnandplanned, and the summary saysplanned n=10, landed at n=12when they differ. - The place answers where a kill request is written (
Place.requestFile(attempt)), and the child writes it durably (a synced temporary file, renamed, the directory synced). The supervisor mints a random nonce for each attempt (KILL_NONCE, which the child reads once and removes from its environment) and honors a request only with that nonce, writing akill.request.ignoredhost line for one without it. One fresh run id per invocation (<demo name>-<8 hex>,DemoResult.runId) reaches the place aspaths.idand the child asRUN_ID, so a second run of one demo on a shared disk never finds the first's run or signals. - A reference kill takes
where: { field: value }: it fires only at an announced crossing whose info has those values (cross(point, { info }), which the crossing log records), and composes withnas the nth matching crossing.transport.tool.startedfires for every tool, so{ point: "transport.tool.started", where: { tool: "install" } }lands on the install step. A storm takes nowhere. - A
--liverun's judge and model have the crossings the fakes' have (judge.asked <label>,judge.answered <label>,model.request turn <n>,model.answered turn <n>, the same names and counting), so a kill lands before a paid question or request is sent, or after its answer and before the run keeps it, and the resume pays the judge once (a kill does not cut a request while the provider has it outstanding). --recordfollows the run:events.jsonlis appended to as the lines arrive (whole lines, section 3's order, a memo after its commit is complete), blobs are written whole before any line names them, and the manifest is last, so a viewer in follow mode reads a live run and its replay as one file; the finished file equals a fresh export. The manifest'sjobblob is always written when the entry knows the job.LiveParts.tools, the same name -> function map asFakes.tools: on--livethe effect door (evidence, receipts, kill crossings) dispatches to it; a tool missing there runs fromfakes.toolsonly when demo.json'stoolsentry sayslocal: "fakes", else the call failsno live source for <tool>. On fakes nothing changes.--operator-kill <file>: a control file the supervisor watches. When it appears while a process runs, the supervisor removes it, writes akillhost line (crossingnull, labeloperator), kills through the place and resumes as usual; a kill still planned for the ended attempt moves to the next.- The reference entry has a place seam:
demo({ place })takes aPlace(where the session lives and how its process starts, stops and restarts), andlocalPlaceis the child process of this machine. The supervisor decides when to kill and writes the kill line first; the place decides how. A place whose runner opens a claim is handed a fresh mount token on the runner's standard input for each attempt, never in argv or env, and a place that cannot lose its machine refuses a box kill. - A place whose child writes its recording inside the place brings it home through
Place.recordHome { dir(attempt), bringHome(into, { final }) }(blobs first, then whole spool lines): the supervisor drives it at each status tick and once final, before the follower closes, the manifest and the release; a failure writesrecording.carry.failed, refuses the recording only for an attempt that ended on its own, and stands (lost: true) for a lost box. The supervisor hands every orderRECORD_STARTED(the recording's clock) andRECORD_GEN; the child's recorder syncs its spool, rolls it into 1 MB segments, and redacts its own machine's host name and boot id with no sign-in file. - The supervisor writes
run.jsonhost lines fromplace.status()(any place that has it): the record verbatim withgenits generation, when its status, generation, holder or sealedSeq changes, read at a runner's start and exit, a kill and the result, and on a 2 s tick. The recorder replacesholder.hostandholder.bootIdwith labels (host-1,boot-1, ...) and redacts this machine's own host name and boot id. - A place's signals may end with
{ kind: 'signals-failed', code, message }(or its reader may throw): the supervisor writes asignals.failedhost line and ends the attempt as after a failed kill, and nothing in the signals pump can end the supervisor. The messages ofkill.failedandsignals.failedhave any absolute path outside the run's directory replaced by<path>and are capped at 240 characters. - The reference entry takes
sitesfrom a demo's spec: the agent getsuse_website, and a task for a site runs a child agent in a browser of its own and answers a short report. A kill while the child works leaves it recorded under its site and request id, and the resumed run finds it instead of starting another. - The reference entry fetches a demo's
sources.json(checked files{ id, url, bytes, sha256, license }) into the run'swork/as<id><extension>, verified by size and hash, on--liveand with--fetch-sources; a no-key run uses the files the demo commits. A file that is not the checked bytes is refused, naming what was checked and what came. A demo whose files depend on the input passessourcesFor(input)(items{ id, url, bytes, sha256 }): only those are fetched, and a no-key run that lacks their files is refused in one line naming--fetch-sources. Downloads are kept by hash in--cache <dir>(defaultagentrun-sourcesin the OS temp dir), so a re-run does not fetch again; a cached copy that no longer has the checked bytes is fetched again. Asources.jsonalso listspages(visited live by the agent, with their URLs and hand-check dates): they are passed through untouched, never fetched or refused. Asourcespath startingexamples/is read from the repository root. - Code mode in the reference host gains
vision.judgebesidevision.see(Jev's typed answers about images, through the host's capped and priced judge seam, with the declaration in the tool's description), andtools.bashfollows the skills' contract: it resolves the output string, and a command that exits non-zero rejects with an Error whose message names the exit code and holds the output (the sandbox hands a script a thrown error's message only). Code mode also gainsvision.transcribe(words with times from the host's speech-to-text model, chosen by its config fromTRANSCRIBE_BASE_URL/TRANSCRIBE_API_KEY,FAL_KEY,ELEVENLABS_API_KEYorOPENAI_API_KEY; the key lives in the adapter, never in the sandbox), filed invision.looksas tooltranscribe. - The reference entry files every look the vision guard reports into a
vision.looksdocument of the run's store, with the screenshot it was of as a blob and its evidence path as its receipt, so the recording's counters read the eyes' looks and cost; a demo passes only its eyes, from its vision lane (fakes.vision(host, running)on fakes, the live parts' on--live), and the entry calls a demo's ownonLookandonMissafter its own, never replacing them.vision.filingis the first of the packages' own kill crossings: the eyes have answered and the look is not yet filed, so the next process files the kept answer and does not ask the eyes again. A planned kill that never lands is reported (unlanded) and the command exits 1. A kill entry takesholdandwaitMsat a parking crossing (vision.filing): the nth filing and those after it are parked untilholdare parked orwaitMshas passed, then the process is killed, so several answers are kept and none filed. A look is kept with its data set item, its miss when it failed and each frame's second, and every read leaves a livevision/shownline. apps/viewer: the document panel draws the PDF pages a result cites, each box over its page- A cited result (
agentrun.cited/1) whose cites name a blob and a page (page=N) has those pages drawn whole with PDF.js, each once, and every box a row cites (page=N&xywh=percent:x,y,w,h, percent of the page's crop box) placed over its page with its row's number (and the row's words where the page is wide enough; rows citing one region share a box); the rows that cite a page are listed under it, numbered, "no box" for one without a region. A box and a row that cite a look select it, as a citation chip does. A recording whose result cites no page draws no document panel. - The panel contract gains a
pdf-pagenode (the shell draws that page of a PDF blob, its children placed over it in percent),showsResult(result, { blobType }), which draws a panel for a recording by its result asshowsdoes by its lines, andViewContext.manifestResult, the manifest's result for a recording in which no line delivers one, which the document panel shows at every stop labelled as the run's final result, since no line gives its time. - PDF.js (
pdfjs-dist6.4.299, Apache-2.0) ships as its release files inapps/viewer/vendor/pdfjs/, copied todist/pdfjs/, so the viewer still builds with no install; a scan's CCITT, JBIG2 and JPEG 2000 images are decoded by its JavaScript decoders (PDFium's, BSD-3-Clause, and OpenJPEG's, BSD-2-Clause), shipped beside it with their licenses. The page loads it only to draw a page, with its worker a file of the same origin, and hands it the PDF's bytes, so it draws under a policy that loads scripts, workers and data from the page's own origin only; a page opened fromfile://says it cannot and links the PDF at its page. - A followed run keeps each PDF it read whole, and a dropped folder reads its files directly, so neither fetches a
blob:address for one.
- A cited result (
- The run viewer's public replay shows what the run's agent did, and no longer its kills, resumes or receipts by default (
apps/viewer, PANELS.md "What a public replay shows").- Not drawn by default: the timeline's process lane with its kills, restores and journal opens (the picture starts below it), the processes, kills, repeated, asked twice and journal opens counters, the chapters made of process starts and kills (in the page and in the embed protocol's
ready), the vision panel's kill and resume badges and its kept, repeated, asked twice and possible payments meters, the workflow map's kills, cut marks and price of chaos, the transport panel's kills, and the disk panel's generation that ended without a holder. A panel marks such a part with the classdurability. - One click away: the strip's looks paid and cost are under a disclosure, Receipts, and so are the vision panel's meters and the receipt of its look.
- A look leads with its answer: the vision panel's film and pages lanes show what the eyes answered and the rows that cite it first, the picture below in a disclosure a click opens.
?debug=durabilitydraws the view as it was, every part inline. A panel reads it fromViewContext.debug.
- Not drawn by default: the timeline's process lane with its kills, restores and journal opens (the picture starts below it), the processes, kills, repeated, asked twice and journal opens counters, the chapters made of process starts and kills (in the page and in the embed protocol's
examples/vision-agent(npm run demo:vision -- examples/<folder>): one vision agent for any question about a set of files, on the reference host. It is given the question in plain words and the files, plans its own steps, writes code withvision.see,vision.judgeandvision.transcribefollowing pi-vision's skills as the package ships them, runs gemini-3.8-flash thinking at medium, and ends with an HTML report that cites every claim at the file's own page, second or region; the recording keeps the report and its pictures and clips. Three examples are questions and files, with no code:examples/video-chaptersandexamples/hear-and-find(NASA's 1969 Apollo 11 film, fetched and checked) andexamples/document-check(a Delaware certified copy filed with the SEC and an altered test copy, committed with their source in ASSETS.md).npm run export:sourceleaves out only a machine's own*.local.*and*.localfiles, as its receipt says. Its rule also dropped any path component namedlocalor starting withlocal., so the source archive was missingpackages/pi-transport/src/box/local.ts, the file behind pi-transport's./box/localexport. The reference host imports that file, so the downloaded source did not build (verify:source). Across the tracked tree, it is the only file the archive gains.changes.mjs --foldwrites a published package's dated section in one order whether a release folds once or in two commits. A later fold used to append its bullets at the end of the version's section; one fold groups the section by kind and orders it by fragment name. When the section is exactly an earlier fold's output (its fragments gone from the tree, present at the merge base), the fold now rebuilds it from the merge base's text with the earlier and the new fragments together in fold order. A section edited by hand since is left as it was and the new bullets go at its end (--checkreads only Unreleased sections, so it does not look at a dated one). This extends the held-package rule to published changelogs.- The hero's kill-beat test claims only what holds under any schedule: at most two processes fetched, the killed one at least the three published figures it had kept, and nine fetches in all. It no longer fails when the nine branches fetch before the third kept crossing is taken alone.
- A recorded blob is written to a temporary file in the recording's
.spool/, synced and renamed intoblobs/, so a viewer following a live run never fetches a half-written one and a killed write leaves no torn blob; the validator and the gate are unchanged.browser: falsein demo.json means the entry creates no browser, live or on fakes (a--liverun then needs no Browserbase key and no Chrome), and a page call fails withthis demo has no browser. - Every node of a workflow run by the reference host thinks: its own
thinkinglevel, elselow(the DSL runner's default), neveroff(a node that carriesoffruns atlow). --recordtakes the exact secret values out of a recording: the values of the environment names the demo declares, and the sign-in's tokens (the access, refresh and id token strings and an API-key credential's key fromauth.jsonunder$AGENTRUN_HOME, else~/.config/agentrun). Every recorded line, text blob and the manifest has them replaced, a sign-in file that cannot be read fails the record, and the finished recording is scanned and refused if a value slipped through, naming the key and the file and never the value. A refreshed token is redacted too (the sign-in file is read again when its mtime or size changes, and the redaction uses every value seen), the finished-recording scan reads the file once more, and the child is handedAGENTRUN_HOMEso it uses the file the recorder reads.- The reference recorder takes the run's own machine out of every line and text blob, beside the exact secret values: the user name (as a whole token, in a path segment or an
ls -lowner or group column, never as a word in a sentence), the home directory (~) and the paths under the run's directories (<run>/...). A tool result that listed a directory no longer carries the box's user name into a recording. - The verify job's time limit is 40 minutes (it was 25). A run on main takes 17 to 24 minutes today, so a slow runner was cut at the limit and the check failed without a test failing.
- The source export carries a PDF only when one line of
ASSETS.mdnames the file, in backticks, together with its SHA-256. Any other PDF stops the export with "Unreviewed PDF: is not in ASSETS.md with its SHA-256 ", so every binary in the source archive is still one someone reviewed..pdfjoins the binary types the export knows; the other binary types are unchanged. docs/examples.mdlists the two examples that already use pi-vision, the reference host (npm run demo:reference) and the hero job (npm run demo:hero), and names the three examples that are to come (video chapters, hear and find, document verification), one line each. Each gets a row in the table when it lands.- The hero's README and
demo.jsonclaim say what the agent does (reads nine grid operators' charts with vision, each number cited to its screenshot). The kill-and-resume beat stays as a test, with a short "For developers" section in the README. - Three more questions for the vision agent, each a folder with no code:
examples/apollo-descent(NASA's Apollo 11 transcript, photographs and film, minute by minute),examples/boulder-chapters(a 1930s government film in chapters) andexamples/fair-robot(a robot first seen in a 1939 film).docs/examples.mdlists all six vision examples;ASSETS.mdrecords each film's source and licence.
0.1.0-beta.16 (2026-10-18)
- The live server's page also lists the agent's saved sign-ins by site (
GET /agent, never a ref) and says when a run started signed in; the live browser floats over the page, opening large while the run waits for the person; and once a run is not live its Browser tab plays back what its browser showed, recorded owner-only beside the run with nothing kept while the person signs in, and never part of a replay. - Reference example: one scanner, and
--recordscans before it finishesexamples/reference/scan.tsis the one scan for what a published recording must never hold: a credential's shape or a machine path. A copied example keeps it.- The recorder's
finishRecordingscans the recording's lines, its text blobs and the manifest's own text before it writes the manifest. A finding throws, naming the shape and the file, and no manifest is written, so the directory is never a finished recording. scripts/recording/validate.mjsimports the same scanner instead of its own copy, and re-exports it, so the validator and the hosted gallery's gate refuse exactly what the recorder refuses.- The scanner refuses a JSON Web Token (
jwt: a base64url JSON header and payload and a signature, the shape of a ChatGPT sign-in's access and id tokens) and a Browserbase API key (bb_live_, the prefix Browserbase's docs show), in a line, a blob or the manifest, and behind a JSON escape too. The source export keeps its own list (scripts/export-source.mjs), unchanged. - The hosted gallery's gate scans every published text file as the recorder does, raw and decoded when it is JSON, so an escaped credential in a blob no line names is found too.
apps/viewer: box time in the run's cost, and a scripted run's real parts on the strip (RECORDING.md drafts 33, 35, 36)- The cost sums each
box.receipthost line'scost_usd, once per box by its label, and labels it at list price:$0.0147 (boxes $0.0047 at list price). A restore keeps it: a box's time was spent whatever the store went back to. - The cost is unknown while a box pi-transport's own lines show started (
box.created) has no receipt of its own (sourcetransport), when a receipt'scost_usdis null (a kind with no published price), and, as before, when the recording has no durable line. Started boxes named by label (box-N, as a receipt names its box) are matched to their own receipts; ones named by the provider's id, which no receipt carries, are counted against the transport receipts. - A scripted run whose boxes cost something reads
scripted lead, boxes $0.0047 at list price, its other parts named scripted with their sum when they priced anything, neverscripted, $0. A scripted run with no box, or whose boxes cost nothing, reads as before. - The counters carry
costParts, the cost by part (lead,judge,eyes,browser,boxes), andcostLabelmoves to./counters.ts. The transport panel's cost row uses it, so it reads as the strip.
- The cost sums each
- The run viewer draws the pictures a cited result names (
apps/viewer): a row of anagentrun.cited/1result that cites a file which is a picture in the recording'sblobs/(PNG, JPEG, WebP or GIF) shows it as a small thumbnail, several side by side; any other file stays a link. A window of film the eyes saw as video, with no picture of its own, shows the keyframes the rows cite beside it, each captioned as that chapter's keyframe and noted as not what the eyes saw; a page keeps its own scan. Pictures come only from the recording: read from a server (?src=,?follow=), whereblobs/cannot be listed, a blob no line names (a picture only the result cites) is looked for by its name the first time a view draws it, and reads loading until then. A result's JSON is read without fetching adata:orblob:URL (an embedded site decodes it in the page; a followed run keeps what it read), so a site served withconnect-src 'self'still draws the result. A missed tile in the photographs lane is labelled by its miss code. A panel's view reads a blob's media type throughViewContext.blobType. apps/viewer: the run's picture (RECORDING.md draft 31)- The picture panel draws the latest
picturehost line's image at or before the stop, a gap before the first, and the last one after the run ends. A recording with nopictureline draws no picture panel; one with pictures shows it first. - The viewer's format check takes a
pictureline, which names no process (nodata.gen), and checks itsblob(a SHA-256 inblobs/),label(a string) andcommit(a whole number). apps/viewer/RECORDING.mdfollows the format's draft 31: the host row gains the supervisor's failure lines and thepictureline.
- The picture panel draws the latest
apps/viewer: a message's text parts are kept apart, and a prompt's footer foldstextOfjoined a message's text blocks with nothing, so a second text part (a footer a job adds after its prompt) ran into the first. Text parts are now joined with a blank line (textPartsOfgives them in order).- The model turns panel shows the prompts (user messages) too: a prompt shows the start of its first text part, and everything else, the rest of that part and every later part (a footer after the prompt), folds whole under one "more"; a short prompt of one part is shown as it is.
examples/web: a run in an imported home reads its timeline from its store. A pack carries norun.json, so the runs server answered 409 ("the run is live") for every run of a home made byimportPack; a run whose entry in the home's index has anendedAtwas carried back at its source, so the server now treats it as released with or withoutrun.jsonand gives its timeline, state and cost from its store. A run with noendedAtand norun.jsonis still not read from its store.- examples/web: a message that comes after a run answered, between its instance's exit and its carry back, waits for the carry back and gets
409 RUN_FINISHED. Before, it started the run again, and once the carry back landed that instance was never stopped, so it stayed up with its browser. An instance still up for a run that is already carried back (by another writer of the home) is now sent SIGTERM. changes.mjs --foldwrites the same held-package changelog when a release folds in two commits as when it folds once. A held package's Unreleased section keeps what it had; the bullets of this release's fragments follow it in the fold's order (kind, then file name). When a release folds, merges main and folds a late fragment again, the earlier fold's run is rebuilt from the merge base's text with the late fragments in that order, so--checkaccepts it.--base <ref>(defaultorigin/main) names what the merge base is taken against; a section that is not an earlier fold's exact output is left as it was and the new bullets are appended after it.scripts/recording/validate.mjsandapps/viewer: two recordings that follow the format are no longer refused- An inline image is found by its structure, never by the length of a string. An image block that holds its
data, and adata:image/URL, are still refused: an image belongs inblobs/. A run of 4000 or more base64 characters anywhere else (a store's chunk, a checkpoint) is data and is accepted, so a writer no longer cuts it up to pass. - A restore whose pi committed once at open is accepted (RECORDING.md draft 37). Its process may start at
head + 1when its own snapshot is at commithead, since pi commits once at open when a generation was running and that commit's state is in the snapshot. Both the viewer's check ("the restore's head is not the process's first new commit") and the validator's continuity check refused it. A first new commit abovehead + 1,head + 1without the snapshot athead, and every move fail as before.
- An inline image is found by its structure, never by the length of a string. An image block that holds its
- The reference recorder writes each blob to a temporary file in the recording's
.spool/, fsyncs it and renames it intoblobs/whole, so a process killed while it writes (the budget stop, a planned kill) leaves a temporary file in.spool/and never a torn blob under a name that says it is whole. The validator and the gallery gate are unchanged:.spool/is never published. apps/viewer: a build skips a blob still being written- A recorder writes a blob as
.<sha>.<ext>.tmp-<pid>and renames it once whole, so a copy of a recording taken while it goes may hold such a name inblobs/.build.mjsneither lists it in the gallery (embedded or--files) nor copies it, so the build of such a copy passes the hosted gallery's gate.
- A recorder writes a blob as
- Two notes for host authors. The
pi-agent-learnREADME says how a judge on a general model, rather than Jev, answers curate's questions: ask for{ answer, confidence }and derive the yes-probability, since a model asked for a bare probability reads it as its confidence. The host integration guide says to give each tool a workflow calls throughcreateWorkflowToolaresultSchema: without one,describereports pi's result envelope and the result reaches the next node untyped. - The
examples/agent-learn/demo now shows a workflow earning its place. After run 2's workflow is admitted as a trial, runs 5 to 7 are offered it, load it by name and run it with the DSL'sagentruntool;finishRunproposes their uses from the records the tool left in each run's own store, and curation promotes the trial to active on the third clean run (a run counts once, however many cases it ran). Run 8 saves a second revision that hands an issue with no body to a person; runs 9 and 10 escalate at its gate, so curation demotes it and the first revision is active again, and a run that saves the demoted document again proposes nothing. The README has a new section, "A workflow earns its place", with the rules: three clean uses promote, two failed or escalated uses in a row demote and roll back, and a demoted revision never returns. The tests read each use from the tool's own records. - New example
examples/agent-learn/: an issue-triage agent that learns in its own git repository, end to end in one command with a scripted model and judge (npm run demo:learn, no key, no network). The agent'sAGENTS.mdsays to calllearnwith type correction when a maintainer corrects it. In run 1 a maintainer gives a project rule no model can guess; the host proposes the note onruns/run-1, and curation merges it tomain. Run 2's first model request carries it in<learned>; run 2 applies it, marks it used, and saves a workflow with the DSL'sagentruntool, which curation admits as a trial skill.retireLearningthen takes the rule out (agit revertwould conflict once the rule was used), so run 3 labels without it and is offered the trial, and a run that tries to keep a host secret proposes nothing.host.mjsis the reference host loop over@parcha/pi-git-remote,@parcha/pi-agent-learnand@parcha/pi-durable-agent:prepareRun(fetch main, refresh the agent home at its sha with a compare-and-swap, start the run there, seed its section and its trials),finishRun(carry back, then propose notes, saved workflows and uses) andcurateNow(the judge is the DSL'srunJudgeport).--liveruns the triage runs on a model at an OpenAI Responses endpoint.
0.1.0-beta.15 (2026-10-16)
- The
agentruntool dry-runs the inspected workflow: itsdry-runaction (with an optionalinput), the/agentrun dry-runcommand and the inspector's Dry-run action run it on synthetic values, with no model, Jev or tool call, no run journal, and the last run's receipt left as it was. The result'sstatusispassed,not_run(a check could not run andskippedsays why; it proves nothing) orfailed(problemssay why). A workflow with code nodes isnot_rununtil the user issues/agentrun dry-run --trusted, and a code node that throws is reported by its label, without what it threw. The tool's parameters gain thedry-runaction, and the packaged skill tells the agent to dry-run a workflow before it runs it. @parcha/agentrun-pi/durablegives a pi-durable session the agentrun tool.createWorkflowTool(host)returns one extension that carries the tool and the workflow task: the host installs it and lists the tool's name in the agent definition. Its describe, inspect, dry-run, save, load and list are the coding-agent tool's, run by the same code, over the project storehost.projectDirnames. Its run starts the inspected draft as one backgroundagentrun.workflowtask of the calling conversation, writes the run's progress to the call's running output, and answers with the outcome. The same draft and input asked for again, after a restart too, reach the same run and make no effect twice;again: truestarts a fresh run of them, which a later run reaches. Code nodes run only through the host'srunCode. A workflow uses only the tools the host registers intools, as a call's tool and as an agent node's tools, and inspect names any other. A root agent offers the tools of the extensions it lists: list the tool's extension beside any other the conversation needs.createWorkflowExtension'sstartandreadtake a tool'sapias their caller (WorkflowCaller), naming the conversation inoptions.conversationId.- New entry
@parcha/agentrun-pi/signin: sign in, pick a model, check keys, for a demo or your own app before its first model call.@earendil-works/pi-aiis a new optional peer at*; only this entry loads it, and every other entry imports and runs without it.checkKeys(spec, options)reports what a run needs and what is missing (keys by env name and where to create them, programs onPATHwith their platforms and minimum versions, packages with the exactnpm install --no-saveline), with exit code 2 and no request sent when a required item is missing.checkSetupPrompt(spec, text)checks a demo's setup prompt against its spec, andscripts/check-setup-prompts.mjsruns it in CI.resolveModel(spec, options): an explicit provider and model with that provider's env key wins; else a stored ChatGPT sign-in with the model chosen from the account'sGET /v1/modelsby capability; elsesignIn. The eyes model resolves the same way or is refused naming the gap.signIn(options)runs pi-ai's own Sign in with ChatGPT with this app's name and a persisted host id, and fails at once with a clear message when port 1455 is taken. It refuses a pi-ai that ignores the app name (before 1.1).- The token lives in
$AGENTRUN_HOME/auth.json(default~/.config/agentrun/, directory 0700, file 0600, written whole and renamed). Refreshes are serialized across processes by an operating-system lock (an exclusive SQLite transaction onauth.json.lock) that a killed process never leaves behind, so a rotating refresh token is never sent twice. - The ChatGPT plan's usage limit maps to
UsageLimitErrorand is never retried.
SystemOneQuestionandSystemOneAnswercarry the Box question ({ type: "box", instructions }answered by{ type: "box", box: { top, bottom, left, right }, confidence }, the edges fractions of the image's height and width), new typeSystemOneBox, and a response reasonbox_coordinates.validateAnswersrefuses edges outside [0, 1], a box whose top or left edge lies past its bottom or right, or a confidence outside [0, 1] (a missing one isnull);answerConfidencereads anullconfidence as none;synthesizeAnswersandanswersToValuehandle it. A Box is a host-side question: no workflow node declares one and the validator is unchanged.dryRunWorkflowis bounded and can be stopped. It stops aftermaxStepssynthetic steps (an agent's or a judge's answer, an effect, a code node the host'srunCoderuns; 10,000 by default) or when itssignalends, and answersok: truewithskippedgiving the reason. It also yields to the event loop every 64 steps, so a host takes a stop meanwhile. Before, a loop whose synthetic answers never met itsuntilran to itsmaxIters(which has no ceiling) and held the event loop for the whole time: amaxItersof 200,000 took 5 to 21 seconds, during which no timer ran.@parcha/agentrun-dsl/graph, an import-free subpath:workflowGraph(workflow)returns the nodes (JSON Pointer path, kind, label,viafor a call) and edges (next,branch,route,item,child) of a workflow document from its JSON alone, andnodeStatus(graph, source)says for each node whether a run has itdone,working,failedornot-run, from the recovery driver's frame and the map items' records. A map that ended on an item's failure (pin.partial) is failed, and a node inside a map's or loop's body is not done before the map or loop is.nodeStatus(graph, source, { item })reads one map item's (or loop iteration's) record on its own: that item is the instance, so its body is done when the item is. The pi session's text view now takes its node list fromworkflowGraph, so the terminal view and a graph panel cannot disagree about which nodes a workflow has. The subpath's fixtures (packages/dsl/test/fixtures/graph: a sequence, a parallel, a route, a nested map with a child workflow, and a map in each layout, each finished and cut, with the driver's frame and the map items' records) come fromgenerate.mjs, so a panel's test reads what a real run kept. One recording of the nested shape in the keyed layout, cut by a kill and resumed (packages/dsl/test/fixtures/graph/nested.recording.jsonl, the store's durable lines), comes fromexamples/fixtures/graph-nested/run.tsthrough the reference entry; a test reads it back throughnodeStatusat every commit. The reference entry's run options (and a demo's) take an optionallayout(v4keeps the map items asagentrun.itemsdocuments); aJobis unchanged.@parcha/agentrun-dslexportsJob,{ workflow: Workflow; input: Record<string, unknown>; files?: Record<string, Uint8Array> }, the shape a host passes when it runs a workflow with the input and files it starts with, anddefineJob(job), an identity that checks that shape and keeps the workflow's literal type (a workflow declaredas const satisfies Workflowflows through). New public exports; no behavior or stored format changes.- The
examples/webrun page reads only through aRunSource, so the same page shows a live server's runs (httpSource) and a replay from static files (staticSource).mountRunPage(root, source, { theme })shows the ways to act and the live browser only when the source has them, and builds no absolute URL, so it works under any path; it draws light, dark or the system's (auto, the default) from--ar-*color tokens a host can override.writeReplayis the one writer of a replay's static layout, refusing ids and file paths a page could not ask for, a summary for another id and runs whose paths collide, and swapping the new replay in so a failed write leaves the old one whole, andpack-page.mjswrites the self-contained replay page with its Content-Security-Policy in a meta tag. A replay names a dollar figure only for a recording whose manifest says its providers arereal: otherwise its cost isnull, its usage rows count model requests with no amount, and the page's header shows no figure. @parcha/agentrun-jev: images and Box questions, on@typesafe-ai/sdk0.7.0createJevRunnertakes images (assets: a reference to PNG or JPEG file bytes and their type, the reference written in the state or a question) and Box questions (Jev 1.14). It sendsTypeSafe-Danger-Zone-Images: trueon every request that carries an image or a Box and on no other, as a multipart request. Priced, receipted and retried as text is;request_sha256names each image by its own digest.- Refused before any request: a Box on a model that does not answer it (name
jev-1.14.0; the defaultjev-latestis 1.13), a Box with no image, a file that is not PNG or JPEG or is over 16 MiB, a reference nothing uses, and, forprovider: "openrouter", anything that carries an image or a Box.modelcan be named per call. - New types
JevJudge(whatcreateJevRunnerreturns),JevJudgeParams,JevJudgeResultandJevImage: the port a host hands to a package that asks Jev about images without importing the SDK. @typesafe-ai/sdk0.6.0 to 0.7.0. The SDK now compresses request bodies with zstd by default; the runner turns that off, so bodies are the uncompressed JSON they were.
createJevRunnercan reach Jev through OpenRouter:provider: "openrouter"(default"typesafe", unchanged) sends to OpenRouter's System One path (https://openrouter.ai/api/v1/systemone, or an explicitbaseURL) with~typesafe/jev-latestas the default model, and takes its key fromapiKeyorOPENROUTER_API_KEY; it never falls back toTYPESAFE_API_KEY.jevProviderFromEnv(env)returns"openrouter"only whenOPENROUTER_API_KEYis set andTYPESAFE_API_KEYis not. The caps (timeoutMs,maxStateBytes, retries) are unchanged.cost_usdis the provider'susage.costwhen reported, else the configuredpricingestimate, elsenull, never 0, and ausagewith a cost and no token counts is now accepted (it returnsusage: null).providercannot be combined withclient. New public API; no stored format changes.@parcha/agentrun-pi/workflows: save, load and list saved workflows against a directory the host passesWorkflowStore(withWorkflowStoreError,StoredWorkflowandWorkflowRevision) is the store the/agentrunextension keeps for its project directory, now a public entry that loads nothing from pi.new WorkflowStore(projectDirectory)takes a project directory, never the workflows directory itself: records live in<projectDirectory>/.pi/agentrun/workflows/<name>/, so the extension (which keeps using itsctx.cwd) and a host that passes the same directory read each other's records.save(name, workflow)publishes an immutable revision,load(name, digest?)returns the given revision or the last one saved, andlist()returns every revision, newest first.- Nothing about the format or the checks changes: real directories and regular files only (a symbolic link in the path or among the records is refused
with
unsafe_path), the record's metadata and its SHA-256 are checked on every load, and a<name>/directory copied into the workflows directory loads.
- The recovery driver can keep a run in a keyed layout (
agentrun.frozen_run.v4), so a map's commits stay linear in its items. Opt in withRecoveryOptions.layout: "v4"(for a pi task host, return it fromWorkflowTaskHost.options); the default in this release is"v3", which keeps the driver's whole state in one record rewritten at every commit. On the reference job, a map of 400 one-call items wrote 2.007 GB in 643 s in"v3", and 10.6 MB in 20 s in"v4".- What lies in each map item is kept in that item's record beside the state, each committed state inside an item as a delta against its map's base, checked against its digest when it is rebuilt (a state that does not rebuild refuses the open with
FROZEN_CHECKPOINT_INVALID), and each state the driver references once by its digest, a failed map's finished results included. In the pi document store these are two new document families,agentrun.items(one per map item, keyed by the journal key and the item's path) andagentrun.states(one per state, keyed by the journal key and its digest);agentrun.driverkeeps its record shape and itsgeneration. - Only a store that keeps parts can hold the keyed layout: its opened journal has
items(paths)andstateOf(sha256), and every write takes the parts that go with its state (RecoveryJournal'ssave,admitandcompletegain an optionalparts,admitan optionalowner). The memory, file and pi document stores keep them. On a store without them the driver keeps"v3", says why once per open in a new note kind,layout, and reports it asdriver.layout. The store conformance suite (@parcha/agentrun-dsl/recovery/testing) gains three cases for parts. itemState(item, states, path)(@parcha/agentrun-dsl/recovery) rebuilds a map item's state at a path from itsagentrun.itemsvalue and theagentrun.statesvalues by digest, as the driver does, and checks it against that path's digest; it returns{ state }, or{ unreadable }with the reason when the base is missing or a digest differs, never a guess. A reader of a recording calls it rather than reimplement the rebuild; the SHA-256 is Node's unless the caller passes its own synchronous{ sha256 }, and the module that defines it imports nothing, so a reader with no Node can carry it whole.partialResults(partial, states), beside it, reads a failed map's finished results from the driver state'spin.partial: in the keyed layout one digest names the list of slots, eachnullor the digest of that item's result, and each slot comes back asnull,{ result }or{ unreadable }.- A run in the keyed layout stays in it whatever a later process asks for. A run stored whole moves to it at its first write that knows the base of every map still running (a map names its base when it resumes, a moment after the open). After the move, the checkpoint bases of
agentrun.driverkeep the ids of the effects admitted before it: a cost bounded by the work done before the move, which later commits do not grow; each commit after the move costs the same however many items the map has. - A driver that knows only
"v3", from an earlier release, refuses a run in the keyed layout (FROZEN_CHECKPOINT_INVALID) rather than run it with its items missing. 0.1.0-beta.13 and earlier fail their own task on a v4 run and report it refused; that ends the run's agent nodes in flight, and the run then fails on this release too, with no call repeated and nothing corrupted. So opt in only when no process on an earlier release can open the run. The default moves to"v4"in a later release. - From this release, a run stored in a schema newer than the build knows is refused with its own code,
RECOVERY_SCHEMA_NEWER(the error names the schema itfoundand the ones it knows,known), and the pi workflow task leaves that run pending, never terminal: the process's waiters get the refusal, and a build that knows the schema continues the run. A checkpoint the driver cannot read (FROZEN_CHECKPOINT_INVALID) still ends the task. validateWorkflowresults carry an optionalwarningslist that never refuses a workflow. Its first warning is a map with noresultPathwhose body writes noas: each item's result is its whole state, the mapped list included, so the map's result grows with the square of its items. The author skill says the same.
- What lies in each map item is kept in that item's record beside the state, each committed state inside an item as a delta against its map's base, checked against its digest when it is rebuilt (a state that does not rebuild refuses the open with
- The reference entry honors
Fakes.extensionsandFakes.vision(host, running)on a no-key run, takes aresult(record, { dir, keep })hook that writes the recording's result (agentrun.cited/1) from what a demo makes, hands toolsToolContext.live, uses a local box instead of Docker on fakes (Docker only with--live), and takes--input <name>for a capture named indemo.json'sinput.fixtures(a map from a name to a JSON file underexamples/<demo>/fixtures/, read as the input); on fakes a name that is not a capture is refused with the list of captures, and a path outsidefixtures/is refused. A live child is given exactly the environment namesdemo.jsondeclares (keys,oneOfmembers, programs) and nothing undeclared;LiveParts.visiontakes(host, running)likefakes.vision. - The reference example is now a platform for demos:
demo({ self, spec, job, fakes })reads the standard flags (--live,--kill,--record,--input,--max-usd,--local-chrome,--dir,--json) and runs a spec'skillsthrough a supervisor. A kill is a named crossing, a storm, or a box kill (kind: "box"), and may target a tool's box (target: "tool") instead of the run's process. The spend cap is metered on the recording and stops a run past it. A live run drives Browserbase when its key is set, and a local Chrome only when asked for. Every demo's judge passes one seam with Jev's caps (50 requests, 40,000 tokens,JEV_CAP_REACHED) and Jev's public list price. The entry hands a demo a box provider withtransport.box.*crossings and transport box lines built in; the crossings are listed inexamples/reference/README.md. - A run's result can be the generic cited result,
agentrun.cited/1(the recording format's own shape): rows of a label, a value and an optional note, each citing a file inblobs/, a vision look by its key, or both, with where in it (page=,t=,xywh=percent:), and the page it was read from and when. The vision panel reads it besideapollo11.account/1: a row a line, a look's chip opens its receipt and moves its lane (drawing the row's box over a page), a file's chip opens the file (at the cite's fragment, when it has one), a page known only by its address (url, read atat) is linked and marked not kept, and a row that cites nothing says so. A blob link node may carry afragmentthe shell appends to its URL, and a new link node{ tag: 'a', href, text }names a page outside the recording, linked only when it is an http or https address. Fields the format does not name are the run's own and ignored. apps/viewer: embed mode&embed=1frames the viewer in another page: no header (a recording that breaks the format still says so) and the full width of its frame.- Framed, it speaks a
postMessageprotocol (PANELS.md, "Embedding"). It postsready(its stops and chapters),positionat each stop (stop, gen, commit, t and chapter) andfocus. It obeysseek(a stop, a commit of a generation, or a time),focus(a workflow path) andtheme(light, dark or auto), each field checked, and only from the framing page at the origin its URL names with&parent=<origin>; without it, the viewer listens to nothing.
- The run viewer follows a run while it goes (
apps/viewer,?follow=<url>): given a recording directory served over HTTP that grows (events.jsonlgaining whole lines, read from the last byte with aRangerequest; each blob inblobs/before a line names it;manifest.jsonwritten last, once the run finished), it reads what is new about once a second, places it in the format's file order (the order a recorder's merge writes), and redraws every panel keeping the reader's place, with the time cursor on the newest line unless the reader scrubbed back. Kills and later processes arrive as lines like any other, and a file the writer replaced whole is read again from its start (each read compares the last bytes it read, and every thirtieth read all of them). A page opened before the run wrote anything, or while its server does not answer, waits and retries; a blob whose download breaks off is asked for again without losing the lines that name it, and once read it is drawn (and a result in it read) with no new line; a reader who scrubbed back stays at their time when a line arrives before it. Each blob is hashed by the browser where the page has its digest, else a MiB at a time, so a large video does not hold the page. While the run goes, its cost reads unknown and a blob not read yet shows as loading; a URL on another server is refused. When the manifest appears, the page reads the file whole, as a fresh load does, checks it against the format, and plays it as any recording. A panel's view is told when the run it draws is still going (ViewContext.following). apps/viewer: the browser's page actions in repeated and asked twice- The counters strip folds the browser's replay-unsafe page actions (RECORDING.md section 6, draft 25) from the session
records'
pendingEffectentries and thepriorones they carry, per(conv, tool, codeSha). A dispatch after one that reached the page (finished, or a sent row that was not denied) is one extra: in repeated when its process is later, in asked twice when it is the same. A dispatch after ones that never showed reaching the page is possible, shown as "+ N possible" beside repeated and never counted. - A recording whose
browser.sessionslines all carry not(a bare export's) shows page actions not measured, and repeated and asked twice read "not measured" rather than 0 when nothing else measured adds to them. apps/viewer/RECORDING.mdnames that signal in the format's own words: a recording whosebrowser.sessionslines all carry not(a bare export's) shows page actions not measured, never 0.
- The counters strip folds the browser's replay-unsafe page actions (RECORDING.md section 6, draft 25) from the session
records'
resolveModel'ssignalnow reaches a first-time sign-in. Cancelling model setup during the sign-in ends it at once:resolveModelrejects with the signal's reason, and pi-ai's callback server lets go of port 1455. Before, the sign-in kept waiting for the browser with the port held, so the next sign-in in that process failed with "Port 1455 is in use". A cancel also ends the sign-in when the interaction's prompt does not watch its own signal./agentrun statusnames the supported Pi range,>=1.0.4 <1.4.0(the range of the package's pi-durable and chord peers), and flags only a host outside it, suggesting the newest version CI tests:npm install -g @earendil-works/pi-coding-agent@1.3.0. It compared the host with one version, 1.1.0, so it flagged every supported host but 1.1.0 and told it to install 1.1.0. A host outside the range is still not blocked.- An agent node in flight when its workflow task failed is run again when a later start replaces the failed task, instead of failing the run. The node's conversation belongs to the task that started it; when that task ends, pi aborts the request in flight, and a later start runs a new task over the same journal. The node runner used to find the old conversation, receive the aborted request back, and fail the node for good (
NODE_NOT_DELIVERED, "the model did not answer (aborted)"), so any path that failed a task with an agent node in flight lost the run at its next start: an open the driver refused, a host whose options threw. Now an attempt whose conversation another task owns runs again in a conversation of the task that runs it, under the same attempt, unless the old conversation already delivered the node's record, which is then the node's. The model is asked again; no call the workflow admitted is made twice.reuseReceipts'sessionIdis optional: absent, each call's own conversation is its session, so a node run again in another conversation after its task was replaced is answered from the receipt the aborted conversation paid for and does not pay again. A host that passes its ownsessionIdkeeps it; one that keys by the node's attempt would pay again here.
readJournal(file, key).effectslists every effect of a journal in the keyed layout (agentrun.frozen_run.v4). It listed only the ones the driver's record keeps, so a reader of a v4 run missed every outside call made inside a map, unknown ones included, and nested maps' too. Each map item's record lists the effects it owns, and they are now read from there: every effect once, in the order they were admitted, as the whole layout lists them. A resume was never affected: the recovery driver reads the items itself.statestays the driver's own record; in the keyed layout what lies in each map item is in that item's record.- The reference entry's boxes test no longer flakes under load: when the demo plans to lose the install box, the install tool's first command waits until it is killed (no timer), so the loss always lands mid-command and the second box always runs; before, a slow loss let the first command finish and the second box was never made.
apps/viewer: only the host lines that name a process must carrydata.gen- The viewer's format check asked every supervisor (
host) line fordata.gen, so a valid recording with apictureor abox.receiptline showed a broken-format banner. It now asks only the kinds whose fields name the process (process.started,kill,result,kill.failed,kill.request.ignored,signals.failed,recording.carry.failed,run.json,delegation,revoke,fenced); a host kind it does not know is not checked for it.
- The viewer's format check asked every supervisor (
- The stranger job's
registrymode, which installs@parcha/pi-durable-disk@betafrom the registry with the README's own install line as written and runs the README's quickstart, is blocking: 0.1.0-beta.14, whose peer ranges accept pi below 1.4, installs with it, where 0.1.0-beta.13 (ranges below 1.3) failed with ERESOLVE once pi 1.3.0 shipped. apps/viewer: the embed tests can no longer hold a CI job to its limittest/embed.test.tsread its pages with--dump-domand--virtual-time-budget, which now and then never finishes, throughspawnSync, whose timeout sends SIGTERM and then waits for the process to exit with the event loop blocked: a Chrome that did not die heldnpm run test:vieweruntil the job's limit, and no test timeout could fire. Every embed test now drives Chrome over the DevTools protocol in real time, throughtest/driven-page.ts(the module the follow tests use): every wait bounded and named (the launch, each request, the socket's opening, each page evaluation, a page drawing), an answer that can no longer come (the socket closed, Chrome exited) failing at once, and the test's signal killing Chrome's process group at once.- Every embed test has a timeout in seconds, and so do its cleanups, which close every Chrome (through
closeAll) before removing its files, and the file ends checking that no browser was left open.test/chrome-bounds.test.tsshows the bounds on a stand-in Chrome that ignores SIGTERM and leaves a helper running.
- The hosted gallery ships its recordings as files
.github/workflows/pages.ymlbuilds the viewer with--files: the recordings are published as files withrecordings/index.json, and the page fetches a run only when it is opened, instead of loading every run's lines and blobs ingallery.js.scripts/recording/gate.mjschecks a files build:recordings/index.jsonlists exactly the recordings beside it, and the index is the only file beside the recordings.- The gate holds every gallery entry, in either build, to exactly what its recording's files give, since the page plays
the gallery's copy: the same manifest as its
manifest.json, every blob in its directory and no other, each under its own SHA-256 (a files build names the blob's path; an embedded one carries its bytes, which must be the file's), and, embedded, the same lines as itsevents.jsonl. A swapped, missing or extra blob, another manifest or other lines are refused.
0.1.0-beta.14 (2026-10-16)
Accept pi 1.3: the
@earendil-works/pi-durableand@earendil-works/chordpeer ranges now end below 1.4, with the floors unchanged (1.0.4, and 1.1.0 for@parcha/pi-browser). pi 1.3.0 pins its own packages to exact versions, so an install is all 1.2 or all 1.3; CI runs every package's whole suite on a 1.3.0 family as well as on 1.2.0.- On pi-ai 1.3, a tool call whose arguments are not a JSON object reaches no tool: pi-durable refuses it and shows the model the text it sent. A durable node's
submitthen spends none of its submissions. Before 1.3 the tool ran with{}, sosubmitanswered with the schema's violations and counted one submission.
- On pi-ai 1.3, a tool call whose arguments are not a JSON object reaches no tool: pi-durable refuses it and shows the model the text it sent. A durable node's
apps/viewer: one disk panel for every demo that runs on a disk place- A lane per claim generation of the run (who held it, from when, and how it ended: released with its sealed sequence, killed or its box taken away, fenced, or replaced between two polls), the claim's events in order (the orphan flag as seen, the revoke and the takeover time, the fence), and each paid call marked made once, repeated, cut by a kill or in flight, from the shell's own counter fold.
- It reads the supervisor's
hostlinesrun.json(the package'sRunRecordverbatim),delegation,revokeandfenced, whichRECORDING.mdsection 5 now describes (draft 26). A recording with no disk line is unchanged. Viewer only: no package export.
apps/viewer: the gallery bundles two recorded runsreference-jobandten-kills-seed21: the reference example's job with scripted providers (no key, $0), once uncut and once killed ten times and resumed, recorded at one commit from a clean checkout.recordings/README.mdsays how each was recorded and what its counters show.- A test holds every bundled recording to the format, with the blobs it names, and its manifest's cost and processes
to the counters' fold; the source cost check (
scripts/recording/fold.test.mjs) covers the gallery too. - The README says what the deploy buttons publish: this replay viewer only, which never runs an agent or calls a model and asks for no key. A test parses both deploy configs and both buttons' URLs and fails on any variable, secret or binding.
- A run of one process or one kill reads "1 process", "1 kill" in the gallery and the header.
A second example, the hero job: one agent reads nine electricity grid operators' public dashboards in nine browser sessions at once, vision reads two numbers from each chart, and the run ends in
snapshot.html, a table of the nine grids with each reading linked to the screenshot it came from and the operator's published figure beside it.npm run demo:heroruns it on fakes with no key;-- --kill1kills the run inside a paid call and resumes it without paying that call twice, and-- --record <dir>keeps the run as a recording. The nine dashboards were checked by hand (examples/hero/sources.json).apps/viewer: a build that ships recordings as filesnode apps/viewer/build.mjs --files(orbuild(target, dir, { files: true })), off by default, writesrecordings/index.json(each recording's id, manifest and blob paths) and agallery.jsthat only points at it, instead of embedding every recording's lines and blobs ingallery.js. The page lists the gallery from the index and fetches a recording's lines when it is opened, its blobs at the paths the index names, with no guessing. It needs a static host; the default build still opens fromfile://.
apps/viewer: the manifest's job and store- A manifest's
job(the job that ran, a JSON blob) andstore(the run's store at its end, a SQLite blob, with the commit and generation it was taken at), from the format's draft 24, are linked in the header: the job opens in a tab, the store downloads (.sqliteis never opened in the viewer's page). - Both count among the blobs a recording names, so a recording opened from a URL fetches them, and the viewer's format
check notes either one missing from
blobs/.
- A manifest's
apps/viewer: the gallery is hosted on GitHub Pages.github/workflows/pages.ymlbuilds the viewer and publishes it to https://parcha-ai.github.io/agentrun/ on each push tomainthat touchesapps/viewer/, or by hand. Its token reads the repository and writes Pages; it names no secret.- It publishes nothing unless what was built passes
scripts/recording/gate.mjs: every recording in it (a hidden one too) passes the recording validator; a recording holds only its manifest, its lines and blobs named by their own SHA-256; every published text file is free of credential shapes and machine paths; and the gallery embeds exactly the recordings beside it. An empty gallery fails. scripts/recording/gate.test.mjsrefuses each of those;scripts/pages-workflow.test.mjsholds the workflow's triggers, permissions and step order, and runs its gate step as written.
apps/viewer: a panel with nothing in a recording is not drawn- A panel is drawn only for a recording that holds a line it is for: one its new optional
shows(line)says yes to, or, withoutshows, one itaccepts. With none, the shell draws nothing for it, not even its title. The shell judges this once over the whole recording, never up to the stop, so no panel appears or vanishes mid-scrub. - The transport panel declares
showsfortransportlines: it reduces the counters' lines for its own counters, so until now it drew an empty titled box on every run without transport lines.
- A panel is drawn only for a recording that holds a line it is for: one its new optional
apps/viewer: a scripted run says so- The cost on the counters strip names a run whose manifest says
providers: "scripted"(at least one priced part was a fake, a cassette or a script): "scripted, $0" rather than "$0.0000", or "scripted, $" and the price reported, because that is not a provider's bill, though it may include real charges for the other parts. A baseline's strip uses the baseline's ownproviders. - The header's meta line says "scripted providers" or "real providers" when the manifest says which.
- The cost on the counters strip names a run whose manifest says
The viewer's panel contract can draw motion by time: a node's
varsset CSS custom properties (numbers only),ViewContext.nowis the playback clock, ananimatedpanel is redrawn about ten times a second while playing, and afeatured(lines)panel is shown first with the playback controls under it (the first registered one wins).?t=<seconds>opens the viewer at a time. Panels that set none of these draw exactly as before; seeapps/viewer/PANELS.md.The viewer's transport panel is now a stage: one row per environment, the session token riding a rail between them, a flight shown as its own phases (drain, seal, ship, take, resume) with the token flying while the bytes ship, a whole-box kill as a red flash with the token left as a ghost and popping back in the rebuilt box, a process kill as a blink in place, a skipped wait as a hatched band with its seconds, the browser tile beside the lane it runs in, and the film's card at the end. The token, the lanes' marks and the status line all read one answer for where the session is. It is a pure view over the same reduced state (plus the box's lane and the lane the browser runs in), in light and dark and at phone width, and with
prefers-reduced-motionit does not animate and tells the same states in words. It uses the newfeatured,animated,varsandnowshell additions.The reference example's reconcile now hands a host's
rerunthe args the journal kept for an effect (RecoveryEffect.args). Before, a call admitted but never sent (a kill between admission and the send) could not be sent again after the kill, and the resumed run was refused withFROZEN_EFFECT_UNKNOWNeven for a host that could rerun it. With arerun, the resumed process now sends it exactly once.The viewer's transport panel no longer reads a host kill line with no
kindas a whole-box kill: a kill's kind isprocessorbox, an absent kind means a process, and onlyboxis a box (the strip counts follow). A process that comes back with no box named no longer prints "at box ?". A recording with no transport lines now draws nothing from this panel, so a reference run's plain process kills no longer open an empty "Boxes" heading and a kills strip.pi-1-2fails unless every package with a pi-family peer is in its leg: each one is built, type-checked and tested on pi 1.2.0, so a package whose ranges stop accepting 1.2 fails CI instead of leaving the leg.The reference kill test no longer claims that the same seed lands every kill at the same place: under load, later landings depend on the order concurrent work takes inside a process. It asserts what is true: the same seed draws the same first kill's plan, another seed draws another, and every ledger check and gate passes on each run.
scripts/changes.mjs --check, which the requiredverifyjob runs on pull requests and in the merge queue, fails a release commit (a workspace package version differs from the merge base's) whilechanges/holds anything butREADME.md:release commit carries unfolded fragments: <names>; refold on current main. The tag now always goes on the release pull request's own merge commit, with no separate fold pull request.The examples' Since cells are enforced by the stranger test
scripts/stranger.mjsreads the examples index (packages/pi-durable-disk/examples/README.md) and, for every keyless example whose Since is at or below the registry'sbetatag, installs exactly that published version in an empty directory (the index's own install line), copies the example's files, runsnode main.tsand expects itsok:line. An example above the beta is skipped with the reason printed. It runs in the existingstrangerjob through thepi-durable-diskspec (exampleskey), so it needs no workflow change;node scripts/stranger.mjs pi-durable-disk --examplesruns only this check. An example that claims a Since its imports do not have fails, naming the missing export.
The validator sweep collector (
spec/lean/sweep/collect.mjs) takes--test-concurrency <n>, orTEST_CONCURRENCY=<n>, and passes it to the DSL, Pi and example suites it runs. With neither set, node's default is unchanged.The release's last step,
verify-published, installs its consumer with the same@earendil-workspins the package check uses, and fails if the install lands on a version the repository lockfile does not hold. Before, the consumer floated pi-codemode to a release that dropped an export pi-coding-agent imports, and the step failed after every package had published.The floor legs of
scripts/pi-leg.mjspin the packages the family depends on outside it (pi-codemode and pi-mcp under the coding agent) at the leg's version too, and fail a leg whose coding agent loads one at another version. Before, they stayed green only because the lockfile held pi-codemode 1.1.0; a refresh to 1.2.0, which dropped an export that coding agents 1.0.4 and 1.1.x import, would have broken them.The install line and the requirement in the package README name Pi 1.2.0, the tested host.
npm install -g @earendil-works/pi-coding-agent@1.1.0now installs a Pi that crashes on start: its^range pullspi-codemode1.2.0, which no longer exportsMCP_TYPESCRIPT_PREAMBLE.apps/viewer/RECORDING.mdfollows the format's draft 25- A supervisor's
killline may say what was killed:kindisprocesswhen only the run's process died andboxwhen its whole machine or sandbox was taken away. Absent meansprocess, and a panel never guessesbox. - A JSON result blob may be
agentrun.cited/1: rows of a label, a value and its cites, each naming a blob, a look or both, with a fragment in pi-vision's form, and optionally theurlthe evidence was read from and the timeat. A cite with a url and no blob or look is shown as linked, not kept; a row with no cite says on screen that it cites nothing. - The manifest may name the
jobthat ran and the run'sstoreat the end, each as a blob, and other recorded runs of the same demo insee_also, linked only. - Repeated and asked twice count the browser's replay-unsafe page actions, by
(conv, tool, codeSha)from the session records'pendingEffectentries: each later dispatch of an action that already reached the page counts once, and one after a dispatch that shows no sign of reaching it is possible, never proven. A recording that keeps only the latest session records shows page actions not measured. - A look live lines show started is not a possible extra payment when a later process filed its key as kept: that filing is the same answer read back, already counted.
- A supervisor's
A runnable example of one agent behind a tiny web page (
examples/web): a job history, a live run page with chat, grouped steps, files and the agent's browser, and a hand-over where the agent asks the person to sign in through the browser's live view (request_sign_inends its turn until the person replies). It runs on the open packages: the agent lives in an agent home of@parcha/pi-durable-agent, each run is api-durable-diskrun directory started withstartRunand served by its own door, the steps are@parcha/pi-durable-agent/timelinerows, and the server releases a run once it has answered and carries its Browserbase sign-in back to the home, so the next run starts signed in.AGENT_BROWSER=chromeruns the agent on its own headless Chrome instead, with its profile in the agent home, and mounts pi-browser's live view for it where that pi-browser has one; Browserbase stays the default until pi-browser ships./live-view. Its tests run offline.
0.1.0-beta.13 (2026-10-14)
@parcha/agentrun-pi/durablereads and writes tool results the same way on pi-durable 1.0.4 to 1.2.x, in one build; pi-durable 1.2 names a tool result's blocksoutputinstead ofcontentand reads a result that has onlycontentas empty, with no error. The peer ranges do not move yet.- Every tool result the package writes carries its blocks under both keys: the record tool's answers and its fatal return, and
reuseReceipts' refusal and its answer from a receipt.toolResult(blocks, fields)builds such a result for a host's own tools. - Behaviour change: a call through
reuseReceipts(...).wrapwhose result has its blocks only under the key the loaded pi-durable does not read now throwsToolResultKeyError(codeTOOL_RESULT_KEY, with the tool's name), and no receipt is written. Before, on 1.2, the model received an empty result and the receipt stored an empty text. Results are read under the loaded version's key, so a failure envelope in a 1.2 result is no longer recorded as paid. - A Session that fails at a storage error (pi-durable 1.2 then rejects later calls with
SessionFailed) reaches the recovery driver as the storage's own message and code, with theSessionFailedas its cause, and a run'swait()rejects with the storage's error, as on 1.1.
- Every tool result the package writes carries its blocks under both keys: the record tool's answers and its fatal return, and
@parcha/agentrun-dsl/recovery: the host's effect adapter is told the admitted effect. Under the driver,runEffectreceiveseffect: { id, argsHash }(typed onRecoveryEffectParams, besidecall): the effect's journal id and the hash of its arguments, asjournal.admitstored them and the same in every process of the run. A host that keeps what an outside call answered under them settles an effect whose call was cut withjournal.complete, without recomputing the driver's hash. Additive; the journal's stored format is unchanged.- CI gains two jobs.
pi-1-2builds, type-checks and tests every package whose pi-family peer ranges accept pi 1.2.0 on that version, while the workspace develops on 1.1.0:scripts/pi-leg.mjspicks the packages from their ranges, installs one copy of each family package at the version, and fails on any other resolved copy.agentrun-pi-pi-floorruns@parcha/agentrun-piwith all six pi-family packages at 1.0.4, beside@parcha/pi-durable-diskat 1.0.4. - New example,
examples/reference/(npm run demo:reference): one program that composes the open packages on pi-durable and runs one job end to end with no key and no network but loopback. A disk run on a local directory, the workflow as one pi-durable task (createWorkflowExtension) with its LLM step on the node runner, the browser on its fake backend, vision'sreadand a scripted judge.host.tsdeclares what a host supplies as one value,assemble.tswires it andrun()returns the task's outcome (complete,escalated,refusedwith its code and message, orstopped). The tool door keeps its answers, so a run killed between a call's dispatch and its commit is settled from the answer before the driver opens; each judge's receipt is kept in the run's store and replayed by a resumed run; the gates read the run's store, not its status rows; and the scripted lead asks an errored read again, looks before asking a cut page action again and decides from the browser's details, and never cites a read of a page it did not open,--jsonprints the result as one object,--dirkeeps the run so a second process resumes it, and--chromedrives a local Chrome that reaches only the job's pages.examples/reference.test.mjspins the output and kills a run after the browser read, then resumes it. Its README lists the package gaps it works around. - New package,
@parcha/pi-transport: run a pi-durable session's tools in another machine. This release is its first layer: theBoxandBoxProviderinterfaces (create, attach, list, exec with streamed output and input, upload, download, status, destroy, an optional snapshot with a declared kind, codedBoxErrors returned as values),localBoxes(./box/local) anddockerBoxes(./box/docker), the stricttransportconfig (parseTransportConfig: unknown keys fail with their path, every reference names a declared environment), and a box conformance suite in./testing(registerBoxConformance, 19 cases).- Every box has a required maximum lifetime that its provider enforces with no call from the host, a program in a box never receives the host process's environment, and a provider refuses a spec field it cannot honour. The first two are conformance cases; the lifetime case observes the machine, not the provider.
- It does not depend on the other packages. Publishing it is on hold: its manifest is
"private": true, so it is built, tested, verified and kept at the workspace version but is in no release (seedocs/releasing.md). Its README andpackages/pi-transport/CHANGELOG.mdhave the details. - It depends on
typebox; pi-durable and chord (1.0.4 to 1.1.x) are peers. It follows pi's own code style and tooling inside the package: Biome, and Vitest on source through asourceexport condition. Both are development dependencies of this package only. - CI gains two jobs:
pi-transport-pi-floorruns its build, type-check and suite again on pi-durable 1.0.4, andtransport-dockerruns the box conformance suite on real containers.
@parcha/agentrun-pi/durablestores a run's decision rows in pi-durable: the session documentagentrun.decisions(document version 1, row version 1),appendDecision,readDecisions, andcreateDecisionWriter({ open }), which commits rows in the order they are recorded, opens the run's Session on the first row, reports the first failed commit fromsettled()andflush(), and releases the Session inflush()even after a failure. The entry point now imports pi-durable and chord at runtime; both stay optional peers of the package, needed only by a host that imports./durable.@parcha/agentrun-pi/durableruns a workflow's LLM nodes on pi-durable.nodeRunner(host)returns one extension to install before the Harness opens, andon(scope)gives therunNodethatwithRecovery(driver, { runNode }, { durableNodes: true })calls once per attempt and thecloseStepSessionthatopenRecoverytakes. A host passesagent()and, as it needs them,stance,record,verify,onNodeOpen,init,submit,digestandmaxAttempts. One attempt is one conversation, found again after a crash: an attempt that already delivered makes no request, a turn that committed is never requested again, and nothing of a resumed node reaches a model before the host'sonNodeOpenran.legacy(entry)gives a conversation an older build opened its extension back under that build's name.- The record core the node's
submittool is built from is exported with it.deliver(contract, store, args)decides one call over a small store interface: transport repair, the file envelope under a host-named key, the delivery attempts, one feedback round of a second reading, and the model-facing texts.lintRecordSchemareports what an author should fix in a record schema, for a host or a validator: a root that admits no object, and a schema that declares its fields and requires one outside them.recordTool,recordNudgeandworkspaceRecordFileput it on a pi-durable conversation, andrunNodeAttempt(scope, attempt)is the kernel under the runner (taskScope,hostScope). - Four document kinds.
agentrun.record({ record, attempts }) andagentrun.gate({ bounced, disagreements }) are conversation documents, andagentrun.nodesis a session document family keyed by the attempt's session id (conversation,digest,startedMs,closed).agentrun.nodeis new: a conversation document with the node's system text, label, schema and file key. It is additive, and a conversation an older build opened gets one when the runner first reaches it. - A node that delivers no record throws
NodeFailure(code: "NODE_NOT_DELIVERED").final: truesays a second attempt would spend the same again: the delivery attempts are spent, the model did not answer past pi's retries (pi's reason and detail are onunanswered), or the schema is one nosubmitcall can satisfy (lintRecordRoot: it is no JSON Schema object, or its root type admits no object), which is refused before any conversation exists. A schema that requires a field outside itspropertiesis not refused: the tool's parameters are open and the node's task carries the schema, so a model can deliver on it.final: falsesays the node's run ended with no record and attempts left, so a driver may admit a second attempt, a new conversation. An attempt found under another binding digest is refused withNodeBindingMismatch(code: "NODE_BINDING_MISMATCH"). A pause rethrows the run's stop and leaves the conversation for the attempt's next entry; any other stop aborts the conversation's work. A host whoseonNodeOpenthrows fails the node's call with that error and releases nothing: what pi resumed of the node waits for the attempt's next entry, or ends with the Harness's close. - Two limits of the attempt count, each pinned by a test. The nudge that spends the last attempt still continues the run, so a run can take one request more than its attempts and the stored count can read one past the limit. A rejection cut by a crash after its commit is decided and counted again when the call replays, so a crash there costs the node one attempt.
spec/lean/AgentRunEnvelope/Gate.leanmodels the gate's delivery (attempts, the one feedback round, termination), with an audit that fails when its list of theorems changes;lake buildbuilds it by default.
- The record core the node's
@parcha/agentrun-pi/durableruns a workflow as one pi-durable task.createWorkflowExtension(host)returns the extension to install before the Harness opens and the host's three operations on a run:start(harness, { key, workflow, input, bind? }, context),read(harness, key, context)andstop(harness, key, "pause" | "cancel", context, source?). The task isagentrun.workflow, version 1, with one phase: it opens the recovery driver overdocumentStoreas the journal's one owner, runs the interpreter to its end inside one invocation and makes one terminal commit. A process that dies leaves the task pending, and the next process'sharness.resume()runs the phase again over the same journal, which answers what committed and refuses an effect left unknown. The host gives the driver's options (options), then either its adapters, which the task wraps withwithRecovery, or the dependencies it built over the open driver, with its owninterpretwhen it runs its documents under its own policy (run).- A run's key names one task. The session document family
agentrun.task(version 1, keyed by the run's key,{ task, stop }) is new and additive, and the journal's documents are unchanged. The entry and its task are created in one commit, so two starts of one key make one task.startreportscreated,attached(the task was live) orsettled: a task that ended complete, escalated or cancelled is returned as it is, and one that ended refused or failed is replaced by a new task over the same journal, which refuses again until the host reconciles it. A journal an older build started, with no entry, gets its task at the next start. - A start that reaches a task asks for the run that task holds. Its workflow, what the host binds and its input are compared with the task's stored request by canonical digest, so key order does not matter. A request for another run is refused with the driver's
RUN_STORE_BINDING_MISMATCH, whosemovednames the workflow's parts, what the host binds, or the input's fields (input.<field>), and the start commits nothing: no task, no stop, no cancel. Before, a changed request reused its key silently: a live task ran its original request, a finished one returned its old outcome, and a replaced one ran the changed input over the earlier run's journal when that run had failed before its first step. The driver's binding is unchanged; it does not cover the input. waitgives aWorkflowOutcome:complete,escalated,refusedorstopped. A typed failure is a committed outcome, never a faulted task:refusedcarries its code and message, anddetailthe failure's own plain fields. A run whose terminal commit the Session refuses fails its waiters in that process with the run's own failure instead of leaving them waiting.- A pause reaches a run where it is live, in this process.
stop(..., "pause")resolvespausedonce the run's work has stopped with the pause in its journal (an effect in flight completes and commits first, see the next entry),endedwhen the run ended otherwise, andnot-livewhen it is not running here. The task stays pending; the host closes the Harness, and the next open continues the run with no second dispatch. A cancel is pi'sabortTaskand resolves once the task is marked: pi ends the run's invocation and the conversations the task owns, then the task's abort handler commitscancelledin the journal and anabortedoutcome. The effect a cancel cut stays unknown, and a cancelled run never resumes. The first cancel names the run's source, and a later one does not change it. A stop given with the start (options.stop) is recorded in the commit that creates or reaches the task and is taken before any step; a cancel that a process recorded and died before pi marked the task is taken by the run's next invocation, before any step and before the host's ladder. A pause given with a start that attaches to a run already running in this process is recorded and taken when the run's next invocation opens;stoppauses a run that is running here. - A host's ladder runs inside the task. With
escalated(ending, invocation), a run that did not complete and was not refused continues there in the same invocation, so the continuation the host opens is owned by the task and a cancel aborts it before the task. The host commits the escalation row throughinvocation.driver.escalatebefore anything acts on it; a run reopened after that commit goes back to the ladder with the row, and the interpreter does not run again. A refusal, a stop or an abort never ladders.
- A run's key names one task. The session document family
@parcha/agentrun-pi/durable: three seams for a durable host.- Breaking:
readJournal(file, key)anddurableDoc(file, kind, key?)take the run's store file instead of a directory. They no longer look for<directory>/durable/run.sqlite, so a store is read where its host keeps it (pi-durable-disk keeps it atstore/run.sqliteunder the claim); a directory passed as the file throws.committedRoutes(state)is unchanged. decisionRecovery(invocation)returns{ recordDecision, decision }for a workflow task, from the invocation the host'srunis handed:recordDecisioncommits each decision receipt as a row through the task's own commit, in the order receipts are recorded, anddecision(id)answersrecovery.decisionfrom the last answered row that run recorded under that id, so a resumed run does not ask the judge again.DecisionRowgains four optional fields,decision_id,request,state_sha256andrun_key, which make the row the whole receipt again for the run that asked it; another run in the same Session is never answered from it. The row version stays 1; a row without them is read as before and never answers.WorkflowTaskHost.optionsis documented as where a host settles an effect a killed process left unknown: an effect it completes there (journal.complete, before the driver opens) is answered from that result and is not dispatched again. No change in behaviour.
- Breaking:
@parcha/agentrun-dsl/recovery: a pause lets an effect in flight complete. Breaking for a host that relied on a pause cutting an effect.driver.stop({ action: "pause" })refuses new work at once and is taken when no effect is in flight: an effect already admitted dispatches, completes and commits its receipt first, so a paused run resumes without a reconciliation. Before, a pause aborted the effect through the driver's signal and left it unknown. New work (a node's resume, an effect, a wait, a step's attempt) waits at the newdriver.seam()until the pause is taken and is then refused withFROZEN_PAUSED; finished work still commits. A cancel is unchanged: it is immediate, and the effect it cuts stays unknown. The recovery models do not move: neither has a stop, a pause adds no state between an admission and its completion, and the state a cancel leaves is the one a crash leaves.- New package,
@parcha/pi-vision: eyes for pi agents.readkeeps its name and its arguments and reads images fitted or tiled to the model's limits, PDFs, office documents, spreadsheets and video; what cannot be done is a typed miss, and every look a second model answers is filed with a receipt. It has a host-neutral core, the pi-durable adapter (./durable), the Node environment (./node), eyes on pi-ai (./eyes/pi-ai), the providers (./providers/<name>) and./testing(fake eyes, fixture builders, the provider conformance suite, the leak check).lookis declared and not built: it throwsNotBuiltError.- It does not depend on the other packages, and a release publishes it with them under the
betatag. Its README shows a first read that needs no key and nothing optional installed; its stranger spec (scripts/stranger/pi-vision.json) runs that read from the packed tarball in an empty directory with an empty environment, and fails when its output differs from the README's by a byte. Its README andpackages/pi-vision/CHANGELOG.mdhave the details. - It depends on
unpdf,@hyzyla/pdfium,fflateand@xmldom/xmldom; pi-durable and chord (1.0.4 to 1.2.x), pi-ai (*, the host's copy, as Pi's package guidance asks of a package that imports it) and Photon are optional peers. sharp is passed in by a host and is neither a dependency nor a peer, so its LGPL libvips binaries stay out ofdocs/dependencies.md. - CI gains two jobs:
vision-binariesinstalls ffmpeg and poppler and runs the package'stest/binariessuites, andpi-vision-pi-floorruns its build, type tests and suite again throughscripts/pi-leg.mjswith pi-durable, chord and pi-ai at 1.0.4, one copy of each, the lowest its peers accept. The package develops against 1.1.0, andpi-1-2runs it on pi 1.2.0.
- It does not depend on the other packages, and a release publishes it with them under the
docs/dependencies.mdcounts the copies a package resolves inside its own workspace directory.scripts/export-source.mjsrecorded only root-level lockfile locations, so a package whose dependencies or peers resolve to workspace-local copies dropped out of their rows: 7 package/version pairs were missing (248 listed, 255 now), all permissive.@parcha/pi-durable-diskinstalls beside pi-durable 1.1: its peer ranges for@earendil-works/pi-durableand@earendil-works/chordwiden from~1.0.4to>=1.0.4 <1.2.0, and it is developed against 1.1.0 with its whole suite run again on 1.0.4 in CI. Its store passes pi-durable 1.1.0's storage conformance suite, which now requires scans in either order; the Docker runtime image installs pi 1.1.0. Seepackages/pi-durable-disk/CHANGELOG.md.@parcha/pi-durable-diskinstalls beside pi-durable 1.1: its peer ranges for@earendil-works/pi-durableand@earendil-works/chordwiden from~1.0.4to>=1.0.4 <1.2.0, and it is built and tested against 1.1.0. Its store passes pi-durable 1.1.0's storage conformance suite, which now requires scans in either order; the Docker runtime image installs pi 1.1.0. Seepackages/pi-durable-disk/CHANGELOG.md.@parcha/agentrun-pialso takes@earendil-works/pi-durableand@earendil-works/chordas optional peers at>=1.0.4 <1.2.0; install lines select@beta.@parcha/agentrun-pitargets Pi 1.1.0. Breaking for standalone use:@earendil-works/pi-agent-core,@earendil-works/pi-coding-agent,@earendil-works/pi-tuiandtypeboxmove from exact dependencies to optional peer dependencies at*, which is Pi's own packaging rule (a second physical copy can shadow the host's).pi installneeds nothing more; an application or theagentrun-piCLI installs the peers itself. A host tool called from a workflow now receives Pi 1.1's tool context (tools, and anexecuteToolthat answers with an error outcome, since a workflow node has no sibling tools).PiRunErrormoves to its own module so the workflow service loads without a Pi package installed./agentrun statusreports Pi 1.1.0 as the tested host.@parcha/agentrun-dslrecords typed-question decisions. A host that suppliesrecordDecision(receipt, exchange)gets oneDecisionReceiptper request (judge,pick,sift,route, anaskpredicate, averifyreview), awaited before the answers reach state, an event or a branch; a rejection fails the node withWorkflowDecisionRecordError. A receipt holds the questions, the answers, the rule with its thresholds and the action, andrederiveDecision(receipt)recomputes the action without a model call. See "Decision receipts" indocs/host-integration.md.- The decision events carry the receipt's id:
decision_idonjudge.answered,route.chosen,ask.evaluatedandverify.answered, anddecision_ids(one per request) on a sift'sjudge.answered.runJudgereceives it asdecisionId. The id is the request's identity, so the same request asked again has the same id. - A sift split over the host's limits records one receipt per request. When one request fails, the requests in flight are recorded as
cancelled, and a split sift now refuses a request's answers as soon as they arrive instead of after every request has returned. recovery.decision(id)answers a request from the receipt an earlier run recorded. Before, a resumed run asked again: a loop'suntil: askcould end the loop on an earlier pass's state after later passes had run, and a route could take another branch than the one already started. With the hook, both follow the recorded decision.- No change for a host that supplies neither hook, except the new event fields.
- The decision events carry the receipt's id:
- The Lean model's
siftno longer fails when its items exceed the question limit: like the interpreter, it refuses only a question set that cannot fit one request. The conformance corpus gains its first sift cases, a sift is scripted per item, and a case may setlimits.maxQuestionsPerRequest. - Formal models of recovery.
spec/receipts/Receipts.tla(TLA+) andspec/lean/AgentRunRecovery/Frozen.lean(Lean) state what a recovery driver does with effects over aRecoveryJournalacross crashes and failed commits: admitted before dispatch, one dispatch per effect, a completed effect answered and an unknown one refused on resume, nothing admitted after a failed commit. TLC checks seven properties on two configs, nine guard mutants must each be caught by its named property, and Lean proves the driver's properties for every number of steps and crashes. A newreceipts-specCI job runs the TLA+ checks when a change touches the spec, and a nightly workflow runs them onmain. AGENTS.md and CONTRIBUTING.md add the rule: a change to modeled behavior changes the model in the same pull request. applyHostOutputTypes(candidate, host, { normalizeType })takes an optional normaliser: an authored artifact type (another case, dashes for underscores, an alias) is mapped to the host's name for it before the host's prose types are matched; a type it maps to null or undefined is left as authored, an exact name included. Without it a type is matched exactly, as before.candidatePolicyErrorsstill compares a candidate's artifact types to the host's list exactly.@parcha/pi-durable-diskrevokes, with a fenced or deleted run's delegations, the ones its holders keep on their own private directories (.archil/client-<id>, listed with no path), when those holders hold nothing else. They block no run; before, every killed client left them orphaned, and they piled up and resurfaced in listings. Its CHANGELOG has the details.@parcha/agentrun-dsladmits a tool effect with the external call it makes.RecoveryEffectgains an optionalintent: { tool, argsHash }andRecoveryJournal.admitan optional lastintentargument; the driver admits avia: "tool"node withgatewayIntentOf(node.tool, input)(for a node that calls the gateway through thefetchwrapper, the tool the wrapper names and the hash of its inner arguments), and the intent is kept from admission, through completion, at every later open. Shell and executor effects get none, and an effect admitted without one has nointentkey. Additive: a journal written before this change reads as before, and its unknown effects stay matched by name and hash alone. Before, a continuation could match a completed effect by its receipt but never an unknown one, so a tail dispatched an identical paid call again;inheritableUnknownsnow carries the intent andreuseReceiptsrefuses a call whose key matches it. A store built for the store contract must keep the intent: the conformance suite gains a case for it, and a mutant store that drops it.@parcha/agentrun-dsl/recoverybuilds a handoff from an escalation the driver committed:buildHandoff(journal, options)returns the bounded, redacted block and digest a continuation reads first, the attempt written as files underevidence/frozen/, and the receipts and unknown effects the journal hands over (inheritableReceipts,inheritableUnknowns,gatewayIntentOf). An input field named like a credential is never copied, and a failed file write is reported on the handoff, not hidden.@parcha/agentrun-pi/durablekeeps a run's paid receipts as a document:ReceiptIndex(the session documentagentrun.receipts, document version 1, one entry per paid call keyed by tool and argument hash, with the paid result and the session that paid),reuseReceipts({ tools, sessionId, answer, unknown })andseedReceipts(host, receipts, from).reuseReceipts(...).wrap(tool)records a paid call's result, answers a repeat of another session's call from the receipt and says so (whenansweris set), and refuses a call equal to an effect whose outcome is unknown; a tool result that is an error, or whose text is a failure envelope, is not a paid result and is not recorded.seedReceiptsputs a handoff's receipts in the document before a continuation's first request and keeps a receipt already held. A receipt holds the result's text (the text blocks, joined by a newline): another kind of block, an image for one, is not kept, and a repeat is answered with the text alone; a call that returned nothing is a receipt whose result readsnull, and its repeat is answered, not dispatched. The document's kind, version and key are a stored format.@parcha/pi-browseris published from the next release, under thebetatag, with the other packages: its manifest no longer says"private": true. Its README shows one command that needs no Chrome and no key; its stranger spec (scripts/stranger/pi-browser.json) runs that command from the packed tarball in an empty directory with an empty environment, and fails when its output differs from the README's by a byte. It runs on pi-durable 1.1 and 1.2 (@earendil-works/pi-durableandchord>=1.1.0 <1.3.0;@earendil-works/pi-coding-agentstays*). Its README andpackages/pi-browser/CHANGELOG.mdhave the details.@parcha/agentrun-piaccepts pi-durable and chord 1.2: both peer ranges are now>=1.0.4 <1.3.0(they ended below 1.2). Pi's own packages (pi-ai, pi-agent-core, pi-coding-agent, pi-tui, typebox) stay*, as Pi's package guidance asks. The workspace still develops on 1.1.0.- CI gains
pi-cross-version: the package's crash tests, with every first process of a run in an install of the packed packages on pi 1.1.0 and every later process in one on 1.2.0. A run left by a 1.1 process (killed with a tool call in flight, mid-submit, at any crossing of a sweep, or paused) completes on 1.2 with no effect dispatched twice.scripts/pi-ranges.test.mjsholds every package's pi-durable and chord ranges below 1.3, and Pi's own packages at*. - A documented limit. A run that pi 1.2 left with a tool call in flight cannot be continued by pi 1.1: pi 1.2 keeps that call as a version 2
pi.tooltask, and 1.1 waits on it. The only thing 1.1 writes is that task's mark from running back to pending, as pi does for any task a dead process left running. Continue such a run on 1.2, and do not downgrade pi under a live run. A test inpi-cross-versionpins what holds: after the 1.1 process ends, every other row of the store is as it was, and 1.2 then completes the run with no request made twice. - New in
@parcha/agentrun-pi/durable:refuseNewerTasks(harness, context)andNewerTaskError. A host that may open a store on pi-durable before 1.2 callsawait refuseNewerTasks(harness, context)afterHarness.openand beforeharness.resume(). When the store holds a tool call that pi 1.2 left in flight, it throwsNewerTaskError(codePI_TASK_NEWER, with the task'skindandversion) instead of letting the run wait; the host then leaves that store to a process on pi-durable 1.2 or later, which continues the run. On pi-durable 1.2 the call does nothing, so a host can make it on every open.
- CI gains
apps/viewer: a credential gap says why- A browser session's
frame.gapwith reasoncredential_on_pagecarries itsdetail(field,typed,too_deep,unreadable_frameorhost) into the picture, and the viewer shows it as the gap's tooltip: a credential or code field holds a value, held one since the last check, the page nests deeper than the check reads, a visible frame cannot be read, or the host paused the session. A detail it does not know is shown as it is.
- A browser session's
- New app,
apps/viewer: a static player for runs recorded asagentrun.recording/1- It plays a recording (its format is
apps/viewer/RECORDING.md) from a gallery built with it (none yet), a URL beside the page, or a folder dropped on the page. One classic script with no dependency, so it opens fromfile://; Cloudflare and Vercel deploy configs serve it as static files. - The timeline draws a band per process from the supervisor's lines with each kill labelled, a restore after a box died
(accepted only with its
transport.movesrecord, every move record'sheadchecked), the calls and the model turns. A scrub stops only between commits, never inside a process's snapshot, and equals a fresh reduce to that point. - The counters strip folds durable lines only: paid calls made (a lost branch's included), repeated and asked twice per
call (an effect's applied calls, or an agent's paid call by its receipt key; an effect that reports no calls shows
repeated as not measured), unknown, judge asks, model turns (an aborted entry is not one), re-asks from the store's
pi.live(proven or possible), processes, kills, and cost, unknown when any part is (model, judge, browser sessions, vision looks). A recording with no durable line shows every count as not measured, never 0. - Panels follow a pure
accepts,reduce,viewcontract (apps/viewer/PANELS.md); focus is a view input, and an extension's panel shows before the shell's own two. A manifest'schapters,baselineandprovidersare read.?at=,?cite=and?focus=links. A recording's file opens in a tab only when its type is passive. npm run test:viewer(innpm run checkand CI) typechecks it and tests it against a fixture cut from a real killed run, a restore built line by line and a real 30-kill storm, and opens the build fromfile://in Chrome when there is one.
- It plays a recording (its format is
@parcha/agentrun-pi/durablerecords a run's store as durable lines foragentrun.recording/1(a recording a viewer replays): one line per document revision (the document's whole value after it, its kind, family key and version), per conversation entry (as stored) and per task record, each with the store's commit and its order inside the commit, the generation of the latestagentrun.driverrevision at or before it, and the execution path and conversation the record names. A record in a node attempt's conversation (an entry, a conversation's document, a task) takes the node's path. No stored format changes.recordCommits(session, { gen?, from?, write, clock? })records inside the run's process, on pi's commit publications: every commit as it is adopted, tasks at their real commit.fromis the snapshot the recording continues, carrying its generation and its node attempts' conversations. The listener only maps and queues;writegets one commit's lines at a time, and the returned stop waits for them and throws the first that failed. With awritethat completes synchronously, a kill loses at most the commit in flight.snapshotLines(storage, { at, gen?, context })gives every document of the run as it stands, markedsnapshot: true, for a process to record after it opens the run and before it resumes, so the state is exact after any kill.recordingLines(file, { after? })reads a store no process holds, read-only: the history pi kept. pi deletes a current-only document's older revisions when it writes a new base (the driver document checkpoints every 32 deltas), so a line before the earliest surviving driver revision has no generation, and every task's current record is one line at the store's last commit.
@parcha/agentrun-dsl/recovery:RecoveryOptions.reconcilelets a host settle the effects a killed process left unknown through the driver's own open of the run.openRecoverycalls it once, after the open and before anything resumes, with the unknown effects (each with itsintent,argsand anycallsit kept) and acomplete(id, value, receipt?)that records one's result,receiptbeing{ intent?, calls? }; a settled effect is answered from it and never dispatched again, one left unknown is refused as before (FROZEN_EFFECT_UNKNOWN), and a throw fails the open withRECOVERY_RECONCILE_FAILED(cause kept) before anything is dispatched. An effect now keeps the arguments it was admitted with (RecoveryEffect.args, an optional field on theagentrun.effectsrecord;RecoveryStore.admittakes them as a seventh argument), so a call the host's door never received can be re-sent fromreconcile's list, andcomplete(id, value, receipt)takesreceipt.calls, the call records ({ outcome: "applied" | "failed" | "unknown", ... }) the host made or found applied while settling the effect, so that its calls list every applied call once, which the driver keeps on it before it completes, as a dispatch keeps its own, after the calls an earlier process kept (RecoveryEffect.callscarries them from the journal); a record written before has none and reads as it did. A host that opened the journal itself before the driver (documentStore().open) raised the run's generation twice per process; withreconcileit is one per process.WorkflowTaskHost.optionspasses it through and is told itsphase: the task's abort handler opens the driver too, soreconcilealso runs during a cancel unless the host returns none for"abort". The stored format changes only by the one optionalargsfield onagentrun.effects(document version 1, no version bump): additive, and a reader that does not know it reads the record as before.- The reference example records its run as an
agentrun.recording/1directory (npm run demo:reference -- --record <dir>): live and host lines through the host's redactor, image blocks as blobs, pi's agent events per conversation, and the durable lines throughrecordCommits, taken inside the disk package'sbeforeResumehook.node scripts/recording/validate.mjs <dir>checks a recording against the format, andnode scripts/recording/fold.mjs <dir>prints the counters strip with the viewer's fold. - The run viewer gains a workflow map panel for fan-out runs (
apps/viewer). A grid of single glyphs (empty, working, done, finding) is laid out from the workflow document on the task line, so every branch has its cell before any line names it. Clicking a cell, or a recording's scripted camera, zooms into that branch by execution path while the grid stays whole: the branch's steps (done, working, still to come), the box's network log (the connections the install made, with the one that is not the package registry flagged, and the end of its log), the repository page the agent read, and what vision read of each badge, side by side. Kill marks show which branches a kill cut, and a closing ledger sets a baseline recording's counters beside the run's: cost unknown stays unknown, and the price of the kills is computed only when both manifests sayproviders: "real", otherwise it reads "not comparable". The panel is pure (accepts,reduce,view) and reads only what the recording's lines carry. No package or stored format changes. - The release fold no longer dates the changelog of a held package. A package whose manifest says
"private": trueships nothing in a release, so its fragments are now appended to itsUnreleasedsection, which keeps its name and its entries, and no version heading is written for it. Before, its entries moved under the release's version heading and the package's changelog claimed a version it did not publish. The check that no pull request edits anUnreleasedsection accepts that fold: a held package'sUnreleasedmay gain exactly the bullets of the fragments the same change deletes, and any other edit is still refused. - Changelog entries are one file per pull request. A change adds
changes/<slug>.md(front matter naming its packages and kind, then the bullet's text) instead of editing anUnreleasedsection, so two pull requests no longer collide on the top ofCHANGELOG.md.node scripts/changes.mjs --checkvalidates every fragment and fails a change that edits theUnreleasedsection of anyCHANGELOG.md, unless the edit leaves the section empty, which is what the release fold does. Theverifyjob runs it.node scripts/changes.mjs --fold <version> [--date]is the release's one step. In the root changelog, and in each package changelog that has fragments, it renamesUnreleasedto## <version> (<date>)with its existing bullets first and the fragments' bullets after them, opens a new emptyUnreleasedabove it, and deletes the fragments. A fragment goes to its package's own changelog, else the root.node scripts/release-preflight.mjs --tagrefuses a tag while unfolded fragments remain or the rootCHANGELOG.mdhas no section for the tag's version.
node scripts/verify-packages.mjs --pi <x.y.z>installs the clean consumer's pi family at that version and runs the reference example there, and thepi-1-2job runs it at 1.2.0. The example's token counts are checked as counts, not exact numbers, because they move with any text a pi release changes in a tool description.docs/dependencies.mdno longer carries the lockfile's SHA-256, so two pull requests that both touchpackage-lock.jsonno longer conflict on that one line when the lock itself merges clean. The inventory is generated from the locked runtime graph alone: a change to a development-only entry leaves it byte for byte. The lock's hash stays in the source export's receipt (packageLockSha256), andnode scripts/check-generated.mjsstill fails a committed inventory that does not match the lock it was generated from.- The stranger test can install with a README's own install line, against the peers the registry serves now
- A spec names its README's install block (
readmeInstall), andnode scripts/stranger.mjs <name> --readme-lineinstalls with exactly that line: with--registryas written (does the published package install today?), without it with this tree's packed package in the line's place (does the next release install?). The peers stay as the line writes them, not pinned to the developed versions, so a published range that excludes the registry's latest peer fails here. The line is read without a shell, and must install the spec's package. pi-durable-disknames its quickstart's install line. A new CI job,stranger-readme-line, runs both modes and is not blocking yet: today both fail withERESOLVE(the published 0.1.0-beta.12 accepts chord and pi-durable~1.0.4, main<1.2.0, and the registry's latest is 1.2.0), and the failure is shown as a warning.
- A spec names its README's install block (
apps/viewer: vision on the counters strip- Paid calls made counts vision's paid looks, per look key over ok looks that are not the lead's own: each filing that is not kept is a payment, and a key whose first ok filing is kept (the eyes answered in a process that died before filing it) is one, priced from that filing; a kept filing after a payment adds nothing.
- Repeated and asked twice count each extra payment of a key once, by generation. Cost adds each payment's
facts.cost_usdand each failed look's (a failed look is no payment); a native look charges nothing and never makes cost unknown. The latest revision of a filing says what it cost. - The reference example's manifest cost (
examples/reference/fold.ts, which stands alone) prices looks by the same rule, and a test holds it equal to the viewer's on each rule. A look record with nofacts.keyis not a look and prices nothing; before, every record'scost_usdcounted. - A look live lines show started in a process that neither filed it nor had it read back as kept later is a possible payment no durable line shows: the strip says so, and the cost reads "at least".
npm run demo:kill -- --record <dir>records the killed and resumed run as anagentrun.recording/1directory: each process records its own durable and live lines, and the supervisor, which outlives every kill, writes theprocess.started,killandresultlines.examples/reference/index.tsis one entry for another example that wants the reference example's pi-durable host:run,placeAt,localHostand the recorder, with avisionoption onrun,Fakes.responsesandFakes.tokensPerSecond(the faux model streams by default),Host.tools.rerunfor an effect a killed process left unknown, and a host that lists each applied call once. The pi-1-2 job runs a mid-stream kill and resume on the pi it installed.- The run viewer gains a vision panel (
apps/viewer) for a run whose lead reads through eyes. It draws three lanes from theLookRecords a host files into avision.*document (a film read in windows with its frames at their seconds, scanned pages with the cited line's box, a grid of photographs that flip to their labels), the cited account from the run's result (apollo11.account/1) with a chip per citation, the receipt of the look a chip names (ask, answer, model, tokens, price, kept), and its meters, folded as the counters strip folds vision (RECORDING.md section 6): looks paid, kept after a kill, repeated, possible extra payments (observed, from live lines, which make spend a lower bound), spend (unknown when a look's price is), and typed misses by code. The photo sort's accuracy against the captions and a weaker eyes model's dropped digits are drawn as the result states them. The shell gives every panel two new view inputs:ViewContext.cite, the citation a node with aciteprop selects on click (kept in?cite=, with no scrub), andViewContext.json, the result's JSON blobs as the shell read them; and a node'sboxprop places it over its parent in percent. A redraw keeps each scrolled element's offset and the keyboard focus, found again by place.
0.1.0-beta.12, 2026-10-09
@parcha/pi-durable-archilis renamed@parcha/pi-durable-disk(directorypackages/pi-durable-disk, commandpi-durable-disk, imagepi-durable-disk), for branding: other disks may follow. Renamed from@parcha/pi-durable-archil; the0.1.0-beta.10and0.1.0-beta.11entries below name it as it was published. It supports Archil only. Archil-specific names stay:bin/archil-scoped,ARCHIL_API_KEY,archilEnv,ArchilCodingTools, thearchilclient.- Install paths named after the package move too:
/usr/local/lib/pi-durable-disk/archil-scoped,/run/pi-durable-disk(the token directory),/etc/sudoers.d/pi-durable-disk; an existing host's sudoers drop-in and wrapper copy need the new paths.
- Install paths named after the package move too:
- The release workflow waits up to 20 minutes, not 5, for npm to show a version it has just published, and backs off (5 s rising by half to 30 s) instead of polling every 5 s. npm showed
@parcha/agentrun-dsl@0.1.0-beta.1116 minutes after accepting it (published 16:35:56Z, visible 16:52:12Z), so run 37809477150 failed after about 5 minutes withRegistry rejected @parcha/agentrun-dsl: HTTP 404; the rerun, 37812713360, resumed and passed. Beta.10 took about 5 minutes.- 404 (only where the version must exist), 429, 5xx and a failed request stay transient; any other status stays final; the per-request timeout is unchanged. The waits for the version's document, its tarball and the install metadata share the schedule and each get the 20 minutes.
@parcha/pi-durable-diskfinds a holder that the Archil control API lists without a path, by the inode its run directory resolves to throughDisk.exec; before, such a holder read as none, so the supervisor started into it and its run directory could not be deleted. Its CHANGELOG has the details.
0.1.0-beta.11, 2026-10-08
@parcha/pi-durable-archilwakes a released run on a request (run --serve,requestRun), parks a run whose work only waits (its host is released and the supervisor starts it again at the deadline), drains on SIGTERM, and forks a sealed run (fork). Its CHANGELOG has the details.@parcha/pi-durable-archilgainsdockerHost, a host driver that runs each instance in a container of the package's image (packages/pi-durable-archil/docker/Dockerfile), so a machine with Docker (macOS included) needs no root, FUSE or archil client;packages/pi-durable-archil/examples/docker-quickstart.shruns the paid-effect example that way. Its CHANGELOG has the details.- The image installs the checkout's own package version, from the workspace (
npm packof the package intodocker/package) or from npm, and its own dependencies come fromdocker/runtime/package-lock.json, which is not a workspace and not part of the root lockfile.
- The image installs the checkout's own package version, from the workspace (
- Known issue in
@parcha/pi-durable-archil0.1.0-beta.10, fixed here: an idleruninstance (no--serve, nothing running) exited without draining or releasing. It now stays up until it parks, drains or fails.
0.1.0-beta.10, 2026-10-08
- New package,
@parcha/pi-browser: a browser for pi agents that keeps its promises across crashes.snapshot,run,screenshot,browser_read,browser_relaunch,browser_release,web_fetchandweb_search, with session custody recorded before every provider call, evidence filed with its SHA-256, failures classified from typed facts, and no credential in anything a model, a document or a log can see.pi installloads its coding-agent extension; the pi-durable adapter, the Browserbase and local-Chrome providers and a fake backend (./testing) are separate entries.- It does not depend on the other packages. Publishing it is on hold: its manifest is
"private": true, so it is built, tested, verified and kept at the workspace version but is in no release (seedocs/releasing.md). Its README andpackages/pi-browser/CHANGELOG.mdhave the details. - It is a synced snapshot of the agentrun harness's
packages/pi-browser, produced by a transform that applies the workspace adaptations; do not edit it here. - Its unit tests run in
npm teston every Node version in CI. Its real-Chrome suites run in thereal-chromejob, against a pinned Chrome for Testing. - The workspace now holds two copies of
@earendil-works/pi-coding-agent,pi-aiandchord(0.87 for the Pi package, 1.0 for this one) and two ofzod; the package's own copies are nested under it.
- It does not depend on the other packages. Publishing it is on hold: its manifest is
- New package,
@parcha/pi-durable-archil: a pi-durable host on an Archil disk. One exclusive mount of a run's directory is the claim; revoking it fences a lost or hung holder; a heartbeat lease, a stateless supervisor and host drivers (localHost,daytonaHost) restart a run on another host, where pi-durable resumes from its last commit and never repeats a tool call whose intent it had committed.- It does not depend on the other packages and is published last. Its README and
packages/pi-durable-archil/CHANGELOG.mdhave the details. - Its unit tests run in
npm teston every Node version in CI. Its live suites need an Archil account and stay opt-in (PDA_LIVE=1). - The workspace holds two copies of
@earendil-works/pi-aiandchord(0.87 for the Pi package, 1.0 for this one), so the package's typecheck maps both to its own copy (packages/pi-durable-archil/tsconfig.json).
- It does not depend on the other packages and is published last. Its README and
routecan run the branch a workflow has already decided. WithvaluePathin place ofstateandinstructions, the string at that path names the branch, and no model is asked.- A missing value or a name that matches no branch takes
otherwise(a branch). Without one, the route fails before any branch runs, asroute_missingorroute_unknown. A value that is not a string fails asroute_typeand is never converted into a name. asrecords{value, taken, fallback}. There is no$answerssidecar, and theroute.chosenevent names only the branch taken.- A route by value needs no
runJudgeadapter. - The two forms never mix. The validator refuses
state,instructions,unsureor branchcriteriabesidevaluePath, refusesotherwisewithout it, and refuses avaluePaththat no earlier node or input produces. - Before, a workflow that had already decided either asked a second
route, which costs a request and can contradict the decision, or ran each branch as amapover a list of zero or one items. - The support example now uses it.
- The author contract and the Jev guide say when to ask Jev, when to compute in code, and when to route by value.
- The Lean model gains the form, with four theorems and six conformance cases. The Jev guide no longer says a
siftalways sends one request.
- A missing value or a name that matches no branch takes
0.1.0-beta.9, 2026-10-04
runEffectreceivesdeadlineAt: the attempt's deadline instant on this process'sperformance.now()clock, exactly as the interpreter armed its deadline timer, so a host can measure its own kill and an effect's duration against the instant the timer fires instead of a start time it stamps a moment later. Each attempt carries its own instant, and a host's composed and mapped calls receive it unchanged. It is absent only for a call with no deadline. Nothing else changes.validateAnswersaccepts a Score whosescorecould be the two-place rounding of its distribution's weighted level, by the same rule as the probability mass: a value on the two-place grid may be off by up to 0.005, and any other value is taken as exact. The unrounded probabilities lie within their rounding intervals and still sum to 1, which bounds the weighted level, and the score must meet those bounds. jev-1.13 rounds both the probabilities and the score, so valid Score answers were refused asscore_consistency. A full-precision score with full-precision probabilities is still held to 1e-5, and a score no rounding explains is still refused. A score that isn't a number still fails asscore_range. Answers are accepted as reported, never recomputed. (GRE-3057)- A Jev HTTP failure that Jev classifies as
max_tokens_exceedednow says so in the error message:Jev returned HTTP 400: the request exceeds the model's token budget (max_tokens_exceeded). Send fewer or shorter items per request.Before, the message was onlyJev returned HTTP 400.and the reason sat inresponseDiagnostic, which a host that logs only the message never showed. The Pi extension's run report says the same in its message. Only the structured reason is named. The provider's response body still never reaches the message, the report or the trace, because it can echo the request's input. An unclassified failure keeps its plain message. (GRE-3058) - A
siftwhose items × questions exceedmaxQuestionsPerRequest, or whose judge state would exceed the new host-ownedWorkflowDeps.maxStateBytesPerRequest, is split in item order into requests that each fit. Up to four requests run at a time, the same default asmap.maxConcurrency, and their answers join back onto the original items. The first failed request stops dispatch, cancels the requests in flight, and fails the sift once they settle. Before, the interpreter refused the sift over the question limit, and an oversized state surfaced only as the provider's refusal.maxStateBytesPerRequestcounts UTF-8 bytes of the JSON state, the measure the Jev adapter uses formaxStateBytes. It has no default, so without it only the question limit splits.- Each request carries the sift's
stateand only its own items, asitems[j]with the originalitem_<i>id. - A sift that fits one request sends exactly the request it did before.
- An item that cannot fit a request on its own is refused before any request. So is a sift
statetoo large before any item, and that error names the state. judge.answeredsumscost_usdacross the requests and reportsrequestswhen there was more than one.- The Pi extension passes
maxStateBytesPerRequestthrough, and itsjev_state_too_largehint names it. It now forwards any setmaxQuestionsPerRequestormaxStateBytesPerRequest, including an invalid0orNaN, so the interpreter refuses an invalid setting instead of silently running without it. (GRE-3059)
- Each request carries the sift's
onEventobservers receive a detached snapshot of each event, not the objects the interpreter goes on to use. Before, a callback that modified an event could change a code step's patch, a judge's answers, a route's branch, or the source records apickorsiftselected, just by observing them. The snapshot never calls getters or proxy traps. Functions, proxies and non-plain objects arrive as inert markers, so a host that validates its trace as JSON still rejects them. Observer errors are still ignored. This is a visible change for a host that changed execution by modifying an event, compared event objects by identity, or expected aDate,Mapor class instance in an event: it now gets a copy, and those values arrive as markers. A host with required trace validation can pass a synchronousprepareEvent(event)to bound or reject an event before it is copied, and the Pi extension uses it for its existing trace limits.- New example,
examples/rank-candidates.mjs(npm run demo:rank): a reusable workflow that ranks a list with Jev. Asiftscores each candidate against a brief, acodestep sorts and applies a minimum score, a secondsiftover pairs finds near-duplicates, and a lastcodestep applies per-bucket quotas and the top k, with a reason for every candidate cut. It runs offline with a scripted judge, on your own list with--live, and from another workflow through aworkflownode.
0.1.0-beta.8, 2026-10-02
- A call node's deadline never interrupts its effect early. Node timers can fire up to 1 ms before the requested instant, so a 1 s deadline could abort an effect at 999.7 ms. When the deadline timer fires before the deadline, it reads the monotonic clock and re-arms for the remainder. The attempt deadline is measured on
performance.now()instead ofDate.now(). - Package verification: the production
npm auditgate accepts GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p and GHSA-q2hr-2g5m-vwhr until 2026-12-01, and only for brace-expansion installed under@earendil-works/pi-coding-agent. That package'snpm-shrinkwrap.jsonpins brace-expansion 5.0.9 in every release through 1.0.0, and a consumer cannot override a dependency's shrinkwrap (earendil-works/pi#10288). Any other high or critical advisory still fails the gate, and so do these once the exception expires. The receipt records the raw counts and the accepted entries. The release preflight applies the same exception: it re-reads.release/npm-audit.json, requires its counts to match the receipt, and re-checks the scope and expiry at publication time. deps.runCode(node, state, ctx)is the host's executor forcodenodes. When present, the interpreter hands it the node, the state and the transform's context (sop,label, the execution path, the map item, the signal). It merges what the host returns under the same patch rules and state checks, and a child workflow's code node reaches it relabelled. A host rejection fails the node as the host typed it. WithrunCodepresent,runWorkflow,runWorkflowSliceanddryRunWorkflowvalidate withexecuteCode: false, so no code body runs in this process, not even as a validation probe. Without it, nothing changes.EffectFailurecarries a closedcodefromEFFECT_FAILURE_CODES(exported), beside its retry class and the host'sdetail:effect_timeout,effect_exit,effect_transport,effect_not_granted,effect_produces_missing,effect_path_escape,effect_code_unsafe,output_invalid,state_invalidandeffect_unknown_transport. A retry class implies its code when the host names none.retry.onnames failure codes as well as retry classes. A code in the list retries a failure with that code, and a code not in it does not. The default list and the named classes retry as before.- Every
effect.failedevent carriescodeand the host'sdetail. The row'scodeis closed: it is theEffectFailure's code when that code is inEFFECT_FAILURE_CODES, else null. Any other typed error's own code, such as an unknown outcome or a host's receipts failure, rides beside it aserror_code. deniedGlobalReferences(source)(exported) names theDENIED_GLOBALSa code body reaches for. It skips comments, string and template text, regular expressions and property reads. A host uses it to refuse untrusted code before it runs; it is a lint, not a boundary.- An
agentnode may declarebudget_usd, the most it asks to spend in dollars (a finite number above 0), andcontext,freshorfork(AGENT_CONTEXTS, exported with theAgentContexttype).freshis the default: the node sees only its instructions and state.forkasks the host to start the node from the caller's transcript; a host that has none to fork refuses it. Both fields are accepted onagentonly. The validator refuses a value outside their range with the node's path and label. The interpreter passes them torunNodeasbudgetUsdandcontext, and omits them when the node declares neither. The author contract describes both and no longer says that nodes carry no budgets. The shipped schema boundsbudget_usdabove 0, as the validator does. An authored candidate that writesbudgeton anagentis refused bycandidatePolicyErrorswith a message namingbudget_usd; an older document that carriesbudgetstill validates. - The Pi runner refuses an
agentnode that declarescontext: "fork"before it starts a session: every Pi node runs in a fresh session, with no caller transcript to fork.budget_usdis passed torunNodeand recorded; enforcement belongs to the host, and no runner stops a run on money. - The author contract's Predicates section shows a predicate's shape with an example,
{"predicate": "field_true", "path": "review.approved"}. A predicate object with nopredicatefield is refused with that shape, naming the predicate the object used as a key ({"field_true": {...}}), where the validator saidunknown predicate "undefined". A learner spent 30 attempts on that message (GRE-2809). - Workflow behaviour knobs have no upper ceiling:
loop.maxIters,call.deadline_s,retry.attempts,retry.backoff_s,poll.interval_s,poll.deadline_sandverify.maxDriveskeep only their floors.deadline_sstays required and above 0,poll.interval_sstays at least 0.1, and the parser guards (nesting 128, 100000 values, schema depth 12) are unchanged. The validator now accepts values it refused before; it refuses nothing it accepted. The author contract states each as "no maximum". - Deadline, poll-interval and retry-backoff waits past Node's 2^31-1 ms (~24.8 day) timer limit fire on time: they re-arm in chunks (
setLongTimeout) instead of overflowing into a near-immediate fire. A call deadline still re-reads the monotonic clock before it aborts. - A
loopnode may declareexpect_iters, the number of passes a host's run gate prices it at: an optional integer of at least 1 with no upper bound, not clamped tomaxIters. When it is absent, a host prices one pass.maxItersstays the bound, and the child's budget is the real stop. The validator refuses a non-integer or a value below 1 and names the node.
0.1.0-beta.5, 2026-09-26
validateAnswersaccepts a choice or score distribution when it could be the two-place rounding of a distribution that sums to 1. A value on the two-place grid may differ from its true value by up to 0.005, within [0, 1]. Any other value is taken as exact. A full-precision distribution is therefore still held to the 1e-5 check. System One rounds each probability to two places, so about 1% of real eight-option choices summed to 0.99. The validator refused them asprobability_mass, and the Jev adapter then failed the whole request. The allowed drift now grows with the number of rounded options: eight equal options report 0.13 each and sum to 1.04. Probabilities are still passed through unchanged, never rescaled. A distribution that no rounding explains is still refused.score_consistencyis unchanged. (#25)- Release tooling: the final registry check now waits for npm to list every release version in both the full and the abbreviated packument before its one clean install. It uses the same 60 x 5 second budget as the per-package check. The beta.4 publish succeeded, but this step ran
npm installone second after the Pi check passed and gotETARGET, so the run was marked failed.
0.1.0-beta.4, 2026-09-24
- Every node accepts an optional
metadataobject for host markers. Validation refuses a non-object value and names the node. Otherwise the engine never reads it. Desugaring, dry runs, runs and the author's candidate loop carry it verbatim, and it never reaches state, events, prompts, judge requests or effect idempotency keys.workflowSha256covers it because it hashes the whole document.HOST_NODE_FIELDSand theNodeMetadatatype are exported, and the editor schema admitsmetadataon every node kind. - The author contract states once that
metadatais host-owned and that authors set only the keys the host names.AuthorHostAddendum.metadataKeysnames those keys. The host enforces them in its acceptance checks. spec/lean: a conformance case with metadata on several node kinds. The model ignores the field by design.
0.1.0-beta.3, 2026-09-23
Pi can inspect workflow steps, save named definition revisions, and load them in another session. Saved definitions exclude run input and execution permission. Save and list show commands for loading the exact revision. Code runs require
/agentrun run --trustedeach time.The support example registers its agent tool as
support_read, a name accepted by providers that prohibit dots in tool names.One author.
@parcha/agentrun-dslnow owns the author contract, the author skill and the candidate loop.authorContract({ host? })renders the language every author receives (renderAuthorContractalso returns the sha256 of that exact text, whichauthorWorkflowrecords with every candidate ascontractSha256), then an optionalAuthorHostAddendum(name,initialState,outputTypes,nodeKinds,rules) that names a host's vocabulary without changing the language.authorWorkflowmoved from@parcha/agentrun-piand now takes the host'srunNodeadapter instead of Pi runner options; it enforces a declared addendum's node kinds and output types and validates against its initial state.candidatePolicyErrorsis the shared author policy.authorWorkflowreturns the candidate its review accepted;applyHostOutputTypesgives the interpreter view of a host's prose output types. The skill ships in the dsl package underskills/author, with the contract generated asreferences/language.md;authorSkillDirectory,loadAuthorReferenceandloadAuthorSkillBundleexpose it.The Pi package registers the dsl skill rendered with its own host addendum (
PI_HOST_ADDENDUM, exported), built intodist/skills/author, so a session reads the Pi rules without callingdescribe.describereturnsauthoring.languageandauthoring.hostbeside the guides.One vocabulary.
WORKFLOW_NODE_KINDS,NODE_FIELDS,WORKFLOW_PREDICATESand the other vocabulary constants are exported, and the validator and the author contract both read them, so the contract names exactly what the validator admits.Validation now refuses an
escalate.whenpath or key that no input or earlier node produces, aloop.untilpath that neither the state before the loop nor its body produces, and a parallel branch that reads a key only a sibling branch writes. None of them could hold at runtime.spec/lean: a contributor model for checking control-flow rules and their assumptions. Its proofs apply to the abstract model; shared conformance cases compare selected behaviors with the TypeScript interpreter. Executable regressions cover runtime input checks, parallel conflicts and recovery adapter obligations. The model does not prove the TypeScript runtime correct or predict model output.Validation rejects a child workflow input that assigns the reserved
$hostkey before any parent or child step executes.One path resolver for every state reader. Predicates (
escalate.when,loop.until,sift.keep) now index arrays the wayrequires, interpolation,itemsPathandoutput.pathalready did:scores.0reads9from{scores: [9]}everywhere, so a gate on an array index fires. A record is read by key, an array by a non-negative canonical integer index, and any other value (a string, a number, a missing value) resolves toundefined; an array exposes indexes, not properties.getPathis exported.The parallel
$hostmerge refuses a mixed-shape write in either branch order. An array append that meets a scalar a sibling wrote to the same new key is aparallel_write_conflict, as the reverse order already was; before, the append silently replaced the scalar. Appends from several branches still join in branch order, and equal writes remain one write.
Breaking changes
This is a prerelease, so there are no forwarding exports. Every removed @parcha/agentrun-pi export and its replacement:
Removed from @parcha/agentrun-pi |
Replacement |
|---|---|
authorWorkflow(options) with options.pi: PiRunnerOptions |
authorWorkflow from @parcha/agentrun-dsl, with runNode: createPiRunner({ ...options, tools: [], maxSubmissions }) in place of pi |
type AuthorWorkflowOptions |
AuthorWorkflowOptions from @parcha/agentrun-dsl (runNode replaces pi; adds host and signal) |
type AuthoredWorkflow |
AuthoredWorkflow from @parcha/agentrun-dsl (adds contractSha256) |
AUTHOR_CONTRACT (string) |
authorContract({ host? }), or renderAuthorContract({ host? }) for { text, sha256 }, from @parcha/agentrun-dsl |
loadPiAuthorSkillBundle() |
loadAuthorSkillBundle() from @parcha/agentrun-dsl |
the package's skills/author directory |
the neutral skill at authorSkillDirectory() in @parcha/agentrun-dsl; the Pi-rendered copy ships at dist/skills/author |
Validation is also stricter when the caller supplies an input contract (input or inputKeys). It refuses the three unreachable reads listed above, which a workflow validated without an input contract never triggers.
0.1.0-beta.2, 2026-09-23
WorkflowDeps.hostPolicy: application policy around generative nodes and completed steps, handed in by the host and unreachable from workflow documents.systemBlocksappends host text to a node's prompt;submissionSchemawidens the adapter's transport schema with host-owned channels;decodeSubmissionsplits an accepted submission into the domain value and host state;afterNodeenriches a completed step's state before commit and checkpoint. The domain value is always validated against the unchanged stage schema and is what averifyclause reviews; host state lives under the reserved$hoststate key (checkpointed and restored with the state, isolated per map item, branch and child, merged by delta across parallel branches), is excluded from a path-less output projection, and is returned asresult.host. Validation rejects anasthat names a$-prefixed key; a code node orafterNodepatch writing$hostfails withreserved_state_key. Eventshost.decodedandhost.patchedname the keys written. Absent, the engine behaves exactly as before.- Exported for host facades:
childSteps,declaredWrites,terminalArtifactType,artifactNodeIsProse,schemaProblems,buildReferenceContext,normalizeStringNullsForSchema,parseCsvRows,compileTransform,compileTransformSyntax. - CI also runs on Node 24.21.0.
0.1.0-beta.1, 2026-09-23
Initial beta of @parcha/agentrun-dsl, @parcha/agentrun-jev, and @parcha/agentrun-pi.
- Define workflows in JSON or TypeScript with supported Standard JSON Schema and Zod contracts. Input and output types are inferred; intermediate state paths are validated at runtime.
- Compose tools, code, agent steps and typed decisions with branches, bounded loops, parallel work and isolated child workflows.
- Inspect workflow structure without executing code. Run scripted examples and evaluate a selected step with independent expectations.
- Use the optional Jev adapter for typed decisions or the Pi extension to author and run workflows with the active Pi model.
- Run the support quickstart without credentials, then connect the same workflow to your own tools, Jev and agent runtime.
Live Pi demos keep status and cancellation commands responsive. Support example failures preserve the original error and eventual tool outcome for host reconciliation while keeping JSON reports redacted. Dry-run accepts valid empty-only output schemas.
Code and documentation use Apache-2.0. All three packages are available on npm under the beta tag. Pi workflows are session-local; durable storage and external delivery belong to the host. See compatibility and execution limits.