Repository navigation
Conversation
The scene store's history bookkeeping lived in module globals: pause depths and leases, the commit bus and its pending commit, commit node-id scopes, gesture drafts, the hydration boundary, the space-detection pause and the batched dirty-mark frame. A second store would pause, commit to and draft in the editor's. Each of these is now a slot of a SceneEnvironment. `createSceneStore` (exported) binds a store to one; its actions, writes and undo run inside it, and functions that take a store use that store's. The editor's store is `createSceneStore(liveSceneEnvironment)`, and module-level calls fall back to the live environment, so existing callers are unchanged. Disposing an environment makes every later read or write of its store throw. Dirty marks fall back to a microtask where there is no animation frame (headless), instead of throwing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Derived document writes were spread across viewer-mounted systems: room structure in the space-detection subscription, stair rises and owned floor openings in the stair and elevator opening systems (a microtask after the commit), and wall-supported roof elevation in the roof system (another microtask, publishing a second commit). Anything that applies an edit outside the editor, or takes a patch right after one, missed the deferred writes; applying that patch in the editor then produced unreceipted follow-up commits. `reconcileDocument(store, before)` runs all of them in one synchronous pass to a fixed point: the structure of the levels whose structure changed, stair rises, stair and elevator openings (re-deriving the levels they cut), then roof elevation. The live systems plan with the same functions (`planRoofElevationUpdates`, `isStairOpeningInput`, `isElevatorOpeningInput`, `planOwnedFloorOpenings`), so after the pass they find nothing to write. Support queries were bound to the singleton spatial grid, which reads the editor's store and its live previews. `SpatialGridManager` now reads a source; the singleton keeps the live one, and `createSnapshotSpatialGrid` indexes a fixed set of nodes without previews. Support helpers read `activeSpatialGrid()`, which `withSpatialGrid` scopes; reconcileDocument queries a grid built from the store it reconciles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new `@pascal-app/core/runtime` subpath lets a computation run on its own
copy of the scene and hand back a patch, without touching the editor's
store, history, commit bus or subscribers.
- `openDetachedScene(snapshot, { base, context, ids })` loads a canonical
(already migrated) snapshot as it is: no migration, healing or
normalisation runs again, so loading never closes level gaps or re-cuts
openings. The scene owns a store, environment, history and a support
index built from its own nodes, and checks the node registry against the
versions captured in its ExecutionContext.
- Ids: `generateId` (and so every schema default) reads a synchronous
allocator scope, `withIdAllocator`; `effectIds(effectId)` mints
`prefix_` + 16 base-36 characters of SHA-256(effectId:n), so recomputing
an effect mints the same ids.
- Reads: the scene's `DocumentReader` records each read as a Dependency:
node fields, entity queries with their (possibly empty) results, ordered
children where order is read, collection fields, materials, plugins;
hosts declare file, artifact and scope dependencies. Views are read-only
and per record version (identity-keyed memos stay correct); `untracked`
marks copy-through, `materialize` hands results out of the scene.
- Writes: `host()` applies an outcome as the editor's actions do and
settles it with `reconcileDocument`, reading through the recorder;
`execute(operation, input, context)` runs a shared operation end to end.
While a host settles, the editor's own derived writers stand down
(`runSettlingDocument`), so derived ids are minted once, in one order.
- `patch()` is the field-level SceneOperationPatch since the snapshot:
creates and deletes with their positions, changed and removed fields,
materials, collections and plugin state, a parent's children only when
reordered; it is checked by applying it to the base.
- `dispose()` makes every later read or write throw, including from
views and hosts captured earlier and from late async continuations.
`liveSceneRuntime` is the editor's store behind the same interface.
The comparison contract is tested over every shared-tool and add_object
fixture (195 cases): from the same canonical snapshot, context, id seed
and registry, the detached run and the live run (document systems
mounted) give identical patches and results, nothing writes after the live
call, and the detached patch applied to the editor lands on the same
document with no further write. No fixture is excepted, including the
duplicate_level gap case the live runners skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a detached scene, an operation that enumerates `nodes` records a read
of every node, so any node created meanwhile invalidates it. The shared
operations and the helpers they and the document passes reach now select
what they read:
- `queryNodes(nodes, { type, parentId })` and `queryCollections(...)`
record the selection (absent matches included) through the scene's
reader, and filter plainly elsewhere: levels (`levelsOf`, storey
elevations), buildings and sites (`add_level`), walls, columns and
stairs counted for default names, a level's walls, rooms and children
(`create_room`, `furnish_room`, `place_items`, floor fit), door
keepouts, and the owners, stairs and roofs the opening, rise and roof
passes plan for.
- `fit_stair` shares risers out in flight order, so the sizing reads the
stair's children through `orderedChildIds`, recording the order.
- `edit_collection` looks a collection up by template or name as a
collection query (so a collection created meanwhile with that name
conflicts) and copies the fields it does not set through `untracked`,
so a rename does not depend on the colour.
Behaviour on the editor's store is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies a patch computed on a detached scene to the scene as it is now, or says what changed meanwhile so it cannot be: - every dependency is compared between the computation's base and the current document: node fields and presence, entity-query membership (absent matches included), ordered children, collection fields, materials, plugins, scopes (a level, or the whole document), and files and artifacts through host lookups; - every field the patch writes must not have been changed meanwhile to another value (node fields, collection fields, materials, plugin state); a deleted node must be as it was; - otherwise the patch lands field by field: fields others changed on the same node or collection are kept, and children created independently under the same parent each keep their place after the sibling they followed. Only that parent-list bookkeeping is exempt; a list whose order was read is a dependency. Returns the next document and the patch rebased onto it, or the changed entities (kind, id, field, reason). Tests cover isolation (two scenes and the editor never see each other's writes, commits, history or subscribers), per-scene undo, disposal (including a late async write), deterministic ids, recorder cases (fields vs copy-through, absent query matches, order), the conflict table (stair flights reordered while re-sized, independent insertions on one level, rename beside a recolour, two renames, a collection created by name, two levels added, field merge and clash) and roof reconciliation parity: a detached wall edit carries the roof it supports, and the editor writes nothing after the patch, while the same patch without the roof update draws the roof system's own extra commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Grit rule (Biome plugin) on `packages/core/src/agent-operations` refuses `Object.values / keys / entries` of a `nodes` or collections record: on a detached scene that reads every entity, so anything created meanwhile conflicts. Operations select through `queryNodes` / `queryCollections`. The read-only operations that genuinely read the whole scene (verify_scene, find_by_type, scene views, furnish_room's keep-outs) say so in a biome-ignore comment; list_collections now lists through a collection query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two things kept reconcileDocument from running where no editor (and no built-in node plugin) is loaded, such as a bare MCP server: - the structure signature formatted a wall's height with `toFixed`, so a legacy node with a non-numeric height threw; it now signs it as is; - stair rise sync elected the stair's support through its registered floor-placed footprint, so without the built-in stair kind every stair rose from the level base; it now falls back to the stair's own support query (`stairBaseElevation`) there. A record view's raw record is reachable through `untracked`, so copying a whole record through (as a write-back or a before-state) is not a read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`new SceneBridge({ runtime })` binds the bridge to a runtime from
`@pascal-app/core/runtime`: the editor's store by default (unchanged for
the MCP server), or a detached scene a host opened. Core does not import
the MCP; the host constructs the bridge.
The bridge now reads through the runtime's reader (recorded on a detached
scene; `findNodes` selects through a query), and every write, undo and
derivation runs in the runtime's scope: its history environment, its id
allocator, its support index and its disposal guard. After each edit it
settles the document with the runtime's `reconcile`, the core
`reconcileDocument`, instead of its own partial copy (owned openings and
level structure only): stair rises and roof elevation now settle in the
MCP as they do in the editor, so a stair built between two storeys
reaches the upper one and verify_scene no longer reports it.
The shared-tool runner and edit_collection plan their operations inside
the scene's scope (`SceneOperations.runInScene`), so their ids come from
the runtime's allocator, and copy records through `untracked`, so the
operation's own selections are what a detached scene records.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The standalone history case mounts the stair opening system without the built-in stair kind. Its stair stood over the baseline slab, and the flight rose from the level base only because, unregistered, nothing elected the slab. Now that rise sync falls back to the stair's own support query there (as the registered kind elects it), the stair is placed off the slab so the case keeps testing what it tests: history restoring derived flight heights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`compileAndStoreGeometryScript` read the live scene and minted a new object's id after awaiting the compile. On a detached scene that bypasses its reader and its synchronous id scope, and a compile resolving after the scene is disposed would read the editor's store instead. It now takes the scene's nodes and the artifact service to use (the editor's by default) and, before its first await, reads the node it rebuilds, its stored script hash and its metadata, and mints the new object's id. A compile for a detached scene called inside `scene.run` records those reads there and allocates the id from the effect's ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `wiki/architecture/scene-runtime.md`: per-store history environments, detached scenes (canonical loading, scope, ids, patch), tracked reads and their rules, complete reconciliation, applyPatchWithDependencies, the comparison contract, and the MCP bridge over a runtime. - agent-surfaces: operations select what they read; computing off the live scene on both surfaces. - systems, vertical-model: reconcileDocument is the synchronous form of the stair, elevator and roof systems' writes; the headless stair rise. - DECISIONS.md E-017: agent computation runs on a scene runtime, with what enforces it. - review-architecture reads the new page for diffs to operations, stores, derived writers and the runtime; the MCP README shows the bridge over a detached scene. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from review of the detached runtime: - A host's commit carries what it settled. `runAsSettledSceneStep(store, run)` makes one undo step and one commit of everything `run` changed: its own writes and the history-paused derived writes reconciliation makes after them (`settlePendingSceneCommit`), with the derived ids among the changed ids. Before, an agent host's commit was captured at its first write: raising a wall moved its roof, but the commit carried the old Y and only the wall. `createAgentHost` writes through it. - Live previews stay out of computations. Inside a detached scene's scope and inside `reconcileDocument`, `withoutLivePreviews` hides field overrides and an in-flight terrain stroke (`terrainFieldOf`, `getEffectiveNode`): an uncommitted stroke raised a detached roof. - A detached scene owns its data: it loads a deep-frozen copy of the snapshot and freezes what its writes produce, so a nested array reached through its reader can no longer change the caller's snapshot, the editor's store, or its own patch baseline. - Writes the store's actions plan beyond the changes asked for (default gutters, merged walls, sanitised values) read the scene directly; they now declare a scope dependency on every level they and the changes touch (`declareActionDependencies`). `SceneRuntime.declare` is optional: the editor's store records nothing. - A level scope also covers the level's building and site: committed terrain under a level now conflicts with a support read on it. - Materials returned by a material query are recorded one by one. - Selections over an earlier version of the nodes record (`queryNodes`, `orderedChildIds` on a view) read that version, not the current one. Each comes with a test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ementally Measured on generated multi-storey scenes (Bun, synchronous cost of one edit through `createAgentHost(liveSceneRuntime)` with the editor's document systems mounted, p50/p95 ms): | scene | edit | origin/main | before | after | |---|---|---|---|---| | 2.9k nodes | wall | 141.8/158.6 | 229.8/243.2 | 103.4/110.3 | | 2.9k nodes | stair | 80.6/98.6 | 175.6/223.4 | 73.6/77.4 | | 2.9k nodes | roof | 63.3/74.8 | 156.1/175.5 | 30.0/34.7 | | 10k nodes | wall | 731.8/846.1 | 1164.9/1242.2 | 437.2/461.1 | | 10k nodes | stair | 450.9/500.1 | 999.2/1041.2 | 454.4/481.3 | | 10k nodes | roof | 431.6/457.4 | 903.2/969.7 | 158.4/209.5 | (origin/main: the store action plus the systems' deferred writes.) The editor's own path (an edit by hand, undo, a drag preview) is unchanged within noise; a drag preview writes nothing and reconciles nothing. - While a host settles, space detection rebuilt its whole topology index and every level's spaces on each write (`adoptSceneBaseline`). It now rebuilds only the levels whose walls the write changed. - The support index was rebuilt for every node-map version, slab by slab with a cache invalidation per insertion (S(S+1)/2 for S slabs on a level), and indexed item collision cells nothing in reconciliation reads. `supportIndexOf(store)` keeps one per store: bulk-loaded once (slabs, walls, ceilings), brought up to date in place from the nodes an edit changed (`syncSnapshot`), rebuilt only on a change of kind or level or a large batch; item cells are built on the first placement query. - Structure levels are chosen as space detection chooses them for a commit (`structureLevelsForChange`), from the changed nodes rather than a signature of every level; a parent whose child list alone changed adds nothing, in that selection and in the passes' input checks. - The roof pass resolves only the roofs an edit can move (`roofsAffectedBy`: a changed roof, and roofs on a changed wall's or slab's level and the level above), not every roof in the scene. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from review of the bridge over a runtime: - Every bridge write (create, update, delete, apply_patch, derivation, `runAsSingleHistoryStep`) is a settled step: one commit and one undo step carrying the derived construction it settles, with derived ids. - Writes the store's actions plan beyond the patch asked for declare their levels as scope dependencies on a detached scene. - `createPascalMcpServer` starts every tool handler in the scene's scope, so ids minted and schema defaults filled before a handler's first await are the scene's (create_unit, create_roof). add_object and add_column mint a new object's id before awaiting the compile and re-enter the scope to plan; edit_collection and the shared-tool runner plan inside it. - `SceneOperations.runInScene` is optional: a host's own implementation without it keeps working, with unscoped (random) ids; the README says when a host must provide it. - `exportJSON` reads materials and the plugin list through the runtime's reader, so an export on a detached scene records them. Tests: the same effect recomputed through create_unit, create_roof, add_column and add_object on a real server gives the same patch; a wall edit and the roof it moves land in one commit and one undo step; a custom SceneOperations without runInScene serves the shared tools; an export records its material and plugin reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scene-runtime page covers settled commits, owned read-only data, hidden live previews, action-planned writes and level ancestors as dependencies, the per-store support index and incremental level choice, roof scoping, and the scene scope of MCP handlers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from the second review of the detached runtime, each with a test that fails without it: - A planner that decides to write nothing was still read: a detached roof-segment widening with gutters disabled recorded no dependency, so gutters enabled meanwhile (8.52 m) survived the 12 m roof. Changes that run a store-action planner (roof, roof-segment, lean-to-extension, gutter, downspout and ridge-vent writes, deletions, a wall's curve) now always declare their level scope. - The roof pass missed two inputs. A roof's own child list is one (a segment added over a taller wall moves the roof); a segment edit selects its roof; and a slab change selects every roof, since a slab carries the walls on its level, bounds plane-bound wall tops below, and a base plate lifts the storeys above. - An upper slab's `recessed` is part of the covering underside below, so its change now re-derives the level below (also in the editor's own space detection): the ceiling under it is clamped as complete reconciliation clamps it. - What a detached scene is given to write is copied before the scene freezes it: the caller's nested objects stay the caller's. The scoped-versus-complete tests run the same edit settled as an edit and settled whole (every level, every roof) and require the same document. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- add_door and add_window await their (possibly absent) compile before
planning; the planning now re-enters the scene's scope, as a rebuild of
either does, so a detached scene recomputed from the same effect mints
the same door and window ids (tested through the registered handlers).
- Operations built over a store alone (`createSceneOperations({ store })`)
run `runInScene` callbacks as they are: list_scenes no longer fails with
`scene_bridge_unavailable` under the server's scene scope.
- Bridge writes copy what they are given before a detached scene freezes
it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Planner inputs as dependencies, copied writes and what disposal does to values already handed out, and the roof pass's selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nner Follow-ups from the third review: - A slab change made the roof pass resolve every roof in the scene: slow with many roofs, and every roof read became a dependency, so a slab edited in one building conflicted with a wall renamed in another. A slab now selects the roofs on its own level (it carries the walls there), the level below (it caps plane-bound wall tops there) and every level above it in its building (a base plate lifts them). The scoped-versus-complete slab test still holds; a new test applies a slab edit in one building beside a concurrent rename in another. - A roof segment's planners (default gutters, ridge vents) run on the fields they read; an edit touching none of them (a rename) no longer declares its level a dependency, so it merges with a same-level edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`setScene` (nodes, roots and extras) and `setCollections` now copy their inputs before writing, as the other bridge writes do: a detached scene froze the caller's metadata and collection arrays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 71cbe07. Configure here.
| ] | ||
| for (const levelId of levels) topologyIndex.rebuildLevel(levelId, state.nodes) | ||
| if (levels.length) publishSpaces(levels, state.nodes, editorStore) | ||
| previousNodes = state.nodes |
There was a problem hiding this comment.
Settling skips space-detection baseline
High Severity
The new settling branch never runs for a host that settles through reconcileDocument. Those writes pause history, so the subscriber returns earlier and never advances previousNodes. After an agent or MCP edit on the live store, the next hand edit treats the whole settled change as new structure input, which can re-derive rooms and plates.
Reviewed by Cursor Bugbot for commit 71cbe07. Configure here.
| const [levelId] = args | ||
| if (typeof levelId === 'string' && store.getState().nodes[levelId as AnyNodeId]) | ||
| recorder.declare({ kind: 'scope', level: levelId }) | ||
| return value.apply(target, args) |
There was a problem hiding this comment.
Support scope records the wrong id
Medium Severity
The support-grid recorder treats every method's first argument as a level id. canPlaceOnCeiling passes a ceiling id, so the apply check looks for nodes whose level is that ceiling and misses concurrent items on the same ceiling or storey. Two ceiling placements can both land on top of each other.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 71cbe07. Configure here.


