Skip to content

Latest commit

 

History

779 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

agent-exec

Documentation site

A durable command runner for AI agents: start a command now, observe it briefly, then retrieve or control the job later.

Agent harnesses often need to run tests, builds, and deployments whose duration and output size are unknown. A synchronous subprocess can block the agent, while a detached shell command loses structured status and log discovery. agent-exec keeps the process running under a stable job_id and returns machine-readable JSON for later status, tail, wait, kill, or restart calls.

Try it in 30 seconds

Prebuilt binaries are available for Linux x86_64 and macOS Apple Silicon. This example downloads the latest release, verifies its SHA-256 checksum, and installs it in ~/.local/bin:

case "$(uname -s)-$(uname -m)" in
  Linux-x86_64) TARGET=x86_64-unknown-linux-gnu ;;
  Darwin-arm64) TARGET=aarch64-apple-darwin ;;
  *) echo "No release binary for this platform" >&2; exit 1 ;;
esac

VERSION=$(curl -fsSL https://api.github.com/repos/tumf/agent-exec/releases/latest | sed -n 's/.*"tag_name": "v\([^"]*\)".*/\1/p')
ARCHIVE="agent-exec-v${VERSION}-${TARGET}.tar.gz"
curl -fLO "https://github.com/tumf/agent-exec/releases/download/v${VERSION}/${ARCHIVE}"
curl -fLO "https://github.com/tumf/agent-exec/releases/download/v${VERSION}/${ARCHIVE}.sha256"
shasum -a 256 -c "${ARCHIVE}.sha256"
mkdir -p ~/.local/bin
tar -xzf "$ARCHIVE"
install agent-exec ~/.local/bin/agent-exec
~/.local/bin/agent-exec --version

Start a long-running command without blocking the caller, save its job_id, then retrieve its status and final output:

AGENT_EXEC=~/.local/bin/agent-exec
JOB=$($AGENT_EXEC run --no-wait -- sh -c 'sleep 2; echo done' | sed -n 's/.*"job_id":"\([^"]*\)".*/\1/p')
$AGENT_EXEC status "$JOB"
$AGENT_EXEC wait "$JOB"
# Use tail later to reread bounded logs or inspect their paths.
$AGENT_EXEC tail "$JOB"

run normally observes a job for up to 10 seconds, which catches many startup failures without blocking indefinitely. Use --no-wait when the caller must return immediately. wait reports the state, exit code when terminal, and bounded output available at the observation deadline. Use tail later to reread bounded logs. Complete logs remain available at the paths in each response.

Why not nohup or a plain subprocess?

Capability Plain subprocess nohup agent-exec
Return without stopping the command No Yes Yes
Stable job identifier No No Yes
Structured status and exit result Limited No Yes
Discoverable stdout/stderr logs Caller-managed Caller-managed Yes
Later wait, tail, kill, and restart Caller-managed Caller-managed Yes

Pass ordinary commands as argv after --. Use an explicit shell only when the workload needs pipelines, redirects, expansion, or compound statements.

Output Contract

Response-producing CLI commands write exactly one JSON object to stdout by default. --yaml changes those responses to YAML. Diagnostic logs go to stderr and are controlled by RUST_LOG, -v, and -vv.

This contract applies to commands such as run, status, tail, list, gc, and install-skills. It does not apply to generated shell completions, the MCP stdio protocol, the HTTP server, MCP startup configuration errors, or Clap help and version output.

Inline Output Compression

run, start, restart, and tail include a compressed view by default while preserving the raw excerpt and byte metadata as the canonical output.

  • Default mode: route
  • CLI selection: --compress <MODE> or --rtk <MODE>
  • Modes: off, route, errors, tests, logs, git, json, and summary
  • Compatibility mode: --compress off omits the compression field
  • Configuration: [compression].default = "off" or another supported mode

Precedence is --compress or --rtk, [compression].default, then the built-in route default. If compression would not make either nonempty stream smaller, the expansion guard sets applied to false, returns empty compressed streams, and records "expansion-guard" in strategy. The raw stdout, stderr, ranges, totals, encoding, and log paths remain unchanged.

If you are integrating through MCP, see MCP Server for the same lifecycle as tools. To see the observation deadline and reconnect behavior end to end, run the one-minute demo.

Installation

GitHub Releases

GitHub Actions builds the Linux x86_64 archive for each v* release. Download its matching checksum, then verify and install:

ARCHIVE=agent-exec-v<VERSION>-x86_64-unknown-linux-gnu.tar.gz
shasum -a 256 -c "$ARCHIVE.sha256"
mkdir -p ~/.local/bin
tar -xzf "$ARCHIVE"
mv agent-exec ~/.local/bin/
agent-exec --version
agent-exec run -- echo "release-smoke"

macOS artifacts are built locally on the trusted mini host after GitHub Actions creates the release. From a checkout at the release tag:

scripts/release-macos.sh --tag v<VERSION>
scripts/release-macos.sh --tag v<VERSION> --upload

The first command builds, smoke-tests, packages, and checksums the native architecture archive without network mutation. The second uploads it to the existing GitHub Release. Install the resulting macOS archive with the checksum, extraction, version, and managed-command steps above.

Windows release binaries are not provided. ARM, 32-bit, musl Linux, Windows, and other unsupported targets can use crates.io or a source build.

crates.io

A Rust toolchain can install the published package:

cargo install agent-exec --locked
agent-exec --version
agent-exec run -- echo "release-smoke"

Source build

For development or unsupported targets, build from a repository checkout:

cargo install --path . --locked

Shell Completions

agent-exec generates dynamic completion scripts for Bash, Zsh, Fish, and PowerShell.

Candidate scope and state filters

All job ID candidates are limited to jobs whose persisted current working directory matches the caller's current working directory. Candidates come from the resolved jobs root. Entries with missing or mismatched current working directory metadata are excluded.

State filters depend on the command:

Command Candidate states
status, tail, restart, tag set, notify set All known job IDs; unreadable states may still appear
start created
wait created, running
kill, abandon set, abandon clear running
delete exited, killed, failed

Completion is advisory. Command implementations still validate the selected job and may support behavior not offered by completion.

Bash

agent-exec completions bash >> ~/.bash_completion
source ~/.bash_completion

Zsh

mkdir -p ~/.zsh/completions
agent-exec completions zsh > ~/.zsh/completions/_agent-exec
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit

Fish

mkdir -p ~/.config/fish/completions
agent-exec completions fish > ~/.config/fish/completions/agent-exec.fish

PowerShell

agent-exec completions powershell | Out-String | Invoke-Expression

After registration, job ID completion is available at command arguments such as:

agent-exec tail <TAB>
agent-exec kill <TAB>

Quick Start

Short-lived job

The default run behavior observes the job for up to 10 seconds and returns inline output:

agent-exec run -- echo "hello world"

The following is an illustrative complete response for a successful terminal job. Paths, timestamps, and timing values vary.

{
  "schema_version": "0.3",
  "ok": true,
  "type": "run",
  "job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
  "state": "exited",
  "tags": [],
  "stdout_log_path": "/home/user/.local/share/agent-exec/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
  "stderr_log_path": "/home/user/.local/share/agent-exec/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log",
  "elapsed_ms": 8,
  "waited_ms": 2,
  "stdout": "hello world\n",
  "stderr": "",
  "stdout_range": [0, 12],
  "stderr_range": [0, 0],
  "stdout_total_bytes": 12,
  "stderr_total_bytes": 0,
  "encoding": "utf-8-lossy",
  "exit_code": 0,
  "finished_at": "2026-07-19T12:00:00Z",
  "duration_ms": 1,
  "compression": {
    "mode": "route",
    "applied": false,
    "detected_kind": "summary",
    "stdout": "",
    "stderr": "",
    "stdout_original_bytes": 12,
    "stderr_original_bytes": 0,
    "stdout_compressed_bytes": 0,
    "stderr_compressed_bytes": 0,
    "omitted": false,
    "strategy": ["expansion-guard"]
  }
}

Generated job IDs are 32-character lowercase hexadecimal strings. Commands that accept a job ID also accept an unambiguous prefix and return the canonical full job ID.

Long-running job

Use --no-wait when the launch response must return immediately, then inspect the job separately:

JOB=$(agent-exec run --no-wait -- sleep 30 | jq -r .job_id)
agent-exec status "$JOB"
agent-exec tail "$JOB"
agent-exec wait "$JOB"

Without --no-wait, run observes for up to 10 seconds before returning.

Abandoning a job and force killing it

--abandon-job-after gives up on the job, terminates it, and may permanently lose unfinished results. It is not an observation deadline: to stop waiting without stopping the job, use --until. Because it is destructive, it requires --acknowledge-result-loss; without that flag the command fails before a job is created.

The following command sends SIGTERM after 5 seconds and SIGKILL 2 seconds later if necessary:

agent-exec run \
  --abandon-job-after 5 \
  --acknowledge-result-loss \
  --kill-after 2 \
  -- sleep 60

The default is no limit. When abandonment actually terminates a workload, the terminal state is unchanged, and abandoned_by="abandon_job_after" and result_loss=true are added to state.json, status, list, and the job.finished completion event so the loss is identifiable after the fact. A configured limit that never fires adds neither marker.

The removed --timeout spelling always fails with migration guidance and never launches a job.

Changing the deadline of a job that is already running

A deadline chosen at launch often turns out to be wrong once you can see the job making progress. abandon set replaces it and abandon clear removes it, both against a job that is already running:

# Give a job that is already running 10 more minutes, measured from now.
agent-exec abandon set <JOB_ID> --in 600 --acknowledge-result-loss

# Remove the deadline entirely; the workload keeps running and is never signaled.
agent-exec abandon clear <JOB_ID>

--in is measured from the moment the update is durably accepted, not from when the job started. Set is destructive for exactly the same reason --abandon-job-after is, so it requires --acknowledge-result-loss; clear is not, so it does not.

Both operations are serialized against the supervisor's own deadline handling through a per-job lock, so the outcome is never ambiguous:

  • an update that commits first is observed before the old deadline can signal;
  • once abandonment has begun, a later update fails with invalid_state instead of claiming it prevented an abandonment that had already started.

A running job launched by a release older than this one has no supervisor-authored control record. Both operations reject it with invalid_state and tell you to agent-exec restart it, rather than reporting a change its supervisor will not honour. Created and terminal jobs are rejected the same way.

Restart keeps whichever deadline is actually in force: a launch-configured limit is re-armed from its configured duration, while a deadline you set or cleared at runtime keeps its accepted absolute value and is never overwritten by the launch-time one.

Argv-first invocation

Pass ordinary commands as argv after --:

agent-exec run -- sleep 8
agent-exec run -- cargo test --all
agent-exec run -- npm run build

Use an explicit shell only when the workload requires shell syntax such as pipelines, redirects, expansion, or compound statements:

agent-exec run -- sh -lc 'sleep 8; echo done'

Deferred Job Lifecycle

create persists a job definition without starting a process. start launches it later.

JOB=$(agent-exec create -- echo "deferred hello" | jq -r .job_id)
agent-exec start "$JOB"
  • create writes the execution definition to meta.json, initializes state.json with state set to created, and returns type set to create.
  • start reads the persisted definition, launches the supervisor, and observes for up to 10 seconds by default.
  • restart reuses the job ID and definition. It terminates a running process tree before launching the replacement run.
  • run combines definition and launch in one command.

Persisted environment

Values passed through create --env KEY=VALUE are durable configuration. The real values are stored in meta.json for the later start, while the display-oriented env_vars metadata applies --mask. Use --env-file FILE when values should be read from a file at start time instead of being stored directly in the job definition.

--mask KEY only replaces the named --env value in display-oriented environment metadata and response fields. It does not redact child stdout, child stderr, persisted logs, notification payload content, or values loaded from --env-file. A child process that prints a secret will expose it in its output. Do not treat --mask as a general secret-filtering mechanism.

State transitions

State Meaning
created Definition persisted; no process started
running Supervisor and child process active
exited Process exited normally
killed Process terminated by a signal
failed Supervisor-level failure

kill rejects created jobs because there is no process to signal. wait continues through created and running until a terminal state or its client-side deadline. list --state created selects jobs that have not started.

Global Options

Flag Default Description
--root <PATH> Platform data directory Override the jobs root. Precedence is --root, AGENT_EXEC_ROOT, $XDG_DATA_HOME/agent-exec/jobs, then the platform default.
--yaml false Emit YAML instead of JSON for response-producing CLI commands.
-v, -vv Warnings Increase diagnostic verbosity on stderr.

Place global options before the subcommand:

agent-exec --root /tmp/jobs run -- echo hello
agent-exec --root /tmp/jobs status <JOB_ID>
agent-exec --root /tmp/jobs list
agent-exec --root /tmp/jobs gc --dry-run

For backward compatibility, job-store commands also accept --root after the subcommand where defined:

agent-exec run --root /tmp/jobs -- echo hello
agent-exec status --root /tmp/jobs <JOB_ID>

Use agent-exec --help and agent-exec <COMMAND> --help for the complete current CLI surface.

Commands

create: define a job without starting it

agent-exec create [OPTIONS] -- <COMMAND> [ARGS...]

create persists execution-definition options for the command, effective working directory (--cwd, or the caller's current working directory), environment and inheritance, input, runtime limits, progress updates, tags, completion and output-match notifications, and shell configuration. Materialized input is limited by --stdin-max-bytes. It does not accept launch observation, compression, or automatic GC options. Use agent-exec create --help for the complete option list.

--stdin VALUE and --stdin-file PATH are mutually exclusive. Their contents are copied to <job-directory>/stdin.bin; later start reuses the persisted file reference.

Unlike run, create never captures piped stdin implicitly: a create definition stores input only when --stdin, --stdin -, or --stdin-file is given explicitly. Without one of those, the child of a later start receives null stdin.

The response includes job_id, state, stdout_log_path, and stderr_log_path.

start: launch a created job

agent-exec start [OPTIONS] <JOB_ID>

Only a job in created state can start. By default, start observes for up to 10 seconds and returns the same inline stream fields as run. Observation controls include --wait [true|false], --until, --forever, --no-wait, --max-bytes, and compression selection. Automatic GC controls are also available.

restart: relaunch a job in place

agent-exec restart [OPTIONS] <JOB_ID>

restart preserves the job ID and persisted definition. If the job is running, it sends the signal selected by --signal and confirms termination before relaunching. It clears prior-run stream logs, full.log, and stale completion_event.json so subsequent observation reflects the replacement run.

restart supports the same inline observation, compression, and automatic GC controls as start.

run: define and launch a job

agent-exec run [OPTIONS] -- <COMMAND> [ARGS...]

Common options:

Flag Default Description
--abandon-job-after <SECONDS> 0 Destructive. Give up on the job and terminate it after this runtime, possibly losing unfinished results; 0 disables the limit. Requires --acknowledge-result-loss.
--acknowledge-result-loss false Acknowledge that --abandon-job-after may permanently lose unfinished results.
--kill-after <SECONDS> 0 Delay between SIGTERM and SIGKILL after abandonment.
--cwd <PATH> Inherited Set the child current working directory.
--env KEY=VALUE None Set an environment variable; repeatable.
--env-file <FILE> None Load environment variables from a file; repeatable.
--no-inherit-env false Do not inherit the launcher environment.
--mask <KEY> None Mask the named --env value in display metadata; repeatable.
--stdin <VALUE> None Provide input directly; --stdin - reads noninteractive caller input. Omit both stdin flags to forward piped caller input automatically.
--stdin-file <PATH> None Copy file content to job-local input; takes precedence over automatic detection.
--stdin-max-bytes <BYTES> 64 MiB Limit materialized input size, including automatically forwarded input.
`--wait [true false]` true
--until <SECONDS> 10 Bound inline observation.
--forever false Observe until the job becomes terminal.
--no-wait false Return without observation.
--max-bytes <BYTES> 65536 Limit the head excerpt from each stream.
--tag <TAG> None Assign a tag; repeatable and deduplicated.
--notify-command <COMMAND> None Run a shell command when the job finishes.
--notify-file <PATH> None Append a job.finished NDJSON event.
--config <PATH> XDG default Load a specific config.toml.
--shell-wrapper <PROGRAM AND FLAGS> Config or platform default Override the shell wrapper.
--compress <MODE> Config or route Select inline compression.

Input examples:

# Piped or redirected input is forwarded automatically; no stdin flag required.
printf 'abc' | agent-exec run -- cat
agent-exec run -- cat < ./input.txt

# The explicit forms remain available and behave exactly as before.
printf 'abc' | agent-exec run --stdin - -- cat

agent-exec run --stdin - -- cat <<'EOF'
line1
line2
EOF

agent-exec run --stdin "abc" -- cat
agent-exec run --stdin-file ./input.txt -- cat

When neither --stdin nor --stdin-file is given, run reads its own stdin to EOF only if that stdin is not a terminal. The bytes go through the same path as --stdin -: they are materialized into <job-directory>/stdin.bin, recorded in meta.json.stdin_file, and bounded by --stdin-max-bytes. Input above that limit fails before the job launches with error.code set to stdin_too_large.

--stdin and --stdin-file are authoritative: when either is present, caller stdin is not read implicitly. When stdin is a terminal and no stdin option is given, run does not read it, does not block, and the child receives null stdin. Automatic detection applies to run only — create, start, restart, and the MCP surfaces stay explicit-only.

If --stdin - receives a terminal instead of redirected input, the command fails with error.code set to stdin_required.

status: read job state

agent-exec status <JOB_ID>

The response can report created, running, exited, killed, or failed. It always includes job_id, state, and created_at; it includes started_at, finished_at, and exit_code when available.

Schema 0.3 adds execution context and observability derived from data the job already persists:

  • execution context: command (persisted argv, never shell-expanded), cwd when recorded, and tags
  • process observation: pid when recorded, and process_alive
  • timing: updated_at, live elapsed_ms, persisted terminal duration_ms, and signal
  • output: logs_drained, stdout_log_path, stderr_log_path, stdout_total_bytes, and stderr_total_bytes
  • abandonment provenance: abandoned_by and result_loss, present only when a configured --abandon-job-after limit actually terminated the job
  • effective abandonment control: abandon_job_after_ms, abandon_deadline, abandon_remaining_ms, abandon_revision, and abandon_configured_by

abandoned_by="abandon_job_after" with result_loss=true identifies a job the caller gave up on, whose unfinished results may be lost. The terminal state value itself is unchanged, so existing clients keep working; both markers are absent when the limit never fired and for jobs recorded before the markers existed. The same pair appears in list and in the job.finished completion event.

The abandon_* fields report the deadline the supervisor will actually act on, not the one the job was defined with, so they reflect any runtime abandon set / abandon clear. abandon_configured_by is launch or update. All five are null when the job has no supervisor-authored control record, which is how a running job launched by an older release identifies itself; duration, deadline, and remaining time are also null when the control is disabled, and remaining time is null for terminal jobs. abandon_remaining_ms is computed at response time and clamped to the inclusive range [0, abandon_job_after_ms]; it is diagnostic, because firing is decided by the supervisor's locked control transition rather than by this value. status remains read-only and never creates a control record.

state is the persisted lifecycle state and status never rewrites it. process_alive is a separate best-effort, same-user-scoped probe that runs only for persisted running state; it is omitted otherwise, and omission means no live observation was made. A stale running job therefore appears as state="running" with process_alive=false here, and as unknown in list. The probe is not an authoritative liveness guarantee and does not defend against PID reuse.

elapsed_ms is live (response time minus started_at) and appears only for non-terminal jobs that have started. duration_ms is the persisted terminal wall-clock duration and is never computed at read time.

Byte totals come from file-size metadata only, so status cost does not grow with log size and log contents are never read; missing or unreadable logs report 0. Use tail for content.

The response never includes environment-variable values, stdin content, notification secrets, or shell-expanded command strings.

{
  "schema_version": "0.3",
  "ok": true,
  "type": "status",
  "job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
  "state": "running",
  "created_at": "2026-07-19T12:00:00Z",
  "started_at": "2026-07-19T12:00:00Z",
  "command": ["./scripts/run-heavy-task.sh"],
  "cwd": "/path/to/current-working-directory",
  "tags": [],
  "pid": 41234,
  "process_alive": true,
  "updated_at": "2026-07-19T12:00:03Z",
  "elapsed_ms": 3000,
  "logs_drained": false,
  "stdout_log_path": "/home/user/.local/share/agent-exec/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
  "stderr_log_path": "/home/user/.local/share/agent-exec/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log",
  "stdout_total_bytes": 128,
  "stderr_total_bytes": 0
}

tail: read bounded output tails

agent-exec tail [--tail-lines <N>] [--max-bytes <N>] [--compress <MODE>] <JOB_ID>

The response includes bounded stdout and stderr tails, their raw byte ranges and totals, encoding, and both log paths. Defaults are 50 lines and 65,536 bytes per stream.

wait: observe until completion or deadline

agent-exec wait [--until <SECONDS> | --forever] [--poll <SECONDS>] <JOB_ID>

The default client-side deadline is 30 seconds. Every response includes bounded stdout and stderr, byte ranges and totals, and utf-8-lossy encoding; terminal responses also include the exit code. Reaching the deadline does not stop the job. Use tail for later or repeated log retrieval, and run --abandon-job-after when the intent is to give up on the job rather than to stop waiting for it.

kill: send a signal

agent-exec kill [--signal <NAME>] [--no-wait] <JOB_ID>

The default signal is TERM. By default, kill briefly observes the result; --no-wait skips that observation.

list: list jobs

agent-exec list [--state <STATE>] [--limit <N>] [--cwd <PATH> | --all] [--tag <PATTERN>]...

By default, list returns jobs whose persisted current working directory matches the caller's current working directory. --cwd selects another directory, and --all disables current working directory filtering. States are created, running, exited, killed, failed, and unknown.

Repeated --tag filters use logical AND. An exact pattern such as ci matches that tag only. A namespace pattern such as project.build.* matches tags below that namespace.

agent-exec list --all --tag ci
agent-exec list --all --tag project.build.*
agent-exec list --tag ci --tag release

ps: list running jobs

agent-exec ps [--limit <N>] [--cwd <PATH> | --all] [--tag <PATTERN>]...

ps is equivalent to list --state running and returns the same type set to list.

tag set: replace job tags

agent-exec tag set <JOB_ID> [--tag <TAG>]...

The command replaces all tags, preserving the first occurrence of each duplicate. Omit --tag to clear the list.

agent-exec run --tag project.build --tag ci -- make build
agent-exec tag set 7f3a9c1e4b2d8a605e7c9f0134ab6d82 --tag project.release --tag approved
agent-exec tag set 7f3a9c1e4b2d8a605e7c9f0134ab6d82

Stored tags use dot-separated segments containing alphanumeric characters and hyphens, such as ci, project.build, and release.v2. The .* suffix is reserved for filter patterns.

notify set: update notification metadata

agent-exec notify set <JOB_ID> [OPTIONS]

This metadata-only command updates the persisted notification configuration. It never invokes a sink immediately, including for a terminal job. Unspecified fields are preserved. --command replaces notify_command, while notify_file remains unchanged.

Completion notification:

JOB=$(agent-exec run --no-wait -- sleep 5 | jq -r .job_id)
agent-exec notify set "$JOB" --command 'cat > /tmp/event.json'

Output-match notification:

JOB=$(agent-exec run --no-wait -- sh -c 'sleep 1; echo ERROR foo' | jq -r .job_id)
agent-exec notify set "$JOB" \
  --output-pattern 'ERROR' \
  --output-command 'cat >> /tmp/matches.ndjson'

agent-exec notify set "$JOB" \
  --output-pattern '^ERR' \
  --output-match-type regex \
  --output-stream stderr \
  --output-file /tmp/stderr-matches.ndjson

Output-match settings apply only to lines observed after the configuration becomes active. Use agent-exec notify set --help for all fields.

gc: collect old job data

agent-exec gc [--older-than <DURATION>] [--max-jobs <N>] [--max-bytes <BYTES>] [--dry-run]

gc scans the entire jobs root, regardless of the current working directory. In the current implementation, it first builds an oldest-first pool of terminal jobs older than --older-than; --max-jobs and --max-bytes apply only within that pool and never select newer terminal jobs. Jobs in created or running state and jobs with unreadable state are preserved.

Selection is order-dependent: age eligibility is established first, --max-jobs removes age-only selection from the newest N pool entries, and --max-bytes may then select oldest pool entries until their removal would bring pool storage within the limit.

Flag Default Description
--older-than <DURATION> 30d Build the age-eligible terminal pool. Accepted suffixes include d, h, m, and s.
--max-jobs <N> None Apply count policy within the age-eligible pool.
--max-bytes <BYTES> None Apply oldest-first byte policy within the age-eligible pool.
--dry-run false Report aggregate effects without deleting directories.

The age timestamp is finished_at when present, otherwise updated_at.

agent-exec gc --dry-run
agent-exec gc --older-than 7d --dry-run
agent-exec gc --older-than 7d
agent-exec --root /tmp/jobs gc --older-than 7d

The gc response is aggregate-only and has no jobs array:

Field Description
root Resolved jobs root.
dry_run Whether deletion was disabled.
older_than Effective retention duration.
older_than_source default or flag.
deleted Directories actually deleted; always 0 for a dry run.
skipped Total skipped directories; equal to out_of_scope + failed.
out_of_scope Entries excluded by state, age, timestamp, readability, or policy limits.
failed Eligible entries that could not be deleted or remained after deletion.
freed_bytes Bytes removed, or bytes that a dry run would remove.
scanned_dirs Directories scanned.
candidate_count Directories selected by policy before deletion limits.

delete: remove explicit or current-directory jobs

agent-exec delete <JOB_ID> [--dry-run]
agent-exec delete --all [--dry-run]

rm is a visible alias and returns the same response shape.

Single-job deletion is not scoped by the current working directory. The implementation rejects a job whose readable state is running. It may delete created, terminal, or unknown jobs, including a directory whose state is missing or unreadable. Inspect the job or use --dry-run before deleting an explicit ID when state integrity is uncertain.

delete --all removes only terminal jobs whose persisted current working directory matches the caller's current working directory. It skips created, running, unreadable-state, and terminal jobs whose recorded PID is still alive. Jobs from other directories contribute to out_of_scope but are not listed individually.

agent-exec delete 7f3a9c1e4b2d8a605e7c9f0134ab6d82
agent-exec delete --all --dry-run
agent-exec delete --all
agent-exec --root /tmp/jobs delete --all

The response includes root, dry_run, deleted, skipped, out_of_scope, failed, and per-job jobs. cwd_scope is present only for --all. Each job result contains job_id, state, action, and reason; action is deleted, would_delete, or skipped. A reported deleted action means the path was confirmed absent after deletion.

schema: print the response schema

agent-exec schema

The response contains the JSON Schema document for CLI response types, its schema format, and generation timestamp.

Automatic Cleanup

After a successful launch, run, start, and restart perform bounded, best-effort automatic GC by default.

  • Default retention is 30d.
  • created, running, and unreadable-state jobs are preserved.
  • Cleanup failure does not fail the launch command.
  • Scan and deletion limits bound launch-time work.

Per-invocation controls are --no-auto-gc, --auto-gc-older-than, --auto-gc-max-jobs, and --auto-gc-max-bytes.

[gc]
auto = true
older_than = "30d"
max_jobs = 200
max_bytes = 1073741824
scan_limit = 200
delete_limit = 20

CLI values override configuration for that invocation.

HTTP Server

agent-exec serve exposes job operations to HTTP clients.

agent-exec serve [--bind <HOST:PORT> | --port <PORT>] [--allow-origin <ORIGIN>]

The default address is 127.0.0.1:19263. --port changes the loopback port. A non-loopback bind requires both --insecure and a nonempty AGENT_EXEC_SERVE_TOKEN.

Security model

AGENT_EXEC_SERVE_TOKEN enables bearer authentication only for the mutating endpoints POST /exec and POST /kill/{id}. It does not protect GET /health, GET /status/{id}, GET /tail/{id}, or GET /wait/{id}. Those read endpoints can expose job state and output.

Keep the default loopback bind unless remote access is required. For non-loopback access, restrict the port with a firewall, private network, or authenticating reverse proxy. Do not expose the server directly to the public internet. The required --insecure flag acknowledges that the built-in token does not secure read endpoints.

--allow-origin enables CORS for one explicit origin. The wildcard origin * is rejected.

Endpoints

Method Path CLI equivalent Behavior
GET /health None Returns schema_version, ok, and type set to health.
POST /exec run Starts a job and returns RunData.
GET /status/{id} status Returns job status.
GET /tail/{id} tail Returns bounded stdout and stderr tails.
GET /wait/{id} wait --forever Blocks until a terminal state and returns bounded stdout/stderr output metadata.
POST /kill/{id} kill Sends TERM; ?no_wait=true skips observation.
PUT /abandon/{id} abandon set Replaces a running job's abandonment deadline.
DELETE /abandon/{id} abandon clear Removes a running job's abandonment deadline.

HTTP responses use the same schema_version, ok, and type envelope fields as CLI responses.

POST /exec request

{
  "command": ["bash", "-c", "echo hello"],
  "cwd": "/tmp",
  "env": {"FOO": "bar"},
  "abandon_job_after": 30,
  "acknowledge_result_loss": true,
  "wait": true,
  "until": 10,
  "max_bytes": 65536
}

Only command is required. abandon_job_after gives up on the job and terminates it, and may permanently lose unfinished results; use until to stop waiting without stopping the job. Pass it as a nonnegative number of seconds; it may be fractional, and a non-null value requires acknowledge_result_loss: true or the request is rejected with HTTP 400 before a job is created. until must be a nonnegative integer number of seconds. wait defaults to true, until to 10, and max_bytes to 65536. The removed timeout and timeout_ms fields are rejected with migration guidance.

PUT /abandon/{id} and DELETE /abandon/{id}

{
  "abandon_in": 600,
  "acknowledge_result_loss": true
}

PUT replaces a running job's abandonment deadline; abandon_in is a nonnegative number of seconds measured from durable acceptance of the update, not from job start. Because it is destructive it requires acknowledge_result_loss: true, and a request without it is rejected with HTTP 400 result_loss_not_acknowledged before any control state is read or written. DELETE clears the deadline, takes no body, and requires no acknowledgement because it never signals the job.

Both return the abandon_set / abandon_clear envelope with the durable abandon_revision the operation committed. A job that is not running, whose abandonment has already been triggered, or that has no supervisor-authored control record is rejected with HTTP 400 invalid_state; an unknown job is HTTP 404 job_not_found. Both routes require the bearer token when one is configured, and CORS preflight permits PUT and DELETE.

Docker client example

Start the host server on a non-loopback address with a strong token:

export AGENT_EXEC_SERVE_TOKEN="$(openssl rand -hex 32)"
agent-exec serve --bind 0.0.0.0:19263 --insecure

Provide the same token to the container through its secret or environment configuration. From a Docker container on a platform that supports host.docker.internal:

curl -sS \
  -H "Authorization: Bearer $AGENT_EXEC_SERVE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":["my-agent-script"],"wait":false,"until":0,"max_bytes":65536}' \
  http://host.docker.internal:19263/exec

Use the returned job ID with the read endpoints. Because read endpoints do not require the bearer token, network restrictions remain mandatory.

MCP Server

agent-exec mcp exposes the managed-job lifecycle over stdio. It uses the same jobs root, persisted metadata, detached supervisor, logs, and response envelopes as the CLI; it does not require the HTTP server.

For tested setup instructions for Claude Code, Codex CLI, OpenCode, and Hermes Agent, see AI agent integrations.

Configure an MCP client to launch:

command: agent-exec
args: ["mcp"]

Set observation limits in the MCP server process environment:

AGENT_EXEC_MCP_DEFAULT_UNTIL_SECONDS=10
AGENT_EXEC_MCP_MAX_UNTIL_SECONDS=55

AGENT_EXEC_MCP_DEFAULT_UNTIL_SECONDS supplies an omitted until for MCP run and wait. AGENT_EXEC_MCP_MAX_UNTIL_SECONDS caps every MCP observation duration. Set the maximum below the MCP client's request timeout. An over-limit request uses the cap; it does not fail or stop the job.

Use a nondefault jobs root only when needed:

command: agent-exec
args: ["--root", "/path/to/jobs", "mcp"]

Hermes Native MCP configuration

mcp_servers:
  agent-exec:
    command: agent-exec
    args: ["mcp"]
    env:
      AGENT_EXEC_MCP_DEFAULT_UNTIL_SECONDS: "10"
      AGENT_EXEC_MCP_MAX_UNTIL_SECONDS: "55"

When MCP is unavailable, use agent-exec run -- <command> with CLI observation commands.

MCP tools

Tool Parameters Behavior
run command: string[], cwd?: string, env?: object, abandon_job_after?: integer, acknowledge_result_loss?: boolean, until?: integer, stdin?: string, stdin_file?: string, notify_command?: string, notify_file?: string Starts a detached job. abandon_job_after and until are seconds; the legacy omitted until is 10 seconds unless configured. abandon_job_after gives up on the job and terminates it, may permanently lose unfinished results, and requires acknowledge_result_loss: true; the removed timeout and timeout_ms fields are rejected with migration guidance.
status job_id: string Returns canonical job status.
tail job_id: string, lines?: integer, max_bytes?: integer Reads bounded tails; defaults are 50 lines and 65,536 bytes.
wait job_id: string, until?: integer Observes for a bounded duration and returns bounded stdout/stderr output metadata; the legacy omitted until is 30 seconds unless configured. Indefinite MCP waits are not supported.
kill job_id: string Sends TERM.
set_abandonment job_id: string, abandon_in: number, acknowledge_result_loss?: boolean Replaces a running job's abandonment deadline. abandon_in is seconds from durable acceptance of the update, not from job start. Gives up on the job and terminates it at that point, may permanently lose unfinished results, and requires acknowledge_result_loss: true.
clear_abandonment job_id: string Removes a running job's abandonment deadline. Non-destructive: the job is never signaled, so no acknowledgement is required.

Retain the job ID returned by run. Closing the MCP transport, reaching an observation deadline, receiving no output, or encountering a tool error does not stop the job. Use kill only for explicit cancellation.

MCP run accepts job stdin through the same materialization as the CLI. Supply stdin for inline UTF-8 bytes or stdin_file for a path readable by the MCP server process; the two are mutually exclusive and a conflicting call fails without creating a job. stdin_file is snapshotted into the job directory before launch, so later edits to the source file do not change a running job's input. Unlike CLI --stdin, stdin: "-" is one literal dash byte: the MCP stdio transport carries protocol frames and is never read as job stdin. When both are omitted the child receives null stdin. Input above the 64 MiB limit, or an unreadable stdin_file, fails before the child launches.

For an MCP host with a 60-second request deadline, a maximum of 55 seconds leaves time for the response to return. The default can remain shorter, such as 10 seconds.

MCP completion notification

MCP run accepts the same completion sinks as the CLI. notify_command is a shell command string run once on completion, and notify_file is a path that receives one NDJSON job.finished event per completed job. Both are persisted as canonical run notification metadata before the workload launches, and both are server-local privileged configuration: they run with the MCP server process's authority, so granting a client access to this server grants that capability. Unusable input (empty or NUL-bearing) fails before any job directory exists.

When a sink was persisted and the job is still running, the run response adds an optional notification object:

{
  "notification": {
    "state": "armed",
    "sinks": ["command"],
    "polling_required": false,
    "message": "Completion notification is armed through the configured sink. Do not poll wait/status/tail."
  }
}

An agent that receives state="armed" can stop observing immediately and rely on the configured sink. The object is client-independent: sinks names only the generic sink classes (command, file), never a session, chat, or originating client. armed means terminal dispatch metadata was persisted, not that downstream delivery is guaranteed.

The object is omitted whenever the response asserts nothing: no sink supplied, admission failed, or the job already reached a terminal state. wait, status, and tail stay available for explicit progress requests and diagnosis in every case. A host-specific adapter may build a sink before calling run; it owns the destination identity, while the server only validates, persists, and reports it.

The MCP run tool intentionally omits CLI-only masking, tag, compression, and shell-wrapper controls.

Configuration

agent-exec reads optional [shell], [gc], and [compression] sections from $XDG_CONFIG_HOME/agent-exec/config.toml, which normally resolves to ~/.config/agent-exec/config.toml.

[shell]
unix = ["sh", "-lc"]
windows = ["cmd", "/C"]

[gc]
auto = true
older_than = "30d"
max_jobs = 200
max_bytes = 1073741824
scan_limit = 200
delete_limit = 20

[compression]
default = "route"

All keys are optional. Shell values fall back to sh -lc on Unix-like systems and cmd /C on Windows. GC and compression values fall back to their built-in defaults.

Shell wrapper precedence

  1. --shell-wrapper <PROGRAM AND FLAGS>
  2. The file selected by --config <PATH>, when specified
  3. The default XDG configuration file, only when --config is omitted
  4. The built-in platform wrapper

Command launch modes on Unix

The number of command arguments after -- selects the launch mode:

Mode Example Behavior
Shell string agent-exec run -- "echo hi && ls" A single argument is passed unchanged to the shell wrapper.
argv agent-exec run -- cargo test Two or more arguments use an exec "$@" handoff after the wrapper initializes the shell environment.

The argv handoff replaces the wrapper with the target workload, so PID and lifecycle tracking align with the command. Prefer argv for routine commands and shell-string mode for actual shell expressions.

The configured wrapper also runs --notify-command. Notification delivery always uses shell-string mode.

agent-exec run --shell-wrapper "bash -lc" -- ./my_script.sh
agent-exec run --config /path/to/config.toml -- ./my_script.sh

Job Completion Events

--notify-command and --notify-file deliver a job.finished event after a launched job reaches a terminal state.

  • --notify-command runs a shell command through the configured wrapper and writes event JSON to its standard input.
  • --notify-file appends one NDJSON line.
  • completion_event.json stores the event and sink delivery results in the job directory.
  • Delivery is best effort; sink failure does not change job state.
  • Inspect completion_event.json.delivery_results when delivery success matters.
JOB=$(agent-exec run --notify-file /tmp/agent-exec-events.ndjson -- echo hello | jq -r .job_id)
agent-exec wait "$JOB"
agent-exec tail "$JOB"

JOB=$(agent-exec run --notify-command 'cat > /tmp/agent-exec-event.json' -- echo hello | jq -r .job_id)
agent-exec wait "$JOB"

Command sinks receive these environment variables:

  • AGENT_EXEC_EVENT_PATH: persisted completion_event.json or notification_events.ndjson path
  • AGENT_EXEC_JOB_ID: canonical job ID
  • AGENT_EXEC_EVENT_TYPE: job.finished or job.output.matched

Example payload:

{
  "schema_version": "0.3",
  "event_type": "job.finished",
  "job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
  "state": "exited",
  "command": ["echo", "hello"],
  "cwd": "/path/to/current-working-directory",
  "started_at": "2026-07-19T12:00:00Z",
  "finished_at": "2026-07-19T12:00:00Z",
  "duration_ms": 12,
  "exit_code": 0,
  "stdout_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
  "stderr_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log"
}

For signal termination, state is killed, exit_code may be absent, and signal is present when available.

Install Embedded Skill

install-skills installs only the embedded agent-exec skill into .agents/skills/ or .claude/skills/ and updates the corresponding .skill-lock.json. It is not a general skill installer and does not accept external sources.

agent-exec install-skills
agent-exec install-skills --claude
agent-exec install-skills --claude --global

OpenClaw Integration

Return completion to the launching session

A completion callback can return the job ID and event path to the OpenClaw session that launched the work. The session can inspect the persisted event and logs before responding.

SESSION_ID="01bb09d5-6485-4a50-8d3b-3f6e80c61f9c"
REPLY_CHANNEL="telegram"

agent-exec run \
  --notify-command "openclaw agent --deliver --reply-channel $REPLY_CHANNEL --session-id $SESSION_ID -m \"job_id=\$AGENT_EXEC_JOB_ID event_path=\$AGENT_EXEC_EVENT_PATH\"" \
  -- ./scripts/run-heavy-task.sh

When both agents share a filesystem, sending job_id and event_path is more compact than embedding the full event JSON.

Add a callback to a running job

JOB=$(agent-exec run --no-wait -- ./scripts/run-heavy-task.sh | jq -r .job_id)
SESSION_ID="01bb09d5-6485-4a50-8d3b-3f6e80c61f9c"
REPLY_CHANNEL="telegram"

agent-exec notify set "$JOB" \
  --command "openclaw agent --deliver --reply-channel $REPLY_CHANNEL --session-id $SESSION_ID -m \"job_id=\$AGENT_EXEC_JOB_ID event_path=\$AGENT_EXEC_EVENT_PATH\""

notify set updates future delivery metadata and does not invoke the callback immediately.

Use a durable file handoff

agent-exec run \
  --notify-file /var/lib/agent-exec/events.ndjson \
  -- ./scripts/run-heavy-task.sh

A separate worker can process the NDJSON file, retry delivery, and route events without coupling that work to the supervisor.

Keep command sinks short, fast, and idempotent. Common failures include quoting errors, environment or PATH differences, nonzero downstream exits, and incorrect delivery targets. Use a checked-in helper or durable worker for substantial orchestration.

OpenCode Integration

examples/integrations/opencode-auto-resume/ is an opt-in reference integration that returns control to the OpenCode session that launched a managed job, as soon as that job finishes.

An OpenCode plugin watches the tool.execute.after hook, recognizes a successful agent-exec MCP run result, and attaches a completion callback to that job with notify set. When the job reaches a terminal state, the callback reads the persisted completion event, ignores jobs shorter than a configurable threshold (60 seconds by default), and resumes the originating session with opencode run --attach.

It uses only the existing notification and completion-event contracts: no runtime default changes, no MCP schema changes, and no installer subcommand. Delivery is best effort, restricted to loopback OpenCode servers, and delivered as an ordinary role=user message rather than a trusted internal event. Jobs started with the CLI directly, outside an OpenCode MCP tool call, are not covered.

See examples/integrations/opencode-auto-resume/README.md for installation, configuration, security boundaries, limitations, and uninstall steps.

Output-Match Events

When output-match notification metadata is active, the supervisor evaluates newly observed lines from stdout, stderr, or either stream and emits job.output.matched for every match.

  • contains performs substring matching.
  • regex uses Rust regular-expression syntax.
  • Earlier output is not replayed after notify set.
  • Sink failure is recorded in notification_events.ndjson and does not affect job state.
  • completion_event.json remains reserved for job.finished delivery results.
{
  "schema_version": "0.3",
  "event_type": "job.output.matched",
  "job_id": "7f3a9c1e4b2d8a605e7c9f0134ab6d82",
  "pattern": "ERROR",
  "match_type": "contains",
  "stream": "stdout",
  "line": "ERROR: connection refused",
  "stdout_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stdout.log",
  "stderr_log_path": "/jobs/7f3a9c1e4b2d8a605e7c9f0134ab6d82/stderr.log"
}

Embedding in a Rust program

A Rust program that links agent-exec can manage jobs through a typed API instead of spawning the agent-exec executable and reparsing JSON. See the agent_exec::embedded module docs for the full contract.

Managed jobs stay managed because supervision runs in a detached process, never an in-process thread — the supervisor owns the workload's stdout/stderr, abandonment escalation, notifications, state updates, and process-tree cleanup long after the launching call returns. So embedded launch re-executes a supervisor executable, which by default is the consumer's own binary. That is why the consumer has to install the startup delegation before its own argument parsing:

use agent_exec::embedded::{EmbeddedClient, KillRequest, ListRequest, RunRequest,
                           SupervisorDelegation, TailRequest};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Reserved supervisor invocations are claimed here and never reach the
    //    consumer's own CLI. Ordinary invocations fall through untouched.
    if agent_exec::embedded::delegate_supervisor_startup()? == SupervisorDelegation::Supervised {
        return Ok(());
    }

    // 2. The jobs root is always explicit for embedded callers: no
    //    AGENT_EXEC_ROOT / XDG fallback is applied.
    let client = EmbeddedClient::new("/var/lib/my-app/agent-exec-jobs")?;

    // 3. Typed calls only — no JSON, no stdout writes, no CLI subprocess.
    let launched = client.run(RunRequest::new(vec!["echo hello".to_string()]))?;
    let status = client.status(&launched.job_id)?;
    let tail = client.tail(TailRequest::new(&launched.job_id))?;
    let jobs = client.list(ListRequest::all())?;
    let killed = client.kill(KillRequest::new(&launched.job_id))?;
    println!("{} {} {} {}", status.state, tail.encoding, jobs.jobs.len(), killed.signal);
    Ok(())
}

