Watch a live run, and change how it renders, without attaching to it.
A packet run used to be a black box while it ran: you could read its output
and you could kill it, and that was the whole interface. This is the other
half — a process-addressable API with no IO, no terminal, and no assumption
about who is calling. The CLI's control commands are written entirely
against this module, and a Phoenix LiveView would use exactly the same
functions.
Addressing a run
A run_ref is {packet_dir, run_id}. Explicit ids rather than an implicit
"current run" cost nothing now and avoid a rewrite if the runner ever goes
concurrent. current_run/1 finds the newest one for a packet:
{:ok, run_ref} = PromptRunner.Control.current_run("packets/demo")
{:ok, snapshot} = PromptRunner.Control.snapshot(run_ref)Reading
snapshot/1 and log/1 read files the runner writes. They never touch the
session, so polling them cannot slow, block, or crash the run.
Writing
set_view/2 writes a request into control/requests/. The runner consumes
it at an event boundary — never mid-event — applies it, and records the
outcome in control/log.jsonl. :ok means the request was accepted for
delivery, not that it has been applied yet; the log is where the outcome
lives. That asynchrony is deliberate: it is what stops an in-VM caller from
quietly getting a privileged synchronous path the CLI does not have.
Subscribing
subscribe/3 follows the run's canonical event stream:
{:ok, ref} = PromptRunner.Control.subscribe(run_ref, self())
receive do
{:prompt_runner_event, ^ref, event} -> IO.inspect(event["type"])
{:prompt_runner_control, ^ref, {:run_finished, status}} -> status
endEvents arrive as they were written, so maps with string keys.
Summary
Functions
Adds a requirement to a prompt's verify contract.
The packet's contract for a prompt, the contract actually being enforced, and the difference between them.
The newest run recorded for packet_dir, running or not.
Every command the plane has seen for this packet, oldest first.
Removes or weakens a requirement in a prompt's verify contract.
Changes how a running run renders, from outside it.
The current state of the run: prompt, attempt, mode, elapsed, provider, model, tool count, token totals, and the view settings in force.
Says something to the agent while it is working.
Sends this run's events to pid until the run finishes or pid dies.
Stops a subscription started by subscribe/3 from the same process.
Types
@type run_ref() :: {store_root :: PromptRunner.Control.Store.root(), run_id :: String.t()}
Functions
@spec amend(run_ref() | String.t() | PromptRunner.Plan.t(), String.t(), keyword()) :: :ok | {:error, term()}
Adds a requirement to a prompt's verify contract.
Amendment changes what "done" means. It is the one capability here that can make a completed prompt mean something other than what the packet says, so it is governed more tightly than steering, which cannot.
Adding a requirement is the routine direction. Removing or relaxing one takes
relax/4, a different verb with an explicit confirmation — never a different
argument to this one.
Run-local: the packet file stays authoritative and a future re-run from clean
state uses the original contract. persist: true writes it back, which is a
separate explicit act because a packet is a versioned artifact and editing it
is a commit, not a side effect.
Required options:
:clause— one ofPromptRunner.Verifier.contract_keys/0:entries— what to add to that clause:reason— mandatory. An amendment with no stated reason is refused, not defaulted.
Optional:
:author— defaults to the OS user:persist— also write the change back to the packet file
@spec contract(String.t() | PromptRunner.Plan.t(), String.t()) :: {:ok, map()} | {:error, term()}
The packet's contract for a prompt, the contract actually being enforced, and the difference between them.
If you cannot show the diff, you do not have the audit.
@spec current_run(PromptRunner.Control.Store.root()) :: {:ok, run_ref()} | {:error, term()}
The newest run recorded for packet_dir, running or not.
Returns {:error, :no_run} when the packet has no control directory yet,
which is the ordinary state of a packet nothing has ever run.
@spec log(run_ref()) :: {:ok, [PromptRunner.Control.Entry.t()]}
Every command the plane has seen for this packet, oldest first.
Includes refused commands. A refusal that leaves no trace is indistinguishable from a command that was never sent.
@spec relax(run_ref() | String.t() | PromptRunner.Plan.t(), String.t(), keyword()) :: :ok | {:error, term()}
Removes or weakens a requirement in a prompt's verify contract.
The risky direction, and deliberately a different verb. Requires
confirm: true; without it this refuses and says why.
An amendment that weakens a contract after a verify failure is exactly the move pre-registration exists to prevent, so the record says when it happened relative to verification. It is not forbidden — sometimes a requirement was simply wrong — but it is never quiet.
Changes how a running run renders, from outside it.
Accepts log_mode, tool_output, thinking, and diff. These are already renderer
state; this makes them mutable at runtime rather than only at launch. An
unknown key or value is refused here rather than written and ignored.
Options:
:author— recorded on the log entry, defaults to the OS user
@spec snapshot(run_ref()) :: {:ok, PromptRunner.Control.Snapshot.t()} | {:error, term()}
The current state of the run: prompt, attempt, mode, elapsed, provider, model, tool count, token totals, and the view settings in force.
Says something to the agent while it is working.
Steering changes how the agent works toward an unchanged definition of
done — "you're down a rabbit hole, check dependency_sources.exs before you
keep editing mix files". The verify contract is untouched: the prompt still
passes or fails on exactly the criteria it started with, which is what makes
steering safe to allow freely and amendment (amend/4) not.
A steer is never evidence. A contract asserting a document contains X is not satisfied by a human having said "put X in the doc" — the verifier sees what the session produced, not what it was told.
A steer is always recorded, twice: on the control log, and as an append-only
artifact at packet/.prompt_runner/interventions/<prompt>.jsonl that is
committed with the work. The prompt's result records that it was steered and
how many times, so a human-guided result is distinguishable from an
autonomous one — flagged, not disqualified.
Options:
:author— recorded on both records, defaults to the OS user
Sends this run's events to pid until the run finishes or pid dies.
Options:
:from—:start(default) replays the run from its first event, then follows;:currentdelivers only what arrives after subscribing:interval_ms— poll interval, default 200
Stops a subscription started by subscribe/3 from the same process.
A subscription also ends on its own when the run finishes or the subscribing process dies, so this is for a consumer that wants to stop watching earlier.