Skip to content

AXIS v2.26.0

Choose a tag to compare

@github-actions github-actions released this 31 Oct 15:08
· 88 commits to main since this release
59bf314

[2.26.0] β€” 2026-10-31

Changed

  • While ranging, chart paints stay tip-update only. Overlay/bgcolor/OHLC smart-apply skips full-series setData, marker setMarkers defers until the gesture ends, PaneManager.setData and non-fit setDataToChart no-op, and the existing slide-replace deferral stays. Tip updates continue. Cuts the post-slide-defer panFrame max spike (~133 ms at 15k bars) from engine plot/marker writes during pan.

  • Live forming-tip apply skips O(n) align / overlay rebuild and store sync. When executionContext === 'tip', applyPrecomputedChart reuses the last aligned overlay payloads (tip-patch only), skips setLastRun / updateIndicatorStore / drawings when unchanged, and reuses store.bars for the cached OHLCV axis. Append and snapshot still take the full path. Bridge tip deltas reuse cached input descriptors. Aimed at G1 apply ≀ 8 ms on a 5k-bar tip.

  • Live window-slide setData defers while the chart is ranging. Tip series.update stays on; a full-window replace during pan/zoom was the ~84 ms panFrame spike under live ticks. The pending slide flushes on the next frame after the gesture. PaneManager.isRanging() exposes the gesture for the paint path.

  • chart.lag v1 latency rings are a recent window of 120 samples and skip the first 16 after connect/reset. Cold-start / one-off spikes no longer pin live p95 forever in the old 240-cap ring. Field names (queueWait, tickToApply, …) stay stable. Pan and long-task rings share the 120 window but not the warmup skip.

  • Rolling-window slides defer the full-series setData to one animation frame. Forming ticks stay on series.update. A slide still needs a replace (LWC update cannot drop the rolled-off head), but the replace is coalesced to one rAF and no longer flushes a wasted tip update immediately before setData, so the per-minute 5k-bar hit leaves the tick/apply path. Without requestAnimationFrame the replace stays synchronous.

