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
- The person gets a real terminal that shows the live view: a tmux pane, an IDE console or a CI log.
- The tool follows the run's progress in the markers file,
JUNEAU_TERMINAL_MARKERS_FILE. - When the run ends, the tool reads the result file,
JUNEAU_TERMINAL_RESULT: the status, the exit code and one row per step. - 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.
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 field | Meaning |
|---|---|
status | ok, fail or cancelled. |
exit | The script's exit code (130 when cancelled). |
ms | The run's duration. |
project, branch, head, commit | As in the run and done markers. |
rows | One 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. |
notes | The run's note markers: level, text and href. |
logs | full 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 knownev. Later versions add events and fields. - A line with the prefix but bad JSON, or with no
ev, is an ordinary output line. doneis the last marker. When it arrives, close any step that has noend: as failed ifdone.statusisfail, as skipped otherwise.- If the stream ends with no
done, for example because the script was killed, treat it asdonewith statusfail, orcancelledif your tool cancelled the run. - Treat a missing result file the same way: the run did not finish.