Command output, documents, and previews
Where command output lives for Codex and for Claude and how a viewer reads it in ranges, how HTML documents run in a sandboxed origin on three platforms, how web pages become Markdown, and how files travel in chunks.
Checked against the source on 5 October 2026
On this page
Codex command output as a desktop artifact
The two agents keep command output in different places. This section is about Codex. A Codex run is the only thing that writes a desktop artifact: one file per command in the codex-command-outputs folder under the app’s local data folder, named with 32 hexadecimal digits and the extension .output. The catalog table codex_command_outputs maps the key (run_id, command_id) to the file, its byte length, its generation, and the flags complete and source_incomplete. The owner is the base session, and a read from another session fails with a conflict.
- Live rowcommandOutput.artifact
The desktop appends each output delta to its artifact before the row changes, then sends runId, byteLength, generation, complete, and sourceIncomplete at most every 100 ms per command. No output byte enters the timeline.
- Range readrun.chatCommandOutputRead
Only an open viewer reads, 1 byte to 256 KiB per request, and appSessionId binds the read to the owning base session. A generation the artifact no longer holds answers with a conflict, and the viewer refreshes the row before it reads again. Phones call it through the relay, and the desktop viewer calls the same read directly.
- Completionitem/completed
The desktop compares its bytes with Codex’s aggregatedOutput. The same bytes mark the artifact complete in place. Different bytes with no truncation replace it as the next generation. A truncated aggregate never replaces a longer stream, which the next figure shows.
- Settled rowprotocolOutput
Once thread/items/list returns the command, the row points at the capture file Codex wrote while it ran, and an open desktop viewer follows it by the unchanged block id. The desktop forwards reads as thread/commandOutput/read and checks the echoed identity. capturedComplete is false when Codex kept no full capture.
Codex sends every output byte in the delta stream and keeps a bounded aggregate of at most 1 MiB for item/completed. The desktop therefore treats the aggregate as a check and not as the truth. It counts the bytes and the deltas of the stream in a ledger and uses that count to decide whether the stored stream holds everything.
- Two truncation formsAggregateTruncation
Codex unified exec keeps an equal head and tail of 1 MiB in total and puts “... N bytes omitted ...” between them. Codex classic exec cuts the aggregate at 1 MiB with no marker. The desktop detects the second form when the stream delivered more bytes than the aggregate holds, with 8 bytes of slack per delta.
- Stream ledgerStreamedOutputLedger
The desktop counts the bytes and the deltas that Codex sent for the command. The stream is proven complete when the count plus 8 bytes reaches head plus omitted plus tail. An aggregate without a marker can never prove it.
- Keep or replacesettle_truncated_snapshot_from_path
The stream stays when it is proven complete or when the artifact holds more bytes than the aggregate. The aggregate replaces the artifact as the next generation only when the stream is unproven and not longer, or when an append failed.
- FlagsourceIncomplete
True means that the viewer shows less than the command printed or that the desktop cannot prove otherwise. Only a proven complete stream leaves it false.
Each append is written ahead. The desktop inserts a row in codex_command_output_pending_appends with the expected offset, the length, the generation, and a SHA-256 digest of the bytes. It then writes the bytes to the file and commits, which moves byte_length in the catalog and removes the pending row. Every later append, read, completion, and the sweep at start run the same recovery first.
| The app stopped | Recovery finds | Recovery does |
|---|---|---|
| After the pending row, before the bytes | A pending row. The file is as long as the catalog says. | Removes the pending row. The delta is lost and the file is unchanged. |
| In the middle of the write | A pending row. The file is longer than the catalog and shorter than the pending end. | Cuts the file back to the catalog length and removes the pending row. |
| After the bytes, before the commit | A pending row. The file ends at the pending end. | Compares the SHA-256 digest of the tail. A match syncs the file and commits the append. A mismatch cuts the tail and reports a storage error. |
| With bytes that no row explains | No pending row. The file is longer than the catalog. | Cuts the file back to the catalog length. |
| And the file lost bytes | The file is shorter than the catalog, or it is gone. | Reports an error. At item/ |
After a failed append the row shows “Command output is unavailable because this device could not store it.”, and the desktop drops the later deltas of that command. At item/completed a command with an aggregate takes the aggregate as its artifact. A command without one keeps that row, because the partial file must not look complete.
Deleting a session removes its catalog rows in the delete transaction and then removes the files. At start the desktop removes abandoned stage files. After the Codex warm-up it recovers every pending append and sweeps the folder: a file that no catalog row names is removed only when it is older than 1 hour, because a newer file can belong to a command that is still running.
Claude command output stays in the transcript
A Claude session has no desktop artifact. When a Bash tool_result arrives, the desktop adds an output row that carries a Protocol pointer to the saved result and no bytes. The exit code is 0 when the result is not an error. For a failed command the desktop reads it from the first line of the result, which Claude writes as Exit code N.
- Output rowcommandOutput.protocolOutput
The pointer names the transcript in threadId (the Claude session ID, or the subagent ID), the user message that holds the result in turnId, and the tool_use_id in itemId. updatedAtOrdinal is 1, byteLength is the length of the result text, and capturedComplete is true. The live row and the saved row carry the same pointer.
- Readrun.chatCommandOutputRead → toolOutput
The desktop starts a host process for this one request. The host loads every message of the conversation or the subagent with getSessionMessages or getSubagentMessages, returns the first tool_result with that tool_use_id, and exits.
- SliceMAX_COMMAND_OUTPUT_RANGE_BYTES
The desktop cuts the requested range from the whole result text. One request asks for 1 byte to 256 KiB, availableBytes is the full length, and complete is always true. The phone viewers ask for consecutive ranges, as drawn. The desktop viewer asks for overlapping pages and cuts them at line ends.
- No desktop copycodex_command_outputs
Only a Codex run writes a desktop artifact. For Claude, no output byte enters appdata.db or the artifact folder. A result that the transcript no longer holds answers “This command output is not in the saved Claude conversation.”
The read uses the same method as for Codex, run.chatCommandOutputRead with a Protocol source, and the same limit of 256 KiB per answer. The cost differs. Each call starts a one-shot agent host in the main Claude folder, sends toolOutput, and waits for the host to exit. The host loads all messages of the conversation, or of the subagent when the pointer names one, and returns the first tool_result with the tool_use_id. The desktop then slices the text. A result that is not plain text becomes text first: a text block keeps its text, a tool_reference block becomes the tool name, and any other block becomes its type in square brackets.
Only Bash gets an output row. Every other Claude tool has one row: the call row, completed in place with the result text. For both agents, the detail view of such a tool row decodes the result with decode_tool_output_container. It unwraps JSON envelopes to a depth of 6, and it treats a nested object as command transport only when the object has transport fields such as stdout, stderr, or wall_time_seconds.
| Question | Codex | Claude |
|---|---|---|
| Bytes while the command runs | Desktop artifact file, appended per delta | None. The output row appears with the result. |
| Bytes after the command | Codex’s capture file, read through the app-server | The tool_ |
| Row source | Desktop artifact while live, pointer to Codex when settled | Pointer to the transcript, live and saved |
| Cost of one range read | One file range, or one app-server request | One host start and one full transcript load |
| Can be incomplete | Yes, when the desktop or Codex kept less than the command printed | No, the result is saved whole |
HTML documents in a sandbox
- Resource rootopen_html_preview_command
The project folder when it contains the document, otherwise the document’s own folder. A filesystem root, the home folder, and a parent of the home folder never serve as a shared root: a document that sits directly in such a folder can read only itself. The caller generates the preview UUID, and the desktop refuses to bind that ID to a second document.
- Path checkptc-document://<preview-id>.localhost/<path>
The handler refuses a decoded path with .., a backslash, a colon, or NUL, serves index.html for a folder, and answers 404 when the canonical path is missing or leaves the root. It also answers 404 for a file or folder whose name starts with a dot, except inside the hidden folders that contain the document.
- Sandboxsandbox allow-scripts
Scripts, modules, and fetch run in an opaque origin with no access to the app’s page or storage. A hbcode.html.open or hbcode.html.ready message from that frame is the one thing the app acts on, and only for a file under the root or an http(s) page.
- Phonesfiles.readHtmlPreviewResource
The iPhone app at ptc-document://<preview-id>.localhost and the Android app at https://ptc-<preview-id>.invalid resolve the same paths through the desktop, in verified chunks of up to 4 MiB, with no script bridge into the app.
The protocol answers OPTIONS with 204, serves GET and HEAD, answers every other method with 405, and sets Cache-Control no-store and X-Content-Type-Options nosniff. Every refused or missing path answers 404. The file on disk stays unchanged, and the desktop changes each HTML response in two places. It inserts a style element with the id hb-theme at the end of the head, which gives the page the app theme as --hb-* custom properties. A document with no head element, or one that is not UTF-8, gets no theme. It then appends navigation.js after the document end. That script posts hbcode.html.open to the parent and blocks every scheme other than ptc-document and http(s). Local links inside a document enter the preview history, web links open in the web preview, and Reload or a file change refreshes the rendering.
The read scope is smaller than the resource root. A file or folder whose name starts with a dot, such as .env, .git, or .ssh, is refused. The hidden folders that contain the document are the exception: a site built into .vitepress/dist still reads its own files. A project folder that is the filesystem root, the home folder, or a parent of the home folder is never the root. The root is then the document’s own folder, and when that folder is also one of these, the document reads only itself.
Web pages as Markdown
A web link opens as a Markdown rendering instead of a live page. The WebView extracts the article content and converts it with a GFM-aware Turndown pipeline, the Rust side converts with quick_html2md for content that never reaches the WebView, and the Browser Bridge has a third, hand-written DOM-to-Markdown renderer whose output the desktop stores as capturedBrowserMarkdown and wraps with the same title header. Three converters, one target format.
File reads and transfers
files.readContent returns a text document of at most 2 MiB. A larger file fails with “Text preview is too large”. Its request carries projectDirectory, path, and optional encoding and allowExternalFile, and the desktop accepts the encoding and ignores it. The request has no chunk offset or media fields, because text and binary requests stay separate. files.readBinary and files.readMediaRendition read sequential chunks from offset zero. Each response carries offset, nextOffset (null at EOF), dataBase64, byteCount, contentVersion, modifiedAt, and mimeType, and renditions add originalByteCount. Neighbouring limits: media rendition sources up to 1 GiB, stream transcodes up to 256 MiB with 40-second timeouts, media range reads of 1 MiB, and chat-attachment uploads in 1 MiB chunks, at most 2,048 of them.
- Chunk readfiles.readBinary
Each request names the path and an offset. The desktop opens the file, reads up to 4 MiB there, and answers with the next offset, the total byteCount, and a contentVersion. HTML preview resources and media renditions use the same reader.
- Version pincontentVersion
For files.readBinary it hashes the path, size, and modification time each time a read opens the file. Both phones keep the first answer’s version and size and discard the whole read when a later answer differs. A file that shrank below the offset gets the same message from the desktop.
- Relay frameDEVICE_LINK_MAX_FRAME_BYTES
A 4 MiB chunk is about 5.3 MiB of base64, so every answer stays well inside the relay’s 32 MiB frame limit, and the file itself has no size cap.
Syntax highlighting
Source previews on the desktop use Shiki grammars, and each phone app highlights with its own engine.