Recording Post-process (External)
Opening Explanation
This stage exists to turn a raw terminal recording into a finished recording with the agent’s markers embedded at the right times. During the run, Terminus 2 can note important moments as marker data, but it does not rewrite the asciinema cast file live. That would make recording more fragile and would mix “capture the session” with “edit the finished file.” So this work is deferred to session stop. Concretely, this is the recording-finalization substep inside terminal/session shutdown, not part of the agent’s main run() loop. Its job is narrow: take the completed .cast file, merge in the saved markers, and leave behind a final recording artifact that external tools can read.
Main Flow
TmuxSession.stop()(shuts down the remotely driven terminal session) is the point where this stage begins. That matters because the recording file is now complete enough to safely finalize.- There is an early branch: if no marker data was collected, there is nothing to merge. In that case, stop/finalize leaves the cast file unchanged and returns.
- If markers do exist,
AsciinemaHandler.merge_markers()(finalizes the recording by inserting marker events) reads the existing cast file and the saved marker list. - The merge keeps the cast header, then walks the recording frame by frame.
AsciinemaHandler._process_recording_line()(handles one recording line at a time) inserts any marker whose timestamp should appear before that frame. This is the core responsibility of the stage: place markers in time order without disturbing the original terminal output. - If some markers fall after the last frame,
AsciinemaHandler._write_remaining_markers()appends them at the end so they are not lost. - The result is a rewritten, finalized
.castartifact. The point of the stage is not to change agent behavior. It is to make the recording understandable after the fact.
State Flow
- writes: none — this stage does not write any Terminus 2 register; it updates the recording artifact instead
- reads:
reg-asciinema-markers— reads the accumulated(timestamp, label)marker tuples to decide whether a merge is needed and, if so, where marker events should be inserted - clears: none proven — no register clear is evidenced here
- triggers downstream:
side-S1 Context Summarization— sequencing only; once terminal/session stop finishes, the broader pipeline may continue, but this stage does not hand off a new register
Additional artifact flow for this stage:
- reads: existing
.castrecording artifact — the already-recorded asciinema file is read as the base material for finalization - writes: finalized
.castrecording artifact — if markers exist, the cast is rewritten with embedded marker events; if no markers exist, the cast is left unchanged
Pipeline Hand-Off
Upstream leaves behind two things: a finished raw .cast recording and, if any were collected, marker entries in reg-asciinema-markers. This stage turns those into a finalized recording artifact; downstream sequencing happens after finalization, but no new Terminus 2 register is produced here.
TmuxSession.stoptmux_session.py:496–508 ↗
Stop-time asciinema marker merge gate
Stage context: This region performs the stage-6 handoff from in-memory marker collection to cast-file post-processing. The Harbor framework reaches it through
TmuxSession.stop()after the agent run has already finished, so it runs outside the mainrun()path. In this stage, its job is narrow: decide whether merge work is possible from_markersand_local_asciinema_recording_path, then delegate the actual file rewrite toAsciinemaHandler.merge_markers().
What this code does
This region checks whether self._markers contains any recorded markers and whether self._local_asciinema_recording_path points to a local cast file. When both are present, it logs the merge, builds an AsciinemaHandler from those two inputs, and asks that handler to fold the markers into the recording. Otherwise it only emits a debug message and leaves the recording unchanged.
Interface · params / IO
(self) -> None
- params:
self:TmuxSession— session object that holds collected markers, recording path, and logger - reads:
self._markers,self._local_asciinema_recording_path,self._logger - returns: None; the real product is a possible on-disk update to the local asciinema recording file and debug log output.
- effects: writes debug messages through
self._logger.debug(...); instantiatesAsciinemaHandler(self._markers, self._local_asciinema_recording_path); may rewrite the cast file atself._local_asciinema_recording_pathviahandler.merge_markers()
Execution flow
- Check the merge preconditions by reading
self._markersandself._local_asciinema_recording_pathin the same branch guard. - If both values are truthy, log how many markers will be merged with
len(self._markers). - Create
AsciinemaHandlerwith the current marker list and the local recording path, then callmerge_markers()to perform the post-process rewrite. - After a successful call, log that the merge completed for
self._local_asciinema_recording_path. - If either precondition fails, skip handler creation entirely and log
"No markers to merge".
Source
if self._markers and self._local_asciinema_recording_path:
self._logger.debug(
f"Merging {len(self._markers)} markers into recording"
)
handler = AsciinemaHandler(
self._markers, self._local_asciinema_recording_path
)
handler.merge_markers()
self._logger.debug(
f"Successfully merged markers into {self._local_asciinema_recording_path}"
)
else:
self._logger.debug("No markers to merge")
Non-obvious design decisions
- It gates on both
_markersand_local_asciinema_recording_pathbefore constructingAsciinemaHandler. That avoids invoking file post-processing when the session never recorded markers or never obtained a local cast path; a looser check would push missing-input handling into the handler. - It delegates all merge mechanics to
AsciinemaHandler.merge_markers()instead of touching the cast file here. That keepsTmuxSession.stop()focused on stop-time orchestration, while the specialized handler owns the recording-format logic described for stage-6. - It treats the no-work case as a debug-only branch (
"No markers to merge") rather than as an error. The branch condition shows that missing markers or path are acceptable outcomes at shutdown, not exceptional failures.
Relations
- Callers:
TmuxSession.stopsurrounding shutdown flow; Harbor framework post-run session teardown; stage-6 external post-process entrypoint after agent return - Core callees:
AsciinemaHandler;AsciinemaHandler.merge_markers;self._logger.debug - Config / state sources:
self._markers;self._local_asciinema_recording_path;self._logger - Results to: local cast file at
self._local_asciinema_recording_path; debug logging output; stage-6 recording artifact with markers merged
reg-asciinema-markers— consumes accumulated markers during stop-time merge
AsciinemaHandler.__init__asciinema_handler.py:11–20 ↗
Constructor storing ordered markers and recording path
Stage context: This entry covers only
AsciinemaHandler.__init__itself. Within the owning stage, it prepares anAsciinemaHandlerinstance by putting constructor inputs onto instance state in normalized form.
What this code does
AsciinemaHandler.__init__ takes markers and recording_path and stores them on the instance. It writes self._markers as either a new list sorted by each tuple's timestamp element (x[0]) or [] when markers is falsy. It also writes self._recording_path with the provided Path. The constructor returns no value; its product is initialized instance state.
Interface · params / IO
(self, markers: list[tuple[float, str]], recording_path: Path)
- params:
markers:list[tuple[float, str]]— marker pairs to store on the instance;recording_path:Path— recording file path to store on the instance - returns: None; it initializes
self._markersandself._recording_path. - effects: writes
self._markers; writesself._recording_path
Execution flow
- It evaluates
markersfor truthiness. Ifmarkersis truthy, it builds a new list withsorted(markers, key=lambda x: x[0])and assigns that list toself._markers. - If
markersis falsy, it assigns an empty list[]toself._markersinstead. - It assigns the incoming
recording_pathdirectly toself._recording_path.
Source
def __init__(self, markers: list[tuple[float, str]], recording_path: Path):
"""
Initialize the AsciinemaHandler.
Args:
markers: List of tuples containing (timestamp, label) for each marker
recording_path: Path to the asciinema recording file
"""
self._markers = sorted(markers, key=lambda x: x[0]) if markers else []
self._recording_path = recording_path
Non-obvious design decisions
- The constructor sorts
markerseagerly withsorted(..., key=lambda x: x[0])instead of storing the input list as-is. That choice makesself._markersordered by the first tuple element at construction time, and becausesorted()returns a new list, it does not reuse the caller's original list object. - The
if markers else []expression collapses any falsymarkersinput to a concrete empty list. That gives the instance a list in both branches rather than preserving the original falsy value.
Relations
- Callers: code that instantiates
AsciinemaHandler - Core callees:
sorted - Config / state sources: constructor argument
markers; constructor argumentrecording_path - Results to:
self._markers;self._recording_path; the initializedAsciinemaHandlerinstance
AsciinemaHandler.merge_markersasciinema_handler.py:22–39 ↗
Guarded cast-file marker merge entrypoint
Stage context: This method is the public entrypoint on
AsciinemaHandlerfor applying queued marker data to a cast file. Within this class, it sits above_write_merged_recording, which does the actual merged-file generation, and it limits itself to the guard, temporary-path setup, and final replacement step.
What this code does
merge_markers checks two prerequisites from instance state: self._markers must be truthy and self._recording_path must already exist. If either check fails, it returns None immediately and leaves the filesystem unchanged. Otherwise it asks _write_merged_recording to write a merged copy to a temporary path derived from self._recording_path, then replaces the original recording with that temporary file.
Interface · params / IO
(self) -> None
- params:
self:?— handler instance with_markers,_recording_path, and_write_merged_recording - reads:
self._markers,self._recording_path - returns: Returns
None; may make no filesystem changes whenself._markersis falsy orself._recording_pathdoes not exist. - effects: writes a merged recording to a temporary path via
self._write_merged_recording(temp_path); replacesself._recording_pathon disk withtemp_path.replace(self._recording_path)
Execution flow
- Evaluate the single guard
if not self._markers or not self._recording_path.exists(): returnand stop immediately when there are no markers or no existing recording file. - Build
temp_pathfromself._recording_path.with_suffix(".tmp"). - Delegate merged-file creation to
self._write_merged_recording(temp_path). - Promote the temporary file to the target path with
temp_path.replace(self._recording_path).
Source
def merge_markers(self) -> None:
"""
Merge asciinema markers into a recording.
Inserts marker events into an asciinema recording file at specified timestamps.
Markers are added as special events with type 'm' in the recording format.
The original recording is preserved until the merge is successful.
In the future Asciinema might support adding markers via RCP:
https://discourse.asciinema.org/t/add-markers-with-title-from-cli/861
"""
if not self._markers or not self._recording_path.exists():
return
# Create a temporary file in the same directory as the recording
temp_path = self._recording_path.with_suffix(".tmp")
self._write_merged_recording(temp_path)
temp_path.replace(self._recording_path)
Non-obvious design decisions
- It uses an early-return guard on both
self._markersandself._recording_path.exists()to skip all file work when a merge cannot or should not happen. - It writes to
temp_pathfirst and only then callsreplace(...), which keeps the original recording in place until the merged output has been produced.
Relations
- Callers: external caller of
AsciinemaHandler.merge_markers - Core callees:
self._recording_path.exists;self._recording_path.with_suffix;self._write_merged_recording;temp_path.replace - Config / state sources:
self._markers;self._recording_path - Results to: merged cast file at
self._recording_path; filesystem state at the recording path - Related siblings:
AsciinemaHandler.__init__initializesself._markersandself._recording_pathused here
AsciinemaHandler._write_merged_recordingasciinema_handler.py:41–60 ↗
Write merged cast file with inserted markers
Stage context: This helper performs the file-writing part of stage-6's recording post-process. It sits under
AsciinemaHandler.merge_markers, which decides whether to run the merge and later swaps the temporary output into place; this method only reads the source recording and writes the merged copy.
What this code does
_write_merged_recording rewrites the recording at self._recording_path into output_path while delegating marker insertion to helper methods. It copies the first input line unchanged, then feeds each remaining line plus the current marker_index into _process_recording_line, and finally asks _write_remaining_markers to emit any unconsumed tail from self._markers[marker_index:]. It returns None; its product is the newly written output file.
Interface · params / IO
(self, output_path: Path) -> None
- params:
output_path:Path— destination path for the rewritten recording file - reads:
self._recording_path,self._markers - returns: Returns
None; the observable result is content written tooutput_path. - effects: opens
self._recording_pathfor reading; creates or overwritesoutput_pathand writes merged recording content to it
Execution flow
- It initializes a local
marker_index = 0to track how much of the marker list downstream helpers have consumed. - It opens
self._recording_pathasinput_fileandoutput_pathasoutput_fileinside onewithblock. - It reads the first line from
input_filewithinput_file.readline()and writes that line directly tooutput_file. - For each remaining
lineininput_file, it callsself._process_recording_line(line, output_file, marker_index)and replacesmarker_indexwith that call's return value. - After the loop, it slices
self._markers[marker_index:]and passes that remainder toself._write_remaining_markers(output_file, ...).
Source
def _write_merged_recording(self, output_path: Path) -> None:
"""
Write a new recording file with markers merged in at the correct timestamps.
"""
marker_index = 0
with (
open(self._recording_path) as input_file,
open(output_path, "w") as output_file,
):
# Preserve header
output_file.write(input_file.readline())
for line in input_file:
marker_index = self._process_recording_line(
line, output_file, marker_index
)
# Add any remaining markers at the end
self._write_remaining_markers(output_file, self._markers[marker_index:])
Non-obvious design decisions
- It copies the first line separately with
input_file.readline()before the main loop. That visible split preserves one line verbatim without sending it through_process_recording_line. - It tracks progress with a local
marker_indexreturned from_process_recording_lineinstead of mutatingself._markers. That keeps instance state read-only in this method and makes the final remainder explicit asself._markers[marker_index:]. - It delegates line-level merging and tail emission to
_process_recording_lineand_write_remaining_markersrather than embedding both policies here. This keeps_write_merged_recordingfocused on file streaming and output assembly.
Relations
- Callers: AsciinemaHandler.merge_markers
- Core callees: self._process_recording_line; self._write_remaining_markers; open
- Config / state sources: self._recording_path; self._markers
- Results to: writes merged recording data to
output_path; produces the temporary file later used byAsciinemaHandler.merge_markers - Related siblings: AsciinemaHandler.merge_markers decides whether to call this helper and later replaces the original file with the output it writes.; AsciinemaHandler.__init__ stores the
markersandrecording_pathvalues that this method reads.
reg-asciinema-markers— reads marker list for per-line and trailing writes
AsciinemaHandler._process_recording_lineasciinema_handler.py:62–90 ↗
Per-line cast merger with marker insertion
Stage context: This helper does the line-by-line work inside stage-6's cast rewrite.
AsciinemaHandler._write_merged_recordingcalls it for each recording line after the header, andmerge_markerslater swaps the merged temp file into place. It complements_write_remaining_markers, which handles any markers left after all input lines are consumed.
What this code does
AsciinemaHandler._process_recording_line examines one cast-file line, tries to read an event timestamp from JSON array lines, and inserts any queued markers from self._markers whose timestamps are less than or equal to that event time. It always writes the original line to output_file, even when parsing fails or the line does not look like an event record. The function returns the next marker_index to continue the merge pass; it does not modify instance attributes.
Interface · params / IO
(self, line: str, output_file: TextIO, marker_index: int) -> int
- params:
line:str— One input line from the asciinema recording being merged;output_file:TextIO— Destination stream for marker events and the preserved recording line;marker_index:int— Current position inself._markersfor the next marker to consider - reads:
self._markers - returns: An updated
marker_indexafter consuming any markers written before this line's timestamp - effects: Writes zero or more marker lines to
output_fileviaself._write_marker; Writes the originallinetooutput_file
Execution flow
- If
linedoes not start with"[", treat it as a non-event line, write it unchanged tooutput_file, and return the incomingmarker_index. - Otherwise, try to parse
linewithjson.loads(line)and read the event time asfloat(data[0]). - While
marker_indexstill points insideself._markersand that marker's timestampself._markers[marker_index][0]is less than or equal to the parsedtimestamp, call_write_marker(output_file, self._markers[marker_index])and advancemarker_index. - If JSON parsing or timestamp extraction raises
json.JSONDecodeError,ValueError, orIndexError, suppress the error and keep the line unchanged. - Write the original
linetooutput_fileand return the finalmarker_index.
Source
def _process_recording_line(
self,
line: str,
output_file: TextIO,
marker_index: int,
) -> int:
"""Process a single line from the recording, inserting markers as needed."""
if not line.startswith("["):
output_file.write(line)
return marker_index
try:
data = json.loads(line)
timestamp = float(data[0])
# Insert any markers that should appear before this timestamp
while (
marker_index < len(self._markers)
and self._markers[marker_index][0] <= timestamp
):
self._write_marker(output_file, self._markers[marker_index])
marker_index += 1
except (json.JSONDecodeError, ValueError, IndexError):
# If we can't parse the line, preserve it as-is
pass
output_file.write(line)
return marker_index
Non-obvious design decisions
- It gates JSON parsing behind
line.startswith("["). That cheap shape check avoids trying to decode obvious non-event lines, while still preserving them verbatim. - The
except (json.JSONDecodeError, ValueError, IndexError): passbranch chooses lossless preservation over strict validation. If a cast line is malformed or not an array with a numeric first element, the merge keeps the original content instead of failing the whole post-process. - The marker loop uses
<= timestampagainstself._markers[marker_index][0]. That places markers at the first frame whose time is at or after the marker time, which matches the stage's merge policy described inmerge_markersand_write_merged_recording.
Relations
- Callers: AsciinemaHandler._write_merged_recording
- Core callees: json.loads; float; AsciinemaHandler._write_marker; output_file.write
- Config / state sources: self._markers from AsciinemaHandler.__init__;
marker_indexthreaded through AsciinemaHandler._write_merged_recording; inputlineread from the cast file by AsciinemaHandler._write_merged_recording - Results to: Updated
marker_indexback to AsciinemaHandler._write_merged_recording; Merged output stream later finalized by AsciinemaHandler._write_merged_recording; Remaining marker handling in AsciinemaHandler._write_remaining_markers after line processing completes - Related siblings: AsciinemaHandler._write_merged_recording drives this helper across the file; AsciinemaHandler.merge_markers decides whether the merge runs at all; AsciinemaHandler.__init__ pre-sorts
self._markers, which this function consumes in order; TmuxSession.stop triggers the stage by constructing AsciinemaHandler and callingmerge_markers
reg-asciinema-markers— consumes sorted marker tuples during merge
AsciinemaHandler._write_remaining_markersasciinema_handler.py:98–103 ↗
Write trailing markers through delegated marker serializer
Stage context: This helper is a minimal writer in the stage-6 cast post-processing code. Among the translated siblings,
AsciinemaHandler._write_merged_recordinginvokes it after processing the main recording stream, and this function only handles the marker list it is given by delegating each item toAsciinemaHandler._write_marker.
What this code does
AsciinemaHandler._write_remaining_markers walks the supplied markers list in its existing order and writes each marker to output_file by calling self._write_marker(output_file, marker). It takes no direct input from instance attributes and returns None. Its observable effect is whatever output self._write_marker produces on output_file for each tuple.
Interface · params / IO
(self, output_file: TextIO, markers: list[tuple[float, str]]) -> None
- params:
self:AsciinemaHandler— method receiver used only to reachself._write_marker;output_file:TextIO— text stream passed through to_write_markerfor each marker;markers:list[tuple[float, str]]— caller-supplied marker tuples to emit in list order - returns: Returns
None; the real product is delegated marker writes tooutput_file. - effects: Calls
self._write_marker(output_file, marker)once per item inmarkers; May write to the externaloutput_filestream via_write_marker; Does not catch exceptions raised by_write_marker
Execution flow
- Iterate over
markerswithfor marker in markers, preserving the caller-provided sequence. - For each
marker, callself._write_marker(output_file, marker)and do no other transformation, filtering, or accumulation.
Source
def _write_remaining_markers(
self, output_file: TextIO, markers: list[tuple[float, str]]
) -> None:
"""Write any remaining markers that come after all recorded events."""
for marker in markers:
self._write_marker(output_file, marker)
Non-obvious design decisions
- The helper delegates all per-marker formatting and output to
self._write_markerinstead of duplicating that logic here. This keeps this function to ordered iteration only; any exception from_write_markertherefore propagates unchanged.
Relations
- Callers:
AsciinemaHandler._write_merged_recording - Core callees:
AsciinemaHandler._write_marker - Config / state sources:
markersparameter;output_fileparameter - Results to:
output_filetext stream; caller-visible completion or propagated exception from_write_marker - Related siblings:
AsciinemaHandler._write_merged_recordingsupplies themarkersslice this helper emits;AsciinemaHandler._process_recording_linehandles marker insertion during line-by-line processing; this helper only emits the leftover list
AsciinemaHandler._write_markerasciinema_handler.py:92–96 ↗
Emit one asciinema marker event line
Stage context: This helper handles the smallest write unit in stage-6: one marker record in asciinema cast format.
AsciinemaHandler._process_recording_lineandAsciinemaHandler._write_remaining_markerscall it whileAsciinemaHandler._write_merged_recordingrewrites the final.castfile. It runs only during the external post-processing merge triggered after the agent finishes.
What this code does
AsciinemaHandler._write_marker takes an output_file stream and one marker tuple of (timestamp, label), converts that tuple into the asciinema marker event shape, and writes it as one JSON line. It returns None. Its only observable effect is appending a newline-terminated record to output_file.
Interface · params / IO
(self, output_file: TextIO, marker: tuple[float, str]) -> None
- params:
self:AsciinemaHandler— handler instance; not consulted by this method;output_file:TextIO— destination stream that receives the marker event line;marker:tuple[float, str]— marker payload as(timestamp, label)supplied by merge helpers - returns: None; the real product is one marker JSON record written to
output_file - effects: writes a newline-terminated JSON string to
output_file
Execution flow
- Unpack
markerintomarker_timeandmarker_label. - Build
marker_dataas[marker_time, "m", marker_label], using the literal event-type code"m". - Serialize
marker_datawithjson.dumps(...), append"\n", and write the resulting line tooutput_file.
Source
def _write_marker(self, output_file: TextIO, marker: tuple[float, str]) -> None:
"""Write a single marker event to the output file."""
marker_time, marker_label = marker
marker_data = [marker_time, "m", marker_label]
output_file.write(json.dumps(marker_data) + "\n")
Non-obvious design decisions
- The function emits the exact three-element array
[time, "m", label]instead of passing through the input tuple. That hard-codes the asciinema marker protocol in one place, so sibling helpers can work with simple(timestamp, label)tuples and avoid repeating format knowledge. - It writes one complete JSON line with a trailing newline in a single
output_file.write(...)call. That matches the line-oriented cast-file format used by_write_merged_recordingand avoids forcing callers to manage record termination separately.
Relations
- Callers: AsciinemaHandler._process_recording_line; AsciinemaHandler._write_remaining_markers
- Core callees: json.dumps; output_file.write
- Config / state sources:
marker[0]supplies the event timestamp;marker[1]supplies the marker label; the literal"m"selects the asciinema marker event type - Results to:
output_file, which is the merged cast being produced byAsciinemaHandler._write_merged_recording; the final recording file later installed byAsciinemaHandler.merge_markers - Related siblings: AsciinemaHandler._write_remaining_markers loops over marker tuples and delegates each one here.; AsciinemaHandler._process_recording_line uses this helper when a queued marker timestamp is due before the current recording event.; AsciinemaHandler._write_merged_recording provides the output stream that receives these emitted marker lines.