Added

  • MCP logs.debug.get / logs.debug.clear. Reads or clears the rotating axis.debug.log.* localStorage ring written when AXIS debug mode is on (VITE_AXIS_DEBUG or localStorage.axis.debug).

  • chart.lag v1.queueWait is recv β†’ apply:start. On the worker path that is batch:post β†’ bridge entry, so a live G1 read can separate main-thread queueing from the apply span. Live ticks also skip the O(n) internal-hole scan and debug-pin clear when pins are off.

  • CLI 0.3.6. @hoox-sh/axis-cli default PYNE URL is the hosted Pro API (https://pynescript.online); AXIS_PYNE_URL still overrides it. The stale defaultPyneUrl test now matches the shipped default.

  • Pine hover/autocomplete refreshed against PYNE 0.6.25. src/editor/data/pyne-builtins.json re-synced from pyne (builtin_metadata.json, 88 improved docs incl. line.fill / linefill.new). Library annotation cards now render the description verbatim as Markdown (headings, code fences, blockquotes, tables, links, images, italic/strikethrough and two-space line breaks), matching PYNE's hover. //@description on library shows a library card. Card sections use PYNE's **Fields:** / **Parameters:** / **Returns:** shape with - \name`: desc` bullets.

Fixed

  • Shortcuts help no longer steals typing keys. app.show-shortcuts was bound to bare Shift-? / Shift-/, so typing / (Shift+7 on many layouts) or ? opened the shortcuts overlay β€” the editor could not type //. The binding now requires a real modifier (Alt-Shift-? / Alt-Shift-/) and is skipped while focus is inside an editor/input/dialog.

  • Series live no longer storms full snapshots (~6/s) into chart setData. Debug ring showed 228 wire.series.full + 200 setData in ~40s: gap recovery cleared its backoff on every full, then unsubscribe+subscribed again. Series gaps now resume (scripts still fresh-subscribe); sliding fulls after the first snapshot tip-merge only; backoff clears only after a continuous delta.

  • Live candle paint no longer clamps or repairs OHLC. PaneManager.appendBar no longer calls repairOhlc on forming ticks β€” exchange OHLC is clean ~99.9% of the time, and clamping could alter real values. Non-finite ticks are dropped instead.

  • sanitizeSeriesDataForSetData removed entirely. No donor fill, no OHLC fill-from-neighbor, no clamping β€” bad rows are simply dropped before setData. A minimal filterValidCandleRows guard drops rows with non-finite time/OHLC or duplicate times (last write wins) so LWC never throws Value is null. repairSeriesPayload, setCandleData, and setVolumeData pass data straight to Lightweight Charts.

  • Live script input-change resubscribe no longer stays idle/released. An input edit (e.g. Momentum Length 10β†’100) unsubscribes then re-subscribes on the same id; the PYNE idle/released ack arriving during awaitSnapshot used to dropScriptLocal the half-open replacement (wiping appliers / ctx), so the next eval re-subscribed forever and never rejoined while Bollinger/SMA stayed live. In-flight script subscribe now ignores that ack (same idea as gap-resync holdUntilFull), clears the inbox on release, and re-binds applier state when the new full lands.

  • Venue live owns candle paint β€” engine series snapshots/deltas no longer setData or overwrite forming bars. Worker tips were painting stub OHLC (tiny volume, disconnected opens) on top of Binance candles β€” visual gaps β€” and 5k setData on every engine full stalled scripts (websocket Β· cooldown / stop updating). Replica stays on the wire for indicators; chart tail stays venue.

  • Engine-owned mode no longer repaints the full window from PYNE on every tick. The full diff branch called setDataToChart(proBars), replacing every candle β€” including venue-closed bars whose OHLC PYNE could report collapsed or stale. Now engine bars are merged into the venue window via mergeEngineSnapshot: existing times keep venue's OHLC, only new times from the engine are appended, and the chart is only repainted when the merged window actually changed. Closed candles no longer get collapsed on a new candle.

  • Live start / scheduleRerun re-subscribes every visible non-library script, including idle/released. PYNE unsubscribe acks (idle + message released via live-wire releaseβ†’unsubscribe) no longer leave EMA/Momentum blank while Bollinger stays on the wire: multiplex bypasses stream-eval backoff for released scripts, clears their retry state, and on stream-route live start kicks an immediate (0-debounce) scheduleRerun. scriptHistoryBars uses the stable chart window so a 1-bar length flicker cannot change history_bars and bounce subs.

  • Price chart printing pipeline refactored. store.appendBar splits into applyCandlePoint / applyPriceLineTint / updateVolumeBar for a simple O(1) live-tick path; setCandleData / setVolumeData extract full-history setData out of manager-access.setDataToChart so both full loads and live slides share one code path. Test doubles / managers without the new methods fall back to inline painting. appendBar no longer bumps chartDataGen on a live slide β€” ChartHost's chartDataGen effect responded with fit:true β†’ fitContent(), resetting zoom/position on every new candle. appendLiveBar already paints slides with fit:false, so ChartHost does not need to repaint.

  • Live candle open no longer jumps when historical data arrives. store.appendBar preserves the candle's open once it has started forming: a historical close for the same bar updates high/low/close only, so the open set by the venue's first tick is not overwritten.

  • store.appendBar now writes live-tick OHLC to store.bars instead of a discarded unwrap() copy. Object.assign(unwrap(tip), payload) mutated a deep copy returned by Solid's unwrap(), so live ticks never persisted updated OHLC/high/low/close into store.bars. A full setData (window slide at capacity, engine mergeEngineSnapshot, etc.) then painted stale OHLC from the store β€” old candles collapsed/lost their OHLC when a new candle arrived. Both the OHLC write and the closed = true flag now go through setStore('bars', index, ...) so the store is the source of truth for both series.update (live tip) and setData (full window).

  • Engine deltas no longer overwrite venue OHLC on forming candles. store.appendBar now accepts a via parameter ('worker' | 'venue'). When the engine (via: 'worker') updates a forming tip (same bar time, tip not closed), venue's OHLC values are preserved β€” engine deltas may have slightly different values due to computation/rounding, and overwriting them caused candles to collapse/change OHLC on every engine tick. Venue ticks (via: 'venue' or undefined) always overwrite β€” they are the source of truth for forming candles. Threaded through appendLiveBar β†’ appendBar β†’ store.appendBar.

  • Indicator live pushes no longer shift right on a new bar. alignApplySeries head-padded shorter series with nulls when the engine evaluated the previous window, shifting every indicator value right by one position instead of extending onto the new bar. On venue live pushes (non-session, no engine clamp), shorter series now tail-extend by holding the last value forward (extendSeriesToWindow), so plots like MA ribbons extend onto the new candle instead of shifting. Session runs and free-tier windowBars clamps keep the existing head-pad / tail-pin behavior.

  • Chart view no longer resets (zoom/position) on each new candle. appendBar no longer bumps chartDataGen on a window slide. ChartHost's chartDataGen effect previously responded with fit:true β†’ fitContent(), which reset the viewport on every new candle. appendLiveBar already paints slides with fit:false via scheduleSlideReplace, preserving the view.

  • Candle rendering no longer merges bars or hides bodies. LWC conflation (enableConflation) and minBarSpacing: 0.5 were merging sub-pixel bars at 2500+ bar histories, rendering candles as dojis with gaps between them. Conflation is now disabled (enableConflation: false, minBarSpacing: 0.001) in both series-factory.ts base config and heavy-data.ts, and time scale options are re-applied on every live tick so existing charts pick up the change without a reload. Viewport auto-scrolls to follow new candles (shiftVisibleRangeOnNewBar: true).

  • Live candle debug logs include full OHLC. live.append entries now record open / high / low alongside close, so MCP logs.debug.get shows each candle's complete OHLCV for verifying forming-tick and historical-bar values.

  • Default rolling window lowered to 5,000 bars to match PYNE free tier limit. ROLLING_WINDOW_DEFAULT was 10,000 β€” the free tier engine refuses windows above 5,000 with HTTP 413, surfacing β€œLive re-run failed: OHLCV exceeds free-tier limit (5000 bars)” on boot. Pro tier users can still raise the window in Settings β†’ Chart β†’ Rolling window.

  • Adding a second indicator no longer supersedes the first. runAndApply reused the current epoch when an interactive run was already in flight, so rapidly added indicators no longer mark each other β€œRun superseded” before either engine evaluation completes.

  • Comprehensive debug logging added to chart paint path. chartDebug entries added to store.appendBar, normalizeBarPayload, repairOhlc, mapBarsToPriceData, toOhlcData, isFinitePriceBar, mapBarUpdate, mapHeikinAshiUpdate, sanitizeSeriesDataForSetData, findOhlcDonor, setCandleData, PaneManager.appendBar, applyCandlePoint, and updateVolumeBar. Use MCP logs.debug.get to trace OHLC values through the entire paint pipeline and identify where candles collapse to lines or lose their OHLC values.

  • sanitizeSeriesDataForSetData no longer overwrites all four OHLC fields from a donor. Previously, when any OHLC field was missing/invalid, all four values were replaced from a single donor neighbor β€” making multiple bars identical (collapse to lines). Now only the fields that are actually non-finite are repaired from the donor; valid fields are preserved. Look for sanitizeSeriesDataForSetData { verdict: 'filled-fields-from-donor', repaired: [...] } in MCP logs.debug.get.
    \n\n- repairOhlc logging reduced to actual clamping only. Previously logged open-eq-close for every doji candle (thousands of noise entries). Now only logs when high/low are actually clamped. Raw venue OHLC is logged before repair via sanitizeStreamBar { verdict: 'pre-repair' } and sanitizeBar { verdict: 'pre-repair-array|object' }, so you can compare venue OHLC against stored OHLC to find where corruption happens.

  • Indicator overlays update on the live forming candle again. alignApplySeries staleNoAxis skipped every stream live push whose engine series was shorter than the chart window β€” which is the steady state, because script snapshots are capped at LIVE_SNAPSHOT_BARS (500) while the rolling window is typically 5000. Tail-clamp shorter series onto the chart tip (head-padded with na, newest values on newest bars) instead of skipping, so indicators repaint every tick instead of freezing until the snapshot outgrows the window (skipped: 'stale-no-axis').

  • Live-wire seq-gap thrash no longer blanks indicators. A script/series gap now does one backoff-gated unsubscribe+subscribe for a fresh full (instead of resume-loop forever), honors seqAckFrom as continuous, paints when that full lands, and clears holdUntilFull. history_bars shrink/flicker no longer bounces the sub; engine idle/released drops local state so the next eval rejoins; script HUD live/connecting clears a stale β€œdelta gap β€” resubscribing” message.

  • Live stream overlays paint when the engine series is longer than the chart window without a bar axis. A 20k replica vs 5k rolling window used to hit staleNoAxis and skip forever (tip cache never seeded β†’ blank indicators). Live pushes now tail-clamp onto the chart tip; wire snapshots synthesize bar_times from the subscription window when the engine omits them. Shorter-than-chart axis-less pushes still skip.

  • Main-thread live WS shares the soft UNAUTHORIZED authBlock policy with the ingest worker. shouldAuthBlock lives in frames.ts; JSON and worker transports only permanently block on real bad-key errors, and otherwise close to remint/re-auth.

  • Stream script eval retries with backoff after StreamFallbackError instead of staying degraded Β· websocket forever. Multiplex records a per-script retry-after (1s β†’ 2s β†’ 5s … cap 30s), skips mid-backoff ticks (still no REST full-window fallback), and clears backoff when streamEvalScript succeeds so status returns to live.

  • Live-wire auth-race subscribe and reconnect cooldown. Series/script subscribe errors with UNAUTHORIZED (or "Authenticate first") retry once after a short delay instead of immediate fail/cooldown; a successful reconnect (resumeAll) clears coolUntil so a transient fail cannot keep rejecting ensureSeries; a failed script subscribe fully release()s the applier so the next streamEvalScript can try again.

  • Ingest worker no longer permanently authBlocks on PYNE UNAUTHORIZED. Connection-level UNAUTHORIZED / "Authenticate first" means not-yet-authed (pre-auth race), not a bad API key: while waiting for auth.ok the worker resends auth; otherwise it clears the auth wait and reconnects. Permanent blocks stay for other non-retryable connection errors (e.g. invalid/missing API key).

  • Tickβ†’paint p95 no longer absorbs hidden-tab stalls or same-frame slide setData. paint:raf drops bursts armed while hidden or suspended mid-flight (rAF occlusion was pinning live p95 ~177 ms / max ~300 ms while apply stayed ~30 ms). A window slide tip-updates the new bar immediately and yields the head-drop setData to a microtask after rAF so sibling paint:raf callbacks in that frame are not queued behind a 5k-bar replace. Snapshot field names unchanged.

  • Live-wire same-slot series tips close queueWait synchronously. Metrics-only apply:start/end no longer waits on a kick microtask (the ~16 ms p50 after the prior microtask move). A new tip time still coalesces one cached-script paint on a microtask so a real script frame in the same turn can cancel it. holdUntilFull, seq, and one-tip inbox unchanged.

  • Live tip-cache now hits on forming deltas that lacked a tip stamp. stream-delta coerces a leftover snapshot context (seriesβ†’script kick after applyFull) and forming: true without executionContext to tip, so applyPrecomputedChart tip-patches instead of re-aligning ~5k bars every tick (live apply was p50 ~6.4 ms / p95 ~10.6 ms; cached tip microbench ~0.03–0.3 ms). OHLC overlays tip-patch the last candle; lean tip hits sync lines/OHLC only. Append/snapshot full path unchanged.

  • Live-wire inbox and series kick cut residual droppedTip / pan queueWait. Live forming tips (already applied via routeFrame) skip the waiter inbox once the sub has an applied seq, so same-slot replaces no longer inflate droppedTip (was 85–239 under load). The seriesβ†’script kick closes batch:post β†’ apply:start on a microtask instead of rAF, so pan/zoom at ~15k bars cannot stall queueWait behind a delayed animation frame (~430 ms p95). TipCoalescer forwards contiguous UNCHANGED keepalives (after flushing a pending tip) so the applier advances seq without seqAckFrom holes that client-apply counted as drops; gap keepalives stay observe-only. Snapshot-waiter inbox still keeps ≀1 forming tip; same-slot kick stays metrics-only.

  • Live-wire seriesβ†’script kick closes queueWait without flooding reapply. Series-only tips still end batch:post β†’ apply:start once per frame, but same-slot forming OHLC only stamps metrics (no script reapply); a new tip time paints at most one cached script; a real script paint cancels a pending kick. Fixes the post-4df26790 queueWait p50 regression (~426 ms ← ~16–30 ms) from reapplying every live script on every tip. Mixed batches, holdUntilFull, seq gates, and one-forming-tip inbox stay unchanged.

  • Ingest host subscribe/resume omit encoding unless the caller asks, and misaligned AXB1 views no longer decode as malformed. IngestHostClient had hardcoded encoding: 'axb1' on every subscribe, so live-wire's JSON fallback (snapshot timeout / jsonSubs) still negotiated AXB1 and could not leave the binary path β€” matching live chart.lag jsonFallback: 3 with unknownOpcode: 0. Resume now matches the JSON client (encoding only when set). A sliced AXB1 frame whose buffer byteOffset is not a multiple of 8 is copied before decode so absolute i64 alignment checks cannot drop a valid snapshot.

  • Single-chart layout arms chart.lag panFrame on pan/zoom. createSharedPane never set ranging or wired wheel β†’ v1NotePanActivity, so the default one-chart host left panFrame.n = 0 after user gestures. The shared price host now mirrors the multi-pane listeners (pointer ranging + wheel).

  • chart.lag v1 is counters only. The per-tick latency rings (tickβ†’apply / tickβ†’paint / apply / queueWait / panFrame / longTask), the rAF paint arming, the pan-sampling loop, the conflation / visibleBars gauges and the long-task PerformanceObserver were removed from the hot path β€” they sampled every tick and kept 120-sample windows just to report p95. v1Count counters (seqGap, resync, droppedTip, jsonFallback, unknownOpcode, longTask) stay; chart.lag v1 now reports counts + transport + layout. chart.v1/invariants.ts 1-in-32 sampling and v1BudgetRegressed are gone.

  • Live-proxy buffers client frames until the engine upstream is open, and the ingest worker resends auth on live-relay. Browser onopen could send auth before the Worker’s upstream socket was ready; the frame was dropped and subscribe then failed with UNAUTHORIZED. The proxy queues early clientβ†’engine frames; the ingest worker also resends auth when it sees live-relay and never signals host open without auth.ok.

  • Ingest tip coalesce and send-buffer backpressure cut tip delay under load. A second forming tip for the same subscription folds and releases immediately instead of waiting out the 16 ms coalesce window, so batch:post is not stuck behind a quiet-period timer. Send-buffer hold uses hysteresis (enter above 1 MB, clear only below 256 KiB) so a buffer that jitters around 1 MB cannot resume every live sub in a loop. UNCHANGED keepalives are no longer treated as droppable forming tips under send hold, so their seq still reaches the coalescer and is acked. Closes and snapshots stay immediate; the pending queue remains one tip per subscription.

  • Ingest worker waits for auth.ok before signaling host open. With the Worker ticket proxy, subscribe was racing ahead of PYNE auth (UNAUTHORIZED β†’ subscribe-error cooldown β†’ venue+REST). INGEST_AUTH_WAIT_MS (5s) still opens on timeout so a slow key hash cannot wedge the socket.

Added

  • P6 flag flip proven in unit tests. The test switches worker+single to json and back: the live client follows both flags together, but the chart stays the shared single layout β€” the axis.chart.layout=multi rollback is gone (see Removed below).

  • Inactive chart slots paint without stealing the focused manager. setDataToChart takes an optional manager / slotId so a 2h/2v/4 cell can setData on its own PaneManager. Drawings and selection stay on the focused slot.

  • chart.lag v1 names the live transport and chart layout. The snapshot already had tickβ†’apply / tickβ†’paint. It now also reports axis.engine.transport and axis.chart.layout so a live G1/G2 read can tell worker+single from the JSON/multi rollback.

  • The one-chart layout resizes panes with Lightweight Charts layout.panes.enableResize. Separator colors follow the void theme. A drag writes pixel heights into the store. PaneManager.resize calls IPaneApi.setHeight on that layout so a sub-pane drag does not resize the whole chart. The multi-chart rollback keeps the custom handles and turns LWC separators off.

  • AXIS debug mode (VITE_AXIS_DEBUG) β€” detailed chart-action and engine logs. Set VITE_AXIS_DEBUG=1 (also true / yes; alias VITE_DEBUG) in .env and restart bun run dev. Boot prints [axis:debug]; DevTools filter [axis:chart] / [axis:engine]. Lines also persist in a rotating localStorage ring (axis.debug.log.0….3, 64 KiB each, oldest dropped). Download the ring as axis-debug-*.log from System Logs (download icon) or window.__AXIS_DEBUG__.download(). Forming ticks and live deltas are sampled (~1/s). Optional overlay: localStorage.axis.debug=1 then reload. e2e forces the flag off.

  • The engine socket mints a Worker live ticket instead of putting api_key on /ws/live. When a cloud Worker key is set and neither the engine nor the Worker is loopback, the PWA GETs /api/ws/live?issue=ticket with Bearer and upgrades ?ticket=. The PYNE auth frame still carries the engine key. Direct local /ws/live still uses ?api_key= because the browser cannot send Authorization. A 401/403 mint does not reconnect; transport failures remint.

  • One live batch commits price, plots, markers, then alerts. commitLiveBatch is the Β§6.1 transaction. A worker onFrames batch of mixed series+script deltas paints price then plots in the same task. Stream deltas apply through synchronous applyStreamDelta / applyPrecomputedChart (snapshots stay on async applyStreamPayload).

  • v1 concept tests cover pane/crosshair identity, drawings through a window slide, the 20% p95 budget, and pan/zoom e2e. Playwright @v1 loads 10k mock bars while mock-poll is live and bounds pan/zoom frames; applies RSI + MACD + ATR for a price+3-indicator pane-parity screenshot; and keeps a trend line through pan, zoom, and a clock-advanced window slide. v1BudgetRegressed fails review when p95 grows more than 20% vs the fixture.

  • A tab keeps at most two engine sockets. Chart slots share one WebSocket per engine endpoint (acquireSlotWire / releaseSlotWire). Same endpoint from N slots is one live client with N refs. A second socket opens only for a different endpoint. A third waits until a slot is released. The primary chart drops its previous fingerprint on engine change so a single chart does not pin two unused sockets.

  • Each chart host pins that pool for its slot. ChartHost calls pinChartSlot on mount and unpinChartSlot on cleanup. Four hosts on one endpoint share one client (refs 4); unmounting three leaves one. Unpin remembers the fingerprint from pin, so a later engine change still releases the original socket. A second pin of the same slot id does not bump refs.

Removed

  • The shared-memory ring, GPU candle overlay, and HP flags are gone. Lightweight Charts paints candles, indicators, and fills. The ingest worker still owns /ws/live and decodes AXB1 when axis.engine.transport is worker (the default). axis.engine.transport=json keeps the main-thread client. Settings no longer has a high-performance section. hp.get, hp.sample, and dev.trace are no longer MCP capabilities. Isolation headers stay.

  • The axis.chart.layout=multi rollback is gone. axis.chart.layout is no longer read or persisted β€” stored values are ignored like axis.engine.hp.*. One Lightweight Charts chart per slot only: ChartLayoutMode/chartLayoutMode/setChartLayoutMode/resetChartLayoutMode/usesSingleChartLayout, CHART_LAYOUT_KEY, SlotChart, shared-pane chrome, multi-chart slot-bar caches, custom pane-resize handles (pane-handle-*), crosshair mirroring across panes, and the map/mirror branches in syncTimeScales, alignTimeRangesFromPrice, and crosshair rewiring are removed. chart.lag v1 always reports layout: 'single'.

Fixed

  • MCP stream.reset re-pins mounted chart hosts. resetStreamWire keeps live ChartHost slot ids, drains the pool, then pins those hosts onto the current engine fingerprint (new sockets). Unpin still drops a slot so reset cannot resurrect it. With no hosts mounted the pool stays empty until getWire.

  • streamStop keeps mounted ChartHost slots. Stop drains and disconnects sockets and clears remembered fingerprints, but still-mounted slot ids survive. The next getWire re-pins those hosts onto the current engine. Unpin still drops a slot so stop cannot resurrect it. With no hosts mounted, getWire opens one primary socket.

  • A pane whose visible logical range disagrees with the price axis is reported. alignTimeRangesFromPrice samples logicalRangeSkew once against the one shared chart (no per-pane mirror). Dev warns on every new skew size. Production warns about 1 in 32 times, once per size. Matching ranges are silent.

  • A plot column shorter than the price axis is reported. applyChartMutations samples plotLengthSkew. Dev warns on every new absolute size. Production warns about 1 in 32 times, once per size. A longer column still uses the overhang text and is not warned twice.

  • Crosshair stays on the shared single chart. Data-driven (no sourceEvent) moves no longer attempt a cross-mirror that never had a second chart to write to; syncCrosshair/rewireCrosshair subscribe once per IChartApi instead of per pane, and applyCrosshairToPane + the multi-pane resize handle are gone. Hovered-pane gating only gates the host callback now.

  • The single chart wires one crosshair listener. Sub-pane facades are different Proxy objects around the same Lightweight Charts chart. Once the slot exists, later panes do not subscribe again.

  • A series that ends after the price tip is reported. applyChartMutations samples overlay and plot times against the candle axis. Dev warns on every new past time. Production warns about 1 in 32 times, once per time. A shorter series is warmup and is not a warning.

  • Inbound live frames as Blob decode like ArrayBuffer. Cloudflare compat β‰₯ 2026-03-17 delivers binary WebSocket frames as Blob. decodeInbound stays sync for string / ArrayBuffer / views; decodeInboundAsync awaits blob.arrayBuffer() then decodes. AXB1 is detected on the bytes and is never TextDecoder'd. Malformed Blob bytes stay malformed and do not throw. The ingest worker calls decodeInboundAsync for Blob instead of unwrapping itself.

  • Strategy and plotshape markers commit with the plot series. Marker lists are built before the chart mutates. One runApplyTransaction paints markers then plots, so a throw in setShapeMarkers / setTradeMarkers does not leave a new plot generation on the chart. The 2500 marker cap is unchanged. Live ticks still update that bar's markers.

  • A price seq gap waits for a snapshot. A live series delta that skips seq no longer paints. The subscription resumes once, later ticks stay dropped, and the next full paints.

  • The first live script snapshot uses the same paint pump as later ticks. evalScript no longer paints off to the side of the chain, so a delta cannot race the snapshot.

  • A live stream snapshot paints the whole indicator. Stream deltas still update the tip only. Treating the first snapshot as a live tick skipped the apply when the engine window and the chart window disagreed and there was no bar_times axis, so the pane stayed empty once live started.

  • A newer snapshot is not painted under a late forming tip. Script paints wait on a per-subscription chain and always apply the newest payload. The last paint is the snapshot, then later deltas.

  • APPEND_BAR flushes a pending forming tip then the append. Same as a close: a confirmed (forming: false) delta for that subscription does not wait for the coalesce timer.

  • AXB1 SNAPSHOT_PLOTS keeps bar_times aligned with count. A plots snapshot with times round-trips exactly. Encode throws when times or a plot column is the wrong length. The ingest mapper copies those times onto the JSON full as bar_times. Times stay optional so a frame without them still encodes.

  • A refused replay stays on the socket. After a gap, an error from the engine asks once more without AXB1. A second error leaves the subscription stale and does not paint later deltas. There is no REST repair.

  • An inbox full of closes asks for one snapshot. Forming tips are still the only frames dropped. When the inbox is over its cap and every frame left is a close, a snapshot, or a confirmed delta, those frames stay and the subscription resumes once. The next snapshot allows another resume.

  • A full send buffer drops forming tips and resyncs when it drains. Past 1 MB of unsent socket data the socket stays open, forming deltas are not applied, and each live subscription is marked resyncing. When the buffer drains, those subscriptions resume once. Snapshots and closes still pass.

  • A forming tick notifies Solid once per frame. The bar value is written immediately. The quote tick and the tip subscription flush together on the next animation frame, so a burst of ticks does not re-render the price chrome once per tick. A new bar and a window slide still commit immediately.

  • Pane badges sit on the shared chart's plot cell. On the one-chart layout, a volume or indicator badge was rebuilt onto the hidden placeholder the next time badges refreshed. The badge now stays on that pane's plot cell, and a refresh finds it there. The multi-chart rollback still badges its own pane div.

  • Script and strategy runs stay on the WebSocket. A failed socket run does not POST /run or open a REST data session. Live scripts that miss the stream wait on the socket instead of falling back to venue+REST. REST /run remains only when WebSocket is explicitly turned off.

  • The live inbox drops extra forming tips. Each subscription keeps at most 32 frames. Only a forming delta is dropped. A close, a full, an error, and a non-forming delta stay, and the snapshot waiter still receives the full.

  • The quote card stays on the candle while the stream is down. The quote sheet, the float price panel, and the chart price card use the last bar close when the stream plane is connecting, degraded, closed, or in error. An open stream still shows the live tick.

  • The single chart does not copy its time range or crosshair onto a second chart. Volume and indicator panes share the price chart's time scale. Range mirroring and crosshair mirroring run only for the multi-chart rollback.

  • A subscription that is waiting for a snapshot does not paint deltas. After a gap or a failed apply, further deltas are dropped until the next full. That snapshot clears the wait and paints once.

  • A late snapshot does not paint over a newer sequence. A full whose seq is behind the cursor already applied for that subscription is dropped. The newer bars and the newer seq stay.

  • A closed bar's OHLC is not rewritten after commit. appendBar warns (noteClosedRewrite) and returns ignored. The store keeps the committed close. Closing the forming tip is still an update. REST/dataset merge is unchanged.

  • A forming tick does not change the closed-bar hash. closedBarHash hashes only bars that are closed (closed !== false). History rows that omit the flag still count as closed. Closing the tip still changes the hash. A closed OHLC restatement is dropped and does not.

  • Engine series closed-bar alerts run in the live apply. onSeriesDelta evaluates alerts after a non-ignored append of a closed bar. The venue multiplex skips that evaluation while the stream route is active so the same close does not fire twice.

  • A closed bar whose OHLC changes after commit is reported and dropped. Dev warns on every new bar time. Production warns about 1 in 32 times, once per bar. The committed close stays.

  • A plot column longer than the price axis is reported. applyChartMutations samples the column lengths. Dev warns on every new overhang. Production warns about 1 in 32 times, once per size. A shorter column is sampled as length skew.

  • Several live ticks in one turn paint the chart once. The store still updates immediately. Same-time ticks keep the latest bar. A newer time is applied after the previous bar in that same flush. A window slide still replaces the series immediately, with fit off.

  • An open stream with no tick for 3 seconds says delayed Ns. Reconnecting keeps its own text. The live tooltip on the top bar, the connection HUD, and the mobile live button reads the stream plane's last event time.

  • Sync checks for the price tip, plot length, and closed bars. paneAxisSkew, seriesPastTip, plotLengthSkew, and closedBarHash are pure helpers. They do not paint.

  • A dropped socket waits with jitter and does not retry a rejected login. The reconnect delay grows from 500 ms and stays at or under 30 s, with a little downward jitter. An error frame with no subscription and retryable: false stops the loop until the next connect().

  • Only a close marks a bar closed. An AXB1 tip is always closed: false. APPEND_BAR and CLOSE_VERIFY set closed: true on the committed candle. A later forming tick cannot reopen it.

  • Alerts wait for a closed bar. A forming tick no longer evaluates price, drawing, or indicator alerts. The live hook runs only when the bar is marked closed.

  • A live bar time comes from the server, not the local clock. AXB1 snapshot, tip, close, and append times go through the same unix-second normalizer as venue bars. A tip that omits the bar open stays on the previous server bar. The frame's tsMs is not used as the candle time.

  • A live setData puts the visible range back. Window slides and left-edge prefetches replace the series with fit off. setData can still jump the logical range, so the range from before the replace is written back. A full load still fits. A tick still uses update and does not call fitContent.

  • A snapshot that never arrives retries twice, then that subscription asks in JSON. The waits stay bounded (the same snapshot deadline, then 50 ms and 200 ms). An AXB1 socket resubscribes once without encoding. A JSON socket just fails. The series socket stays open.

  • An unknown AXB1 opcode does not close the socket. The decode counts unknownOpcode, names the subscription, and that subscription resumes without encoding: 'axb1'. Other subscriptions stay on AXB1. A second unknown opcode for the same subscription does not resume again.

  • A hidden tab does not replay every missed tick. The live socket stays open. Forming ticks fold into one slot per subscription. When the tab is shown, a closed_upto that moved resumes once from the last painted close. Otherwise the latest tip is applied once. A seq that never arrived still resumes.

  • A dead ingest worker is replaced. onerror terminates that worker, starts another, and connects again. The next open resubscribes through the same reconnect handlers as a socket drop. After five crashes in one connect() the host stays down. It does not switch the socket back to main-thread JSON.parse.

  • A dropped keepalive no longer looks like a missed frame. The tip coalescer still drops UNCHANGED frames and still folds several forming tips into one. Those seqs are now acknowledged on the next frame that is actually released (seqAckFrom). The applier and the apply transaction advance through that run. A seq nobody saw is still a gap and still resubscribes. JSON decode strips a forged ack, so a socket payload cannot hide a real hole.

  • Tail-align pads at the head again, so a clamped series no longer paints early. tailAlignSeriesToWindow was flipped to pad at the end by the P03 perf commit (33cc4269) without a test or changelog change, contradicting its own doc block ("a shorter one head-padded with null") and the tailAlign* contract. Consequence: a meta.windowBars: 3 free-tier clamp ([11,12,13] on a 5-bar chart) painted 11/12/13 on the three oldest bars instead of the newest three β€” every value shifted two bars early. Restored head-padding (the newest engine value belongs on the newest chart bar); the engine-evaluated-fewer-history-bars case stays the job of the bar-time join (alignSeriesToTimes). Added a unit regression guard so a future "pad at the end" edit fails loudly, and corrected the stale comment in apply-context.ts that described the flipped behaviour. tests/runner-engine-owned-apply.test.ts goes 22/7 β†’ 30/0.

  • fillVisualAttrFromViewport no longer spins forever (GPU overlay / test-suite hang). The P03 single-pass rewrite left const len = 0 with no increment in the merged hot+page fill loop, and len was returned as the fill length. Any viewport that touched the page slot β€” a zoomed-out chart on HP-3, or tests/hp/visual-attr.test.ts β€” wedged the thread in an infinite loop; because that file never returned, bun run test stalled after its last completed test and only exited on timeout. Rewritten as the same two-pass shape fillVisualAttrFromHot uses: pass 1 walks the contiguous covered prefix and tracks the visible volume maximum (a gap ends the run), pass 2 writes the records. Also removed the duplicated open/high NDC writes the merge left behind.

  • ColdStore cap test reads the absolute index it documents. After setBars(bars(20)) with max: 10, baseAbs is 10, so read(0, 1) is before the retained head and correctly returns empty (the A18/A23 contract). The test asserted the oldest surviving bar through relative index 0; it now reads coverage().baseAbs.

  • Session overlays no longer tail-pin a window they never evaluated. A session/stream run whose engine shipped no bar axis was tail-pinned onto the chart even when its series length missed the window by thousands of bars (a 9577-bar engine window on a 5000-bar chart shifts every value and marker by 4577 slots β€” the SOLUSDT desync). The staleNoAxis skip now also covers full runs whose length misses the window by more than a few mid-run appends (PIN_TOLERANCE_BARS = 8); the deliberate free-tier clamp (windowBars) keeps pinning, and equal-length runs keep the index zip. The WS /ws/run path now also carries meta.engineTimes like REST, so WS session runs can time-join instead of pinning. And a series repair now invalidates memoized data sessions + live tick/append sessions for that key, so the next run creates a fresh session over the repaired window instead of evaluating (and pinning) the stale one. Pinned by four new alignApplySeries guard tests (gross mismatch skips, plots-array mismatch skips, clamp still pins, small race still pins) plus data-session / live-session invalidation tests.

  • The WebGL2 overlay engages on HP-3 selection (hp2 D1). The overlay was a forced-gpu=webgl2 instrument; auto stayed dormant even where the rung selector already landed on HP-3. startGpuOverlay now gates on the same selection the HUD chip reports β€” probeRendererKind webgl2 plus selectHpRung() === 3 β€” so auto on a WebGL2-only machine paints the parity overlay. off, GPU-less auto, the painterless HP-4 rung, session-cap clamps below 3, and HP disabled all stay dormant. The hide-LWC switch remains gated (D2): this is still additive parity, not the production painter.

Added

  • A thrown paint resumes once instead of leaving a stale badge. The apply transaction still skips the rest of the batch. The status says why, and that subscription asks for one snapshot. A later failure does not resume again until a full arrives.

  • The worker transport and the single chart are the defaults. A tab with no axis.engine.transport uses the ingest worker. A tab with no axis.chart.layout uses one Lightweight Charts chart per slot. json and multi remain the rollback. A missing Worker still counts one JSON fallback and stays on the main-thread socket.

  • Panning to the left edge prefetches one older history page. The dataset read does not run on the drag frame. The page is painted with fit off, and the logical range shifts by the number of bars inserted on the left so the viewport stays put. The rolling window grows with that page, up to the 100k cap, so the next live bar does not slice the page away. Conflation itself is unchanged: on at 2,500 bars, precomputed from 10,000.

  • A live bar keeps its plot and its fill. When the engine axis ends before the chart tip, the last value is extended onto the newer bars instead of head-padding the series, which slid the fill onto later candles. A live tick whose axis is behind also drops the memoized session so the next run evaluates the bar the candle is already showing.

  • A script axis with a hole the candles do not have asks for a series repair. noteChartSeriesHole runs at apply time, so Donchian does not wait for the next series snapshot.

  • A PYNE tick that reports stale drops the script session. That flag means the engine clamped its history walk, not that the feed was idle. The next run loads the store after the gap page is written.

  • A fresh engine tip no longer hides a hole in the series. Repair used to stop when newest was current, so a tick walk that skipped the middle (about 22 hours on DOGEUSDT 1m) stayed in the store and the Donchian axis. A bounds deficit of 30 or more bars now uploads the local window. An upload that adds nothing is remembered, so a weekend on a session venue does not re-upload every cooldown. A snapshot whose stamps jump, while the chart's do not, asks for that repair.

  • axis.chart.layout=single uses one Lightweight Charts chart per slot. The default stays multi (one chart per pane, manual time-scale sync). single adds indicator and volume panes with addPane on the price chart, sends each series to that pane index, and removing a sub-pane does not remove the candles. Time-scale mirroring is skipped because there is one scale.

  • The ingest tip coalescer folds a window instead of keeping one frame. Last OHLC tip wins, plot columns union by key, and a plot tip no longer drops the pending candle. A non-tip frame flushes that subscription's tip first. UNCHANGED keepalives with no bars and no columns are dropped.

  • A script delta paints inside one apply transaction. runApplyTransaction refuses a seq gap, a rewind, a generation mismatch, or a pane/plot shape check before commit. A thrown step skips the rest of the batch and does not paint. The live wire commits the merged script delta through it, so a gap never reaches applyScriptPush.

  • A new candle uses series.update unless it is the first bar or the window slides. appendLiveBar used to setData the whole window on every append, on the belief that update cannot add a time. Lightweight Charts 5.2 appends when the time is newer than the tip. The first bar and a rolling-window slide still setData. Forming ticks stay on update.

  • The live proxy forwards a Blob frame as the original bytes. relayFrame accepts Blob (Workers compat dates β‰₯ 2026-03-17 deliver binary frames that way) and both sockets prefer binaryType = 'arraybuffer' when the property exists. Text and ArrayBuffer frames are unchanged. The compat date stays 2024-11-01.

  • Subscribe and resume send encoding: 'axb1' only when the ingest worker owns the socket. That is axis.engine.transport=worker or HP on. The main-thread JSON client omits the field, so a server that ignores it keeps the old JSON frames, and a server that honors it does not switch a parse-only client onto binary.

  • axis.engine.transport=worker decodes /ws/live off the main thread without turning HP on. worker is now the default (see above); json is the main-thread rollback. worker reuses the ingest worker for the same JSON frames and does not allocate the hot ring, negotiate AXB1, or start the overlay. A tab that asks for the worker where Worker is missing stays on the JSON client and counts one jsonFallback. Pinned by tests/engine/v1-transport.test.ts.

  • v1 engine metrics record tickβ†’apply, tickβ†’paint, long tasks, and pan frames. src/engine/v1/metrics.ts stamps tick:recv before main-thread JSON.parse on full/delta/close, batch:post when the ingest worker delivers a decoded batch, and apply:start/apply:end around applyStreamPayload, then one frame for tickβ†’paint. The first stamp in a burst wins. Seq gaps and gap-driven resumes increment counters; conflation and the visible bar count are gauges on setData; pan and wheel frame deltas sample while the price pane is ranging. chart.lag returns the rolling p50/p95/p99 snapshot as v1. Pinned by tests/engine/v1-metrics.test.ts.

  • Tick β†’ frame latency is split into pipeline stages (hp2 A1). The Β§14.2 number was one delta β€” applied tick to next frame β€” so a bad p95 could hide anywhere between the worker and the glass. The ingest worker now stamps publishedAt on every frames message (dedicated-worker performance.now shares the main clock origin), and paint-latency records per-burst stage marks: worker publish β†’ main apply (publishToApply, covers postMessage + dispatch), apply β†’ hot-ring copy-out, copy-out β†’ overlay draw. Stages that never stamp (JSON path, LWC-only paint) report null instead of fictional zeros, and the existing total is untouched. hp.get exposes the split as paint.stages p50s next to paint.latency, so the p95-tail hunt in .opencode/hp2/implementation.md can point at a stage instead of the whole pipeline. Pinned by three new paint-latency stage tests (first-stamp-wins, null-path, burst reset). Controlled live runs (2 Γ— 10 s, HP-0 JSON path, MCP idle): 587/593 frames @ ~59 fps, rolling paint p50 ~13 ms / p95 ~16 ms / max ~129 ms (n = 240) β€” the Β§14.2 tail (p95 β‰ˆ 160 ms, max β‰ˆ 520 ms) does not reproduce under quiet conditions and attributes to observer load during that measurement (heavy MCP diagnosis traffic + screenshots), not the pipeline. HP-on rung runs still pending for the A2 gate.

  • The ingest worker reads its own cold history (hp2 B1). The page slot used to paint only what the main thread's chart window happened to hold: a range older than the REST window arrived as a short (or empty) slice and the overlay fell back. The worker now owns a ColdStore per series bind β€” hydrated from bars-cache IDB on connect via the new coldKey (sourceId/symbol/interval, posted on connect), extended by every snapshot (upsertByTime), every verified bar close (appendTick β€” forming re-ticks share the tail time and are ignored), and every page slice the main thread sends (the REST slice accumulates, so gaps close over time). The page message carries the wanted count, and the worker publishes the merged range β€” main bars win on overlap, cold fills the rest, truncation at the first uncovered slot instead of an invented bar. ColdStore tracks its absolute base (baseAbs) so an evicted head or a prepended deeper history cannot silently misalign slots; losing absoluteness flips complete and the merge stops trusting positions. A symbol switch re-seeds; a hydrate racing the socket merges instead of wiping. Pinned by new write-back/upsert/eviction/merge tests in tests/hp/page-buffer.test.ts.

  • AXB1 codec fuzz + relay reconnect proof (hp2 E1). The audit found no dead surface: HPC1_MAGIC is the live control-block magic and AXB1_HDR_COMPRESSED is live as a v1 reject-rule (decode throws, encode refuses) β€” both are now marked as audited in constants.ts. New tests/hp/axb1-fuzz.test.ts throws 500 seeded-PRNG frames across all 11 opcodes at encode β†’ decode (exact toEqual, 4-byte alignment asserted) plus 50 single-bit-flip corruptions that must fail loudly with RangeError/TypeError, never silently. worker/tests/live-proxy.test.ts gains the reconnect leg: upstream drop closes the server side, the client redials with a fresh ticket, the JSON resubscribe stays text, and the first binary frame on the new socket is still byte-identical.

  • Calc session runs in a worker (hp2 C1). CalcSession was main-thread library code; the HP topology needs it off the Window thread with cold history as its replay source. New src/hp/workers/calc-engine.ts (pure, testable) owns one session per series plus a ColdStore each: snapshots seed cold and replay from cold once it holds history (full-history replay, not the 500-bar main window β€” a rebind with no reposted bars replays identically), tips stay scratch, only appends commit. FrameValuesEvaluator files the Pro-API server values by bar time; its snapshotCommitted/restoreCommitted are an O(1) cursor β€” the pickle-restore analog for a host with nothing to pickle β€” and evaluateCalls pins the no-rewalk bound (12 bars + 3 tips = 15 calls, not 12 + 3Γ—12). Hydrate merges IDB behind live bars instead of wiping them; per-series isolation and error-instead-of-throw replies are covered. Pinned by six calc-worker tests against the PYNE-recorded barstate-parity fixture (ta-cum confirmed series identical). The thin calc.worker.ts entry ships with the protocol; the live frame producer that posts to it is the explicit next step, not this commit.

  • The GPU overlay paints zoomed-out viewports via pixel-column downsampling. The hot window plus one cold page cover at most 2 Β· HOT_CAP bars, so a fully zoomed-out chart fell back to LWC for most of the pane β€” and paging the whole series in would break the O(HOT_CAP) memory contract. When the exact hot+page fill cannot cover the viewport's real bars, the overlay now downsamples the chart window LWC paints (downsampleToPixelColumns in src/hp/paint/): one bin per pixel column, first-open / column-extremes / last-close / summed volume, bin centers in fractional viewport slots that the renderer positions natively. It reads the exact array LWC paints, so parity is by construction and it needs no SAB traffic; the exact fill stays the path wherever the viewport is coverable, and the forming tip keeps its flag through the bin. Pinned by binning-semantics, fractional-center, hole-skipping, and viewport-clip tests (tests/hp/downsample.test.ts).

  • Cold paging is wired: the GPU overlay paints past the hot ring. The overlay used to clear whenever the viewport started left of the hot window, because the page slot existed only as library code nothing populated. Now the overlay reports its absolute viewport every frame (noteHpViewport in src/streams/stream-live.ts, throttled to 200 ms), the main thread slices the cold range out of the chart window it already owns, and the ingest worker β€” still the SAB's single writer, so a page replace can never tear a concurrent hot publish β€” replaces the page slot via the new page message (publishBarPage, converted with the buffer's own price scale). A re-seed or reconnect retires the page (its absolute base is snapshot-relative), and the overlay fills the merged hot+page range in one seqlock sample (fillVisualAttrFromViewport), stopping at the first uncovered bar so LWC keeps painting underneath. Chart index ↔ absolute mapping lives in one place (chartRangeToAbsolute) so the pager, the page slot, and the fill cannot disagree. hp.get reports the page (page.active/from/count). Pinned by chartRangeToAbsolute, publishBarPage, fillVisualAttrFromViewport, and host page-message tests. The worker-side ColdStore/IDB adapter stays library-only (future calc-reader territory). The @gpu e2e re-asserts the shrunken-ring step: the cold viewport must repaint without zooming (downsample, then the paged exact fill) β€” the old clear assertion encoded the pre-paging contract.

  • The HP engine is configured in Settings, not the console. Every axis.engine.hp.* flag used to require localStorage in DevTools, which made an opt-in plane look like a developer hidden switch. Settings β†’ General gains a High-performance engine section: the master toggle (off keeps the JSON /ws/live β†’ Lightweight Charts path byte-identical), the GPU painter (auto / webgl2 / webgpu / off) and the SAB transport (auto / off), each writing the same tab-local keys so automation still works. The section renders the resulting hpChip() verdict inline (json Β· lwc, hp Β· sab+gpu, Kill switch, …) and, once something changed, the Reload to apply button the flags need β€” the plane is read at boot, so a silent write would look like a no-op. The architecture page and the README now point at the section instead of DevTools, and an e2e smoke asserts the toggle, the select write-through and the reload affordance (e2e/smoke.spec.ts).

  • Tick β†’ frame latency is measured (the plan Β§14.2 gate). src/chart/paint-latency.ts stamps the first applied live tick of each frame and records the delta to that frame's timestamp β€” one rAF callback per burst, no steady loop, no timers, and the recording is a no-op for rejected ticks. appendLiveBar calls it, and both hp.get (paint.latency) and hp.sample (paint) expose p50 / p95 / max over the last 240 samples, so the default-flip decision can be argued with production numbers instead of a stopwatch. hp.sample's ring progress is now closedUptoTimeDelta β€” the engine's closed_upto is a time stamp, not an index, and the old name invited that misreading.

  • Chart screenshots include the HP GPU overlay. chart.screenshot (MCP) and the Screenshot menu composed LWC's pane canvases, drawings and the watermark β€” but never the HP-3 WebGL candle canvas, which is a sibling element, so a captured image silently differed from what the trader sees. capturePaneCanvas now composites the overlay under the drawings (annotations stay on top) via overlayPlacement, which maps the overlay's CSS rect into the screenshot's pixel space (shot.width / pane.width, so 1Γ— and 2Γ— captures both line up). A WebGL drawing buffer is discarded at composite time, so the copy runs in the next animation frame β€” the overlay's own rAF tick was registered earlier, which is what keeps the buffer intact without preserveDrawingBuffer. Verified by sampling 1 948 candle pixels: the live composite reads (174,92,94), the app screenshot (184,96,98), LWC alone (229,119,122) β€” the screenshot follows the composite.

  • High-performance chart engine implementation spec on branch engine/high-performance (.opencode/hp/implementation.md). It pins generation-based double-buffering, ingest/calc/render workers, the AXB1 4-byte-aligned binary delta, viewport-only SAB paging, int64 price ticks (float32 only after NDC), worker-only Atomics.wait, tip-vs-append Pine barstate context, and a HP-0…HP-4 fallback matrix on top of the existing JSON /ws/live plane.

  • AXB1 codec + JSON execution context (HP PR 1). Canonical prices are int64 ticks (decimalToTicks / priceToTicks); the encoder/decoder round-trips the 32-byte little-endian envelope and the TIP/APPEND/SNAPSHOT opcodes. Live JSON deltas with forming: true stamp executionContext: 'tip' on the merged payload so a later calc worker will not commit barstate.isconfirmed accumulators until APPEND_BAR.

  • Cross-origin isolation headers (HP PR 2). Every static host (Pages _headers, nginx, Vite dev/preview, Bun and Python PWA servers) and the SW offline shell send one locked-step COOP / COEP: credentialless / CORP: same-origin trio β€” COOP landed popup-safe and later flipped to same-origin (see Changed), so SharedArrayBuffer is now reachable without a second header pass. isolationProbe / selectHpRung stay HP-0 until axis.engine.hp.enabled and report SAB only when crossOriginIsolated is true. The kill switch is axis.engine.hp.disabled.

  • Ingest worker: /ws/live decode off the Window thread (HP PR 3). createLiveWsClient returns an IngestHostClient only when axis.engine.hp.enabled === '1' and a Worker exists; otherwise it returns the original Redesign2WsClient, so the flag-off path is byte-for-byte today's client. The worker owns the socket (binaryType: 'arraybuffer'), decodes JSON and AXB1 through the shared decodeInbound, holds forming tips for a 16 ms vsync coalesce while close / APPEND_BAR flush immediately, and posts typed frames over a HostToIngest / IngestToHost port β€” the auth token rides once on connect and is never echoed back. The host keeps the WireClient contract (onFrame, awaitFrame, reconnect handlers) so StreamLiveWire is unchanged, and the Workers Manager gains an hp-ingest card that probes as none (there is nothing to HTTP-probe in a tab). Tests: tests/hp/ingest-core.test.ts (JSON + AXB1 fixtures through the pure decoder) and tests/hp/ingest-host.test.ts (loopback port: connect, frame delivery, exact subscribe serialization, reconnect, error mapping).

  • Hot double-buffer + main-rAF copy-out (HP PR 4). One SharedArrayBuffer is sectioned into a 256-byte control block (the seqlock words), a series header (price scale + epoch), two reserved tip records, and the hot window itself: HOT_CAP = 5_120 bars Γ— 2 slots of int64-tick columns (~450 KiB β€” O(hot window), not O(N), so a mobile WebView never has to allocate 200kΓ—2). The single writer (ingest) fills the unpublished slot and flips the generation from odd to even; readers sample only even generations, and a copy is re-verified by a second generation load β€” a torn sample is discarded and retried, and after the retry budget copyOutBars returns null so the paint path keeps the previous frame instead of showing a mixed candle. Atomics.wait is worker-only: waitInWorker throws the moment globalThis.Window / self says Window thread, which the main thread replaces with subscribeGeneration's requestAnimationFrame poll. A RangeError from the allocator drops the tab to rung 1 for the session (axis.hp.rung = '1') instead of retrying the OOM on reload. copyOutBars clips the visible range to the hot window (cold pages are HP PR 5) and produces the float64 Bar[] Lightweight Charts paints; the tests feed those bars through appendLiveBar and assert all four store verdicts (ignored / updated / appended / slid) plus the torn-write, main-thread-guard and 5_120Γ—2 allocation cases (tests/hp/{hot-buffer,atomics-main-guard,copy-out-lwc}.test.ts).

  • Viewport paging + columnar cold store (HP PR 5). The SAB gains one single-buffered page section (PageSlot[HOT_CAP]) after the hot double-buffer, so panning into cold history copies one page of bars β€” a far-left jump replaces that page instead of allocating a third history buffer, and "fit content" over a range that still overlaps hot is acked without pulling 100k bars into shared memory (Β§9.2). The allocation is a fixed 691,968 bytes (~676 KiB): O(HOT_CAP) and provably independent of series length, because a 200k-bar history allocates the same bytes as a 1k one. A page replace runs its own seqlock transaction (beginPageWrite / commitPageWrite) that never moves the hot published slot, so the hot window and the forming tip keep updating while the page is on screen. Readers get copyOutPage and the copyOutViewport dispatcher beside copyOutBars, all three sharing one seqlock-verified clipped copy. ColdStore keeps the worker's full series as columnar int64 ticks β€” a page copy is a straight column read, never a re-encode β€” hydrates from the existing bars-cache memory-first, and on rung 1 (copyOutPageFromCold, no SAB at all) the viewport is still served straight from the cold store (tests/hp/page-buffer.test.ts).

  • Calc scratch-vs-commit state machine (HP PR 6). The calc worker's core rule, made testable without a Worker or a Pine runtime: a forming tip is a scratch evaluation and a bar close is a commit. barstate.ts pins the Β§10.1 contexts β€” history replay is new + confirmed + never realtime (isfirst only at index 0, islast only on the rightmost bar), the snapshot's forming tip and every incremental tip are never confirmed so ta.cum / ta.valuewhen / var accumulators stay scratch, isnew is true only on the first tip after an append, and APPEND_BAR closes the bar with a confirmed + realtime eval that folds the accumulators before the next slot opens. CalcSession (calc-core.ts) drives any CalcEvaluator β€” PYNE in a worker, Pyodide, or a test double β€” through onSnapshot / onTipUpdate / onAppendBar: each tip restores the committed snapshot before evaluating, so ticks cannot accumulate into committed state, and only the append path grows the committed plot series. This split is not invented: PYNE's own multi-tick realtime window sets isconfirmed = tick_i == n_ticks - 1 (forming ticks unconfirmed, closing tick commits), which is exactly this table. Parity is pinned by fixtures recorded from PYNE (scripts/record-hp-fixtures.py β†’ tests/hp/fixtures/barstate-parity.json, pynescript 0.6.18, re-recordable after a PYNE release): the tests drive the session through wild forming ticks and assert the committed series still matches the recorded closed-run ta.cum / ta.valuewhen / var outputs bar for bar, that three 200β†’150β†’125 ticks leave committed untouched, and that the recorded barstate flag columns match the replay contexts (tests/hp/barstate-tip.test.ts).

  • AXB1 golden vectors now pin every payload shape (HP PR 7 prep). tests/hp/axb1-vectors.json grows from a lone heartbeat to 12 vectors β€” heartbeat, unchanged, status, tip (close+volume+time and full OHLCV), append-bar, close-verify, plot-tip (int64 ticks + float64), a 2-bar OHLCV snapshot, a plots snapshot, error, and a JSON blob. Each vector carries the language-neutral frame spec (int64/uint64 as decimal strings, masks and value kinds as names) and the exact little-endian hex the encoder must emit, plus hash_checks pinning fnv1a32 on shared inputs. This is the cross-repo contract: PYNE's tests/test_axb1.py builds the same frames from the same spec with its own encoder and must produce identical bytes, so a wire-format drift now fails both repos' test suites instead of silently corrupting live frames. bun scripts/generate-axb1-vectors.ts regenerates the hex from the specs through the real AXIS encoder, and the codec test loops every vector through spec β†’ encode β†’ golden hex β†’ decode β†’ re-encode (tests/hp/axb1-codec.test.ts).

  • The live relay no longer corrupts binary frames (HP PR 7b). worker/src/live-proxy.ts decoded every non-text WebSocket frame with TextDecoder before relaying it β€” fine for JSON, fatal for AXB1: any byte that is not valid UTF-8 would be mangled before reaching the client. The relay now hands payloads through untouched (relayFrame: text stays text, ArrayBuffer and views stay bytes, in both directions), which is exactly what a transparent proxy means once the engine can speak binary. The test drives the real handleLiveProxy upgrade path with a faked WebSocketPair and upstream, and asserts an encoded AXB1 TIP_UPDATE crosses the relay byte-for-byte in both directions while JSON control frames stay text (worker/tests/live-proxy.test.ts).

  • GPU paint plane: NDC staging + instanced WebGL2 renderer (HP PR 8). hp/paint/visual-attr.ts turns visible candles into the plan's 32-byte VisualCandle record (Β§6.5): x / open / high / low / close / volume as float32 and nothing else β€” prices are mapped to NDC in float64 against the caller's visible y-range and narrowed once at the store, so neither float64 nor int64 ever crosses the GPU boundary (WebGPU has no float64) and canonical ticks stay in the SAB. fillVisualAttrFromHot mirrors the copy-out's seqlock discipline (sample only even generations, re-verify after the copy, null = keep the previous frame) over a visible + 64 capacity, with volume_norm normalized against the visible maximum. hp/render/ adds the painter seam: probeRendererKind pins the plan's probe order WebGPU β†’ WebGL2 β†’ LWC as a literal 4Γ—4 truth table where off wins over capability and a forced mode never claims a backend the probe did not find, and createWebgl2Renderer draws the whole viewport as one instanced call β€” one VAO, an 8-word-per-record divisor-1 attribute layout read straight out of the staging bytes, and a fragment shader that carves the body box plus a centered 1/8-slot wick out of a single quad per candle (packed 0xAABBGGRR unpacked with core ES 3.00 bit ops, since GLSL ES 3.00 has no unpackUint4x8; alpha 194/255 blending over the transparent-clear, stacked LWC chrome). The factory is null-never-throws, so any context/shader failure falls through to LWC instead of failing the page. Tests pin the struct stride and byte order, the NDC math, the hot-fill contracts, the full probe table and the factory's failure surface (tests/hp/{visual-attr,select-renderer,webgl2-renderer}.test.ts). Live wiring is deliberately deferred: no producer writes visual_generation yet and plan Β§14.2 forbids gutting PaneManager before GPU parity tests exist, so β€” like PR 4–7 β€” this lands as library + tests, and the e2e "candles visible" smoke waits for a producer.

  • HP visibility: architecture page, agent playbook, and rung chip (HP PR 9). docs/architecture/hp-engine.mdx documents the opt-in plane end to end β€” the HP-0…HP-4 rung matrix and selection algorithm, the flag table with the kill-switch rule, the COOP/COEP reality β€” the isolating pair (same-origin + credentialless; COOP flipped from the popup-safe value, see Changed) β€” while HP-3/4 still run on the copy transport until a producer wires the SAB hot ring, the AXB1 transport and its cross-repo golden vectors, the SAB O(HOT_CAP) memory contract, the GPU paint seam, and a landed-vs-library-only status table β€” registered in the Architecture nav and track map. okf/playbooks/hp-engine.md (curated, okf_lock: human) gives agents the same map as a playbook: which hp submodule owns what, the contracts that must not break (seqlock discipline, 8-word/32-byte record stride, float32 only after NDC, GLSL ES 3.00 bit unpack, the right-edge NDC anchor, flag-off byte-identical rollback), and the reminder that rung selection β‰  wiring. The Workers Manager gains a Chart plane card carrying the plan's HUD chip: hpChip() (src/hp/hud.ts) reports the selected rung in plan format β€” hp Β· sab+gpu / hp Β· copy+lwc / json Β· lwc (transport memory + painter) β€” where default-off honestly reads json Β· lwc because that is exactly today's path; the kill switch shows as Kill switch and engaged tabs show the rung word (HP-3). Pinned across the full flag Γ— capability matrix including the session OOM clamp (tests/hp/hud.test.ts). The feed-chip surface and MCP stream.get's hp field stay unwired.

  • A working OpenCode project configuration. The project opencode.json declared a Prettier formatter whose command never passed a file, so every write ran a no-op formatter and then fell through to Biome β€” which is lint-only here (biome.json sets formatter.enabled: false) and exited non-zero, so no file was ever formatted. The command now ends with $FILE, which OpenCode replaces with the absolute path, and the formatter is scoped to the code extensions the repo declares as Prettier-managed (.ts, .tsx, .js, .jsx, .mjs, .cjs, .json, .jsonc, .css); Markdown/MDX and YAML are excluded because Prettier rewrites the #-block license headers into headings and flips the compose files to single quotes. Biome, rustfmt, ruff and uv are disabled as formatters: Biome is the linter, src-tauri/src/*.rs is not rustfmt-clean (the repo uses a 2-space Rust style), and the Python helpers have no formatter config. The same file keeps lsp: true, adds the OKF bundle as project instructions, ignores the heavy build directories in the file watcher, guards git push / git tag / deploy / rm -rf behind an approval prompt, enables the todo tool, and adds the /commit, /verify and /release slash commands plus a read-only axis-reviewer subagent. Two references point OpenCode at the sister checkouts β€” pyne at /home/jango/Git/pynescript (the checkout is still named pynescript, not pyne) and hoox at /home/jango/Git/hoox β€” so Pine Scriptβ„’ parity questions can be checked against the real PYNE builtin signatures without scanning the tree. Automatic compaction is enabled (auto, 15 000 retained tokens, 20 000 buffer) so long /verify and /release runs stop walking into the context ceiling. Four more shell rules join git push / git tag behind the approval prompt: git reset --hard*, git clean -f*, git checkout --* and wrangler secret* β€” the destructive git forms that could discard work, and the command that mutates live Worker secrets.

  • One git backend instead of two near-identical files. GitHub and GitLab were separate modules with twelve functions of the same body, and the dispatch picked between them with nine ternaries. The provider-specific parts β€” URL shape, auth header, content encoding, update token, conflict status β€” now live behind a GitForge, and the shared operations live in one place. The per-provider differences stay explicit: GitHub identifies a blob by its sha and answers 409 on a conflict, GitLab only needs to know whether the file exists and may answer 400/409/422.

  • Engines declare what they are instead of being guessed from their id. Seventy-one places branched on the engine id string β€” id === 'server', id.includes('pyne-worker') β€” to decide how the engine behaves: which credential slot it uses, whether the remote LSP is reachable, which transport the HUD reports, whether the run can be re-applied. An engine now declares a family (server / pyodide / worker / dynamic), and the behaviour branches on that. The id stays the user-facing name and the selection key, and the credential vault still resolves a family exactly, so an unknown engine id is never routed to the shared server or worker slot by accident.

  • One event-listener primitive instead of the same four lines everywhere. Dozens of components repeated onMount(() => target.addEventListener(...)) / onCleanup(() => target.removeEventListener(...)) β€” the same shape, easy to get subtly wrong (a capture flag on one side only, a listener that outlives its component). attachEventListener attaches and returns the disposer, so the two halves cannot drift apart, and useEventListener wires it to the component lifetime.

  • One modal shell instead of six copies of the dialog chrome. The About, Symbol, Script settings, Dataset manager, Keyboard shortcuts and Storage change dialogs each carried their own backdrop, accent, header (title, hint, close), body and footer, plus their own focus trap, Escape handling and backdrop dismiss. They now render through one shared Modal, which owns the chrome, the focus trap and the Escape listener β€” the listener exists only while the dialog is open. The Keyboard shortcuts dialog declared aria-modal="true" and had no focus trap; it now has one, like the other five.

  • The shortcuts dialog's close button now uses the shared icon. It was the only dialog drawing a literal βœ• instead of the Lucide X the other five use.

  • One run-result envelope instead of three. The engine result arrived over the REST path, the WebSocket path and the batch path, and each built the same envelope with its own copy of the body β€” plus a byte-identical copy of the bar-time-axis extractor. One mapper builds it now. The differences between the paths are stated per call site instead of hidden inside three copies: the WS path resolves the run mode server-side, the REST path infers script_type from the payload kind, and the batch path only accepts a real boolean overlay.

  • One Worker route table instead of four copies of the route list. The route knowledge lived in the entry dispatcher's switch, its ad-hoc if / startsWith guards, the MCP proxy's own dispatcher, and the MCP allowlist's method+path pairs β€” and the four drifted. One table declares the routes now; the entry dispatcher and the MCP proxy both read it. The allowlist stays the authority on whether a path may be reached over MCP (it is a security boundary with its own script-id and version-path rules), and a test asserts the table and the allowlist agree.

  • One popover shell instead of four copies of the menu skeleton. The chart-layout, screenshot, extra-widgets and run-split menus each carried an open flag, an anchor element, a measure-and-clamp placement, a resize/scroll reposition, an outside-click dismiss and an Escape handler. One Popover owns that skeleton now. Two of them had drifted: the run-split menu dismissed on mousedown where the others used pointerdown, and it hand-rolled the outside check the shared helper already provided β€” it now dismisses on the same event as the others.

  • A network failure carries its status code. The same three steps β€” read the body, decide what a non-2xx means, give the failure a status β€” were written into every network call, and the status was usually flattened into the error text, so a caller could not tell a 404 from a 429 without parsing a string. src/api/client.ts owns those three steps now, and the cloud storage client and the venue fetchers carry the status on the error. The engine plane, the probe plane, the git adapters and the editor LSP client keep their own contracts on purpose β€” a failed engine request is a verdict, not an exception β€” and the module documents which is which.

  • HP-2 producer wiring: ingest writes the hot SAB ring, the chart samples it on rAF. The SAB plane stops being library-only. When the tab is opted in and cross-origin isolated, IngestHostClient allocates the hot ring once and rides it on connect (hotSab); the ingest worker wraps the same buffer, confirms with a hot reply, and becomes the ring's single writer (hp/workers/hot-ring-writer.ts). The first series snapshot binds the ring (a snapshot for another id rebinds with an epoch bump, so a symbol switch never reuses ticks); a bound series delta is written into the ring and stripped from the posted frame β€” seq, closed_upto and the positional block still cross postMessage, so applySeriesPush's no-bar branch keeps resume/repair sequencing intact β€” while snapshots keep their payload (initial paint) and script deltas, foreign series and stale-only deltas pass through untouched: the frame path is always a correct fallback. Every flip republishes the whole window through publish (an unpublished slot must be fully rewritten or readers see stale zeros outside the touched indices); a forming tip overwrites the newest entry in place, and a full window slides one step with a whole-window rewrite per closed candle. Main-thread paint is startHotRingPaint (src/chart/live-append.ts): a subscribeGeneration rAF pump samples the published generation via copyOutTail and feeds the contiguous tail run that continues the store tail through appendLiveBar β€” the same store verdicts and paint invariants as the frame path (a forming tick never reopens a closed tip; a jumped ring β€” suspended tab, resume replay β€” is left to the snapshot/repair path). stream-live.ts starts and stops the sampler with the wire lifecycle. Flag-off, kill switch, non-isolated tabs and a refused SAB allocation (the existing rung-1 OOM clamp) all keep today's JSON/transferable frame path byte-identical. New tests: tests/hp/hot-ring-writer.test.ts (bind/strip/slide/rebind/passthrough routing), tests/hp/hot-ring-paint.test.ts (sampler verdicts incl. the non-contiguous guard and disposer), tests/hp/ingest-host-sab.test.ts (the isolation Γ— flag transport gate).

  • HP-3 producer wiring: the WebGL2 candle overlay paints over the price pane (parity mode). The GPU paint plane gets its producer. src/hp/render/gpu-overlay.ts stacks a transparent WebGL2 canvas over LWC's price-pane content area and draws the same candles LWC is painting β€” aligned by construction against LWC's own geometry (visible logical range + right-scale coordinates) β€” with LWC staying the painter and the full fallback; hiding LWC's candle series remains the plan-gated next switch (Β§14.2). Data is the HP-2 ingest hot SAB ring: stream-live.ts exposes the ring the wire allocated (getLiveHotBuffer), and ChartHost starts the overlay per active slot after the initial paint, stopping it in the main cleanup. The frame path reuses the PR-8 renderer (fillVisualAttrFromHot β†’ one instanced draw) with a new pure overlayViewport helper (fractional logical anchor, right-edge index, the MAX_OVERLAY_BARS sanity cap) and a priceScaleLinear probe that refuses non-linear (log/percentage/indexed) right scales; every bail β€” no ring, ring/chart length mismatch, cold left edge, non-linear scale, torn seqlock sample β€” clears the transparent canvas, so a skipped frame shows LWC underneath and never a stale GL layer. The overlay engages only when axis.engine.hp.gpu is forced to webgl2: LWC paints candle bodies translucent (0.76) and stacking identical GL bodies composites them darker, so auto / off / webgpu stay dormant until the hide-LWC-candles switch is measured. Tests: tests/hp/gpu-parity.test.ts pins feed parity against copyOutBars (bit-exact NDC + reconstruction), hot-fill bit parity, capacity-drop parity, overlayViewport anchor identity vs LWC, the explicit-gate matrix (auto dormant even when probeable; forced+probeable engages once, disposes safely) and parseCssColorToRgb on LWC's premultiplied palette. The e2e smoke (e2e/gpu.spec.ts, @gpu) drives the full product chain β€” empty chart, offline mock-poll stream, scripted /ws/live full snapshot + per-slot rollover re-seeds against a local WS server (Playwright's WS routing does not intercept the ingest worker's socket) β€” and asserts the overlay canvas turns visible and its framebuffer carries alpha-nonzero GL pixels.

  • MCP observability for the HP plane: hp.get + hp.sample. The HP flags live in tab-local localStorage and isolation is a runtime property β€” neither crossed the sanitized app snapshot, so an agent asking "is HP actually working in this tab?" got nothing. src/mcp/hp.ts adds two read-only capabilities: hp.get reports the flags (enable / kill switch / gpu / sab), the cross-origin isolation probe, the HUD chip verbatim, the session rung cap after a SAB OOM (axis.hp.rung), the actual hot-ring runtime (window bounds, seq / closedUpto, SAB bytes, tailTime vs chartTailTime, and the tail-identity alignment the GPU overlay guard enforces), the wire state, the overlay canvas's DOM state, and a verdict β€” kill / off / awaiting / copy / fallback / degraded / misaligned / live; hp.sample measures a bounded window (100 ms–10 s, default 1 s) of movement: ring seq delta (ingest publishes landing), closedUpto progress, rAF frame cadence, and the chart's append/tail deltas. Pure reads β€” no flag writes, no wire start, no SAB allocation, no secrets. Tests: tests/mcp-dispatch.test.ts pins registration as read-only, the default-off / kill-switch verdicts, the engaged-rung-without-wire wording, and the sample envelope incl. the window clamp.

