PromptRunner.Control (PromptRunnerSDK v0.12.1)

Copy Markdown View Source

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
end

Events 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

run_ref()

@type run_ref() ::
  {store_root :: PromptRunner.Control.Store.root(), run_id :: String.t()}

Functions

amend(target, prompt_id, opts)

@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:

Optional:

  • :author — defaults to the OS user
  • :persist — also write the change back to the packet file

contract(packet_dir, prompt_id)

@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.

current_run(packet_dir)

@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.

log(arg)

@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.

relax(target, prompt_id, opts)

@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.

set_view(run_ref, view, opts \\ [])

@spec set_view(run_ref(), map() | keyword(), keyword()) :: :ok | {:error, term()}

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

snapshot(arg)

@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.

steer(run_ref, text, opts \\ [])

@spec steer(run_ref(), String.t(), keyword()) :: :ok | {:error, term()}

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

subscribe(arg, pid, opts \\ [])

@spec subscribe(run_ref(), pid(), keyword()) :: {:ok, reference()} | {:error, term()}

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; :current delivers only what arrives after subscribing
  • :interval_ms — poll interval, default 200

unsubscribe(run_ref, ref)

@spec unsubscribe(run_ref(), reference()) :: :ok

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.