Reserved invocation ownership. Delegation claims a process only when argv[1] is exactly the reserved marker; anything else returns NotSupervisor with the consumer's arguments unchanged. Once claimed, the generated argument grammar is parsed strictly, and missing, duplicated, malformed, or unexpected arguments fail closed instead of falling through to consumer command handling. The marker is a private dispatch token, not an authentication boundary and not a stable end-user CLI; delegated supervision also validates the explicit root and job identity against the pre-created metadata before acknowledging startup.

Supervisor executable override. EmbeddedClient::with_supervisor_exe selects a different trusted executable for tests and unusual packaging. It must install the same delegation.

Lifecycle guarantees. run returns only after the delegated supervisor acknowledges startup within a fixed five-second deadline, so a running result means supervision really claimed the job and will outlive the launching process. A launch that never produced validated supervision — missing delegation, malformed delegated arguments, an unusable supervisor executable, or an expired deadline — persists terminal failed with no intermediate running transition, launches no workload, and emits no completion notification. The deadline is a launch-integrity check rather than workload observation, so it also applies to no-wait launches, and the inline observation budget starts only after acknowledgement.

API shape and errors. The API is synchronous, matching the jobstore. Every operation returns JobError, whose kind() and is_retryable() let callers distinguish job_not_found, ambiguous_job_id, invalid_input, invalid_state, launch_failed, io_error, and internal_error without parsing message text.

The standalone CLI, MCP server, and HTTP server are adapters over this same typed implementation, so their JSON envelopes, exit codes, and job semantics are unchanged.

Logging

Diagnostic logs use stderr only:

RUST_LOG=debug agent-exec run -- echo hello
agent-exec -v run -- echo hello

Development

cargo build
cargo test --all
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings

About

A shell command runner built for AI agents: run commands, capture logs, monitor status, wait for completion, and kill jobs safely

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages