CodeyBox captures the structured stdout event stream from agent invocations and stores it as JSONL for later analysis. This layer does not interpret events; it only requests the agent CLI's streaming JSON mode when available and persists each stdout line as it arrives.
{
"CodeyBox": {
"AgentStreams": {
"Enabled": true,
"Path": "logs/agents",
"MaxFileSizeMb": 32,
"RetainedDays": 14,
"MaxTotalSizeMb": 2048
}
}
}| Key | Default | Description |
|---|---|---|
Enabled |
true |
When false, no stream flags are added and no files are opened. |
Path |
logs/agents |
Root directory for captured streams. Must be writable at startup. |
MaxFileSizeMb |
32 |
Per-file cap. After the cap is reached, later stdout lines are dropped and a final [...truncated by N bytes] marker is appended. |
RetainedDays |
14 |
Daily sweep deletes stream files whose last-write time is older than this. 0 disables age-based eviction (keeps files forever). |
MaxTotalSizeMb |
2048 |
Size backstop: total bytes across all stream files. When exceeded, the sweep evicts the oldest-by-last-write files first until back under the cap. Runs independently of RetainedDays, so a zero/misconfigured retention window can't grow the directory unbounded. 0 disables the backstop. |
All three numeric knobs (MaxFileSizeMb, RetainedDays, MaxTotalSizeMb) are
hot-reloadable; Path is pinned at startup by the hot-reload guard.
The sweep runs on startup and then daily (AgentStreamRetentionService), so it
executes even when few new streams are being written. Age is measured from the
file's last-write time rather than its creation time, because Linux birth-time
is frequently unavailable and the runtime falls back to the inode change time,
which metadata touches reset — making creation-time cutoffs silently fail to age
files out.
Startup validation rejects an empty path, MaxFileSizeMb < 1,
RetainedDays < 0, or MaxTotalSizeMb < 0.
Files are written under:
logs/agents/<workItemId>/<phase>-<iteration>-<short-uuid>.jsonl
Release-wide deep-audit LLM streams do not have a work item; those captures use the release GUID in the same path position.
<short-uuid> is 6 lowercase hex characters, so retries do not overwrite prior
attempts.
Examples:
logs/agents/abc12345-0000-0000-0000-000000000000/work-1-a1b2c3.jsonl
logs/agents/abc12345-0000-0000-0000-000000000000/audit-llm-security:llm-review-3-d4e5f6.jsonl
logs/agents/abc12345-0000-0000-0000-000000000000/rework-2-789012.jsonl
logs/agents/abc12345-0000-0000-0000-000000000000/merge-1-f0e1d2.jsonl
Captured phases are:
workreworkmergeaudit-llm-<auditor-name>
Work and merge use iteration 1. Audit and rework use the current audit-loop
iteration.
The stream file is stdout only. CodeyBox does not add log prefixes or analysis
records. Each line is redacted for known secret token patterns before writing,
using the same redaction pattern family as SensitiveDataRedactionEnricher.
Capture is best-effort. Disk errors are logged as warnings and do not fail the agent invocation. Writers are closed when the agent run exits, including cancellation and failure paths.
Confirmed structured modes:
- Claude Code 2.1.128:
claude --print --output-format stream-json --verbose … - Codex:
codex exec --json …
Probed at runtime, because the flag depends on the installed version:
- Gemini: CodeyBox probes
gemini --helpinside the invocation sandbox and only adds--output-format stream-jsonwhen that help output advertises BOTH--output-formatandstream-json(gemini-cli ≥ 0.40; the older--jsonflag was removed). If unavailable, the run falls back to normal text output and the plaintext-fallback summariser still produces a row. - Antigravity: the
agybinary is shape-compatible with Claude (Anthropic stream-json for claude-* gateway models, Gemini stream-json for gemini-*). CodeyBox first usesagy --helpas a cheap prefilter, then runs a trivialagy --print --output-format stream-jsonfunctional probe and only adds the flag to real work when that probe exits successfully with parseable NDJSON. Results are cached per agy version. If the probe is ambiguous, prints usage, or fails, the runner falls back to agy's plaintext output and the plaintext-fallback summariser still produces a row.
No structured mode, so plaintext fallback only:
- Copilot: the current Copilot CLI runner does not expose a structured streaming mode, so no stream flag is added.
- Opencode: the
opencode runCLI has no verified structured stream-json mode in this codebase; the runner emits plaintext stdout, captured as-is.
The stream-capture system supports two paths per agent:
- Structured — runners that implement
IStructuredStreamAgentRunnerand passSupportsStructuredStreamAsyncwrite NDJSON events to the capture file. The kind-specificIAgentStreamParser(e.g.ClaudeStreamParser) extracts tool calls, token usage, stalls, and the final assistant message. - Plaintext fallback — every captured stream is summarised even when no
structured events are recognised. The fallback summariser records line
count, byte count, detected-error line count (lines containing
error/fatal/panic/exception/traceback), a duration window from the cost row, and the last ~10 non-empty lines of output as the tail. The row keeps the originally-resolvedAgentKindso dashboards still attribute the run to the right agent.
| Agent | Structured stream-json | Plaintext fallback | Notes |
|---|---|---|---|
claude |
Yes (Anthropic NDJSON) | Yes | --output-format stream-json --verbose |
codex |
Yes (codex exec --json) |
Yes | Codex stream-json shape. |
cursor |
Yes (Claude-shape) | Yes | --output-format stream-json --stream-partial-output |
gemini |
Conditional | Yes | gemini --help probed for --output-format stream-json. |
antigravity |
Conditional | Yes | Functional agy --print --output-format stream-json probe. |
opencode |
No | Yes | Plaintext stdout only. |
copilot |
No | Yes | Plaintext stdout only. |
The orchestrator's sniffer asks each registered parser "do you claim this
line?" using the first 20 NDJSON lines of the file. Two parsers
(AntigravityStreamParser, CursorStreamParser) deliberately return false
for every line — their on-wire event shapes are byte-identical to Claude's,
so claiming by shape would mis-attribute real Claude streams. Attribution for
those two agents instead comes from the cost row (phase-matched, recorded by
the orchestrator at dispatch) and the work item's declared agent. The
production registration order is antigravity, claude, codex, copilot, cursor, gemini, opencode, unknown; a parser-collision regression test
pins the expected sniff outcome. CopilotStreamParser and
OpencodeStreamParser follow the same "claims nothing by shape" rule —
their CLIs emit plaintext, so attribution comes from the work-item's
declared agent kind and the plaintext-fallback path produces the row.
For plaintext-fallback runs the runner tees stderr verbatim into the same
callback so the captured file carries diagnostics (CLI banners, OAuth
refresh chatter, error text); the plaintext summariser's errors= count
reflects the merged stream.
For agents that emit structured stream-json, the runner line-buffers
stderr until the next newline and then wraps each complete line in a
single-line JSON envelope of the form
{"type":"codeybox.stderr","text":"<line>"} before forwarding it through
the same callback. The envelope keeps stderr visible in the captured
.jsonl (so auth and usage diagnostics that fire before any structured
event — a failure mode that otherwise leaves no trace at all — survive
for post-mortem inspection)
without ever interleaving non-JSON noise into the file. The per-line
buffer is bounded (64 KiB) so a CLI that emits a pathological newline-
free line cannot grow host memory before the on-disk size cap engages;
overflow is stamped with a recoverable [...stderr line truncated]
marker.
Provider parsers recognise the codeybox.stderr envelope type and fold
each captured line into the summary's FinalAssistantMessage under a
[stderr-tail] marker (capped at ~8 KiB so the persisted summary row
cannot balloon with attacker-influenceable stderr output). This keeps
stderr diagnostics visible even when the structured stream contains a
few recognised events but no provider final message — the auth/usage
failure shape where the stream begins with a normal system/init and
then emits only stderr. When no structured event is recognised at all,
the run falls through to the plaintext summariser whose errors= count
and tail also surface the envelope lines.
Tool-based auditors (lint, build, file-size, …) do NOT open a capture file:
they emit deterministic output, never invoke an LLM through this codepath,
and would otherwise produce an empty audit-llm-*.jsonl plus an empty
agent_stream_summaries row. Only LLM-style auditors and the work/rework/
merge phases open capture files.
GET /workitems/{id}/agent-streamslists captured files and metadata. The list is bounded (limit, default 100, maximum 500);lineCountis only populated whenincludeLineCount=true.GET /workitems/{id}/agent-streams/{fileName}returns the raw stream asapplication/x-ndjson.
See ../reference/api.md for response shapes.
Typical streams are a few KB to about 1 MB per invocation. A five-iteration item
with three LLM auditors per iteration can produce around 20 stream files. At
roughly 10-30 MB per work item and 100 work items per week, the default 14-day
retention is about 30 GB of stream data. The MaxTotalSizeMb backstop (default
2 GB) caps the on-disk footprint regardless of the age window, evicting oldest
files first once the aggregate size is exceeded.
logs/ is gitignored, which covers the default logs/agents/ path.
Once a work item reaches a terminal state, a read-only pass parses its captured
files and writes derived rows to agent_stream_summaries.
Each invocation summary includes:
- total stream duration and time to first assistant token
- input, output, and cached input token counts
- authoritative cost from the agent result event when present
- tool calls with name, redacted input summary, start/end timestamps, duration, success flag, and output bytes
- stall events
- final assistant text when the captured stream includes it
Claude, Codex, and Gemini parsers are registered separately because their CLI stream shapes differ. Unsupported agents produce an empty unknown summary so the dashboard can remain graceful when stream JSON is not available.
A stall is an inter-event gap greater than StallThreshold (default 30 seconds). Gaps after a result event are ignored because the stream has already ended.
Stalls are classified from the prior event state:
tool_execution: after atool_usewhile a tool result is still outstandingllm: after assistant text or atool_result, while waiting for model outputunknown: any other long gap
Truncated files are accepted. A tool_use without a matching result is recorded as unfinished with null end time, null duration, and unknown success.
Captured CLI streams do not always include timestamps. Claude Code 2.1 stream-json and Codex CLI 0.128 command-execution events can omit per-event timestamp, started_at, completed_at, and duration fields. In that case the analyser keeps the capture file read-only and projects timestamp-less events onto the known invocation start/end window from the matching work-item cost row. It uses JSONL line position when line count is available, otherwise byte offset and captured file size. Those projected times drive total duration, TTFT, stalls, tool-call intervals, and the thinking/executing split for real captured streams that only contain raw CLI stdout. If there is no matching invocation timing context, timestamp-less events still contribute tokens, cost, tool frequency, input summaries, output sizes, and final text, but timing fields remain zero or null.
executingMs is the wall-clock union of completed tool-call intervals per invocation. Overlapping parallel tool calls are counted once. Tool results that include an explicit duration but no start/end timestamps contribute that duration without forming an interval. When stream events are timestamp-less but the invocation timing context is available, projected event times create estimated tool intervals. thinkingMs is totalAgentDurationMs - executingMs, clamped at zero.
The cache table is:
CREATE TABLE agent_stream_summaries (
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
phase TEXT NOT NULL,
iteration INTEGER,
agent_kind TEXT NOT NULL,
total_duration_ms INTEGER NOT NULL,
time_to_first_token_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER,
estimated_usd REAL,
tool_calls_json TEXT NOT NULL,
stalls_json TEXT NOT NULL,
final_assistant_message TEXT,
summarised_at TEXT NOT NULL,
PRIMARY KEY (work_item_id, file_name)
);Retrying a terminal work item invalidates cached stream summaries for that item. The next terminal transition causes a fresh summary pass.
Cost in the agent result event is more authoritative than the pipeline cost extractor. On a summary pass, the SQLite cost store updates the newest matching work_item_costs row by work item, phase, iteration, and agent kind. If no row exists, it inserts a stream-sourced row with raw_metadata_json.source = "agent_stream_analyser".
Audit stream files use detailed phases such as audit-llm-security:llm-review; reconciliation also matches the canonical audit phase used by pipeline cost rows so stream result costs correct the existing row instead of adding a duplicate.
Cached dashboard summaries and on-demand API analysis responses expose the same final assistant text parsed from the stream. The field remains nullable because not every CLI schema emits final text and truncated files may end before the final response.