Skip to main content

Driving a Run from Another Tool

A script built on juneau-terminal draws its live view for whoever is watching the terminal. A tool that starts the script (an AI coding agent, an IDE task, a CI wrapper) needs something else: progress it can follow and an outcome it can read, without scraping a screen. This page shows how to give each of them what they need from the same run.

The pattern​

  1. The person gets a real terminal that shows the live view: a tmux pane, an IDE console or a CI log.
  2. The tool follows the run's progress in the markers file, JUNEAU_TERMINAL_MARKERS_FILE.
  3. When the run ends, the tool reads the result file, JUNEAU_TERMINAL_RESULT: the status, the exit code and one row per step.
  4. Anyone else who wants the details reads the logs. The result file names the full log and the board log.

Neither file changes what the terminal shows. With TERMINAL_MARKERS unset, the run draws its console view as usual, and the markers go to the file as well.

An agent pane beside a run pane

The two files​

Both are written by the script that owns the run, the one that calls run() and done() (Juneau's own push.py and test.py are such scripts).

JUNEAU_TERMINAL_MARKERS_FILE=<path> : Every terminal-protocol marker, one ##terminal {json} line each. The owner empties the file when the run starts. A script it runs, which inherits JUNEAU_TERMINAL_ACTIVE, appends its steps to the same file. A file that can't be written is reported once on stderr and the run goes on without it. Under --pty the file isn't written.

JUNEAU_TERMINAL_RESULT=<path> : One JSON object, written when the run ends (ok, failed, or cancelled by Ctrl-C or SIGTERM). It is written through a temporary file and a rename, so a reader never sees half of it, and an older file at that path is removed when the run starts. Fields with no value are left out.

Result fieldMeaning
statusok, fail or cancelled.
exitThe script's exit code (130 when cancelled).
msThe run's duration.
project, branch, head, commitAs in the run and done markers.
rowsOne entry per row of the console view, in order: id, label, status, ms, summary and, for a test step, tests with total, fail, err and skip.
notesThe run's note markers: level, text and href.
logsfull and board: the paths of the session's logs.

A worked example with tmux​

This opens a side pane for the person, runs a script in it, waits for it, and reads the result. It assumes the script is a juneau-terminal adopter such as push.py --test-only; replace it with your own.

dir=$(mktemp -d)
chan="run-$$"

# 1. The pane: a real terminal with the live view. -d keeps focus where the person is typing.
tmux split-window -h -d "env JUNEAU_TERMINAL_CONSOLE=board JUNEAU_TERMINAL_MARKERS_FILE=$dir/markers \
JUNEAU_TERMINAL_RESULT=$dir/result.json python3 push.py --test-only; tmux wait-for -S $chan; read -r"

# 2. Block until the pane signals (no polling). tmux remembers a signal sent before this line runs.
tmux wait-for "$chan"

# 3. The outcome: a missing result file means the script died before it could write one.
if [ -f "$dir/result.json" ]; then
jq -r '.status, "exit \(.exit)"' "$dir/result.json"
jq -r '.rows[] | "\(.status)\t\(.label)\t\(.summary)"' "$dir/result.json"
jq -r '.logs.full // empty' "$dir/result.json" # where to read a failure's details
else
echo "fail: no result file"
fi

The pane waits for Enter after the script ends, so the person can read the final view; the tool already has what it needs. For a tmux session that holds both the agent and the pane, see Watching a Run from an AI Agent.

Following progress while it runs​

Read the markers file as it grows. A line that has no trailing newline yet is still being written, so wait for it.

import json, time

def follow(path, still_running):
"""Yield each marker from the file; stop after `done`, or when the process is gone with no `done`."""
pending = ""
with open(path, encoding="utf-8") as f:
while True:
pending += f.read()
*lines, pending = pending.split("\n")
for line in lines:
if line.startswith("##terminal "):
try:
marker = json.loads(line[len("##terminal "):])
except ValueError:
continue # not a marker: an ordinary output line
yield marker
if marker.get("ev") == "done":
return
if not still_running():
yield {"ev": "done", "status": "fail"} # EOF with no `done`
return
time.sleep(0.2)

The file doesn't exist until the run starts, so open it once the process is running. Rather than polling the file, a tool that only needs the outcome can skip it and read the result file at the end.

Rules for a consumer​

These come from the terminal protocol's consumer rules, and a wrapper that reads either file should follow them:

  • Ignore an unknown ev, and unknown fields on a known ev. Later versions add events and fields.
  • A line with the prefix but bad JSON, or with no ev, is an ordinary output line.
  • done is the last marker. When it arrives, close any step that has no end: as failed if done.status is fail, as skipped otherwise.
  • If the stream ends with no done, for example because the script was killed, treat it as done with status fail, or cancelled if your tool cancelled the run.
  • Treat a missing result file the same way: the run did not finish.

See also​