omp -p --mode json: Claude Sonnet reliably omits the final closing brace of a JSON-only reply
omp (Oh My Pi) 18.8.8, model claude-sonnet-4-5 via --model sonnet. Driving omp -p --no-session --no-rules --no-skills --no-extensions --no-tools --mode json --model sonnet --thinking off --system-prompt <prompt> '<user>' from a Python 3.12 subprocess as a batch LLM client, with a system prompt that says "JSON only: {"decisions":[...]}". The assistant text in the final message_end event (and the concatenated text_delta events) ends with }] and never emits the outer }: json.loads fails with Expecting ',' delimiter: line 1 column 624 (char 623). The message has stopReason: "stop", usage.output is only 267 tokens, so it is not a max_tokens cut. Re-running the identical command reproduces the identical truncated output every time (temperature is not exposed in print mode); a re-prompt appending "Output only the JSON object, no prose, no code fence" also came back one brace short. Other prompts with the same wrapper return complete JSON (often inside a ```json fence), so I first suspected the JSONL event parsing (taking only message_end with role assistant) or a fence-stripping bug; both were ruled out by dumping the raw stdout.
Treat the model output as possibly-unbalanced and repair it before json.loads; do not spend retries on it, since the truncation is deterministic for a given prompt.
Root cause: with --thinking off and a prompt whose expected output is a top-level object wrapping a long array, the model ends generation on ] (stop reason stop). omp 18.8.8's JSON stream is faithful: text_delta events and the message_end text agree, and omp is not trimming anything. It is a model-side early stop, not a harness bug, which is why retries with the same prompt reproduce it exactly.
Fix that worked (Python 3.12): strip a ```json fence if present, try json.loads, and on failure walk the text tracking string state and an open-bracket stack, then append the missing closers:
def _close_unbalanced(body: str) -> str:
stack, in_str, esc = [], False, False
for ch in body:
if in_str:
if esc: esc = False
elif ch == '\\': esc = True
elif ch == '"': in_str = False
elif ch == '"': in_str = True
elif ch in '{[': stack.append('}' if ch == '{' else ']')
elif ch in '}]' and stack and stack[-1] == ch: stack.pop()
return body + ''.join(reversed(stack))
def parse_json_text(text):
body = strip_fence(text).strip()
try:
return json.loads(body)
except json.JSONDecodeError:
start = body.find('{')
body = body[start:]
end = body.rfind('}')
try:
return json.loads(body[:end + 1]) # prose after the object
except json.JSONDecodeError:
return json.loads(_close_unbalanced(body)) # dropped trailerAlso worth knowing for the same wrapper (omp 18.8.8): --mode text wraps the JSON in a ``` fence, so use --mode json and read message.content[*].text from the last message_end whose message.role == "assistant"; the per-call cost is at message.usage.cost.total and must be summed over all assistant message_end events when tools (e.g. --tools=web_search) cause multiple turns.