Changed

  • The last hand-rolled LRU trims use BoundedMap. The session run-mode memo, the session retry cooldown map and the dataset memory mirror each reimplemented delete + re-insert + evict-oldest; they now use BoundedMap.set, which owns that sequence (and, unlike the dataset store's old loop, needs no special-case to protect the just-written key β€” a fresh write is always newest, so the trim can never evict it). BoundedMap also gains [Symbol.iterator] so the dataset mirror's existing for..of loops keep working. Pinned by the dataset, live-sessions and data-sessions suites.

  • The cloud client uses the shared JSON fetch instead of its own. storage/cloud's private api() reimplemented fetchJson line for line (fetch + readJsonBody + HttpError, same {status, json} shape, same 30 s timeout). It now delegates, which gives fetchJson its first production caller β€” the helper is no longer a tested-but-unused abstraction. No behavior change: same return shape, same timeout, same error contract.

  • One flag pair instead of two state machines. engine/feature-flag and hp/feature-flag were the same module with different keys: an enabled key, a disabled key, in-memory fallbacks, and a kill-switch-wins rule written out twice. They now share createFlagPair from src/utils/flags.ts; the plane modules keep their key constants and public function names, the GPU/SAB/rung handling stays HP-specific, and the kill-switch-wins rule exists once. Side effect, now matching both modules' doc comments: the stream-engine isEnabled also respects an explicit opt-out (previously only HP's did). Pinned by the hp/feature-flag + engine/feature-flag suites.

  • One storage accessor instead of eight helpers and thirty guards. Four modules each owned an lsGet / lsSet / lsRemove triple and ~30 call sites hand-rolled typeof localStorage guards, differing only in whether a failed write returned false or void. They now share src/utils/storage.ts (getItem / setItem / removeItem / getJson / setJson / getFlag / setFlag), which never throws β€” the boolean write return is kept because the alerts persist path reports quota failures. The memory fallbacks in both feature-flag modules and the quota-reporting trySetItem in the store stay exactly as they were. Pinned by the existing storage, alert, credential, vault, catalog, watchlist, plugin, optimize and editor suites (234 cases).

  • Nine listener sets share one emitter instead of hand-rolled loops. bar-replay, both alert modules, the MCP bridge, the dataset store, the secret vault, the credentials store, the editor bridge and the open-script source each kept a Set of listeners with its own subscribe/unsubscribe pair and notify loop β€” and two of them (editor-bridge dispatch, open-script-source emit) had no error isolation, so one throwing subscriber broke delivery for the rest. They now share createEmitter from src/utils/emitter.ts (snapshot iteration, per-listener fault isolation); the emitter gains a clear() for the dataset store's test reset. All public subscribe functions keep their names and signatures. Pinned by the existing bar-replay, editor-bridge, dataset-store, credentials, alert and MCP-bridge suites (236 cases).

  • One public-REST definition per venue instead of two. symbol-catalog (instruments) and watchlist-tickers (24h tickers) each implemented OKX / Bybit / Coinbase / Kraken β€” same hosts, same HttpError contract, same XBT β†’ BTC normalization β€” and had already drifted: the catalog sent cache: 'no-store' where the tickers did not. Each venue now owns its public fetchers (fetchOkxInstruments / fetchOkxTickers, …) in src/data/venues/, sharing one venueFetch (always no-store, throws HttpError) and one symbol spelling (toUsdt, okxInst, coinbaseProduct, Kraken legacy-key handling) in venues/spot.ts. The catalog and the Watchlist are thin mappers over those; toUsdt / okxInst / coinbaseProduct stay re-exported from watchlist-tickers so the WS layer's imports are unchanged. Side-effect fix, not a behavior change: the Coinbase ticker path stripped USDT/USD but not USDC, so a …USDC symbol built a broken product id β€” it now shares the exported helper. Pinned by the existing symbol-catalog and watchlist-tickers suites (42 cases).

  • One wire-bookkeeping core for both live socket clients. Redesign2WsClient (direct WebSocket) and IngestHostClient (HP ingest worker) each carried a verbatim copy of the request/response waiter map, the reconnect-handler set with its swallow-per-listener notify loop, awaitFrame, dropPending, and the stats() snapshot β€” a fix to waiter cleanup or teardown in one never reached the other. They now share PendingRequests<TFrame> (id-keyed waiters, timeout reject, dropAll) and ReconnectHandlers (snapshot iteration, fault isolation) in src/engine/wire-bookkeeping.ts, plus a wireStats() snapshot helper. Public behavior is unchanged (addReconnectHandler, awaitFrame, stats() shapes identical; the one white-box test that read the private handler set now reads the shared core). Pinned by the ws-client, ws-send, ingest-host and ingest-host-sab suites plus the mcp-live / engines-live-sessions / data-sessions suites (124 cases).

  • The Worker answers every route with the same error envelope. The entry fetch opened its try/catch after the MCP, MCP-bridge and stream paths, so an exception in any of them escaped the Worker as an unhandled rejection instead of a 500. The dispatch now runs inside one try, and a shared handleError helper logs the failure server-side and returns the canonical { status: 'error', code: 'INTERNAL' } envelope. The error text is still never reflected to the caller.

  • One rate limiter instead of two. The MCP plane kept a verbatim fork of the fixed-window limiter, with its own bucket map. Both now share worker/src/rate-limit.ts. The shared allowRate takes an optional call weight so a JSON-RPC batch is still charged per method β€” and a batch that does not fit the remaining budget is rejected without being counted, so it cannot eat into the next caller's allowance.

  • One abort/timeout combiner instead of eight. Six modules each had their own requestSignal / withBudget / hand-rolled budget block, and four of them silently dropped the caller's abort signal on runtimes without AbortSignal.any. They now share src/utils/fetch-timeout.ts, whose fallback keeps the parent signal honoured. The one place that deliberately lets the caller's signal win β€” the venue page walk, which owns its own budget β€” uses a separate, explicitly named helper so the difference is intentional rather than accidental.

  • One error helper instead of four. Three modules carried their own errMessage copy and four carried their own abort-detection heuristic. They now share src/utils/errors.ts, which separates the two questions the old copies conflated: isAbortError decides whether to rethrow (name-based only, so a network error that merely mentions a timeout is still a network error), while isAbortLike / isTimeoutLike decide how to word the message.

  • One JSON builder per CORS policy, and one error envelope across the Worker. Eleven near-identical JSON wrappers were spread across the Worker (apiJson, three json copies, corsJson, proxyJson, two jsonErrors), and forty-five hand-written { status: 'error', code, message } objects bypassed the canonical envelope. http.ts now exports scopedJson(cors) and plainJson: a route binds its CORS policy once and cannot answer with another route's headers by accident, and the inline envelopes go through errorBody() so the error shape has exactly one definition. Two name collisions are gone β€” mcp/protocol.ts jsonError (an MCP tool result, not an HTTP response) is now toolError, and onchain.ts's local proxyJson no longer shadows proxy-router.ts's export β€” and proxy-router.ts no longer uses a reversed argument order. The session Durable Object answered 404/426 with a plain-text body; it now answers with the JSON error envelope like every other route. Header values and status codes on every existing route are unchanged.

  • One time normalizer instead of five. The venue parser, the plot-time helper, the engine-time helper and the two on-chain helpers each implemented the same ms-to-seconds rule, and each rejected a slightly different set of values: a plot time of 0 was accepted by one helper and dropped by another, and a time past year 2100 was accepted by some and rejected by others. They now share one function, so a time the venue accepts cannot be rejected by the engine and vice versa. The venue parser keeps its ISO date-string support, which is the one shape the other planes never see.

  • The cached-datasets modal has one state, not three. The Topbar, the StatusBar and the Data Source Manager panel each owned a datasetsOpen signal for the same modal, so the copies could drift: opening it from one surface left the other two showing their own closed state. They now share one module-scope signal, like the mobile-sheet state.

  • One labelled-field component instead of two. The Data Source Manager and the Script Library each had their own sc-field wrapper, byte for byte identical. They now share ScField.

  • The cloud script list is newest-first, like the other backends. The Worker returned the scripts in whatever order the row scan produced, so the Script Library listed them differently depending on the backend.

  • A backend without version history no longer throws a special error. The service probed each backend for optional methods and then threw VERSIONING_UNAVAILABLE at the call site. The backends now declare what they can do, the service reads that declaration, and an unavailable history is an empty list (and null for a single revision) instead of a runtime error.

  • COOP flipped to same-origin β€” cross-origin isolation is on. Chromium sets crossOriginIsolated (and unlocks SharedArrayBuffer) only for COOP same-origin; the previously shipped same-origin-allow-popups pair could never isolate, so every HP rung above the transferable copy was unreachable in a real browser. No AXIS surface depends on a popup opener β€” the GitHub/GitLab device-flow popup is opened noopener,noreferrer and the token arrives by Worker polling (waitForDeviceToken), never via window.opener; the detachable editor and panel companion windows are same-origin (BroadcastChannel / direct DOM wiring) and stay in the same browsing-context group under same-origin. The flip moves COOP_VALUE plus every static host in lockstep (public/_headers, the SW offline shell, axis_pwa_server.py, both nginx configs; Vite/Bun servers import the map) and re-pins the three header tests. The capability probe stays the runtime source of truth: a tab that fails isolation keeps the copy path (hp Β· copy+gpu / HP-1), and the GitHub login is worth one manual pass on a deployed host.

