You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The problem (and why this is the single highest-leverage feature in WorldOS)
DM beats are generation-bound: ~100–126s routine, 280–400s cold-open (worldos-latency-forensics, measured via duration_api_ms; all wall-clock speed levers measured/REFUTED). Today the player stares at a spinner for the entire beat, then the full scene appears at once. Every sat-killer in rc1/rc2 traces here: perceived hangs, give-ups, "the game is frozen." We cannot remove the wait — we must make the wait the experience: the scene should unfold live, word by word, as the DM composes it.
Why every previous agent failed (read this before re-deriving)
Every prior attempt looked at what the wrappers parse today — the final result event of claude -p --output-format stream-json — and concluded "the CLI only yields text when the beat completes." Two facts were missed:
--include-partial-messages exists in the installed CLI (verified claude --help, v2.1.x). With it, the stream-json output carries stream_event rows wrapping the raw API stream — content_block_start, content_block_delta, etc. — written incrementally to the same stdout/file as the model generates.
The "which text is player-safe?" problem (a beat is MULTI-turn: tool calls, internal planning, then the scene — naively streaming assistant text leaks scaffolding, the [security][major] XSS partial sanitization — inner text leaks into chronicle as a player action #732 class) dissolves when you stream the TOOL ARGUMENT instead of assistant text. content_block_start announces the tool name before its args stream; the args then arrive as input_json_delta.partial_json chunks. The DM already writes its player-facing scene through log_event(kind="narration") — so the prose is deterministically player-safe by construction, identified before the first word arrives.
Probe evidence (2026-06-10, both verified on this machine)
# PROBE A — prose deltas stream live:
claude -p "<prompt>" --output-format stream-json --include-partial-messages --verbose
→ {"type":"stream_event",...{"type":"text_delta","text":" weathered lantern flickers above the tavern door as dusk bleeds purple..."}}
# PROBE B — TOOL-ARG deltas stream live (the keystone):
claude -p "Use the Write tool to ..." --allowedTools Write --output-format stream-json --include-partial-messages
→ {"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_...","name":"Write","input":{}}} ← name FIRST
→ {"type":"input_json_delta","partial_json":"led streets into silver rivers that race toward the harbor. Lantern light sm"} ← scene text streaming inside the tool arg
Architecture (5 phases, each independently shippable)
Phase 1 — wrapper flag. Add --include-partial-messages to the DM claude -p invocations in scripts/play.shdm_turn (~L273 _dm_invoke), scripts/play_party.sh, qa/run_duo.sh — behind WORLDOS_STREAM_BEATS (default ON for GUI lanes). The per-attempt $out jsonl files ($DM_LOG.<ts>.jsonl) become live streams. Zero behavior change to anything that reads only the final result event. Cost: none (same generation; more stdout JSON).
Phase 2 — the stream tailer (scripts/stream_tailer.py, python stdlib, launched per-beat by the wrapper next to _dm_invoke, killed on beat end). State machine over the tailing $out:
On content_block_start with content_block.type=="tool_use" and name in {"log_event","mcp__clawdnd-engine__log_event"} → enter CANDIDATE mode for that block index.
Accumulate input_json_delta.partial_json for that index into a buffer. Incrementally scan for the "text" key (and the "kind" key — stream ONLY once kind is known ∈ {narration, dialogue}; if kind appears after text in key order, buffer until known — JSON key order varies, the tailer must handle both orders).
Incrementally JSON-unescape the text value as it grows (a tiny unescape state machine: track backslash/u-escape state across chunk boundaries; emit only fully-decoded characters).
Append decoded prose chunks to $STATE_DIR/stream/current.jsonl rows: {"seq":N,"text":"...","beat":B,"ts":...} + truncate/reset on each new beat or retry attempt (the retry mints a new $out; the tailer follows the newest attempt file — reuse the wrapper's knowledge of $out).
On content_block_stop → mark the stream segment complete. Multiple log_event calls per beat stream sequentially (each its own segment).
NEVER stream assistant text_delta in v1 (scaffolding risk); a WORLDOS_STREAM_REPLY_TEXT=1 experimental mode may add it later gated by a sentinel marker.
Phase 3 — viewer surface. New read-only /stream endpoint in viewer/server.py (polls the stream file — the viewer stays a pure reader; the stream file is wrapper-owned sidecar state, NOT campaign state, so the engine-sole-writer invariant is untouched). In viewer/openworlds/ the chronicle renders a "composing" block: typewriter-render of the growing segments, visually marked live (subtle cursor). On beat resolution the canonical /events narration row replaces the composing block — wire into the EXISTING dedup family (engine_logged + eventsStreamedThisTurnRef + the #763 ingest machinery): the composing block is keyed by beat + segment seq, and the canonical row clears it. The #740 sanitizer applies to streamed text too. Wrapper heartbeat lines (#743/#763WRAPPER_PROGRESS_LINES) are filtered from the stream as they are everywhere else.
Phase 4 — DM contract. Tighten the dungeon-master skill + wrapper prompts: "compose the final player-facing scene by calling log_event(kind="narration", text=<the full scene>), THEN end your turn with a brief reply." This is already the dominant behavior (#357's empty-reply fallback exists precisely because DMs end on log_event); the contract makes it reliable. Non-compliance degrades gracefully: no stream → today's spinner+heartbeat behavior (zero regression).
Phase 5 — telemetry + scoring. Stamp time_to_first_prose_s per beat (first streamed chunk ts − beat submit ts) into the run telemetry; add the column to qa/scores_db.py (additive migration); the #753 latency budget gates on it (e.g. p95 ≤ 60s routine / ≤ 150s cold-open). The persona player agents (qa/playwright palette) will SEE the composing block — expect cross_persona_sat lift; measure it.
JSON-escape across chunk boundaries (\" split between deltas; \uXXXX split mid-escape) → the unescape state machine; property-test with adversarial chunkings of a fixed scene.
kind key after text key → buffer-until-kind; test both key orders.
log_event with non-narration kind (system/roll) → never streamed (test).
The DM writes the scene in MULTIPLE log_event calls → sequential segments render in order (test).
Tailer crash → wrapper unaffected (it's a sidecar); stream file simply stops; the beat resolves normally (test: kill the tailer mid-beat).
tool name namespacing: in-process MCP names may be mcp__clawdnd-engine__log_event — match on suffix (verify against a real GUI-lane transcript before hardcoding).
On a built-app GUI run @ the implementing SHA: (a) prose visibly streams during a routine beat with time_to_first_prose ≤ 60s p50; (b) an adversarial-persona transcript shows ZERO internal scaffolding/tool-JSON leaked to the chronicle; (c) the canonical narration row replaces the composing block with no duplication (extend the #720/#727 dedup tests); (d) a beat with streaming disabled (WORLDOS_STREAM_BEATS=0) behaves byte-identically to today; (e) time_to_first_prose recorded in the run telemetry + ledger row.
Phase-0 for the implementing agent
Re-run both probes above (they cost <$0.25 total) and one REAL play.sh beat with the flag on, captured to a file — confirm the engine's log_event tool-arg streams through the full MCP path before building the tailer. (The probes above used the built-in Write tool; the MCP-tool event shape must be confirmed identical — high confidence, but verify first.)
The problem (and why this is the single highest-leverage feature in WorldOS)
DM beats are generation-bound: ~100–126s routine, 280–400s cold-open (
worldos-latency-forensics, measured viaduration_api_ms; all wall-clock speed levers measured/REFUTED). Today the player stares at a spinner for the entire beat, then the full scene appears at once. Every sat-killer in rc1/rc2 traces here: perceived hangs, give-ups, "the game is frozen." We cannot remove the wait — we must make the wait the experience: the scene should unfold live, word by word, as the DM composes it.Why every previous agent failed (read this before re-deriving)
Every prior attempt looked at what the wrappers parse today — the final
resultevent ofclaude -p --output-format stream-json— and concluded "the CLI only yields text when the beat completes." Two facts were missed:--include-partial-messagesexists in the installed CLI (verifiedclaude --help, v2.1.x). With it, the stream-json output carriesstream_eventrows wrapping the raw API stream —content_block_start,content_block_delta, etc. — written incrementally to the same stdout/file as the model generates.content_block_startannounces the tool name before its args stream; the args then arrive asinput_json_delta.partial_jsonchunks. The DM already writes its player-facing scene throughlog_event(kind="narration")— so the prose is deterministically player-safe by construction, identified before the first word arrives.Probe evidence (2026-06-10, both verified on this machine)
Architecture (5 phases, each independently shippable)
Phase 1 — wrapper flag. Add
--include-partial-messagesto the DMclaude -pinvocations inscripts/play.shdm_turn(~L273_dm_invoke),scripts/play_party.sh,qa/run_duo.sh— behindWORLDOS_STREAM_BEATS(default ON for GUI lanes). The per-attempt$outjsonl files ($DM_LOG.<ts>.jsonl) become live streams. Zero behavior change to anything that reads only the finalresultevent. Cost: none (same generation; more stdout JSON).Phase 2 — the stream tailer (
scripts/stream_tailer.py, python stdlib, launched per-beat by the wrapper next to_dm_invoke, killed on beat end). State machine over the tailing$out:content_block_startwithcontent_block.type=="tool_use"andname in {"log_event","mcp__clawdnd-engine__log_event"}→ enter CANDIDATE mode for that block index.input_json_delta.partial_jsonfor that index into a buffer. Incrementally scan for the"text"key (and the"kind"key — stream ONLY oncekindis known ∈ {narration, dialogue}; ifkindappears aftertextin key order, buffer until known — JSON key order varies, the tailer must handle both orders).textvalue as it grows (a tiny unescape state machine: track backslash/u-escape state across chunk boundaries; emit only fully-decoded characters).$STATE_DIR/stream/current.jsonlrows:{"seq":N,"text":"...","beat":B,"ts":...}+ truncate/reset on each new beat or retry attempt (the retry mints a new$out; the tailer follows the newest attempt file — reuse the wrapper's knowledge of$out).content_block_stop→ mark the stream segment complete. Multiple log_event calls per beat stream sequentially (each its own segment).text_deltain v1 (scaffolding risk); aWORLDOS_STREAM_REPLY_TEXT=1experimental mode may add it later gated by a sentinel marker.Phase 3 — viewer surface. New read-only
/streamendpoint inviewer/server.py(polls the stream file — the viewer stays a pure reader; the stream file is wrapper-owned sidecar state, NOT campaign state, so the engine-sole-writer invariant is untouched). Inviewer/openworlds/the chronicle renders a "composing" block: typewriter-render of the growing segments, visually marked live (subtle cursor). On beat resolution the canonical/eventsnarration row replaces the composing block — wire into the EXISTING dedup family (engine_logged+eventsStreamedThisTurnRef+ the #763 ingest machinery): the composing block is keyed by beat + segment seq, and the canonical row clears it. The #740 sanitizer applies to streamed text too. Wrapper heartbeat lines (#743/#763WRAPPER_PROGRESS_LINES) are filtered from the stream as they are everywhere else.Phase 4 — DM contract. Tighten the dungeon-master skill + wrapper prompts: "compose the final player-facing scene by calling
log_event(kind="narration", text=<the full scene>), THEN end your turn with a brief reply." This is already the dominant behavior (#357's empty-reply fallback exists precisely because DMs end on log_event); the contract makes it reliable. Non-compliance degrades gracefully: no stream → today's spinner+heartbeat behavior (zero regression).Phase 5 — telemetry + scoring. Stamp
time_to_first_prose_sper beat (first streamed chunk ts − beat submit ts) into the run telemetry; add the column toqa/scores_db.py(additive migration); the #753 latency budget gates on it (e.g. p95 ≤ 60s routine / ≤ 150s cold-open). The persona player agents (qa/playwright palette) will SEE the composing block — expect cross_persona_sat lift; measure it.What this moves
Edge cases & risks (write tests for each)
\"split between deltas;\uXXXXsplit mid-escape) → the unescape state machine; property-test with adversarial chunkings of a fixed scene.kindkey aftertextkey → buffer-until-kind; test both key orders.mcp__clawdnd-engine__log_event— match on suffix (verify against a real GUI-lane transcript before hardcoding).Acceptance (the gate for closing this issue)
On a built-app GUI run @ the implementing SHA: (a) prose visibly streams during a routine beat with time_to_first_prose ≤ 60s p50; (b) an adversarial-persona transcript shows ZERO internal scaffolding/tool-JSON leaked to the chronicle; (c) the canonical narration row replaces the composing block with no duplication (extend the #720/#727 dedup tests); (d) a beat with streaming disabled (
WORLDOS_STREAM_BEATS=0) behaves byte-identically to today; (e) time_to_first_prose recorded in the run telemetry + ledger row.Phase-0 for the implementing agent
Re-run both probes above (they cost <$0.25 total) and one REAL
play.shbeat with the flag on, captured to a file — confirm the engine'slog_eventtool-arg streams through the full MCP path before building the tailer. (The probes above used the built-in Write tool; the MCP-tool event shape must be confirmed identical — high confidence, but verify first.)