What does this PR do?
Adds an instance-scoped, detached scene runtime, so an agent tool can compute on its own copy of the scene while the person keeps editing, then apply the result as a field-level patch only if nothing it read has changed. Decision E-017; architecture page
wiki/architecture/scene-runtime.md.@pascal-app/core/runtime(new export):openDetachedScene(doc, { base, context, ids })loads a canonical snapshot (no second migration) into an isolated store.DocumentReader.effectIds/withIdAllocator) and produces a complete field-levelpatch().dispose()makes every later read or write throw.applyPatchWithDependencieschecks the recorded dependencies against the live store and either applies the patch in one undo step or returns a conflict saying what changed.reconcileDocument: one synchronous pass for every system that writes saved fields (room structure, stair rises, stair and elevator openings, roof elevation), shared by the live editor systems and detached scenes. Its support index is built once per store and updated incrementally. Room detection and roof recomputation are scoped to the levels and buildings an edit can affect.agent-operations/.new SceneBridge({ runtime }). Handlers run in the scene's scope, so ids are deterministic, includingadd_door,add_window,add_objectandadd_columnacross their awaits. The bridge now settles stair rises and roofs as the editor does.SceneOperations.runInSceneis optional; the README says when a host should provide it.compileAndStoreGeometryScript(input, { nodes, artifacts })reads the scene and allocates its id before the first await.Behaviour and performance
mainwithin noise, on generated scenes of ~0.5k, 2.9k and 10k nodes.main. For example, a 10k-node wall edit runs at p50 429 ms against 732 ms on main.How to test
bun --cwd packages/core run test: the runtime suites cover isolation, disposal, deterministic ids, the recorder and the conflict table (stair reorder conflicts; independent same-level inserts and cross-building edits merge; collection rename + recolour merge; two renames conflict). Parity: every agent-operation fixture yields an identical complete patch detached and live, then no deferred write; scoped and full reconciliation match.bun --cwd packages/mcp run test: derived changes ship in one commit (raise a wall, its roof moves in the same commit), and handler ids are deterministic.tsgo --noEmitper package.Checklist
bun devbun checkto verify)mainbranch🤖 Generated with Claude Code
Note
High Risk
Touches agent operations, document reconciliation, spatial support, and live-vs-detached parity—core paths for AI edits and derived geometry—with broad test coverage but high blast radius if apply or reconciliation diverges.
Overview
Introduces E-017 and a new
@pascal-app/core/runtimeexport so agent tools can compute on an isolated detached scene (own store, history, deterministic ids viaeffectIds) and land results on the live editor only throughapplyPatchWithDependencies, which compares recorded reads/writes against the current document and rebases or conflicts.The runtime adds a tracked
DocumentReader,queryNodes/queryCollections(replacing ad-hocObject.valuesscans in shared agent operations), patch diffing, andreconcileDocumentso derived writes (rooms, roofs, openings, support) settle synchronously on both detached and live paths—backed by spatial-grid snapshot sources, per-environment space-detection pause/settling, and a Biome rule against raw scene enumeration inagent-operations/.Docs/skills point reviewers at
scene-runtime.md;packages/core/package.jsonexports./runtime; extensive parity and detached-scene tests lock detached vs live behavior and the conflict table.Reviewed by Cursor Bugbot for commit 71cbe07. Bugbot is set up for automated code reviews on this repo. Configure here.