Fixed

  • WebGPU is reported as what it is: selected, but with no painter. auto on a WebGPU-capable machine selected rung HP-4 and the chip read hp Β· sab+gpu β€” while no WebGPU renderer exists anywhere in the tree, so LWC painted and nothing said so. The chip now reads hp Β· …+lwc with a no painter yet β€” HP-4 reserved note whenever the probe outcome is webgpu (the rung and the selected renderer are still reported, only the painter word follows what actually paints), hp.get's dormant-overlay verdict says the same, and the Settings GPU select labels the option (no painter yet β€” rung only). No behavior changed β€” this only stops the UI from claiming a backend that does not exist.
  • The GPU overlay draws LWC's candle widths, not a look-alike fraction. Even with the anchor fixed, the overlay's body was 0.75 Β· barSpacing and its wick 0.125 Β· barSpacing, while Lightweight Charts computes the body with optimalCandlestickWidth(barSpacing, pixelRatio) (a spacing-dependent coefficient, a flat 3-device-pixel special case, and device rounding) and draws a flat one-CSS-pixel wick β€” leaving a visible fringe on every candle edge. src/hp/render/lwc-candle-width.ts mirrors LWC's formula verbatim; the overlay derives the pixel ratio from LWC's own pane canvas (not devicePixelRatio: LWC's canvas can be 1Γ— on a 2Γ— display) and passes CSS widths through the viewport, which the shader consumes as NDC half-width uniforms. Measured on the rendered chart: body 5 CSS px on both painters, wick 1 px on both (direct device-row runs, not column bucketing). The vertical mapping now also uses coordinateToPrice(h) so the GL viewport's bottom edge is LWC's price at h, not h βˆ’ 1.
  • The GPU overlay's anchor now reproduces LWC's own indexToCoordinate β€” no more doubled wicks. The overlay derived barSpacing as width / (to βˆ’ from) and anchored records at width βˆ’ (to βˆ’ i)Β·spacing, but LWC's visible range spans width / barSpacing βˆ’ 1 (half a slot inset on each side), its bar x is width βˆ’ (to βˆ’ i + 0.5)Β·barSpacing βˆ’ 1, and the pane content box is the pane canvas (554 px here, not the 555 a paneRect βˆ’ timeScale estimate produced). The result was a ~5 px shear across the pane β€” near-zero mid-chart, a couple of pixels at the edges β€” which read as "slightly wrong" overlaid candles. overlayViewport now recovers the real spacing from the range, folds the half-slot phase and 1 px inset into the right-edge anchor, and paneContentSize takes the geometry from the pane's own canvas. tests/hp/gpu-parity.test.ts pins the identity against LWC's formula to 1e-9 and the on-screen check (column-profile correlation + crops) went from 0.55 to 0.84 with the wick/body doubling gone.
  • The HP-2 hot ring now allocates when the wire client is constructed, so the sampler and the GPU overlay actually engage. IngestHostClient allocated the hot SAB inside connect(), but buildWire reads rawClient.hotBuffer synchronously right after createLiveWsClient β€” before any connect() β€” so the read was always null: startHotRingPaint never started, getLiveHotBuffer never exposed the ring, and both the HP-2 rAF copy-out and the HP-3 GPU overlay stayed dormant in the real app while the unit tests (which check after await connect()) stayed green. ensureHot() moved into the constructor (still gated on flag + isolation + SAB mode, still OOM-clamped to rung 1), and the regression is pinned by asserting hotBuffer is non-null before the first connect().
  • The GPU overlay places the hot ring as the chart's tail window, not as the whole series. The first production measurement (hp.get on a live 9 500-bar chart) showed ring.aligned: false with hotCount: 501, hotFrom: 0: the guard compared getBarsLength() against the engine snapshot's own index space (hotFrom + hotCount), which only ever matched when the snapshot happened to equal the chart window β€” the overlay therefore never painted on a real REST-loaded chart (the e2e staged equal counts and missed it). The ring is now placed by identity: the ring's newest bar must equal the chart's newest bar (tail time), ring slot j is chart index chartBars βˆ’ hotCount + j (ringWindowPlacement), and a viewport that starts left of the window clears instead of shifting candles β€” cold paging remains unwired, so zoomed-out views over more bars than the ring holds still draw nothing (LWC keeps painting). hp.get now reports ring.tailTime / ring.chartTailTime and a tail-identity aligned, and the @gpu e2e covers the production shape: a shortened snapshot clears the canvas (alpha 0) and a wheel zoom into the tail repaints it.
  • A background backfill can no longer fail an unrelated test's fetch assertion. tests/onchain-load-symbol.test.ts starts a loadSymbolData that queues a Data Source Manager walk; the job outlived the test and kept paging after later files had restored the real GeckoTerminal source, so whichever file installed a fetch mock next counted a stray api.geckoterminal.com call. That is why setScriptChartVisible > show does not run library() sources failed in roughly every other full run with nothing to do with indicators β€” and the same leak failed its sibling show while live… case instead. The case now resets the DSM in afterEach, the visibility describe resets multiplex rerun/live-bar timers in beforeEach, and the assertion prints the offending URL and stack instead of a bare counter.
  • A long session no longer leaks a client, a timer or a memo per key. Six module-level maps grew with the number of distinct keys a session touched and nothing ever evicted them: the engine WebSocket clients (one socket plus its pending map per endpoint), the data-session series memos (one per endpoint + series), the dismissed job ids (one per job, forever), the pending live-bar writes (one per source + symbol + interval), and the bar-close verifiers (one per engine + series). They now share src/utils/bounded-map.ts and are capped. Two of them needed more than a cap: an evicted WebSocket client is closed so the socket does not outlive it, and an evicted pending live bar is flushed rather than dropped β€” it is still a bar the dataset should receive.
  • A Worker route can no longer be reached with an uncapped body. Four routes read the request body outside the shared cap β€” /api/keys created a key from an unbounded body, /api/git/oauth/* parsed the device-flow body unbounded, the MCP bridge forwarded an unbounded invoke payload, and the MCP plane carried its own hand-rolled copy of the byte check. All four now go through the shared capped reader and answer 413 PAYLOAD_TOO_LARGE for an over-cap body. A truncated device-flow body used to look like a request that carried no client id; it is now an explicit 413.
  • Live indicators no longer drift away from the candles on the stream path. The stream plane replicates the engine's series (the wire has no client-side tick), but the stream branch returned before the engine-owned branch that ticks the chart's engine data session β€” so on the stream path nothing drove the engine and its series froze a few bars behind the venue. The script sessions that evaluate over that series froze with it, while the chart kept advancing from the venue, and the apply path tail-aligned the stale values onto the chart's tail: the indicators were displaced by the freeze gap (measured live: 38 bars after 38 minutes, growing with every new bar) and the newest bars carried no indicator value at all. The stream path now drives the engine's series on every venue flush (the series-drive module existed for exactly this but was never wired in), and the re-run awaits that drive so the scripts evaluate against the freshly driven series.
  • Re-apply is no longer skipped once the rolling window slides. The dedupe compared only the bar length, but the rolling window slides with a constant length β€” so every reapply after the first looked "unchanged" and was silently skipped: the UI re-run button and the MCP indicators.run capability became no-ops, and the indicators kept the window they were last computed on while the candles moved on. The guard now compares the window identity (head bar time + length): a repeated call on the same window is still a no-op, a slid window re-runs. indicators.run also reports how many scripts actually re-ran (reapplied).
  • Strategy events stay on the chart on every tick instead of flickering on/off. Silent live re-runs replaced all trade markers for a strategy with only the events from the current evaluation, so entry/exit labels disappeared between bar closes and only reappeared on the next close. Silent re-runs now merge new markers with existing ones (same-time markers are replaced, others are kept), so strategy actions are always visible; interactive runs still replace wholesale. Also live.rerunOn is always honored β€” every-tick (the default) is no longer silently downgraded to bar-close based on engine capability probing.
  • A new candle now advances the chart instead of freezing it. appendLiveBar painted every genuinely new bar (appended) with series.update, which can only overwrite the last bar in place and never adds a time slot, so the chart stuck at the last loaded time and each new candle rewrote that same slot (the right edge stayed at 11:30 while the price there changed). New bars now go through the same full-window setData path as a window slide, so the candle lands at the right edge within rightOffset without auto-scroll; forming-tick updates (updated, same time) still use the O(1) in-place path.
  • The engine flush names every local dataset before it clears anything. The purge list was built after the memory mirror had been wiped, so IndexedDB-only series (the mirror keeps 48) were never deleted on the engine, and the result still said they had been. Settings and engine.flush now capture memory and IndexedDB keys first, delete those engine series, then wipe the local stores. A list that cannot be read is reported as a failure instead of a clean delete.
  • A page that starts during a flush cannot put the series back. The generation check ran only when a write was queued, and a bars-cache put that had already copied its batch could commit after the clear. The flush bumps that generation and clears the mirror after the removes, after the remote deletes, and after the cache wipe. A write that loses the race deletes its key, and a batch copied out before the clear is dropped.
  • Spot and another market no longer share one dataset. Reads and writes never passed the provider lock, so linear and inverse bars merged into the public spot series while the engine key used a different market. Non-spot and keyed series now append |market|authMode. The engine wire still maps linear to swap and inverse to future at that boundary only.
  • A Data Manager job follows the dataset that was loaded. Backfill and apply-when-complete used the chart's current market, so a linear row grew the spot series and could paint onto a chart that had since switched market. The job keeps the selection's lock, and a completed job does not paint when that lock is no longer the chart's.
  • An IndexedDB read failure no longer looks like an empty dataset. getCachedBars returned [] on a failed read, and the next page merge replaced the series. A failed read now throws and the write is refused. A genuine miss, and a browser with no IndexedDB, still look empty.
  • Replacing a dataset keeps a DEX pool address's case. The replace path uppercased the stored symbol while the write path did not, so list and delete rebuilt a different key for an address that contains : or /.
  • A dataset repaint no longer drops chart bars the dataset is missing. The merge kept only bars strictly newer than the dataset tip, so a same-time forming candle jumped backward and an interior hole-fill was punched back out. Equal times keep a closed dataset bar over a forming chart bar, and otherwise keep the chart bar.
  • A script subscription grows with the chart window. history_bars was sent only on the first subscribe, which usually ran against the short first paint, so the indicator stayed blank on the rest of the window. The depth is part of the subscription identity and is the chart window even when the store is still shorter. A script resubscribe does not cool the series socket.
  • A new candle no longer shifts the indicators. Every forming tick pushed the open bar's time onto the candle axis again, so the script series was one row per tick instead of one row per candle. The next candle repainted that series onto a longer index than the price pane, and the plots jumped. A forming tick now keeps the time it already recorded, and a new candle extends the axis by one bar.
  • Zooming while the chart loads no longer blanks the candles. A history replace paints the new candles while volume or an indicator still holds the previous times. That frame throws inside the chart library, and the price series stays empty until the next bar updates both. Those extra times are dropped before the candle replace, and a zoom already in progress is not fitted away.
  • Donchian keeps moving after a new candle. The live snapshot stored the shorter history the subscribe asked for, while the session and every later update were numbered from the longer series. Each new candle was rejected, so the channel stayed where the load left it and drew as a single point out to the last bar. The snapshot now counts the plots it actually contains, and a bad time axis no longer drops the value update.
  • Bollinger %B no longer repaints when a candle closes. The live script snapshot carries bar times, and the deltas that follow carry only the new values. The apply path dropped that axis and zipped %B onto the candles by index, so each new candle moved every closed value onto the next bar. The price series was never moved. Closed %B now stays on the bar it was computed for, and only the new candle takes the new value.
  • Pine plot colors are no longer overwritten by the palette on re-run. The legacy plots[] overlay path dropped the engine's plot_meta.color, so any script evaluated without named series fell back to the palette instead of the script's color. It now uses the same precedence as the named-series path: explicit panel pick (custom) β†’ Pine plot_meta.color β†’ palette. Auto-persisted (non-custom) colors no longer shadow a fresh script color, matching the earlier named-series fix.
  • Re-apply runs only once at a time. Multiple call sites (load-symbol Γ—4, DSM, store, MCP) could fire during a single load, each re-running every script and flashing the chart through intermediate states. A guard now skips concurrent reapply passes; stillCurrent still supersedes stale ones.
  • Script colors survive a reapply when plot_meta.color is missing. The re-run store path previously fell to PLOT_PALETTE[colorIdx] when the engine didn't ship a resolvable plot_meta.color, wiping Pine colors (e.g. #6366f1) to palette colors on every reapply. It now keeps the stored color when the fresh run doesn't ship one.
  • Chart overlays preserve stored script colors when plot_meta.color is absent. buildOverlayPayloads fell to the palette on the chart (even though the script panel stored the correct color) whenever the engine response lacked plot_meta.color β€” e.g. batched reapply. It now falls back to the stored (non-custom) color before the palette, across named-series, legacy plots[], and OHLC overlay paths. Precedence: explicit panel pick (custom) β†’ Pine plot_meta.color β†’ stored color β†’ palette.
  • Fill bands render from plot_meta when no fill series array exists. resolvePlotFillBands only scanned series entries, so fill(plot1, plot2, color=...) bands had no band to draw β€” fills are derived from edge plots, not a separate series. It now also scans plot_meta for fill entries and resolves their Upper/Lower edge series.
  • Re-apply runs only once per bar window. Multiple call sites (load-symbol Γ—4, DSM, store, MCP) fired sequentially during one load with the same bars, each flashing the chart through intermediate states. reapplyChartScripts now skips when store.bars.length hasn't changed since the last pass.
  • Stale session/stream pushes no longer pin or freeze the chart. A live push whose engine shipped no bar-time axis over a moved window (gap fill, backfill, slide while the tab was idle) was index tail-pinned onto the chart tail, and Lightweight Charts spans the resulting na gap as a long diagonal from the last pre-break value to the tip. Those pushes are now skipped instead (staleNoAxis in src/indicators/apply-context.ts, live pushes only β€” fresh full runs keep the tail-pin), so the last good plots stay on screen until a full run with an axis arrives. The same gap left the pane frozen instead when the values-only delta salvage dropped the axis permanently: the wire now resubscribes once per snapshot for a real full (axisResubPending in src/engine/live-wire.ts) instead of painting unpaintable state on every tick, and a seq gap resubscribes instead of force-merging. Pinned by staleNoAxis alignment tests, skip/paint live-push run tests, and wire resub-vs-paint tests (tests/runner-engine-owned-apply.test.ts, tests/engine/live-wire.test.ts).

Desktop installers (Tauri 2). CLI tarball, worker bundle, and checksums attach from the Release workflow.