diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000000..4322f2791b
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,27 @@
+{
+ "name": "pascal",
+ "interface": {
+ "displayName": "Pascal"
+ },
+ "plugins": [
+ {
+ "name": "pascal-agent-skills",
+ "version": "0.1.8",
+ "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.",
+ "author": {
+ "name": "Pascal",
+ "email": "support@pascal.app",
+ "url": "https://pascal.app"
+ },
+ "source": {
+ "source": "local",
+ "path": "./"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/.agents/skills/open-pr/SKILL.md b/.agents/skills/open-pr/SKILL.md
index d3492d7139..35958c71b9 100644
--- a/.agents/skills/open-pr/SKILL.md
+++ b/.agents/skills/open-pr/SKILL.md
@@ -1,6 +1,8 @@
---
name: open-pr
description: Open a pull request on pascalorg/editor using the repo's PR template. Use when the user asks to open/create a PR, push and PR, or ship a branch in the editor repo.
+metadata:
+ internal: true
allowed-tools: Bash(git *) Bash(gh *) Read
---
diff --git a/.agents/skills/open-pr2/SKILL.md b/.agents/skills/open-pr2/SKILL.md
new file mode 100644
index 0000000000..8eed1f1f47
--- /dev/null
+++ b/.agents/skills/open-pr2/SKILL.md
@@ -0,0 +1,216 @@
+---
+name: open-pr2
+description: Open or update a pull request on pascalorg/editor with a plain-language issue-and-fix description based on the full branch diff. Use only when the user explicitly asks for OpenPR2 or /open-pr2.
+metadata:
+ internal: true
+disable-model-invocation: true
+allowed-tools: Bash(git *) Bash(gh *) Bash(bun *) Read
+---
+
+# OpenPR2
+
+Open or update a pull request against `pascalorg/editor` from the current branch. Keep the repository's PR template, but write the body like one developer explaining the change to another.
+
+## 1. Pre-flight
+
+Inspect the working tree and the whole branch before writing anything:
+
+```bash
+git status
+git branch --show-current
+git log --oneline main..HEAD
+git diff --stat main...HEAD
+git diff --name-status main...HEAD
+```
+
+Read the relevant parts of `git diff main...HEAD`. Do not build the description from the latest commit alone or from conversation memory.
+
+Stop if:
+
+- The current branch is `main`. Ask the user to create a feature branch first.
+- The branch has no commits ahead of `main`.
+- There are uncommitted changes the user has not asked to commit.
+
+For a non-trivial change, run checks that match the affected packages. Prefer focused tests plus:
+
+```bash
+bun run check-types
+bun run build
+```
+
+Do not open a PR when a required check fails. Report the failure instead. Do not claim that a command or manual test passed unless it was run.
+
+## 2. Read the current PR template
+
+Read `.github/pull_request_template.md` every time. Its headings and checklist wording are the source of truth.
+
+Keep the template headings in the same order:
+
+1. `## What does this PR do?`
+2. `## How to test`
+3. `## Screenshots / screen recording`
+4. `## Checklist`
+
+Do not replace them with `Summary`, `Details`, `Validation`, or custom headings unless the template itself changes.
+
+## 3. Write the title
+
+- Keep it under 70 characters when practical.
+- State the result, not the activity. Prefer `fix(editor): keep curved room slabs attached` over `update wall files`.
+- Add a package scope when one package clearly owns the change, such as `core:`, `viewer:`, `editor:`, or `mcp:`.
+- Avoid vague verbs such as `improve`, `enhance`, `update`, or `refactor` when a concrete verb fits.
+
+## 4. Write the body in plain language
+
+### What does this PR do?
+
+The reviewer should understand every changed behavior without opening the diff. Do not compress unrelated fixes into a paragraph or a long bullet.
+
+Give each problem its own short item. Use this exact shape:
+
+```markdown
+- **Short feature or problem name**
+ - Issue: One short sentence describing what was wrong or missing.
+ - Fixed: One short sentence describing the behavior after this PR.
+```
+
+Add one more indented sentence only when the reviewer needs an important constraint, risk, or design decision. Keep it short and do not add labels such as `Details`, `Technical`, or `Implementation`.
+
+Example:
+
+```markdown
+- **Curved triangular rooms**
+ - Issue: Slabs and ceilings kept a straight corner after curving a wall.
+ - Fixed: Both surfaces now rebuild from the curved room boundary.
+
+- **Wall and fence thickness**
+ - Issue: Thickness could only be changed from the settings panel.
+ - Fixed: Each face now has a circular thickness handle in 2D and 3D.
+ - The centerline stays fixed, and the change uses one undo step.
+```
+
+Keep the item title concrete. Start with product behavior, not filenames or function names. Cover every meaningful user-visible fix on the branch. Combine items only when they describe the same problem and the same fix.
+
+Avoid this compressed style:
+
+```text
+This PR fixes curved wall topology, adds thickness handles, improves floor-plan previews, updates roof paint slots, and cleans up roof controls.
+```
+
+Link issues with `Fixes #123` or `Refs #123` when applicable. Never invent an issue number.
+
+### How to test
+
+Write numbered reviewer steps. Put the action on the numbered line and the expected result on a short indented line.
+
+Good:
+
+```text
+1. Create a triangular room and curve one wall.
+ - The slab and ceiling should follow the curved corner with no gap.
+
+2. Drag either wall thickness dot.
+ - The wall should stay centered while its thickness changes.
+```
+
+List automated commands only when they were run. Include pass counts when they are known and useful. Do not turn the section into a dump of every command used during development.
+
+### Screenshots / screen recording
+
+- Preserve any existing media verbatim when updating a PR.
+- For a visual or interactive change, add the supplied media. If none exists, write `Not added yet.`
+- For a non-visual change, write `N/A, no visual change.`
+- Do not claim that a recording exists when it does not.
+
+### Checklist
+
+Copy every checklist line from the current template verbatim.
+
+- Tick an item only when it is true.
+- `bun dev` is checked only after local runtime testing.
+- The code-style item is checked only after the requested style command passes.
+- Documentation is checked when docs were updated or when the item explicitly says it is not applicable. Otherwise leave it unchecked.
+- Confirm the actual base branch before checking the target-branch item.
+
+## 5. Human writing pass
+
+Before submitting, read the title and body once as a reviewer who has not seen the branch.
+
+Rewrite anything that fails these checks:
+
+- Use plain words and short sentences.
+- Say what the change does. Avoid phrases that could describe any PR.
+- Remove filler, hype, sales language, and chatbot phrases.
+- Remove repeated points and details that the diff explains on its own.
+- Avoid jargon unless the repository uses the term and the reviewer needs it.
+- Avoid forced lists, excessive bold text, em dashes, and long parenthetical asides.
+- Prefer active voice.
+- Keep a human rhythm. The body should not read like generated release notes.
+- Make every test step concrete and verifiable.
+
+If the summary sounds too small, add the missing problem or behavior. If it sounds dense, remove implementation trivia before shortening the explanation of the bug.
+
+## 6. Push and find the PR
+
+Push the current branch:
+
+```bash
+git push -u origin HEAD
+```
+
+Check whether it already has a PR:
+
+```bash
+gh pr view --json number,url,title,body 2>/dev/null
+```
+
+### No existing PR
+
+Create one with `gh pr create`. Pass the body through a quoted heredoc so Markdown stays intact:
+
+```bash
+gh pr create --title "
" --body "$(cat <<'EOF'
+
+EOF
+)"
+```
+
+### Existing PR
+
+Do not create another PR. Update the current one from the full branch diff.
+
+Before rewriting it:
+
+```bash
+gh pr view --json number,title,body,url
+git log --oneline main..HEAD
+git diff --stat main...HEAD
+```
+
+When rebuilding the body:
+
+- Preserve `Fixes #123` and `Refs #123` lines.
+- Preserve screenshots, recordings, links, and embedded images verbatim unless the user supplied replacements.
+- Preserve the user's checklist state for work that remains true. Never change an unchecked item to checked without evidence.
+- Keep extra reviewer notes that are still relevant.
+- Remove old claims and test steps that no longer match the branch.
+- Leave the title unchanged unless the branch's purpose clearly changed.
+
+Apply the update with `gh pr edit --body ...`. Change the title only when needed.
+
+## 7. Verify and report
+
+Read the PR back after creation or editing:
+
+```bash
+gh pr view --json number,url,title,body,baseRefName,headRefName
+```
+
+Confirm that the title, template sections, base branch, and body were saved correctly.
+
+Return:
+
+- PR URL
+- Title
+- Checks and tests actually run
+- Any unchecked checklist item or missing recording the reviewer should know about
diff --git a/.agents/skills/review-architecture/SKILL.md b/.agents/skills/review-architecture/SKILL.md
index df2ff85755..9c91267638 100644
--- a/.agents/skills/review-architecture/SKILL.md
+++ b/.agents/skills/review-architecture/SKILL.md
@@ -1,6 +1,8 @@
---
name: review-architecture
description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, the slots + world-scale-UV convention for new nodes/geometry, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture.
+metadata:
+ internal: true
allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob
---
@@ -121,7 +123,7 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de
- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`.
- **Node code does not import `useScene` directly.** A kind's geometry / system / tool should read and write through `SceneApi` (passed in by the framework) or `GeometryContext`. Direct `useScene.getState()` calls inside `packages/nodes/src//` are a smell — they bypass the registry's IoC point and make the code harder to test.
- **Live drag motion is imperative, not store-driven.** Tools must not call `useLiveTransforms.set(...)` per `grid:move` tick to animate registered parametric kinds — the selector path doesn't reliably re-render and the mesh visibly disappears mid-drag. Use `sceneRegistry.nodes.get(node.id)?.position.set(x, y, z)` instead, and commit once at the end via `useScene.temporal.getState().resume() → updateNode → pause()`. The reference implementation is `MoveRegistryNodeTool`. This is the *only* sanctioned use of imperative mesh transforms by a tool; flag any other location that does the same.
-- **Data-driven drags preview via `useLiveNodeOverrides`, never per-tick `useScene`.** A kind whose geometry is recomputed from data fields (wall `start`/`end`, opening host-cut, endpoint reshape) previews by publishing field patches to `useLiveNodeOverrides` (merged by `getEffectiveWall` / `getEffectiveNode`), writing the scene store **once on commit**. A tool that calls `useScene.updateNodes`/`updateNode` on `grid:move` (or any per-pointer-move tick) is a **blocker** — it swaps the `nodes` map ref and re-renders every `useScene(s => s.nodes)` subscriber app-wide each frame (`markDirty` per tick is fine). Grep tell: `updateNode(s)?(` in an `onGridMove`/`onMove`/`applyPreview` path under `packages/nodes/src//`. See `wiki/architecture/tools.md` § "Data-driven live drag".
+- **Data-driven drags preview via `useLiveNodeOverrides`, never per-tick `useScene`.** A kind whose geometry is recomputed from data fields (wall `start`/`end`, opening host-cut, endpoint reshape) previews by publishing field patches to `useLiveNodeOverrides` (merged by `getEffectiveWall` / `getEffectiveNode`), writing the scene store **once on commit**. A tool that calls `useScene.updateNodes`/`updateNode` on `grid:move` (or any per-pointer-move tick) is a **blocker** — it swaps the `nodes` map ref and re-renders every `useScene(s => s.nodes)` subscriber app-wide each frame. `markDirty` per tick is fine **for bounded gestures** (drag marks drain every frame); a `useFrame`/animation loop that marks dirty for as long as something animates is a **blocker** — the scene can then never settle to DIRTY 0. Animations signal rebuilds through their own records (`useInteractive` animations), marking dirty once on completion; see `wiki/architecture/node-definitions.md` § "`geometry` + `system`". Grep tell: `updateNode(s)?(` in an `onGridMove`/`onMove`/`applyPreview` path under `packages/nodes/src//`. See `wiki/architecture/tools.md` § "Data-driven live drag".
### D. Selector performance
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000000..f52ffc4303
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
+ "name": "pascal",
+ "version": "0.1.8",
+ "description": "Public Pascal workflows for MCP-capable agents.",
+ "owner": {
+ "name": "Pascal"
+ },
+ "plugins": [
+ {
+ "name": "pascal-agent-skills",
+ "source": "./skills",
+ "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.",
+ "author": {
+ "name": "Pascal",
+ "email": "support@pascal.app",
+ "url": "https://pascal.app"
+ },
+ "version": "0.1.8",
+ "category": "productivity",
+ "skills": ["./pascal-3d", "./furniture-fit"]
+ }
+ ]
+}
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
new file mode 100644
index 0000000000..777a39a15e
--- /dev/null
+++ b/.codex-plugin/plugin.json
@@ -0,0 +1,37 @@
+{
+ "name": "pascal-agent-skills",
+ "version": "0.1.8",
+ "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.",
+ "author": {
+ "name": "Pascal",
+ "email": "support@pascal.app",
+ "url": "https://pascal.app"
+ },
+ "homepage": "https://editor.pascal.app/docs/developers/mcp",
+ "repository": "https://github.com/pascalorg/editor",
+ "license": "MIT",
+ "keywords": ["pascal", "3d", "architecture", "mcp", "furniture", "spatial"],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "Pascal",
+ "shortDescription": "Build 3D scenes and check fit",
+ "longDescription": "Use Pascal's MCP tools to work with editable building scenes, validate and save results, and produce bounded furniture footprint reports with explicit evidence, limitations, and blocker-aware next actions.",
+ "developerName": "Pascal",
+ "category": "Developer Tools",
+ "capabilities": [
+ "Build and edit 3D scenes",
+ "Validate spatial layouts",
+ "Assess furniture footprints"
+ ],
+ "websiteURL": "https://editor.pascal.app/docs/developers/mcp",
+ "privacyPolicyURL": "https://editor.pascal.app/privacy",
+ "termsOfServiceURL": "https://editor.pascal.app/terms",
+ "defaultPrompt": [
+ "Use Pascal to inspect this building project, make the requested bounded edit, validate it, save it, and return the editor URL.",
+ "Check whether this furniture footprint fits in a measured Pascal room, including rotations, collisions, and door access."
+ ],
+ "brandColor": "#171717",
+ "composerIcon": "./assets/pascal-mark.svg",
+ "logo": "./assets/pascal-mark-plate.svg"
+ }
+}
diff --git a/.cursor-plugin/mcp.json b/.cursor-plugin/mcp.json
new file mode 100644
index 0000000000..06e0fd9155
--- /dev/null
+++ b/.cursor-plugin/mcp.json
@@ -0,0 +1,16 @@
+{
+ "mcpServers": {
+ "pascal": {
+ "type": "stdio",
+ "command": "pascal",
+ "args": ["mcp", "connect"]
+ },
+ "pascal-hosted": {
+ "type": "http",
+ "url": "https://editor.pascal.app/api/mcp",
+ "headers": {
+ "Authorization": "Bearer ${PASCAL_API_KEY}"
+ }
+ }
+ }
+}
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
new file mode 100644
index 0000000000..52c44b247c
--- /dev/null
+++ b/.cursor-plugin/plugin.json
@@ -0,0 +1,42 @@
+{
+ "name": "pascal-agent-skills",
+ "displayName": "Pascal",
+ "version": "0.1.8",
+ "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.",
+ "author": {
+ "name": "Pascal",
+ "email": "support@pascal.app"
+ },
+ "homepage": "https://editor.pascal.app/docs/developers/mcp",
+ "repository": "https://github.com/pascalorg/editor",
+ "license": "MIT",
+ "logo": "assets/pascal-mark-plate.svg",
+ "keywords": [
+ "pascal",
+ "3d",
+ "architecture",
+ "mcp",
+ "furniture",
+ "spatial"
+ ],
+ "category": "developer-tools",
+ "tags": [
+ "3d",
+ "architecture",
+ "floor-plan",
+ "furniture",
+ "mcp"
+ ],
+ "skills": "./skills/",
+ "variables": {
+ "type": "object",
+ "properties": {
+ "PASCAL_API_KEY": {
+ "type": "string",
+ "title": "Pascal API key (hosted)",
+ "description": "Optional. Create a key under Settings -> API keys at editor.pascal.app to reach your hosted projects, Pascal Capture scans, and shared workspaces. Leave it unset to use only the local Pascal CLI."
+ }
+ }
+ },
+ "mcpServers": "./.cursor-plugin/mcp.json"
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d75e7f96b..fce7f42723 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,9 @@ jobs:
- name: Lint & format check
run: bun run check
+ - name: Validate agent skills and plugin packages
+ run: bun run skills:validate
+
- name: Type check
run: bun run check-types
diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml
new file mode 100644
index 0000000000..9a813b6a88
--- /dev/null
+++ b/.github/workflows/mcp-registry.yml
@@ -0,0 +1,105 @@
+name: MCP Registry
+
+on:
+ pull_request:
+ paths:
+ - server.json
+ - .github/workflows/mcp-registry.yml
+ push:
+ branches: [main]
+ paths:
+ - server.json
+ - .github/workflows/mcp-registry.yml
+
+env:
+ MCP_PUBLISHER_VERSION: 1.8.1
+ MCP_PUBLISHER_LINUX_AMD64_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
+ with:
+ persist-credentials: false
+
+ - name: Install MCP Registry publisher
+ run: |
+ curl --fail --location \
+ "https://github.com/modelcontextprotocol/registry/releases/download/v${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \
+ --output "$RUNNER_TEMP/mcp-publisher.tar.gz"
+ echo "${MCP_PUBLISHER_LINUX_AMD64_SHA256} $RUNNER_TEMP/mcp-publisher.tar.gz" | sha256sum --check
+ tar -xzf "$RUNNER_TEMP/mcp-publisher.tar.gz" -C "$RUNNER_TEMP" mcp-publisher
+
+ - name: Validate MCP Registry manifest
+ run: "$RUNNER_TEMP/mcp-publisher validate"
+
+ - name: Verify hosted endpoint contract
+ run: |
+ jq --exit-status '
+ (.version | strings | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))
+ and .remotes == [{
+ "type": "streamable-http",
+ "url": "https://editor.pascal.app/api/mcp",
+ "headers": [{
+ "name": "Authorization",
+ "description": "Bearer API key created in Pascal Settings, formatted as Bearer sk_live_...",
+ "isRequired": true,
+ "isSecret": true
+ }]
+ }]
+ ' server.json
+ curl --fail --location --output /dev/null \
+ https://editor.pascal.app/docs/developers/mcp
+ status=$(curl --silent --show-error --output "$RUNNER_TEMP/mcp-response.json" \
+ --write-out '%{http_code}' \
+ --request POST \
+ --header 'content-type: application/json' \
+ --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registry-check","version":"1"}}}' \
+ https://editor.pascal.app/api/mcp)
+ test "$status" = "401"
+
+ - name: Verify live API catalog matches manifest
+ run: |
+ endpoint=$(jq --raw-output '.remotes[0].url' server.json)
+ version=$(jq --raw-output '.version' server.json)
+ transport=$(jq --raw-output '.remotes[0].type' server.json)
+ registry_name=$(jq --raw-output '.name' server.json)
+
+ curl --fail \
+ --connect-timeout 10 \
+ --max-time 30 \
+ --retry 3 \
+ --retry-connrefused \
+ --retry-delay 2 \
+ --header 'accept: application/linkset+json' \
+ --dump-header "$RUNNER_TEMP/api-catalog.headers" \
+ --output "$RUNNER_TEMP/api-catalog.json" \
+ https://editor.pascal.app/.well-known/api-catalog
+ grep --ignore-case --extended-regexp \
+ '^content-type: application/linkset\+json([;[:space:]]|$)' \
+ "$RUNNER_TEMP/api-catalog.headers"
+ jq --exit-status \
+ --arg endpoint "$endpoint" \
+ --arg version "$version" \
+ --arg transport "$transport" \
+ --arg registry_name "$registry_name" '
+ [.linkset[] | .item[]? | select(.href == $endpoint)] as $matches
+ | ($matches | length) == 1
+ and $matches[0].version == [$version]
+ and $matches[0].transport == [$transport]
+ and $matches[0]["registry-name"] == [$registry_name]
+ ' "$RUNNER_TEMP/api-catalog.json"
+
+ curl --fail --head \
+ --connect-timeout 10 \
+ --max-time 30 \
+ --retry 3 \
+ --retry-connrefused \
+ --retry-delay 2 \
+ https://editor.pascal.app/.well-known/api-catalog \
+ | tr -d '\r' \
+ | grep --fixed-strings --ignore-case \
+ 'link: ; rel="api-catalog"'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 560eaeda86..3242c33854 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,8 +8,6 @@ on:
required: true
type: choice
options:
- - capture-protocol
- - capture-viewer
- core
- viewer
- editor
@@ -19,7 +17,7 @@ on:
- cli
- all
bump:
- description: "Version bump (beta publishes a prerelease on the beta dist-tag)"
+ description: "Version bump (beta publishes a prerelease on the beta dist-tag; patch/minor/major on a prerelease graduates it to its base version on latest)"
required: true
type: choice
options:
@@ -69,6 +67,12 @@ jobs:
environment: npm
permissions:
contents: write
+ id-token: write
+ env:
+ # Verbose npm logs show the OIDC token exchange and the registry's
+ # rejection reason when trusted publishing is misconfigured; tokens are
+ # redacted by npm.
+ NPM_CONFIG_LOGLEVEL: verbose
steps:
- uses: actions/checkout@v4
with:
@@ -78,10 +82,28 @@ jobs:
with:
bun-version: 1.3.14
+ # No registry-url here: with it, actions/setup-node writes an .npmrc whose
+ # auth token falls back to the placeholder XXXXX-XXXXX-XXXXX-XXXXX, npm
+ # sends that fake token, the registry answers 404, and the OIDC trusted
+ # publishing exchange never runs. npm publishes to registry.npmjs.org by
+ # default and each publish step passes --access public explicitly.
- uses: actions/setup-node@v4
with:
node-version: 22
- registry-url: "https://registry.npmjs.org"
+
+ # npm refuses direct publishing with 2FA-bypass tokens (EOTP, see
+ # https://gh.io/npm-gat-bypass2fa-deprecation), so no NODE_AUTH_TOKEN is set
+ # and npm >= 11.5 exchanges the GitHub Actions OIDC token itself. Every
+ # @pascal-app package must list this repository, this workflow file and
+ # the `npm` environment as a trusted publisher on npmjs.com; a package that
+ # does not exist on npm yet needs one manual first publish before that.
+ - name: Enable npm trusted publishing
+ run: |
+ npm install --global npm@11.19.1
+ node --version
+ npm --version
+ test -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}"
+ test -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}"
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -111,11 +133,16 @@ jobs:
fi
return
fi
+ if [ "$BUMP" = "none" ]; then echo "$v"; return; fi
+ # A prerelease graduates to its base version on any stable bump
+ # (1.0.0-beta.5 + major|minor|patch -> 1.0.0), matching npm semver.
+ # Splitting "1.0.0-beta.5" on dots would otherwise yield 2.0.0 or
+ # break the patch arithmetic.
+ if [[ "$v" == *-* ]]; then echo "${v%%-*}"; return; fi
IFS='.' read -r MAJ MIN PAT <<< "$v"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
- if [ "$BUMP" = "none" ]; then echo "$v"; return; fi
echo "$MAJ.$MIN.$PAT"
}
@@ -130,7 +157,7 @@ jobs:
# peerDeps sync below must use shell vars, not env indirection.
declare -A NEW_VERSIONS
- for pkg in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do
+ for pkg in core viewer editor nodes mcp ifc-converter cli; do
if [ "$TARGET" = "$pkg" ] || [ "$TARGET" = "all" ]; then
CUR=$(jq -r '.version' packages/$pkg/package.json)
NEW=$(bump_version "$CUR")
@@ -145,9 +172,9 @@ jobs:
# Sync inter-package references in dependencies, peerDependencies, and devDependencies.
# Anything that references a bumped @pascal-app/* package is updated to ^NEW.
- for pkg in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do
+ for pkg in core viewer editor nodes mcp ifc-converter cli; do
FILE=packages/$pkg/package.json
- for dep in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do
+ for dep in core viewer editor nodes mcp ifc-converter cli; do
VAL="${NEW_VERSIONS[$dep]}"
[ -z "$VAL" ] && continue
jq --arg name "@pascal-app/$dep" --arg v "^$VAL" '
@@ -159,7 +186,7 @@ jobs:
done
echo "=== @pascal-app/* refs after sync ==="
- for pkg in capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli; do
+ for pkg in core viewer editor nodes mcp ifc-converter cli; do
echo "--- packages/$pkg/package.json ---"
jq '{ dependencies: (.dependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), peerDependencies: (.peerDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), devDependencies: (.devDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))) }' packages/$pkg/package.json
done
@@ -171,7 +198,7 @@ jobs:
# A single-package release may depend on another package introduced
# by this monorepo. Refuse to publish an uninstallable package when
# that dependency is not part of this run and is absent from npm.
- RELEASE_PACKAGES="capture-protocol core viewer capture-viewer editor nodes mcp ifc-converter cli"
+ RELEASE_PACKAGES="core viewer editor nodes mcp ifc-converter cli"
if [ "$TARGET" != "all" ]; then
FILE="packages/$TARGET/package.json"
while IFS=$'\t' read -r DEP RANGE; do
@@ -197,23 +224,6 @@ jobs:
)
fi
- - name: Build & publish capture protocol
- if: inputs.package == 'capture-protocol' || inputs.package == 'all'
- working-directory: packages/capture-protocol
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: |
- bun run build
- if [ "${{ inputs.dry-run }}" = "true" ]; then
- echo "🏜️ Dry run — would publish @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION"
- npm publish --dry-run --access public --tag "$NPM_TAG"
- elif npm view "@pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION" version >/dev/null 2>&1; then
- echo "📦 @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION is already published; continuing release recovery"
- else
- npm publish --access public --tag "$NPM_TAG"
- echo "📦 Published @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION"
- fi
-
- name: Validate portable editor runtime
if: inputs.package == 'cli' || inputs.package == 'all'
env:
@@ -228,8 +238,6 @@ jobs:
- name: Build & publish core
if: inputs.package == 'core' || inputs.package == 'all'
working-directory: packages/core
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
@@ -245,8 +253,6 @@ jobs:
- name: Build & publish viewer
if: inputs.package == 'viewer' || inputs.package == 'all'
working-directory: packages/viewer
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
@@ -259,28 +265,9 @@ jobs:
echo "📦 Published @pascal-app/viewer@$VIEWER_VERSION"
fi
- - name: Build & publish capture viewer
- if: inputs.package == 'capture-viewer' || inputs.package == 'all'
- working-directory: packages/capture-viewer
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: |
- bun run build
- if [ "${{ inputs.dry-run }}" = "true" ]; then
- echo "🏜️ Dry run — would publish @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION"
- npm publish --dry-run --access public --tag "$NPM_TAG"
- elif npm view "@pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION" version >/dev/null 2>&1; then
- echo "📦 @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION is already published; continuing release recovery"
- else
- npm publish --access public --tag "$NPM_TAG"
- echo "📦 Published @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION"
- fi
-
- name: Publish editor
if: inputs.package == 'editor' || inputs.package == 'all'
working-directory: packages/editor
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/editor@$EDITOR_VERSION"
@@ -295,8 +282,6 @@ jobs:
- name: Build & publish nodes
if: inputs.package == 'nodes' || inputs.package == 'all'
working-directory: packages/nodes
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
@@ -312,8 +297,6 @@ jobs:
- name: Build & publish mcp
if: inputs.package == 'mcp' || inputs.package == 'all'
working-directory: packages/mcp
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
@@ -328,8 +311,6 @@ jobs:
- name: Build & publish ifc-converter
if: inputs.package == 'ifc-converter' || inputs.package == 'all'
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# ifc-converter depends on @pascal-app/core (workspace) — build it first
bun run build --filter @pascal-app/core 2>/dev/null || (cd packages/core && bun run build)
@@ -347,10 +328,16 @@ jobs:
- name: Publish CLI
if: inputs.package == 'cli' || inputs.package == 'all'
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ # Keep token auth unset so npm can exchange the GitHub OIDC identity.
run: |
cd packages/cli
+ # The tarball embeds the URL and digest of the web runtime archive, so the archive
+ # must be staged (by "Validate portable editor runtime") before anything is published.
+ ARCHIVE="build/pascal-web-runtime-$CLI_VERSION.tar.gz"
+ test -f "$ARCHIVE"
+ test -f "$ARCHIVE.sha256"
+ test -f dist/services/pascal-mcp.mjs
+ jq -e --arg v "$CLI_VERSION" '.version == $v' dist/runtime-source.json
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/cli@$CLI_VERSION"
npm publish --ignore-scripts --dry-run --access public --tag "$NPM_TAG"
@@ -368,10 +355,6 @@ jobs:
PKGS=""
TAGS=""
- if [ -n "$CAPTURE_PROTOCOL_VERSION" ]; then
- PKGS="$PKGS @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION"
- TAGS="$TAGS @pascal-app/capture-protocol@$CAPTURE_PROTOCOL_VERSION"
- fi
if [ -n "$CORE_VERSION" ]; then
PKGS="$PKGS @pascal-app/core@$CORE_VERSION"
TAGS="$TAGS @pascal-app/core@$CORE_VERSION"
@@ -380,10 +363,6 @@ jobs:
PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION"
TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION"
fi
- if [ -n "$CAPTURE_VIEWER_VERSION" ]; then
- PKGS="$PKGS @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION"
- TAGS="$TAGS @pascal-app/capture-viewer@$CAPTURE_VIEWER_VERSION"
- fi
if [ -n "$EDITOR_VERSION" ]; then
PKGS="$PKGS @pascal-app/editor@$EDITOR_VERSION"
TAGS="$TAGS @pascal-app/editor@$EDITOR_VERSION"
@@ -416,3 +395,20 @@ jobs:
done
git push --atomic origin HEAD:main $TAGS
+
+ # The npm package points at this asset, so it is uploaded in the same job, immediately
+ # after the tag it hangs off exists on the remote.
+ - name: Upload the CLI web runtime release asset
+ if: ${{ inputs.dry-run == false && (inputs.package == 'cli' || inputs.package == 'all') }}
+ working-directory: packages/cli
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ TAG="@pascal-app/cli@$CLI_VERSION"
+ ARCHIVE="build/pascal-web-runtime-$CLI_VERSION.tar.gz"
+ if ! gh release view "$TAG" >/dev/null 2>&1; then
+ [ "$NPM_TAG" = "beta" ] && PRERELEASE=--prerelease || PRERELEASE=
+ gh release create "$TAG" $PRERELEASE --title "$TAG" --notes "The Pascal web editor runtime for \`@pascal-app/cli@$CLI_VERSION\`. The CLI downloads \`$(basename "$ARCHIVE")\` the first time a command starts the editor and verifies it against the digest published inside the npm package. Offline installs can pass the archive directly: \`pascal editor --runtime $(basename "$ARCHIVE")\`."
+ fi
+ gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber
+ echo "🌐 Uploaded $(basename "$ARCHIVE") to $TAG"
diff --git a/AGENTS.md b/AGENTS.md
index 9ddb52fb38..5329655586 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,8 +6,8 @@ Public, open-source home of `@pascal-app/{core,viewer,editor,mcp}` and the stand
| Path | Purpose |
|---|---|
-| `packages/core` | Scene graph, node schemas, stores, event bus, core systems — pure logic, no Three.js |
-| `packages/viewer` | Standalone 3D canvas: renderers, viewer systems, presentation state |
+| `packages/core` | Scene graph, node schemas, stores, event bus, core systems — pure logic, no Three.js. `src/capture/` holds the capture-session contracts published as `@pascal-app/core/capture` |
+| `packages/viewer` | Standalone 3D canvas: renderers, viewer systems, presentation state. `src/capture/` holds the capture runtime and reference layers published as `@pascal-app/viewer/capture` |
| `packages/editor` | Editor UI components reused by the standalone app and embedders |
| `packages/mcp` | MCP server and scene storage adapters |
| `apps/editor` | Standalone editor app — composes `viewer` + `editor` + tools |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 537d733c01..d1034bbc41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,81 @@
# Changelog
-## Unreleased
+## 1.0.0 (2026-09-12)
+
+### Features
+
+- Add plugin-contributed editor panels and viewer presentations with project-local configuration persistence.
+- Include Environment in the standalone app's Plugins catalogue, with a pinned GitHub dependency and registered editor panel and viewer presentation.
+- Refresh Environment's authoring controls and include its interactive color picker.
+- Expose generic atmosphere and ground-replacement adapters, Site-scoped floorplan output, bake-only GLB geometry, and plugin-owned selection materials.
+- Add portable GLB/USDZ downloads with asynchronous material baking, procedural-content filters, and opt-in static viewer-presentation exports.
+- **Public agent skills** — `pascal-3d` and `furniture-fit` teach MCP-capable agents to build, inspect, validate, and hand off scenes, and to report measured furniture footprints with evidence-scoped conclusions, fail-closed input gates, blocker-aware next actions, and an optional no-sign-in footprint pre-check link ([#777](https://github.com/pascalorg/editor/pull/777), [#781](https://github.com/pascalorg/editor/pull/781), [#791](https://github.com/pascalorg/editor/pull/791), [#794](https://github.com/pascalorg/editor/pull/794), [#824](https://github.com/pascalorg/editor/pull/824))
+- **Plugin bundles 0.1.3 → 0.1.8** — the same canonical `skills/` source ships as one versioned plugin with a recorded validation ledger per bundle ([#782](https://github.com/pascalorg/editor/pull/782), [#795](https://github.com/pascalorg/editor/pull/795), [#802](https://github.com/pascalorg/editor/pull/802), [#811](https://github.com/pascalorg/editor/pull/811), [#825](https://github.com/pascalorg/editor/pull/825))
+- **Claude Code and Codex plugin marketplaces** — this repository is installable as `pascal-agent-skills@pascal`, with a credential-free local `pascal mcp connect` server bundled for Claude Code ([#777](https://github.com/pascalorg/editor/pull/777), [#810](https://github.com/pascalorg/editor/pull/810))
+- **Hosted MCP server in the Claude Code plugin** — `pascal-agent-skills@pascal` now also bundles a `pascal-hosted` Streamable HTTP server for `https://editor.pascal.app/api/mcp`, authenticated with an optional Pascal API key collected as sensitive plugin user configuration and stored in the OS keychain rather than any file ([#835](https://github.com/pascalorg/editor/pull/835))
+- **OpenAI portable plugin manifest** — root `plugin.json` carries the Agent Plugins schema, listing metadata, branding assets, a With MCP review packet, and per-tool annotation justifications for all 46 MCP tools ([#796](https://github.com/pascalorg/editor/pull/796), [#797](https://github.com/pascalorg/editor/pull/797), [#799](https://github.com/pascalorg/editor/pull/799), [#812](https://github.com/pascalorg/editor/pull/812), [#813](https://github.com/pascalorg/editor/pull/813))
+- **ClawHub publication readiness** — scoped `.clawhubignore` policies with regression tests that reject re-inclusion and legacy-override rules ([#798](https://github.com/pascalorg/editor/pull/798), [#806](https://github.com/pascalorg/editor/pull/806))
+- **Official MCP Registry entry** — `io.github.pascalorg/editor` 0.6.1 publishes the hosted Streamable HTTP endpoint, with CI validating the manifest against the live API catalog ([#808](https://github.com/pascalorg/editor/pull/808), [#809](https://github.com/pascalorg/editor/pull/809))
+- **Portable `mcp.json`, Cursor manifest, Gemini extension, plate logo** — Codex and Cursor now register the bundled MCP server (the spec reads root `mcp.json`, not `.mcp.json`); `.cursor-plugin/plugin.json` and `gemini-extension.json` add those marketplaces; the MCP Registry entry gains `repository` and `icons`; marketplace logos use the brand mark on its `#171717` plate; `bun run skills:validate` asserts parity across every descriptor ([#829](https://github.com/pascalorg/editor/pull/829))
+- **`pascal agent claim` and `pascal agent status`** — an agent can open a prefilled 15-minute human handoff and verify its hosted key without storing or printing it, shipped in the npm-published CLI ([#815](https://github.com/pascalorg/editor/pull/815), [#818](https://github.com/pascalorg/editor/pull/818), [#819](https://github.com/pascalorg/editor/pull/819), [#821](https://github.com/pascalorg/editor/pull/821), [#822](https://github.com/pascalorg/editor/pull/822))
+- **Small CLI, downloaded web runtime** — `@pascal-app/cli` now installs from a 0.5 MB npm package instead of 65 MB: the MCP service ships inside it, so `pascal mcp connect` works with no editor process and no download, while the web editor runtime is fetched once per version from its release asset, verified against a SHA-256 digest published in the package, and installed atomically; `--runtime ` covers offline hosts, `HTTPS_PROXY`/`NO_PROXY` are honoured, and an editor started by a pre-split CLI keeps its own MCP child, which must be stopped once by hand after upgrading (#845)
+- **Optional hosted key in the Cursor plugin** — `.cursor-plugin/plugin.json` declares an optional `PASCAL_API_KEY` variable and points at a Cursor-dialect `.cursor-plugin/mcp.json` that adds a `pascal-hosted` server for `https://editor.pascal.app/api/mcp`, so a Cursor install can reach hosted projects, Capture scans, and shared workspaces while the credential-free local server keeps working; the portable `mcp.json` stays credential-free because Agent Plugins 1.0.0 forbids secrets and placeholder expansion in `headers`, so Codex configures the hosted endpoint with `codex mcp add --bearer-token-env-var PASCAL_API_KEY` instead ([#849](https://github.com/pascalorg/editor/pull/849))
+- **Capture packages folded into core and viewer** — `@pascal-app/capture-protocol` is now `@pascal-app/core/capture` and `@pascal-app/capture-viewer` is now `@pascal-app/viewer/capture` (plus `@pascal-app/viewer/capture/preview`), so 1.0.0 ships seven packages instead of nine. Neither package was ever published to npm, so there is no npm migration; in-repo and workspace consumers change their import paths only.
### Fixes
+- Localize terrain and ground-cover brush updates, preserve pending dab uploads, and keep Environment's day/night light graph stable.
+- Keep Site-scoped floorplan overlays aligned with live move and rotation previews.
+- Preserve Site ownership and same-kind sibling context in synchronous and asynchronous export geometry, including Site children without a parent ID.
+- Exclude detached Site children and their descendants from visible-only exports when their owning Site is hidden, including children without a parent ID.
+- Preserve unsaved presentation settings when a project receives its first ID, without overwriting an existing project's stored configuration.
+- Avoid native TypeScript compiler inference overflow in atmosphere fog references without changing rendering.
+- Omit stale viewer-surroundings selections from GLB/USDZ downloads after a presentation is unregistered or its plugin is uninstalled.
+- Keep export settings scrollable and group advanced model options in a keyboard-accessible disclosure.
+- Preserve child geometry when exporting empty mesh containers to USDZ.
+- Export the viewer's shadow-only layer for plugin consumers.
+- Remove the nonworking god-ray post-process and its dedicated viewer API; preserve sky, fog, lighting, and ordinary shadows.
+- Preserve grass and procedural material colors in portable exports; freeze instancing and deformation without changing the live scene or saved-viewer animation clips.
+- Stop registered placement tools when their plugin is uninstalled in either view, preserving authored nodes and requiring explicit reactivation after reinstall.
+- Include enabled, visible Site contributions below architecture in floorplan PDFs, preserving building transforms, inline images, and even-odd holes. Hidden Sites also hide children associated through their declared child list.
+- The Claude Code plugin root is now `skills/` instead of the repository root, so installing `pascal-agent-skills@pascal` copies the two skill bundles and their MCP configuration instead of caching the whole monorepo and running `bun install` against the root lockfile ([#832](https://github.com/pascalorg/editor/pull/832))
- Preserve custom scene materials across save, load, clone, fork, and live sync. Materials were dropped at every persistence boundary, so a scene reopened with default surfaces. Collections were dropped on MCP import for the same reason ([#597](https://github.com/pascalorg/editor/pull/597)) by [@ShiroKSH](https://github.com/ShiroKSH)
- Wall junction mitering is now deterministic for exactly-collinear walls, so identical scenes produce identical geometry regardless of node iteration order ([#596](https://github.com/pascalorg/editor/pull/596)) by [@tomatotomata](https://github.com/tomatotomata)
+### Packages
+
+All seven public packages are published as `1.0.0` under the npm `latest`
+dist-tag: `core`, `viewer`, `editor`, `nodes`, `mcp`, `ifc-converter`, and
+`cli`. `@pascal-app/capture-protocol` and `@pascal-app/capture-viewer` were
+folded into `@pascal-app/core/capture` and `@pascal-app/viewer/capture` before
+the release and were never published.
+
+### Contributors
+
+Thank you to [@wass08](https://github.com/wass08),
+[@Snoopy147](https://github.com/Snoopy147),
+[@sudhir9297](https://github.com/sudhir9297),
+[@ActArtech](https://github.com/ActArtech),
+[@anton-pascal](https://github.com/anton-pascal),
+[@toycenterboss-bot](https://github.com/toycenterboss-bot),
+[@JimmyZheng-ZJU](https://github.com/JimmyZheng-ZJU),
+[@ShiroKSH](https://github.com/ShiroKSH),
+[@tomatotomata](https://github.com/tomatotomata),
+[@alxbouchard](https://github.com/alxbouchard),
+[@SomSamantray](https://github.com/SomSamantray),
+[@konevenkatesh](https://github.com/konevenkatesh),
+[@maherm](https://github.com/maherm),
+[@rootsbymenda](https://github.com/rootsbymenda),
+[@tamg](https://github.com/tamg),
+[@tylergibbs1](https://github.com/tylergibbs1),
+[@vjureta](https://github.com/vjureta),
+[@yorhodes](https://github.com/yorhodes), and
+[@ztffn](https://github.com/ztffn) for their work across the editor, viewer,
+node library, MCP integration, plugins, documentation, and stability fixes.
+
+**Full changelog**:
+https://github.com/pascalorg/editor/compare/v1.0.0-beta.1...v1.0.0
+
## 1.0.0-beta.1 (2026-07-30)
The first Pascal Editor 1.0 beta. Relative to
diff --git a/README.md b/README.md
index cae0e87e14..2f4b9a1be5 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# Pascal Editor
-A 3D building editor built with React Three Fiber and WebGPU.
+An open-source, local-first 3D building editor built with React Three Fiber and
+WebGPU. Run it in the browser or from the CLI, and connect AI agents through MCP.
[](LICENSE)
[](https://www.npmjs.com/package/@pascal-app/core)
@@ -21,20 +22,79 @@ npx @pascal-app/cli editor
```
The CLI starts the editor and an authenticated MCP service in the background, selects
-collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. Configure
-an agent to launch `pascal mcp connect`. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor)
+collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. The npm
+package holds the CLI and that MCP service; the web editor runtime is downloaded once per
+version on the first command that starts the editor and verified against a digest published
+inside the package. Configure an agent to launch `pascal mcp connect`, which needs neither
+the editor process nor that download. Install the `pascal` command with
+`npm install --global @pascal-app/cli`. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor)
for pnpm/Bun commands, project management, MCP setup, updates, storage paths, and
troubleshooting.
+Use one active agent client per local CLI service. The standalone local HTTP runtime shares active scene state between clients; use separate `PASCAL_HOME` directories and service processes when independent concurrent work is required.
+
+## Agent skills
+
+[](https://skills.sh/pascalorg/editor)
+
+Install Pascal's public agent workflows from this repository with [skills.sh](https://skills.sh):
+
+```bash
+npx skills add pascalorg/editor \
+ --skill pascal-3d \
+ --skill furniture-fit
+```
+
+Claude Code users can install the same canonical skill source as a plugin:
+
+```text
+/plugin marketplace add pascalorg/editor
+/plugin install pascal-agent-skills@pascal
+```
+
+The Claude plugin also supplies the local `pascal mcp connect` server. Install and start the Pascal CLI first, and keep `pascal` on the `PATH` used to launch Claude Code. This local connector needs no Pascal account or API key and does not upload projects automatically. Its plugin root is this repository's `skills/` directory, so an install copies only the skill bundles and their plugin metadata rather than the repository.
+
+The plugin bundles two servers: the local `pascal` connector above and a hosted `pascal-hosted` server for `https://editor.pascal.app/api/mcp`, which prompts for an optional Pascal API key at enable time and stores it in the OS keychain. Leave the key empty to run local-only.
+
+Claude Code 2.1.258 loads both the user-scoped `pascal` server created by `pascal mcp setup claude` and the plugin-provided server. Remove the manual entry before reloading or restarting Claude Code so only the plugin owns the connection lifecycle:
+
+```bash
+claude mcp remove --scope user pascal
+```
+
+Use `/mcp` to remove or disable any project- or local-scoped Pascal connection too. Leaving both connections active violates the one-active-agent-client-per-local-service requirement. When the intended project is hosted in a Pascal account or organization, disable the plugin-provided local server in `/mcp` and configure the hosted endpoint from the skill setup guide instead.
+
+Codex users can install the same plugin from the repository marketplace:
+
+```bash
+codex plugin marketplace add pascalorg/editor
+codex plugin add pascal-agent-skills@pascal
+```
+
+OpenClaw installation becomes available after the skills are published under Pascal's ClawHub publisher. See [skills/README.md](skills/README.md) for the owner-qualified install and verification commands.
+
+[`pascal-3d`](skills/pascal-3d/SKILL.md) covers safe local or hosted MCP setup and verified scene work. [`furniture-fit`](skills/furniture-fit/SKILL.md) produces a bounded, evidence-based footprint assessment without claiming unsupported height, swing, or delivery checks. See [skills/README.md](skills/README.md) for package details and validation.
+
+The skills inspect the connected MCP tool schemas before using optional fields. A capability present in this repository may be absent from an older installed or hosted release; the agent should report the narrower supported result instead of assuming source-only inputs are available.
+
+These workflows require a connected Pascal MCP server for their tool-backed actions. An OpenAI directory submission must therefore use **With MCP** and submit the production hosted MCP endpoint together with the skills. The repository package does not prove that the endpoint, OAuth flow, reviewer credentials, domain verification, or portal scan is ready for review.
+
+### MCP Registry
+
+[`server.json`](server.json) is Pascal's manifest for the official MCP Registry. Its
+version tracks the hosted MCP implementation independently of the npm package version.
+Pull requests validate the manifest and production endpoint. A Pascal organization
+owner publishes an approved version from `main` with the official registry publisher.
+
## Using Published Packages
The viewer runtime and built-in node definitions are separate packages. Install the full built-in
viewer set, then load the built-in plugin once before mounting ``. Capture sessions are an
-optional transport-neutral extension:
+optional extension shipped inside those packages as the `@pascal-app/core/capture` and
+`@pascal-app/viewer/capture` subpaths:
```bash
npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes
-npm install @pascal-app/capture-protocol @pascal-app/capture-viewer
```
```typescript
@@ -57,10 +117,8 @@ editor/
├── apps/
│ └── editor/ # Next.js application
├── packages/
-│ ├── core/ # Schemas, scene state, and registry contracts
-│ ├── viewer/ # 3D rendering runtime and shared systems
-│ ├── capture-protocol/ # Static/live capture-session contracts
-│ ├── capture-viewer/ # Capture source runtime and reference renderers
+│ ├── core/ # Schemas, scene state, registry contracts, capture contracts
+│ ├── viewer/ # 3D rendering runtime, shared systems, capture runtime
│ ├── editor/ # Editing tools and UI components
│ ├── nodes/ # Built-in node definitions, renderers, and systems
│ ├── cli/ # Persistent local editor installer and process manager
@@ -72,10 +130,8 @@ editor/
| Package | Responsibility |
|---------|---------------|
-| **@pascal-app/core** | Node schemas, scene state (Zustand), registry contracts, spatial queries, and event bus |
-| **@pascal-app/viewer** | 3D rendering via React Three Fiber, shared render systems, default camera/controls, and post-processing |
-| **@pascal-app/capture-protocol** | Versioned capture manifests, normalized streams, and transport-neutral static/live sources |
-| **@pascal-app/capture-viewer** | Viewer child runtime and reference model, device-motion, and point-cloud layers |
+| **@pascal-app/core** | Node schemas, scene state (Zustand), registry contracts, spatial queries, and event bus. `core/capture` adds versioned capture manifests, normalized streams, and transport-neutral static/live sources |
+| **@pascal-app/viewer** | 3D rendering via React Three Fiber, shared render systems, default camera/controls, and post-processing. `viewer/capture` adds the capture runtime and reference model, device-motion, point-cloud, and surface-mesh layers |
| **@pascal-app/editor** | Editing tools, panels, selection, and direct-manipulation UI |
| **@pascal-app/nodes** | Built-in registry plugin with node definitions, renderers, geometry, and systems |
| **@pascal-app/cli** | Installs and manages a versioned standalone editor runtime and persistent local data |
@@ -440,14 +496,12 @@ turbo build --filter=@pascal-app/core
### Publishing Packages
-```bash
-# Build packages
-turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer
-
-# Publish to npm
-npm publish --workspace=@pascal-app/core --access public
-npm publish --workspace=@pascal-app/viewer --access public
-```
+Releases run from `.github/workflows/release.yml` (`workflow_dispatch`, with
+`package`, `bump`, and `dry-run` inputs). The workflow bumps versions, rewrites
+the internal `@pascal-app/*` ranges, builds, publishes in dependency order
+(`core` → `viewer` → `editor` → `nodes` → `mcp` → `ifc-converter` → `cli`),
+then commits the release and pushes one tag per package. A dry run validates
+the builds without touching the registry.
---
diff --git a/SETUP.md b/SETUP.md
index 80c33bc024..837ed17e72 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -13,6 +13,11 @@ bun dev
The editor will be running at **http://localhost:3002**.
+Environment is included as a pinned GitHub dependency, like the other bundled
+plugins. Open **+ → Plugins → Environment** to manage its installation for the
+current project, then open **Environment** in the sidebar. No separate plugin
+checkout, local tarball, or synchronization script is needed.
+
## Environment Variables (optional)
Copy `.env.example` to `.env` if you need:
@@ -58,13 +63,16 @@ background, and open it in the browser without a repository checkout:
npx @pascal-app/cli editor
```
-The command starts the editor and its authenticated local MCP service together. Configure
-an agent to launch `pascal mcp connect`; for example, run `pascal mcp setup codex`.
+The command starts the editor and its authenticated local MCP service together, downloading
+the web editor runtime for that CLI version on the first run and verifying it against a
+digest published in the npm package. Configure an agent to launch `pascal mcp connect`; for
+example, run `pascal mcp setup codex`. That connector needs neither the editor process nor
+the runtime download, and `--runtime ` covers an offline host.
Use `npx @pascal-app/cli doctor` to check the runtime, storage, editor, and MCP state. Saved
scenes live in `~/.pascal/data/pascal.db` independently from installed runtime versions.
The CLI retains old runtime versions for rollback and warns after more than three have
-accumulated. It also replaces a damaged copy of its bundled runtime on the next start;
+accumulated. It also replaces a damaged copy of the installed runtime on the next start;
neither operation modifies the data directory.
The complete command and storage reference is in [Run Pascal
locally](https://editor.pascal.app/docs/developers/local-editor).
diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css
index 61f2d36f64..77fd6e1d02 100644
--- a/apps/editor/app/globals.css
+++ b/apps/editor/app/globals.css
@@ -6,6 +6,7 @@
@source "../../../node_modules/@pascal-app/plugin-trees/src";
@source "../../../node_modules/@mint/pascal-plugin/src";
@source "../../../node_modules/@pascal-app/plugin-streetscape/src";
+@source "../../../node_modules/@pascal-app/plugin-environment/src";
@custom-variant dark (&:is(.dark *));
diff --git a/apps/editor/bunfig.toml b/apps/editor/bunfig.toml
new file mode 100644
index 0000000000..eec7d338da
--- /dev/null
+++ b/apps/editor/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["../../scripts/bun-preload-three.ts"]
+
+[test]
+preload = ["../../scripts/bun-preload-three.ts"]
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx
index 5ba049ddbe..995b453141 100644
--- a/apps/editor/components/build-tab.tsx
+++ b/apps/editor/components/build-tab.tsx
@@ -1,25 +1,34 @@
'use client'
-import { nodeRegistry } from '@pascal-app/core'
import {
+ nodeRegistry,
+ type RoofType,
+ RoofType as RoofTypeSchema,
+ useRegistryVersion,
+} from '@pascal-app/core'
+import {
+ CATALOG_ITEMS,
type FloorplanMode,
getFloorplanNodeExtension,
isFloorplanToolAvailableInMode,
MaterialPaintPanel,
TerrainSculptPanel,
+ ToolOptionsPanel,
triggerSFX,
useEditor,
useFloorplanMode,
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
+import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
-import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
+import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/toolbar-tooltip'
+import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state'
import { cn } from '@/lib/utils'
/**
@@ -38,7 +47,7 @@ type MepToolKind =
| 'pipe-trap'
type BuildType = {
- /** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */
+ /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */
id: string
label: string
/** Raster asset tile (legacy Build sidebar artwork). */
@@ -72,6 +81,7 @@ const BASE_BUILD_TYPES: BuildType[] = [
{ id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' },
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' },
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' },
+ { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' },
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
@@ -129,6 +139,9 @@ const MEP_ITEMS: MepItem[] = [
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' },
]
+const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet')
+const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp'
+
/**
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
@@ -153,6 +166,17 @@ function activateBuildTool(kind: string): void {
ed.setTool(kind)
}
+function activateModularCabinetTool(): void {
+ const ed = useEditor.getState()
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
+ if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM)
+ ed.setPhase('structure')
+ ed.setStructureLayer('elements')
+ ed.setCatalogCategory(null)
+ ed.setMode('build')
+ ed.setTool('cabinet')
+}
+
/** Enter material-paint mode — the Build tab's "Painting" category. */
function activatePaintMode(): void {
const ed = useEditor.getState()
@@ -169,10 +193,36 @@ function activateTerrainSculptMode(): void {
useEditor.getState().setMode('terrain-sculpt')
}
-type RoofFeature = { kind: string; label: string; iconSrc: string }
+type RoofFeature = {
+ id: string
+ label: string
+ iconSrc: string
+ kind?: string
+}
const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'
+function collectRoofFeatures(): RoofFeature[] {
+ const features: RoofFeature[] = []
+ for (const [kind, def] of nodeRegistry.entries()) {
+ if (
+ def.capabilities.roofAccessory === undefined &&
+ def.presentation?.paletteGroup !== 'roof-features'
+ ) {
+ continue
+ }
+ if (def.capabilities.wallOpeningPlacement) continue
+ const icon = def.presentation?.icon
+ features.push({
+ id: kind,
+ kind,
+ label: def.presentation?.label ?? kind,
+ iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
+ })
+ }
+ return features
+}
+
/**
* Roof accessories and extensions surfaced under the Roof tile. Unlike the
* community editor these aren't DB presets — each is a registry kind, either
@@ -181,13 +231,19 @@ const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'
* populated during app bootstrap. Label + icon come from `presentation`;
* non-url icons fall back to the roof icon.
*/
-function activateRoofFeatureTool(kind: string): void {
+function activateRoofFeatureTool(feature: RoofFeature): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
- ed.setTool(kind)
+ if (feature.kind) ed.setTool(feature.kind)
+}
+
+function activateRoofType(roofType: RoofType): void {
+ const editor = useEditor.getState()
+ if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof')
+ editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType })
}
/**
@@ -206,24 +262,21 @@ const MEP_TOOL_KINDS = new Set([
])
export function BuildTab() {
+ const [mepOpen, setMepOpen] = useState(false)
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
+ const roofDefaults = useEditor((s) => s.toolDefaults.roof)
const floorplanMode = useFloorplanMode((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
+ useRegistryVersion()
const registryReady = useSyncExternalStore(
subscribeToClientMount,
() => true,
() => false,
)
- const buildTypes = useMemo(
- () => (registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES),
- [floorplanMode, registryReady],
- )
+ const buildTypes = registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES
- // The fitting / follow tools are armed from a segment's panel, not a grid
- // tile — keep the segment tile lit so the panel (and the way back) stays
- // visible.
const ductContext =
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
const pipeContext =
@@ -231,40 +284,11 @@ export function BuildTab() {
(activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap')
const liquidLineContext = mode === 'build' && activeTool === 'liquid-line'
- const isMepItemActive = (item: MepItem) =>
- item.kind === 'duct-segment'
- ? ductContext
- : item.kind === 'pipe-segment'
- ? pipeContext
- : item.kind === 'liquid-line'
- ? liquidLineContext
- : mode === 'build' && activeTool === item.kind
+ const isMepItemActive = (item: MepItem) => mode === 'build' && activeTool === item.kind
// Read at render time (not module scope): the registry is populated by the
// app bootstrap, so enumerating earlier would race it and see no kinds.
- const roofFeatures = useMemo(() => {
- if (!registryReady) return []
- const features: RoofFeature[] = []
- for (const [kind, def] of nodeRegistry.entries()) {
- if (
- def.capabilities.roofAccessory === undefined &&
- def.presentation?.paletteGroup !== 'roof-features'
- ) {
- continue
- }
- // Door / window declare `roofAccessory` for the wall-face cut but
- // already have their own Build tiles — listing them here too
- // would duplicate the entry under Roof → Features.
- if (def.capabilities.wallOpeningPlacement) continue
- const icon = def.presentation?.icon
- features.push({
- kind,
- label: def.presentation?.label ?? kind,
- iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
- })
- }
- return features
- }, [registryReady])
+ const roofFeatures = registryReady ? collectRoofFeatures() : []
// Tile highlight derives from the single source of truth (the active tool /
// mode), never a separate local selection — so keyboard shortcuts and panel
@@ -272,27 +296,39 @@ export function BuildTab() {
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
- const isRoofFeatureActive =
- mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
- const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
+ const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool)
+ const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null
+ const isMepActive =
+ (mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)) ||
+ (mode === 'select' && mepOpen)
+ const isKitchenActive = mode === 'build' && activeTool === 'cabinet'
+ const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType)
+ const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable'
const isTypeActive = (type: BuildType) => {
if (type.mode) return mode === type.mode
if (type.id === 'mep') return isMepActive
+ if (type.id === 'kitchen') return isKitchenActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
}
const handleTypeClick = useCallback((type: BuildType) => {
+ setMepOpen(type.id === 'mep')
if (type.mode === 'material-paint') {
activatePaintMode()
} else if (type.mode === 'terrain-sculpt') {
activateTerrainSculptMode()
} else if (type.id === 'mep') {
- // MEP is a group tile: arm its first tool so a usable tool is active
- // (and we leave any prior paint mode), then reveal the MEP sub-grid.
- activateBuildTool('duct-segment')
+ const ed = useEditor.getState()
+ ed.setPhase('structure')
+ ed.setStructureLayer('elements')
+ ed.setCatalogCategory(null)
+ ed.setMode('build')
+ ed.setTool(null)
+ } else if (type.id === 'kitchen') {
+ activateModularCabinetTool()
} else if (type.kind) {
activateBuildTool(type.kind)
}
@@ -300,13 +336,14 @@ export function BuildTab() {
// On open, land on the first build tool — parity with the community Build
// sidebar, so switching to Build immediately arms a usable tool. Skip when a
- // build tool is already active (e.g. the B shortcut armed one before this
- // panel mounted): the active tool is the source of truth, not this default.
+ // Build-tab tool or special mode is already active: the current editor state
+ // is the source of truth, including entry from another panel.
const didInitRef = useRef(false)
useEffect(() => {
if (didInitRef.current) return
didInitRef.current = true
const ed = useEditor.getState()
+ if (ed.mode === 'material-paint' || ed.mode === 'terrain-sculpt') return
if (ed.mode === 'build' && ed.tool) return
const firstType = buildTypes.find((t) => t.kind)
if (firstType) handleTypeClick(firstType)
@@ -364,52 +401,133 @@ export function BuildTab() {
- ) : mode === 'build' &&
- (activeTool === 'roof' || isRoofFeatureActive) &&
- roofFeatures.length > 0 ? (
-
-
- Features & extensions
+ ) : mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) ? (
+
+
+
Roof type
+
+ {ROOF_TYPE_OPTIONS.map((roofType) => {
+ const active = activeTool === 'roof' && activeRoofType === roofType.value
+ return (
+ {
+ triggerSFX('sfx:menu-click')
+ activateRoofType(roofType.value)
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+ {roofType.label}
+
+ )
+ })}
+
+
+
{
+ const editor = useEditor.getState()
+ if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof')
+ }}
+ />
+ {activeRoofType === 'conical' && (
+
+ Select a curved wall to match its radius and arc.
+
+ )}
+
+ {roofFeatures.length > 0 ? (
+
+
+ Features & extensions
+
+
+
+ {roofFeatures.map((feature) => {
+ const active = mode === 'build' && feature.id === activeRoofFeatureId
+ return (
+
+
+ {
+ triggerSFX('sfx:menu-click')
+ activateRoofFeatureTool(feature)
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+
+
+
+
+ {feature.label}
+
+
+ )
+ })}
+
+
+
+ ) : null}
+
+ ) : isKitchenActive ? (
+
+
Kitchen
- {roofFeatures.map((feature) => {
- const active = mode === 'build' && activeTool === feature.kind
- return (
-
-
- {
- triggerSFX('sfx:menu-click')
- activateRoofFeatureTool(feature.kind)
- }}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
-
-
-
- {feature.label}
-
-
- )
- })}
+
+
+ {
+ triggerSFX('sfx:menu-click')
+ activateModularCabinetTool()
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+
+
+
+
+ Modular Cabinet
+
+
@@ -427,6 +545,7 @@ export function BuildTab() {
- {ductContext ? (
-
- Duct
- {
- triggerSFX('sfx:menu-click')
- activateBuildTool(activeTool === 'duct-fitting' ? 'duct-segment' : 'duct-fitting')
- }}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
- Add Fitting
-
-
- ) : null}
-
- {pipeContext ? (
-
- DWV Pipe
- {
- triggerSFX('sfx:menu-click')
- activateBuildTool(activeTool === 'pipe-fitting' ? 'pipe-segment' : 'pipe-fitting')
+ {(['duct-fitting', 'pipe-fitting'] as const)
+ .filter((kind) => (kind === 'duct-fitting' ? ductContext : pipeContext))
+ .map((kind) => (
+ {
+ if (option.id !== 'fittingType') return undefined
+ if (kind === 'duct-fitting' && value === 'elbow')
+ return '/icons/duct-fitting.webp'
+ return `/icons/fittings/${kind === 'duct-fitting' ? 'duct' : 'pipe'}-${value}.webp`
}}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
- Add Fitting
-
- {
- triggerSFX('sfx:menu-click')
- activateBuildTool(activeTool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap')
+ kind={kind}
+ onSelect={(option, value) => {
+ if (activeTool !== kind) {
+ const defaults = useEditor.getState().toolDefaults[kind]
+ activateBuildTool(kind)
+ if (defaults) useEditor.getState().setToolDefaults(kind, defaults)
+ }
+ option.set(value)
}}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
- Add Trap
-
-
- ) : null}
+ />
+ ))}
{liquidLineContext ? (
diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts
index 6708f1305c..fbebec2d12 100644
--- a/apps/editor/lib/bootstrap.ts
+++ b/apps/editor/lib/bootstrap.ts
@@ -10,8 +10,15 @@ import {
import { registerEditorHostPanel } from '@pascal-app/editor'
import { builtinPlugin } from '@pascal-app/nodes'
import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones'
+import {
+ environmentHostPanel,
+ environmentPlugin,
+ environmentPresentation,
+} from '@pascal-app/plugin-environment'
+import { poolHostPanel, poolPlugin } from '@pascal-app/plugin-pool'
import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape'
import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees'
+import { registerViewerPresentation } from '@pascal-app/viewer'
// Idempotency guards: HMR can reload this module, but `registerNode`
// throws on duplicate kinds. Flags live in the module closure so they
@@ -88,12 +95,17 @@ export async function loadExternalPlugins(): Promise
{
// so it is registered separately from the core plugin manifest.
extendPluginDiscovery(async () => [treesPlugin])
registerEditorHostPanel(treesHostPanel)
+extendPluginDiscovery(async () => [environmentPlugin])
+registerEditorHostPanel(environmentHostPanel)
+registerViewerPresentation(environmentPresentation)
extendPluginDiscovery(async () => [bonesPlugin])
// Opt-in: Bones ships uninstalled — users enable it per scene from the
// Plugins panel (engineering X-ray is a specialist view, not a default).
registerEditorHostPanel({ ...bonesHostPanel, defaultInstalled: false })
extendPluginDiscovery(async () => [mintPlugin])
registerEditorHostPanel(mintHostPanel)
+extendPluginDiscovery(async () => [poolPlugin])
+registerEditorHostPanel(poolHostPanel)
extendPluginDiscovery(async () => [streetscapePlugin])
// The upstream manifest still names 'Pascal' as creator; credit the author.
registerEditorHostPanel({
diff --git a/apps/editor/lib/build-tab-state.test.ts b/apps/editor/lib/build-tab-state.test.ts
new file mode 100644
index 0000000000..6e40d899b4
--- /dev/null
+++ b/apps/editor/lib/build-tab-state.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ getActiveRoofFeatureId,
+ ROOF_TYPE_OPTIONS,
+ type RoofFeatureIdentity,
+} from './build-tab-state'
+
+const FEATURES: RoofFeatureIdentity[] = [
+ { id: 'lean-to-extension', kind: 'lean-to-extension' },
+ { id: 'skylight', kind: 'skylight' },
+]
+
+describe('roof feature selection', () => {
+ test('does not select every accessory for the plain roof tool', () => {
+ expect(getActiveRoofFeatureId(FEATURES, 'roof')).toBeNull()
+ })
+
+ test('selects exactly the matching accessory', () => {
+ expect(getActiveRoofFeatureId(FEATURES, 'lean-to-extension')).toBe('lean-to-extension')
+ })
+
+ test('ignores missing tool identities', () => {
+ const malformed = FEATURES.map(({ id }) => ({ id }))
+ expect(getActiveRoofFeatureId(malformed, undefined)).toBeNull()
+ expect(getActiveRoofFeatureId(malformed, 'skylight')).toBeNull()
+ })
+})
+
+test('roof creation exposes every supported roof type', () => {
+ expect(ROOF_TYPE_OPTIONS.map((option) => option.value)).toEqual([
+ 'hip',
+ 'gable',
+ 'shed',
+ 'flat',
+ 'gambrel',
+ 'dutch',
+ 'mansard',
+ 'conical',
+ ])
+})
diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts
new file mode 100644
index 0000000000..478f4e559f
--- /dev/null
+++ b/apps/editor/lib/build-tab-state.ts
@@ -0,0 +1,25 @@
+import type { RoofType } from '@pascal-app/core'
+
+export type RoofFeatureIdentity = {
+ id: string
+ kind?: string
+}
+
+export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [
+ { label: 'Hip', value: 'hip' },
+ { label: 'Gable', value: 'gable' },
+ { label: 'Shed', value: 'shed' },
+ { label: 'Flat', value: 'flat' },
+ { label: 'Gambrel', value: 'gambrel' },
+ { label: 'Dutch', value: 'dutch' },
+ { label: 'Mansard', value: 'mansard' },
+ { label: 'Conical', value: 'conical' },
+]
+
+export function getActiveRoofFeatureId(
+ features: readonly RoofFeatureIdentity[],
+ activeTool: string | null | undefined,
+): string | null {
+ if (!activeTool) return null
+ return features.find((feature) => feature.kind === activeTool)?.id ?? null
+}
diff --git a/apps/editor/lib/floorplan-export-surface.test.ts b/apps/editor/lib/floorplan-export-surface.test.ts
new file mode 100644
index 0000000000..e424f94d7c
--- /dev/null
+++ b/apps/editor/lib/floorplan-export-surface.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from 'bun:test'
+import { exportFloorplanPdf, type FloorplanExportScope } from '@pascal-app/editor'
+
+// Runtime smoke assertion for the package-entry re-export (plan U2 / issue
+// #619): this test imports the whole @pascal-app/editor barrel, so if the
+// entry stops re-exporting `exportFloorplanPdf` or the `FloorplanExportScope`
+// type, the import fails at test-run time (and `check-types`) instead of the
+// regression passing silently. Runtime coverage of the export pipeline
+// itself lives in @pascal-app/editor's floorplan tests; here we only pin the
+// public surface.
+describe('package entry floorplan export surface', () => {
+ test('exportFloorplanPdf accepts every scope member', () => {
+ const scopes: FloorplanExportScope[] = ['full', 'structure']
+ expect(scopes).toEqual(['full', 'structure'])
+ expect(typeof exportFloorplanPdf).toBe('function')
+ })
+})
diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts
index ca63ca9c3e..fe90d58a34 100644
--- a/apps/editor/lib/graph-schema.test.ts
+++ b/apps/editor/lib/graph-schema.test.ts
@@ -1,4 +1,5 @@
import { expect, test } from 'bun:test'
+import { CabinetModuleNode, CabinetNode } from '@pascal-app/core/schema'
import { apiGraphSchema } from './graph-schema'
function buildGraph(nodes: Record, rootNodeIds: string[] = []) {
@@ -39,6 +40,28 @@ test('accepts a builtin container whose children include a plugin node id', () =
expect(apiGraphSchema.safeParse(graph).success).toBe(true)
})
+test('accepts a cabinet run containing a derived L-corner run', () => {
+ const source = CabinetNode.parse({
+ id: 'cabinet_graph-source',
+ children: ['cabinet_graph-derived'],
+ })
+ const derived = CabinetNode.parse({
+ id: 'cabinet_graph-derived',
+ parentId: source.id,
+ children: ['cabinet-module_graph-derived'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_graph-derived',
+ parentId: derived.id,
+ })
+
+ expect(
+ apiGraphSchema.safeParse(
+ buildGraph({ [source.id]: source, [derived.id]: derived, [module.id]: module }, [source.id]),
+ ).success,
+ ).toBe(true)
+})
+
test('keeps plugin child ids in the parsed graph', () => {
const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID])
diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts
index f597bfa73c..7a07027bee 100644
--- a/apps/editor/lib/graph-schema.ts
+++ b/apps/editor/lib/graph-schema.ts
@@ -1,4 +1,4 @@
-import { AnyNode, AssetUrl, BaseNode, SceneMaterial } from '@pascal-app/core/schema'
+import { AnyNode, AssetUrl, BaseNode, nodeKindOf, SceneMaterial } from '@pascal-app/core/schema'
import { z } from 'zod'
/**
@@ -24,9 +24,7 @@ import { z } from 'zod'
* hostile scheme where `AssetUrl` already enumerates the safe ones.
*/
-const KNOWN_TYPES = new Set(
- AnyNode.options.map((o) => o.shape.type.parse(undefined) as string),
-)
+const KNOWN_TYPES = new Set(AnyNode.options.map(nodeKindOf))
/** The envelope every persisted node satisfies, builtin or foreign. */
const ForeignNodeEnvelope = BaseNode.extend({
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts
index 48578416b9..44cffefdbc 100644
--- a/apps/editor/next.config.ts
+++ b/apps/editor/next.config.ts
@@ -32,10 +32,12 @@ const nextConfig: NextConfig = {
'@pascal-app/core',
'@pascal-app/editor',
'@pascal-app/mcp',
+ '@pascal-app/plugin-pool',
'@pascal-app/plugin-streetscape',
'@pascal-app/plugin-trees',
'@mint/pascal-plugin',
'@pascal-app/plugin-bones',
+ '@pascal-app/plugin-environment',
'@dgreenheck/ez-tree',
],
turbopack: {
diff --git a/apps/editor/package.json b/apps/editor/package.json
index 2b2ccc0b50..d60a2f7ab0 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -13,13 +13,15 @@
},
"dependencies": {
"@iconify/react": "^6.0.2",
- "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
"@mint/pascal-plugin": "github:mintdotgg/mint-pascal-plugin#902c546dbaece6b31455c0dc394afe6b9cd136fe",
"@number-flow/react": "^0.6.0",
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
+ "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
+ "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295",
+ "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz",
"@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
@@ -33,11 +35,12 @@
"next": "16.3.0",
"postcss": "^8.5.6",
"react": "^19.2.4",
+ "react-colorful": "^5.6.1",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
- "zod": "^4.3.5"
+ "three": "^0.186.0",
+ "zod": ">=4.5.4 <4.6"
},
"devDependencies": {
"@pascal/typescript-config": "*",
diff --git a/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 b/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3
deleted file mode 100644
index ebcf4dd7b7..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 b/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3
deleted file mode 100644
index 973955ba31..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 b/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3
deleted file mode 100644
index d4ec67d752..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 b/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3
deleted file mode 100644
index 88915bad39..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 b/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3
deleted file mode 100644
index a8967b205d..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 b/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3
deleted file mode 100644
index ed7b590fee..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 b/apps/editor/public/audios/radios/classic/Glass Atrium.mp3
deleted file mode 100644
index 460f72f910..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 b/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3
deleted file mode 100644
index 9b4e527515..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 b/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3
deleted file mode 100644
index 120d20ab00..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 b/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3
deleted file mode 100644
index 037cd88902..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/sfx/success.mp3 b/apps/editor/public/audios/sfx/success.mp3
new file mode 100644
index 0000000000..fc85f51765
Binary files /dev/null and b/apps/editor/public/audios/sfx/success.mp3 differ
diff --git a/apps/editor/public/icons/fittings/README.md b/apps/editor/public/icons/fittings/README.md
new file mode 100644
index 0000000000..f1ca01776d
--- /dev/null
+++ b/apps/editor/public/icons/fittings/README.md
@@ -0,0 +1,32 @@
+# Fitting thumbnails
+
+Generated with the built-in image generation tool using `../duct-fitting.webp` as the style reference. The existing duct elbow keeps that original icon.
+
+The generator emits a large PNG; re-encode it before committing, because these ship in the portable CLI runtime and render at 56 px:
+
+```sh
+cwebp -q 85 -m 6 -alpha_q 100 -resize 256 256 .png -o .webp
+```
+
+## Prompt template
+
+Create one catalog thumbnail asset for SUBJECT. Reference image is STYLE REFERENCE ONLY. Match its polished lavender purple 3D isometric product icon, soft lilac highlights, darker violet interiors, fine bright edges. Actual subject must be SUBJECT, not the reference elbow. Single isolated object centered, occupying 78% of square canvas. Three-quarter view from above showing its identifying geometry clearly at 56px. Transparent background, no floor, no text, no labels, no watermark, no additional objects. Save generated asset. Asset identifier NAME.
+
+## Subjects
+
+- `duct-tee.webp`: rectangular HVAC duct T junction with exactly three rectangular flanged openings.
+- `duct-cross.webp`: rectangular HVAC duct cross junction with exactly four rectangular flanged openings.
+- `duct-reducer.webp`: round HVAC concentric reducer, wide circular opening tapering into a smaller circular opening.
+- `duct-transition.webp`: HVAC transition from a wide rectangular flanged opening to a round circular collar.
+- `duct-end-cap.webp`: short rectangular HVAC duct end cap with a sealed flat rectangular face and flanged rim.
+- `duct-damper.webp`: rectangular HVAC balancing damper, short flanged hollow rectangular sleeve with a visible internal blade and external adjustment lever.
+- `duct-access-panel.webp`: rectangular HVAC access door panel, flat framed closed door with two hinges and two latches.
+- `duct-coupling.webp`: short straight rectangular HVAC coupling sleeve with two opposite equal rectangular openings and a center seam.
+- `pipe-elbow.webp`: round plumbing pipe 90 degree curved elbow, exactly two circular socket openings.
+- `pipe-wye.webp`: round plumbing pipe Y junction with three circular socket openings, branch at 45 degrees.
+- `pipe-sanitary-tee.webp`: round plumbing sanitary tee with three circular socket openings, curved sweeping side branch.
+- `pipe-cross.webp`: round plumbing cross fitting with four circular socket openings.
+- `pipe-end-cap.webp`: round plumbing pipe end cap with a closed circular end.
+- `pipe-cleanout.webp`: round plumbing cleanout fitting with a prominent threaded hexagonal removable plug sealing its end.
+- `pipe-reducer.webp`: round plumbing concentric reducer with wide circular socket tapering to narrow circular socket.
+- `pipe-coupling.webp`: short straight round plumbing coupling with two equal circular socket openings.
diff --git a/apps/editor/public/icons/fittings/duct-access-panel.webp b/apps/editor/public/icons/fittings/duct-access-panel.webp
new file mode 100644
index 0000000000..e111436c31
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-access-panel.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-coupling.webp b/apps/editor/public/icons/fittings/duct-coupling.webp
new file mode 100644
index 0000000000..7a2850397a
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-coupling.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-cross.webp b/apps/editor/public/icons/fittings/duct-cross.webp
new file mode 100644
index 0000000000..2bf4440cd7
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-cross.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-damper.webp b/apps/editor/public/icons/fittings/duct-damper.webp
new file mode 100644
index 0000000000..d6b097fbbe
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-damper.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-end-cap.webp b/apps/editor/public/icons/fittings/duct-end-cap.webp
new file mode 100644
index 0000000000..ac03974669
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-end-cap.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-reducer.webp b/apps/editor/public/icons/fittings/duct-reducer.webp
new file mode 100644
index 0000000000..5af2254b8b
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-reducer.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-tee.webp b/apps/editor/public/icons/fittings/duct-tee.webp
new file mode 100644
index 0000000000..9afdc9f266
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-tee.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-transition.webp b/apps/editor/public/icons/fittings/duct-transition.webp
new file mode 100644
index 0000000000..4af29ceb75
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-transition.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-cleanout.webp b/apps/editor/public/icons/fittings/pipe-cleanout.webp
new file mode 100644
index 0000000000..8837457471
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-cleanout.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-coupling.webp b/apps/editor/public/icons/fittings/pipe-coupling.webp
new file mode 100644
index 0000000000..ead21a241e
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-coupling.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-cross.webp b/apps/editor/public/icons/fittings/pipe-cross.webp
new file mode 100644
index 0000000000..de6c033e61
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-cross.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-elbow.webp b/apps/editor/public/icons/fittings/pipe-elbow.webp
new file mode 100644
index 0000000000..fd7ca58e34
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-elbow.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-end-cap.webp b/apps/editor/public/icons/fittings/pipe-end-cap.webp
new file mode 100644
index 0000000000..24695065a8
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-end-cap.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-reducer.webp b/apps/editor/public/icons/fittings/pipe-reducer.webp
new file mode 100644
index 0000000000..ef3a835947
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-reducer.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp b/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp
new file mode 100644
index 0000000000..fb2bf2bcad
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-wye.webp b/apps/editor/public/icons/fittings/pipe-wye.webp
new file mode 100644
index 0000000000..8eb29b1e3b
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-wye.webp differ
diff --git a/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png b/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png
deleted file mode 100644
index edfdc6af86..0000000000
Binary files a/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png and /dev/null differ
diff --git a/apps/editor/public/items/small-kitchen-cabinet/model.glb b/apps/editor/public/items/small-kitchen-cabinet/model.glb
deleted file mode 100644
index aa28eb1ced..0000000000
Binary files a/apps/editor/public/items/small-kitchen-cabinet/model.glb and /dev/null differ
diff --git a/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp b/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp
deleted file mode 100644
index e8a1ad05f3..0000000000
Binary files a/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp and /dev/null differ
diff --git a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz
new file mode 100644
index 0000000000..4461b548cb
Binary files /dev/null and b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz differ
diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx
index 829a36d5d3..663e9aa024 100644
--- a/apps/ifc-converter/components/IfcConverter.tsx
+++ b/apps/ifc-converter/components/IfcConverter.tsx
@@ -13,7 +13,7 @@ const PascalViewer = dynamic(() => import('./PascalSceneViewer'), { ssr: false }
type Status = 'idle' | 'loading' | 'converting' | 'ready' | 'error'
// The converter writes a fixed shape into BaseNode.metadata, but the
-// underlying type is z.json() — a loose JSON value. This helper gives
+// underlying type is an open `Record`. This helper gives
// the UI dot-access on the fields the converter actually writes.
type ConverterMetadata = {
ifcType?: string
@@ -66,7 +66,7 @@ export default function IfcConverter() {
}, [pascalData])
const elementTypes = useMemo(() => {
- const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'item']
+ const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'block', 'item']
return order.filter((t) => typeCounts[t])
}, [typeCounts])
diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json
index ed86452bfc..c5798f6e32 100644
--- a/apps/ifc-converter/package.json
+++ b/apps/ifc-converter/package.json
@@ -29,9 +29,8 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
- "web-ifc": "^0.0.77",
- "zod": "^4.3.5"
+ "three": "^0.186.0",
+ "web-ifc": "^0.0.77"
},
"devDependencies": {
"@pascal/typescript-config": "*",
diff --git a/assets/pascal-mark-plate.png b/assets/pascal-mark-plate.png
new file mode 100644
index 0000000000..d9c7b80dff
Binary files /dev/null and b/assets/pascal-mark-plate.png differ
diff --git a/assets/pascal-mark-plate.svg b/assets/pascal-mark-plate.svg
new file mode 100644
index 0000000000..0f8a41d8d1
--- /dev/null
+++ b/assets/pascal-mark-plate.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/assets/pascal-mark.svg b/assets/pascal-mark.svg
new file mode 100644
index 0000000000..176994e5a4
--- /dev/null
+++ b/assets/pascal-mark.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/biome.jsonc b/biome.jsonc
index c725787202..8e545ad325 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -89,6 +89,11 @@
"files": {
"ignoreUnknown": true,
"includes": [
+ "scripts/**/*.ts",
+ "skills/**/*.json",
+ ".agents/plugins/**/*.json",
+ ".claude-plugin/**/*.json",
+ ".codex-plugin/**/*.json",
"packages/**/*.ts",
"packages/**/*.tsx",
"packages/**/*.js",
diff --git a/bun.lock b/bun.lock
index d39eff23ae..b63a089de4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,6 +8,7 @@
"@biomejs/biome": "^2.4.16",
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"dotenv-cli": "^11.0.0",
+ "fast-xml-parser": "^5.4.2",
"turbo": "^2.9.17",
"typescript": "6.0.3",
"ultracite": "^7.8.2",
@@ -35,6 +36,8 @@
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
+ "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295",
+ "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz",
"@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
@@ -48,11 +51,12 @@
"next": "16.3.0",
"postcss": "^8.5.6",
"react": "^19.2.4",
+ "react-colorful": "^5.6.1",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
- "zod": "^4.3.5",
+ "three": "^0.186.0",
+ "zod": ">=4.5.4 <4.6",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -86,9 +90,8 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
+ "three": "^0.186.0",
"web-ifc": "^0.0.77",
- "zod": "^4.3.5",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -99,53 +102,14 @@
"typescript": "7.0.2",
},
},
- "packages/capture-protocol": {
- "name": "@pascal-app/capture-protocol",
- "version": "1.0.0-beta.4",
- "dependencies": {
- "zod": "^4.3.5",
- },
- "devDependencies": {
- "@pascal/typescript-config": "*",
- "@types/bun": "^1.3.0",
- "typescript": "6.0.3",
- },
- },
- "packages/capture-viewer": {
- "name": "@pascal-app/capture-viewer",
- "version": "1.0.0-beta.4",
- "devDependencies": {
- "@pascal-app/capture-protocol": "^1.0.0-beta.4",
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
- "@pascal/typescript-config": "*",
- "@react-three/drei": "^10.7.7",
- "@react-three/fiber": "^9.5.0",
- "@types/bun": "^1.3.0",
- "@types/react": "^19.2.2",
- "@types/three": "^0.184.0",
- "react": "^19.2.4",
- "three": "^0.185.0",
- "typescript": "6.0.3",
- },
- "peerDependencies": {
- "@pascal-app/capture-protocol": "^1.0.0-beta.4",
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
- "@react-three/drei": "^10",
- "@react-three/fiber": "^9",
- "react": "^18 || ^19",
- "three": "^0.185",
- },
- },
"packages/cli": {
"name": "@pascal-app/cli",
- "version": "1.0.0-beta.1",
+ "version": "1.0.0",
"bin": {
"pascal": "dist/bin/pascal.js",
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.29.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -155,14 +119,13 @@
},
"packages/core": {
"name": "@pascal-app/core",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0",
"dependencies": {
- "@pascal-app/capture-protocol": "^1.0.0-beta.4",
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
- "zod": "^4.3.5",
+ "zod": ">=4.5.4 <4.6",
"zundo": "^2.3.0",
"zustand": "^5",
},
@@ -178,12 +141,12 @@
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
- "three": "^0.185",
+ "three": "^0.186",
},
},
"packages/editor": {
"name": "@pascal-app/editor",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -219,13 +182,14 @@
"tailwind-merge": "^3.5.0",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "~0.9.8",
- "zod": "^4.3.6",
+ "zod": ">=4.5.4 <4.6",
"zustand": "^5.0.11",
},
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
- "@pascal-app/viewer": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@pascal/typescript-config": "*",
+ "@react-three/test-renderer": "^9.1.0",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
@@ -237,14 +201,14 @@
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
- "@pascal-app/viewer": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"next": ">=15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
- "three": "^0.185",
+ "three": "^0.186",
},
},
"packages/eslint-config": {
@@ -266,9 +230,9 @@
},
"packages/ifc-converter": {
"name": "@pascal-app/ifc-converter",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0",
"dependencies": {
- "@pascal-app/core": "*",
+ "@pascal-app/core": "^1.0.0",
"nanoid": "^5.1.6",
"web-ifc": "^0.0.77",
},
@@ -280,48 +244,50 @@
},
"packages/mcp": {
"name": "@pascal-app/mcp",
- "version": "1.0.0-beta.6",
+ "version": "1.0.0",
"bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js",
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.29.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
"@pascal-app/lingo": "^0.2.0",
- "zod": "^4.3.5",
+ "zod": ">=4.5.4 <4.6",
},
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
"@pascal/typescript-config": "*",
"@types/node": "^22.19.20",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
},
},
"packages/nodes": {
"name": "@pascal-app/nodes",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0",
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
- "@pascal-app/editor": "^1.0.0-beta.5",
- "@pascal-app/viewer": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/editor": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
- "@pascal-app/editor": "^1.0.0-beta.5",
- "@pascal-app/viewer": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/editor": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"lucide-react": "^1",
"react": "^18 || ^19",
- "three": "^0.185",
+ "react-dom": "^18 || ^19",
+ "three": "^0.186",
"zustand": "^5",
},
},
@@ -348,26 +314,29 @@
},
"packages/viewer": {
"name": "@pascal-app/viewer",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0",
"dependencies": {
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5",
},
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
"@pascal/typescript-config": "*",
+ "@react-three/test-renderer": "^9.1.0",
"@types/node": "^22",
"@types/react": "^19.2.2",
+ "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.5",
+ "@pascal-app/core": "^1.0.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
- "three": "^0.185",
+ "react-dom": "^18 || ^19",
+ "three": "^0.186",
},
},
"tooling/typescript": {
@@ -381,7 +350,7 @@
"@types/three": "0.184.1",
"next": "16.3.0",
"react-grab": "0.1.50",
- "three": "0.185.1",
+ "three": "0.186.0",
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -580,9 +549,9 @@
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
- "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546"],
+ "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="],
- "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="],
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
@@ -766,10 +735,6 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="],
- "@pascal-app/capture-protocol": ["@pascal-app/capture-protocol@workspace:packages/capture-protocol"],
-
- "@pascal-app/capture-viewer": ["@pascal-app/capture-viewer@workspace:packages/capture-viewer"],
-
"@pascal-app/cli": ["@pascal-app/cli@workspace:packages/cli"],
"@pascal-app/core": ["@pascal-app/core@workspace:packages/core"],
@@ -784,11 +749,15 @@
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
- "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260"],
+ "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="],
+
+ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="],
- "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9"],
+ "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sha512-nu/7x5xF1k0IYnduNu1rh9YJ9hZrmjaRUiXADcDqF82Kc+GLjZFrPRRHCci37QplREY2lqePIXBYfTOXCQczgg=="],
- "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c"],
+ "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="],
+
+ "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
@@ -882,6 +851,8 @@
"@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="],
+ "@react-three/test-renderer": ["@react-three/test-renderer@9.1.1", "", { "peerDependencies": { "@react-three/fiber": ">=9.0.0", "react": "^19.0.0", "three": ">=0.156" } }, "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg=="],
+
"@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"],
"@repo/typescript-config": ["@repo/typescript-config@workspace:packages/typescript-config"],
@@ -1518,7 +1489,7 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
- "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -1808,6 +1779,8 @@
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
+ "react-colorful": ["react-colorful@5.8.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA=="],
+
"react-doctor": ["react-doctor@0.5.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@effect/platform-node-shared": "4.0.0-beta.70", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", "deslop-js": "^0.0.21", "effect": "4.0.0-beta.70", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxlint": "^1.66.0", "oxlint-plugin-react-doctor": "0.5.0", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-MEx7RgRv2kXp6HoPMzc7fFz5Mm3vCqS3Z7clkjmDbsPamjCz7TrDDWQarfaPr7woWA6FOAaWHt1bGhQ88cfEEw=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
@@ -1940,7 +1913,7 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
+ "three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="],
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
@@ -2076,7 +2049,7 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
- "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+ "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
@@ -2164,6 +2137,8 @@
"postcss/nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
+ "promise-worker-transferable/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
+
"react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="],
"react-doctor/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
@@ -2172,8 +2147,6 @@
"react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
- "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
-
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
@@ -2182,6 +2155,8 @@
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+ "ultracite/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -2198,6 +2173,8 @@
"react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+ "react-doctor/eslint-plugin-react-hooks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"deslop-js/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000000..958800cebc
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["./scripts/bun-preload-three.ts"]
+
+[test]
+preload = ["./scripts/bun-preload-three.ts"]
diff --git a/gemini-extension.json b/gemini-extension.json
new file mode 100644
index 0000000000..3d7c6ff80d
--- /dev/null
+++ b/gemini-extension.json
@@ -0,0 +1,12 @@
+{
+ "name": "pascal",
+ "version": "0.1.8",
+ "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.",
+ "contextFileName": "skills/README.md",
+ "mcpServers": {
+ "pascal": {
+ "command": "pascal",
+ "args": ["mcp", "connect"]
+ }
+ }
+}
diff --git a/mcp.json b/mcp.json
new file mode 100644
index 0000000000..8f2b4523da
--- /dev/null
+++ b/mcp.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
+ "mcpServers": {
+ "pascal": {
+ "type": "stdio",
+ "command": "pascal",
+ "args": ["mcp", "connect"]
+ }
+ }
+}
diff --git a/package.json b/package.json
index e199acdbe2..7d1528c169 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,11 @@
"format": "biome format --write",
"format:check": "biome format",
"check": "biome check",
+ "checks": "bun run check && bun run check-types",
"check:fix": "biome check --write",
"check-types": "turbo run check-types",
"test": "turbo run test",
+ "skills:validate": "bun scripts/validate-skills.ts && bun test scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache",
"restart": "bun kill && bun clean:cache && bun dev",
@@ -30,6 +32,7 @@
"@biomejs/biome": "^2.4.16",
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"dotenv-cli": "^11.0.0",
+ "fast-xml-parser": "^5.4.2",
"turbo": "^2.9.17",
"typescript": "6.0.3",
"ultracite": "^7.8.2"
@@ -44,7 +47,7 @@
"@types/three": "0.184.1",
"next": "16.3.0",
"react-grab": "0.1.50",
- "three": "0.185.1"
+ "three": "0.186.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
diff --git a/packages/capture-protocol/README.md b/packages/capture-protocol/README.md
deleted file mode 100644
index a569059558..0000000000
--- a/packages/capture-protocol/README.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# `@pascal-app/capture-protocol`
-
-Transport-neutral capture-session contracts for Pascal viewers and hosts.
-
-The package contains versioned static manifests, a normalized session descriptor, packet headers
-for incremental data, and a `CaptureSource` interface that can be backed by HTTP, WebSocket,
-WebRTC, local files, or an in-memory producer. It does not contain authentication, persistence,
-React, Three.js, or a canonical network transport.
-
-```ts
-import {
- createHttpCaptureSource,
- type CaptureSessionLocator,
-} from '@pascal-app/capture-protocol'
-
-const locator: CaptureSessionLocator = {
- sessionId: 'capture_123',
- manifestUrl: '/api/captures/capture_123/manifest',
-}
-
-const source = createHttpCaptureSource(locator, { credentials: 'include' })
-const descriptor = await source.describe()
-```
-
-For live producers, use `PushCaptureSource` directly or implement `CaptureSource.subscribe()` with
-the same descriptor and packet event contract.
-
-The package exports its TypeScript source under the `react-native` condition so Metro can consume
-the workspace package from a clean checkout. Web and Node consumers continue to use the compiled
-ES module output.
diff --git a/packages/capture-protocol/package.json b/packages/capture-protocol/package.json
deleted file mode 100644
index 2c3184b39f..0000000000
--- a/packages/capture-protocol/package.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "name": "@pascal-app/capture-protocol",
- "version": "1.0.0-beta.4",
- "description": "Transport-neutral capture-session manifests and live stream sources for Pascal",
- "type": "module",
- "main": "./dist/index.js",
- "types": "./src/index.ts",
- "exports": {
- ".": {
- "types": "./src/index.ts",
- "react-native": "./src/index.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- },
- "files": [
- "dist",
- "src",
- "README.md"
- ],
- "scripts": {
- "build": "tsc --build",
- "dev": "tsgo --build --watch",
- "test": "bun test src",
- "prepublishOnly": "npm run build"
- },
- "dependencies": {
- "zod": "^4.3.5"
- },
- "devDependencies": {
- "@pascal/typescript-config": "*",
- "@types/bun": "^1.3.0",
- "typescript": "6.0.3"
- },
- "keywords": [
- "3d",
- "capture",
- "point-cloud",
- "sensor-fusion",
- "streaming"
- ],
- "repository": {
- "type": "git",
- "url": "https://github.com/pascalorg/editor.git",
- "directory": "packages/capture-protocol"
- },
- "license": "MIT",
- "homepage": "https://github.com/pascalorg/editor/tree/main/packages/capture-protocol#readme",
- "bugs": "https://github.com/pascalorg/editor/issues"
-}
diff --git a/packages/capture-protocol/tsconfig.json b/packages/capture-protocol/tsconfig.json
deleted file mode 100644
index 06b4bb5999..0000000000
--- a/packages/capture-protocol/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "extends": "@pascal/typescript-config/react-library.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "noEmit": false,
- "composite": true,
- "incremental": true,
- "types": ["bun"]
- },
- "include": ["src"],
- "exclude": ["node_modules", "dist", "**/*.test.ts"]
-}
diff --git a/packages/capture-viewer/README.md b/packages/capture-viewer/README.md
deleted file mode 100644
index 01c04f61d5..0000000000
--- a/packages/capture-viewer/README.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# `@pascal-app/capture-viewer`
-
-Reference capture layers for `@pascal-app/viewer`.
-
-Mount `CaptureRuntime` as a child of `Viewer` and provide a source resolver. The host owns access
-control and transport; the runtime owns source lifecycle, scan-node placement, layer visibility,
-and reference renderers for RoomPlan models, device trajectories, and PLY/live point clouds.
-
-```tsx
-
- reportCaptureError(error, context)}
- resolveSource={(locator) =>
- createHttpCaptureSource(locator, { credentials: 'include' })
- }
- retryKey={retryVersion}
- />
-
-```
-
-Unknown streams remain in the descriptor and can be rendered by passing a custom renderer keyed by
-stream role or kind. A live transport implements `CaptureSource.subscribe()`; no particular
-WebSocket, WebRTC, or collaboration backend is required by this package.
-
-`CaptureRuntime` keeps telemetry host-neutral: pass `onError` to report source or per-stream
-failures in the host, then increment `retryKey` to reload every affected session. Direct
-`useCaptureSource()` consumers can call its `retry()` function instead.
-
-Hosts can pass `defaultLayerVisibility` to keep expensive optional layers disabled until a user
-enables them. Persisted values in the scan node's `layers` map always override those host defaults;
-without host defaults, every available layer remains visible for backwards compatibility.
-Hidden sessions and layers are unmounted rather than only made visually transparent, so they stop
-raycasting, artifact work, animation, and live packet subscriptions while disabled.
diff --git a/packages/capture-viewer/package.json b/packages/capture-viewer/package.json
deleted file mode 100644
index 4829d0fa15..0000000000
--- a/packages/capture-viewer/package.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "name": "@pascal-app/capture-viewer",
- "version": "1.0.0-beta.4",
- "description": "Open capture-session runtime and reference renderers for the Pascal viewer",
- "type": "module",
- "main": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- ".": {
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- },
- "files": [
- "dist",
- "README.md"
- ],
- "scripts": {
- "build": "tsc --build",
- "dev": "tsgo --build --watch",
- "test": "bun test src",
- "prepublishOnly": "npm run build"
- },
- "peerDependencies": {
- "@pascal-app/capture-protocol": "^1.0.0-beta.4",
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
- "@react-three/drei": "^10",
- "@react-three/fiber": "^9",
- "react": "^18 || ^19",
- "three": "^0.185"
- },
- "devDependencies": {
- "@pascal-app/capture-protocol": "^1.0.0-beta.4",
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
- "@pascal/typescript-config": "*",
- "@react-three/drei": "^10.7.7",
- "@react-three/fiber": "^9.5.0",
- "@types/bun": "^1.3.0",
- "@types/react": "^19.2.2",
- "@types/three": "^0.184.0",
- "react": "^19.2.4",
- "three": "^0.185.0",
- "typescript": "6.0.3"
- },
- "keywords": [
- "3d",
- "capture",
- "point-cloud",
- "react-three-fiber",
- "viewer"
- ],
- "repository": {
- "type": "git",
- "url": "https://github.com/pascalorg/editor.git",
- "directory": "packages/capture-viewer"
- },
- "license": "MIT",
- "homepage": "https://github.com/pascalorg/editor/tree/main/packages/capture-viewer#readme",
- "bugs": "https://github.com/pascalorg/editor/issues"
-}
diff --git a/packages/capture-viewer/tsconfig.json b/packages/capture-viewer/tsconfig.json
deleted file mode 100644
index 3b503afab8..0000000000
--- a/packages/capture-viewer/tsconfig.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "extends": "@pascal/typescript-config/react-library.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "noEmit": false,
- "composite": true,
- "incremental": true,
- "types": ["bun"]
- },
- "include": ["src"],
- "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"],
- "references": [{ "path": "../capture-protocol" }, { "path": "../core" }, { "path": "../viewer" }]
-}
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 83d4f9de7d..db1d3dc3ae 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -17,10 +17,10 @@ for `status`, `logs`, `stop`, and future sessions without another setup step. If
global installation is unavailable because of local npm permissions, the editor remains
running and the CLI shows the equivalent `npx` commands plus the manual install command.
-The first run walks through local storage, runtime installation, automatic editor and
-MCP port selection, process startup, and both health checks with live terminal feedback.
-It then opens `http://pascal.localhost:`. Your projects are stored separately from
-the runtime, so updating the CLI does not replace your work.
+The first run walks through local storage, the one-time web runtime download, automatic
+editor and MCP port selection, process startup, and both health checks with live terminal
+feedback. It then opens `http://pascal.localhost:`. Your projects are stored
+separately from the runtime, so updating the CLI does not replace your work.
## Why use the CLI?
@@ -36,10 +36,14 @@ the runtime, so updating the CLI does not replace your work.
- Node.js 22.13 or newer
- npm, including when the CLI itself is launched with pnpm or Bun
- A browser, unless you pass `--no-open`
+- Network access the first time you start the editor, or a local copy of the web runtime
+ archive (see [The web editor runtime](#the-web-editor-runtime)); `pascal mcp connect`
+ needs neither
-The initial supported release is macOS. The packed runtime also passes automated
-release smoke tests on Ubuntu; broader Linux and Windows support is still being
-verified.
+The initial supported release is macOS. A clean claim-command installation also passed in
+a Linux arm64 container. This is not an x86_64 or Windows result.
+
+Use one active agent client per local CLI service. The standalone local HTTP runtime shares active scene state between clients; use separate `PASCAL_HOME` directories and service processes when independent concurrent work is required.
## Install and run
@@ -78,12 +82,57 @@ npx @pascal-app/cli editor --no-open
npx @pascal-app/cli editor --foreground --no-open
```
+## The web editor runtime
+
+The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and
+2.5 MB installed. The web editor itself—the Next.js server, its static assets, and the
+bundled item library—is published as one archive per CLI version, about 64 MB compressed
+and 106 MB on disk.
+
+Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`,
+`project open`, `update`) resolves that runtime in this order:
+
+1. `PASCAL_BUNDLED_RUNTIME_DIR`, an already-extracted runtime directory.
+2. `--runtime `, which every one of those commands accepts.
+3. The runtime already installed in `~/.pascal/runtime/` for this CLI version.
+4. The release asset recorded in the package, streamed into `~/.pascal/tmp` with download
+ progress in the terminal.
+
+A downloaded archive is checked against the SHA-256 digest published inside the npm
+package before anything is extracted. On a mismatch the CLI deletes the temporary file and
+installs nothing, so a corrupted or substituted archive never becomes your runtime.
+Concurrent first runs share one download through the runtime install lock.
+
+An offline or air-gapped machine can take the archive from the release page:
+
+```bash
+# On a connected machine
+curl --fail --location --remote-name \
+ "https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@/pascal-web-runtime-.tar.gz"
+
+# On the target machine
+pascal editor --runtime ./pascal-web-runtime-.tar.gz
+```
+
+An archive passed with `--runtime` is digest-verified exactly like a download. A directory
+is installed as it is, which is the escape hatch for a runtime you built yourself from this
+repository.
+
+`HTTPS_PROXY` (or `ALL_PROXY`), including a proxy that requires basic authentication, and
+`NO_PROXY` are honoured; only `https://` URLs are accepted. When a download fails, the CLI
+prints the archive URL, the expected digest, and the `--runtime` command to run after
+copying the file across.
+
+Agent tools need none of this. `pascal mcp connect` starts the MCP service that ships in
+the npm package, so an agent can read and write local projects on a machine that has never
+downloaded the web runtime.
+
## Commands
| Command | Purpose |
| --- | --- |
-| `pascal editor` | Install if needed, ensure the editor is running, and open it. |
-| `pascal start` | Ensure the editor is running without opening a browser. |
+| `pascal editor [--runtime ]` | Install the web runtime if needed, ensure the editor is running, and open it. |
+| `pascal start [--runtime ]` | Ensure the editor is running without opening a browser. |
| `pascal stop [--force]` | Stop the managed editor and MCP processes; `--force` is a guarded recovery path. |
| `pascal restart` | Restart the editor and MCP service with their current configuration. |
| `pascal status [--json]` | Show editor and MCP health, version, PIDs, ports, URL, and runtime metadata. |
@@ -91,12 +140,14 @@ npx @pascal-app/cli editor --foreground --no-open
| `pascal resume [project]` | Open the latest project, or a selected project. |
| `pascal projects [--json]` | List local projects. |
| `pascal logs [--follow]` | Read or follow the managed editor log. |
-| `pascal update [--version ]` | Health-check and activate a published runtime. |
+| `pascal update [--version ] [--runtime ]` | Health-check and activate the runtime this CLI publishes, or an npm-published target. |
| `pascal doctor [--json]` | Diagnose Node.js, storage, runtime, process, and plugin state. |
| `pascal info [--json]` | Print platform, paths, runtime, and plugin context. |
| `pascal project list [--json]` | Explicit form of `pascal projects`. |
| `pascal project open ` | Explicit form of `pascal open `. |
-| `pascal mcp connect` | Stable local connector for MCP clients; discovers the dynamic managed service. |
+| `pascal agent claim [--no-open] [--json]` | Link an autonomous hosted agent to the person accountable for it. |
+| `pascal agent status [--json]` | Verify the hosted agent credential and inspect its claim and organization scope. |
+| `pascal mcp connect` | Stable local connector for MCP clients; starts the bundled MCP service without the web runtime. |
| `pascal mcp status [--json]` | Show managed MCP health. |
| `pascal mcp config [--json]` | Print generic MCP client configuration. |
| `pascal mcp setup ` | Configure an installed client without overwriting existing entries. |
@@ -113,11 +164,13 @@ directory; client configuration never contains that token.
```text
~/.pascal/
- runtime// installed editor runtimes
+ runtime// installed web editor runtimes
data/pascal.db projects and scenes
- logs/editor.log detached editor output
- run/editor.json managed editor and MCP process identity
+ logs/editor.log detached editor and MCP output
+ run/editor.json managed editor process identity
+ run/mcp.json managed MCP service identity
run/mcp-token private local MCP token
+ tmp/ runtime downloads in progress
plugins/ reserved verified-plugin storage
pascal.plugins.lock reserved managed-plugin lock
```
@@ -129,17 +182,44 @@ have accumulated.
## Local AI agents
-The MCP server starts automatically with `pascal editor`. Add the stable connector to
-your client once:
+The MCP service ships in the npm package. It starts automatically with `pascal editor`, and
+`pascal mcp connect` starts it on its own—no web runtime download, no editor process. Add
+the stable connector to your client once:
```bash
pascal mcp setup codex
pascal mcp setup claude
```
-Or use `pascal mcp config` for JSON-based clients. The connector also starts Pascal
-when an agent connects while it is stopped. Ask the agent to read
-`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`.
+Or use `pascal mcp config` for JSON-based clients. Ask the agent to read
+`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. Those
+`editorUrl` values point at the local editor; run `pascal editor` to open one, which is
+also when the web runtime is downloaded.
+
+## Hosted autonomous agents
+
+An autonomous agent registered with hosted Pascal receives its own API key and identity. The
+agent can create a short-lived claim code so the person working with it can establish the
+accountability link:
+
+```bash
+PASCAL_API_KEY='sk_live_...' pascal agent claim
+PASCAL_API_KEY='sk_live_...' pascal agent status
+```
+
+The CLI sends that key once to Pascal's claim endpoint, does not store or print it, and opens
+the claim page. Use `--no-open` on a headless host. `--json` returns structured output without
+opening a browser. A new claim request supersedes the agent's previous code; each code expires
+after 15 minutes.
+
+`pascal agent status` confirms that the credential remains active and reports the agent ID,
+autonomous or delegated mode, claim state, and whether the key is scoped to an organization.
+It does not expose the accountable person's identity or inspect local editor projects.
+
+Claiming lifts claim-gated capabilities for the autonomous agent. It does not transfer project
+ownership, grant the agent access to the person's private projects, or grant the person access
+to the agent's private projects. The local editor and its projects remain local unless a
+separate hosted project action explicitly moves data.
## Plugins
diff --git a/packages/cli/package.json b/packages/cli/package.json
index dae494cfea..962a699758 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/cli",
- "version": "1.0.0-beta.1",
+ "version": "1.0.0",
"description": "Run the open-source Pascal 3D editor, local projects, and MCP agent tools from your terminal",
"type": "module",
"bin": {
@@ -35,7 +35,7 @@
"typescript": "6.0.3"
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.29.0"
+ "@modelcontextprotocol/sdk": "^1.30.0"
},
"engines": {
"node": ">=22.13.0"
diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts
index 763fd39ff4..9db2d6db73 100644
--- a/packages/cli/scripts/smoke-packed-runtime.ts
+++ b/packages/cli/scripts/smoke-packed-runtime.ts
@@ -1,5 +1,7 @@
import { spawn } from 'node:child_process'
-import { mkdtemp, rm } from 'node:fs/promises'
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { copyFile, mkdtemp, open, readFile, rm, stat } from 'node:fs/promises'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
@@ -11,10 +13,17 @@ const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url
const smokeRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-smoke-'))
let tarballPath: string | null = null
let smokeExecutable: string | null = null
+let mcpOnlyExecutable: string | null = null
const defaultPortBlocker = http.createServer((_request, response) => {
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({ status: 'ok', app: 'foreign' }))
})
+/** MCP-only mode is verified in its own home so no web runtime can be installed there. */
+const mcpOnlyEnvironment = {
+ ...process.env,
+ PASCAL_HOME: path.join(smokeRoot, 'home-mcp-only'),
+ PASCAL_NO_OPEN: '1',
+}
const smokeEnvironment = {
...process.env,
PASCAL_HOME: path.join(smokeRoot, 'home'),
@@ -31,22 +40,141 @@ try {
if (!artifact) throw new Error('npm pack did not return an artifact')
tarballPath = path.join(packageDirectory, artifact.filename)
enforceArtifactBudget(artifact)
+ const runtimeArchive = await verifyStagedWebRuntime()
const installDirectory = path.join(smokeRoot, 'install')
await run('npm', ['install', '--ignore-scripts', '--prefix', installDirectory, tarballPath])
- smokeExecutable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js')
+ const executable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js')
+
+ mcpOnlyExecutable = executable
+ await checkMcpWithoutWebRuntime(executable)
+ mcpOnlyExecutable = null
+
+ smokeExecutable = executable
+ await checkTamperedArchiveIsRejected(executable, runtimeArchive.file)
+ await checkEditorFromLocalArchive(executable, runtimeArchive.file)
+ smokeExecutable = null
+
+ console.log(
+ `Packed CLI smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`,
+ )
+ console.log(
+ `Web runtime archive ${path.basename(runtimeArchive.file)} (${formatMb(runtimeArchive.size)} MB) verified against ${runtimeArchive.url}`,
+ )
+} finally {
+ await close(defaultPortBlocker)
+ for (const [command, environment] of [
+ [smokeExecutable, smokeEnvironment],
+ [mcpOnlyExecutable, mcpOnlyEnvironment],
+ ] as Array<[string | null, NodeJS.ProcessEnv]>) {
+ if (!command) continue
+ await run(
+ process.execPath,
+ [command, 'stop', '--force', '--json'],
+ undefined,
+ environment,
+ ).catch(() => undefined)
+ }
+ if (tarballPath) await rm(tarballPath, { force: true })
+ await rm(smokeRoot, { recursive: true, force: true })
+}
+
+/**
+ * Phase 1: agent tools must work on a machine that has never downloaded the web runtime.
+ */
+async function checkMcpWithoutWebRuntime(executable: string): Promise {
+ const client = new Client({ name: 'pascal-cli-smoke-mcp-only', version: '0.0.0' })
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [executable, 'mcp', 'connect'],
+ env: mcpOnlyEnvironment as Record,
+ stderr: 'pipe',
+ })
+ try {
+ await client.connect(transport)
+ const tools = await client.listTools()
+ if (!tools.tools.some((tool) => tool.name === 'save_scene')) {
+ throw new Error('MCP-only mode did not expose save_scene')
+ }
+ const saved = await client.callTool({
+ name: 'save_scene',
+ arguments: { id: 'mcp-only-project', name: 'MCP only project' },
+ })
+ if (saved.isError) throw new Error(`MCP-only save_scene failed: ${JSON.stringify(saved)}`)
+ const listed = await client.callTool({ name: 'list_scenes', arguments: {} })
+ if (listed.isError || !JSON.stringify(listed).includes('mcp-only-project')) {
+ throw new Error(`MCP-only list_scenes failed: ${JSON.stringify(listed)}`)
+ }
+ console.log(
+ `MCP-only mode exposed ${tools.tools.length} tools and stored a scene with no web runtime installed.`,
+ )
+ } finally {
+ await client.close()
+ }
+ const status = JSON.parse(
+ (await run(process.execPath, [executable, 'status', '--json'], undefined, mcpOnlyEnvironment))
+ .stdout,
+ ) as { installed: boolean; running: boolean; runtime: unknown; mcp: { healthy: boolean } }
+ if (status.installed || status.runtime !== null || status.running) {
+ throw new Error('MCP-only mode installed or started the web runtime')
+ }
+ if (!status.mcp.healthy) throw new Error('the managed MCP service is not healthy on its own')
+ const stopped = JSON.parse(
+ (await run(process.execPath, [executable, 'stop', '--json'], undefined, mcpOnlyEnvironment))
+ .stdout,
+ ) as { stopped: boolean }
+ if (!stopped.stopped) throw new Error('stop did not report the MCP-only service as stopped')
+}
+
+/** Phase 2: a modified archive must never reach the runtime directory. */
+async function checkTamperedArchiveIsRejected(
+ executable: string,
+ archiveFile: string,
+): Promise {
+ const tampered = path.join(smokeRoot, 'tampered-web-runtime.tar.gz')
+ await copyFile(archiveFile, tampered)
+ const handle = await open(tampered, 'r+')
+ try {
+ const offset = Math.floor((await handle.stat()).size / 2)
+ const byte = Buffer.alloc(1)
+ await handle.read(byte, 0, 1, offset)
+ byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff
+ await handle.write(byte, 0, 1, offset)
+ } finally {
+ await handle.close()
+ }
+ const failure = await runExpectingFailure(
+ process.execPath,
+ [executable, 'editor', '--no-open', '--json', '--runtime', tampered],
+ smokeEnvironment,
+ )
+ const reported = JSON.parse(failure.stderr) as { error: string; message: string }
+ if (reported.error !== 'runtime_digest_mismatch') {
+ throw new Error(`a tampered archive was not rejected: ${failure.stderr}`)
+ }
+ await stat(tampered)
+ const status = JSON.parse(
+ (await run(process.execPath, [executable, 'status', '--json'], undefined, smokeEnvironment))
+ .stdout,
+ ) as { installed: boolean }
+ if (status.installed) throw new Error('a tampered archive was installed')
+ console.log(`Tampered archive rejected: ${reported.message.split('\n')[0]}`)
+}
+/** Phase 3: the offline install path, then the full editor and MCP flow over that runtime. */
+async function checkEditorFromLocalArchive(executable: string, archiveFile: string): Promise {
const started = JSON.parse(
(
await run(
process.execPath,
- [smokeExecutable, 'editor', '--no-open', '--json'],
+ [executable, 'editor', '--no-open', '--json', '--runtime', archiveFile],
undefined,
smokeEnvironment,
)
).stdout,
- ) as { pid: number; port: number; url: string }
+ ) as { pid: number; port: number; url: string; mcp: { port: number } }
if (started.port === 3000) throw new Error('editor reused the occupied default port')
+ if (!started.mcp?.port) throw new Error('the editor did not report a managed MCP port')
const rootResponse = await fetch(`http://127.0.0.1:${started.port}/`)
if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`)
const scenesResponse = await fetch(`${started.url}/scenes`)
@@ -55,7 +183,7 @@ try {
(
await run(
process.execPath,
- [smokeExecutable, 'editor', '--no-open', '--port', '0', '--json'],
+ [executable, 'editor', '--no-open', '--port', '0', '--json'],
undefined,
smokeEnvironment,
)
@@ -70,7 +198,7 @@ try {
}
const humanStart = await run(
process.execPath,
- [smokeExecutable, 'editor', '--no-open'],
+ [executable, 'editor', '--no-open'],
undefined,
smokeEnvironment,
)
@@ -82,13 +210,13 @@ try {
}
await run(
process.execPath,
- [smokeExecutable, 'project', 'list', '--json'],
+ [executable, 'project', 'list', '--json'],
undefined,
smokeEnvironment,
)
const mcpTransport = new StdioClientTransport({
command: process.execPath,
- args: [smokeExecutable, 'mcp', 'connect'],
+ args: [executable, 'mcp', 'connect'],
env: smokeEnvironment as Record,
stderr: 'pipe',
})
@@ -111,7 +239,7 @@ try {
(
await run(
process.execPath,
- [smokeExecutable, 'resume', 'Smoke project', '--json'],
+ [executable, 'resume', 'Smoke project', '--json'],
undefined,
smokeEnvironment,
)
@@ -120,25 +248,57 @@ try {
if (resumed.project.id !== 'smoke-project' || !resumed.url.endsWith('/scene/smoke-project')) {
throw new Error('CLI project resume did not resolve the MCP-saved project')
}
- await run(process.execPath, [smokeExecutable, 'doctor', '--json'], undefined, smokeEnvironment)
- await run(process.execPath, [smokeExecutable, 'stop', '--json'], undefined, smokeEnvironment)
- smokeExecutable = null
-
console.log(
- `Packed runtime smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`,
+ `Editor installed from ${path.basename(archiveFile)} on port ${started.port}, MCP on port ${started.mcp.port}, and a scene round-tripped between MCP and the CLI.`,
)
-} finally {
- await close(defaultPortBlocker)
- if (smokeExecutable) {
- await run(
- process.execPath,
- [smokeExecutable, 'stop', '--force', '--json'],
- undefined,
- smokeEnvironment,
- ).catch(() => undefined)
+ await run(process.execPath, [executable, 'doctor', '--json'], undefined, smokeEnvironment)
+ await run(process.execPath, [executable, 'stop', '--json'], undefined, smokeEnvironment)
+}
+
+async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; url: string }> {
+ const source = JSON.parse(
+ await readFile(path.join(packageDirectory, 'dist/runtime-source.json'), 'utf8'),
+ ) as { version: string; url: string; sha256: string; size: number }
+ const packageVersion = (
+ JSON.parse(await readFile(path.join(packageDirectory, 'package.json'), 'utf8')) as {
+ version: string
+ }
+ ).version
+ if (source.version !== packageVersion) {
+ throw new Error(`dist/runtime-source.json targets ${source.version}, not ${packageVersion}`)
}
- if (tarballPath) await rm(tarballPath, { force: true })
- await rm(smokeRoot, { recursive: true, force: true })
+ const archiveName = `pascal-web-runtime-${packageVersion}.tar.gz`
+ const expectedUrl = `https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@${packageVersion}/${archiveName}`
+ if (source.url !== expectedUrl) {
+ throw new Error(`dist/runtime-source.json points at ${source.url}, not ${expectedUrl}`)
+ }
+ const file = path.join(packageDirectory, 'build', archiveName)
+ const { size } = await stat(file)
+ if (size !== source.size) {
+ throw new Error(`${archiveName} is ${size} bytes; runtime-source.json records ${source.size}`)
+ }
+ const maximumArchiveSize = 70 * 1024 * 1024
+ if (size > maximumArchiveSize) {
+ throw new Error(
+ `the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`,
+ )
+ }
+ const digestFile = `${file}.sha256`
+ const recordedDigest = (await readFile(digestFile, 'utf8')).trim().split(/\s+/)[0]
+ if (recordedDigest !== source.sha256) {
+ throw new Error(`${digestFile} does not match dist/runtime-source.json`)
+ }
+ const hashed = await sha256(file)
+ if (hashed !== source.sha256) {
+ throw new Error(`${archiveName} hashes to ${hashed}, not the published ${source.sha256}`)
+ }
+ return { file, size, url: source.url }
+}
+
+async function sha256(filePath: string): Promise {
+ const hash = createHash('sha256')
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer)
+ return hash.digest('hex')
}
async function listen(server: http.Server): Promise {
@@ -164,14 +324,18 @@ interface PackedArtifact {
entryCount: number
}
+/**
+ * The npm package carries the CLI and the MCP service only. The web runtime rides a GitHub
+ * release asset, so both budgets are enforced separately.
+ */
function enforceArtifactBudget(artifact: {
size: number
unpackedSize: number
entryCount: number
}): void {
- const maximumSize = 105 * 1024 * 1024
- const maximumUnpackedSize = 160 * 1024 * 1024
- const maximumEntryCount = 4_000
+ const maximumSize = 3 * 1024 * 1024
+ const maximumUnpackedSize = 10 * 1024 * 1024
+ const maximumEntryCount = 250
if (
artifact.size > maximumSize ||
artifact.unpackedSize > maximumUnpackedSize ||
@@ -189,6 +353,31 @@ async function run(
cwd?: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<{ stdout: string; stderr: string }> {
+ const result = await capture(command, args, cwd, env)
+ if (result.exitCode !== 0) {
+ throw new Error(`${command} ${args.join(' ')} failed (${result.exitCode}): ${result.stderr}`)
+ }
+ return result
+}
+
+async function runExpectingFailure(
+ command: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+): Promise<{ stdout: string; stderr: string }> {
+ const result = await capture(command, args, undefined, env)
+ if (result.exitCode === 0) {
+ throw new Error(`${command} ${args.join(' ')} succeeded but should have failed`)
+ }
+ return result
+}
+
+async function capture(
+ command: string,
+ args: string[],
+ cwd?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command
const child = spawn(executable, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] })
const stdout: Buffer[] = []
@@ -199,14 +388,11 @@ async function run(
child.once('error', reject)
child.once('exit', (code) => resolve(code ?? 1))
})
- const result = {
+ return {
+ exitCode,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
}
- if (exitCode !== 0) {
- throw new Error(`${command} ${args.join(' ')} failed (${exitCode}): ${result.stderr}`)
- }
- return result
}
function formatMb(bytes: number): string {
diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts
index 69e27591d9..c973558fdd 100644
--- a/packages/cli/scripts/stage-runtime.ts
+++ b/packages/cli/scripts/stage-runtime.ts
@@ -1,14 +1,58 @@
import { spawn } from 'node:child_process'
-import { chmod, cp, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import {
+ chmod,
+ cp,
+ mkdir,
+ readdir,
+ readFile,
+ realpath,
+ rm,
+ stat,
+ writeFile,
+} from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
+import { fileSha256 } from '../src/runtime-download.js'
+import { createRuntimeArchive } from '../src/tar.js'
const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const repositoryRoot = path.resolve(packageDirectory, '../..')
const appDirectory = path.join(repositoryRoot, 'apps/editor')
const standaloneDirectory = path.join(appDirectory, '.next/standalone')
const standaloneAppDirectory = path.join(standaloneDirectory, 'apps/editor')
-const outputDirectory = path.join(packageDirectory, 'dist/runtime')
+/**
+ * The web runtime is a release asset, not part of the npm package: it is staged and archived
+ * under `build/`, while `dist/` only gains the MCP service and the digest of that archive.
+ */
+const buildDirectory = path.join(packageDirectory, 'build')
+const outputDirectory = path.join(buildDirectory, 'runtime')
+const releaseAssetBaseUrl = 'https://github.com/pascalorg/editor/releases/download'
+
+/**
+ * `next build` copies its tracing root into `.next/standalone`, so the portable runtime
+ * inherits app sources, repository documentation and build-time-only assets that
+ * `server.js` never reads. Every entry below was checked against the staged tree: nothing
+ * in `.next`, `node_modules` or the bundled MCP server resolves it.
+ */
+const buildOnlyRuntimePaths = [
+ 'apps/editor/app',
+ 'apps/editor/components',
+ 'apps/editor/lib',
+ 'apps/editor/AGENTS.md',
+ 'apps/editor/CLAUDE.md',
+ 'apps/editor/README.md',
+ 'apps/editor/bunfig.toml',
+ 'apps/editor/next.config.ts',
+ 'apps/editor/postcss.config.mjs',
+ 'apps/editor/tsconfig.json',
+ 'apps/editor/vercel.json',
+ // The radio catalogue is played by the hosted community app, which serves its own copy.
+ 'apps/editor/public/audios/radios',
+ // `next/dist/server/font-utils.js` is the sole reader of these font metrics and is
+ // itself unreachable from the standalone server.
+ 'node_modules/next/dist/server/capsize-font-metrics.json',
+ 'node_modules/next/dist/server/font-utils.js',
+]
const packageJson = JSON.parse(
await readFile(path.join(packageDirectory, 'package.json'), 'utf8'),
@@ -16,7 +60,15 @@ const packageJson = JSON.parse(
version: string
}
+const archiveName = `pascal-web-runtime-${packageJson.version}.tar.gz`
+const archiveFile = path.join(buildDirectory, archiveName)
+const assetUrl = `${releaseAssetBaseUrl}/@pascal-app/cli@${packageJson.version}/${archiveName}`
+
await chmod(path.join(packageDirectory, 'dist/bin/pascal.js'), 0o755)
+await bundleMcpServer(
+ path.join(packageDirectory, 'dist/services/pascal-mcp.mjs'),
+ packageJson.version,
+)
await assertFile(path.join(standaloneAppDirectory, 'server.js'))
await rm(outputDirectory, { recursive: true, force: true })
await mkdir(path.dirname(outputDirectory), { recursive: true })
@@ -31,12 +83,12 @@ await cp(
path.join(outputDirectory, 'apps/editor/.next/static'),
{ recursive: true, force: true },
)
-await bundleMcpServer(outputDirectory, packageJson.version)
-
+await rm(path.join(outputDirectory, 'apps/editor/vendor'), { recursive: true, force: true })
await removeUnusedSharp(outputDirectory)
await flattenBunNodeModules(outputDirectory)
await materializeSymlinks(outputDirectory)
await rm(path.join(outputDirectory, 'node_modules/.bun'), { recursive: true, force: true })
+await pruneBuildOnlyFiles(outputDirectory)
const nativeFiles = await findNativeModules(outputDirectory)
if (nativeFiles.length > 0) {
throw new Error(`portable runtime contains native modules:\n${nativeFiles.join('\n')}`)
@@ -45,23 +97,32 @@ if (nativeFiles.length > 0) {
await writeFile(
path.join(outputDirectory, 'runtime-manifest.json'),
`${JSON.stringify(
- {
- schemaVersion: 1,
- version: packageJson.version,
- entrypoint: 'apps/editor/server.js',
- mcpEntrypoint: 'services/pascal-mcp.mjs',
- healthPath: '/api/health',
- mcpHealthPath: '/health',
- },
+ { schemaVersion: 2, version: packageJson.version, entrypoint: 'apps/editor/server.js' },
+ null,
+ 2,
+ )}\n`,
+)
+
+const archive = await createRuntimeArchive(outputDirectory, archiveFile)
+const sha256 = await fileSha256(archiveFile)
+await writeFile(`${archiveFile}.sha256`, `${sha256} ${archiveName}\n`)
+await writeFile(
+ path.join(packageDirectory, 'dist/runtime-source.json'),
+ `${JSON.stringify(
+ { version: packageJson.version, url: assetUrl, sha256, size: archive.size },
null,
2,
)}\n`,
)
-console.log(`Staged Pascal editor runtime ${packageJson.version} at ${outputDirectory}`)
+console.log(`Staged Pascal web runtime ${packageJson.version} at ${outputDirectory}`)
+console.log(
+ `Archived ${archive.entryCount} entries to ${archiveFile} (${formatMegabytes(archive.size)} MB)`,
+)
+console.log(`Digest ${sha256}`)
+console.log(`Release asset ${assetUrl}`)
-async function bundleMcpServer(runtimeDirectory: string, version: string): Promise {
- const output = path.join(runtimeDirectory, 'services/pascal-mcp.mjs')
+async function bundleMcpServer(output: string, version: string): Promise {
await mkdir(path.dirname(output), { recursive: true })
const child = spawn(
process.execPath,
@@ -100,6 +161,70 @@ async function assertFile(filePath: string): Promise {
}
}
+async function pruneBuildOnlyFiles(root: string): Promise {
+ await Promise.all(
+ buildOnlyRuntimePaths.map((relative) =>
+ rm(path.join(root, relative), { recursive: true, force: true }),
+ ),
+ )
+ await removeStrayItemAssets(path.join(root, 'apps/editor/public/items'))
+ await removeTraceArtifacts(path.join(root, 'apps/editor/.next'))
+}
+
+/**
+ * Item directories are addressed by convention (`model.glb`, `thumbnail.*`, `floor-plan.*`).
+ * Anything else is an authoring leftover, so it is dropped and named on stdout: a future
+ * asset that does not follow the convention has to be reported rather than silently lost.
+ */
+async function removeStrayItemAssets(itemsDirectory: string): Promise {
+ let entries
+ try {
+ entries = await readdir(itemsDirectory, { withFileTypes: true })
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ return
+ }
+ const isConventional = (name: string): boolean =>
+ name === 'model.glb' || name.startsWith('thumbnail.') || name.startsWith('floor-plan.')
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ const itemDirectory = path.join(itemsDirectory, entry.name)
+ for (const asset of await readdir(itemDirectory, { withFileTypes: true })) {
+ if (!asset.isFile() || isConventional(asset.name)) continue
+ const assetPath = path.join(itemDirectory, asset.name)
+ const { size } = await stat(assetPath)
+ await rm(assetPath, { force: true })
+ console.log(
+ `Dropped unreferenced item asset ${entry.name}/${asset.name} (${formatMegabytes(size)} MB)`,
+ )
+ }
+ }
+}
+
+function formatMegabytes(bytes: number): string {
+ return (bytes / 1024 / 1024).toFixed(2)
+}
+
+async function removeTraceArtifacts(nextDirectory: string): Promise {
+ const walk = async (directory: string): Promise => {
+ let entries
+ try {
+ entries = await readdir(directory, { withFileTypes: true })
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ return
+ }
+ for (const entry of entries) {
+ const absolute = path.join(directory, entry.name)
+ if (entry.isDirectory()) await walk(absolute)
+ else if (entry.name.endsWith('.nft.json') || entry.name.endsWith('.map')) {
+ await rm(absolute, { force: true })
+ }
+ }
+ }
+ await walk(nextDirectory)
+}
+
async function removeUnusedSharp(root: string): Promise {
const nodeModules = path.join(root, 'node_modules')
await rm(path.join(nodeModules, 'sharp'), { recursive: true, force: true })
diff --git a/packages/cli/src/agent-account.test.ts b/packages/cli/src/agent-account.test.ts
new file mode 100644
index 0000000000..54d6a20d0d
--- /dev/null
+++ b/packages/cli/src/agent-account.test.ts
@@ -0,0 +1,215 @@
+import { describe, expect, test } from 'bun:test'
+import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from './agent-account.js'
+import { CliError } from './errors.js'
+
+const API_KEY = 'sk_live_private-agent-key'
+const VALID_CLAIM = {
+ claimCode: 'BCDF-GHJK-LMNP',
+ claimUrl: 'https://editor.pascal.app/settings/agents/claim',
+ expiresAt: '2026-09-10T18:30:00.000Z',
+}
+const VALID_STATUS = {
+ schemaVersion: 1 as const,
+ agentId: 'agent_test',
+ mode: 'autonomous' as const,
+ claimed: false,
+ organizationScoped: true,
+}
+
+describe('agent account claims', () => {
+ test('builds a prefilled handoff URL without changing the API result', () => {
+ expect(agentClaimHandoffUrl(VALID_CLAIM)).toBe(
+ 'https://editor.pascal.app/settings/agents/claim?code=BCDF-GHJK-LMNP',
+ )
+ expect(VALID_CLAIM.claimUrl).toBe('https://editor.pascal.app/settings/agents/claim')
+ })
+
+ test('starts a claim with the agent credential and returns the bounded public result', async () => {
+ let authorization: string | null = null
+ let redirect: RequestRedirect | undefined
+ const fetchMock: typeof fetch = async (_input, init) => {
+ authorization = new Headers(init?.headers).get('authorization')
+ redirect = init?.redirect
+ return Response.json({
+ ...VALID_CLAIM,
+ agent: { name: '\u001b[2Jmalicious', client: 'openclaw' },
+ message: 'server copy is not part of the CLI result',
+ })
+ }
+
+ const result = await startAgentClaim(API_KEY, { fetch: fetchMock })
+
+ expect(authorization).toBe(`Bearer ${API_KEY}`)
+ expect(redirect).toBe('error')
+ expect(result).toEqual(VALID_CLAIM)
+ })
+
+ test.each([
+ [400, 'agent_claim_not_available'],
+ [401, 'agent_claim_unauthorized'],
+ [403, 'agent_claim_forbidden'],
+ [409, 'agent_already_claimed'],
+ [429, 'agent_claim_rate_limited'],
+ [503, 'agent_claim_failed'],
+ ])('maps HTTP %i without exposing the API key or response body', async (status, code) => {
+ const fetchMock: typeof fetch = async () =>
+ new Response(`credential ${API_KEY} rejected`, {
+ headers: { 'content-type': 'text/html' },
+ status,
+ })
+
+ const error = await captureError(() => startAgentClaim(API_KEY, { fetch: fetchMock }))
+
+ expect(error.code).toBe(code)
+ expect(JSON.stringify(error)).not.toContain(API_KEY)
+ expect(error.message).not.toContain(API_KEY)
+ })
+
+ test('rejects malformed or chunked oversized responses', async () => {
+ const malformed: typeof fetch = async () => Response.json({ ...VALID_CLAIM, claimCode: '123' })
+ const unsafeDate: typeof fetch = async () =>
+ Response.json({ ...VALID_CLAIM, expiresAt: 'Wed, 10 Sep 2026 18:30:00 GMT (\u001b[2J)' })
+ const oversized: typeof fetch = async () => {
+ const encoder = new TextEncoder()
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('{"padding":"'))
+ controller.enqueue(encoder.encode('x'.repeat(33 * 1024)))
+ controller.close()
+ },
+ }),
+ )
+ }
+
+ expect((await captureError(() => startAgentClaim(API_KEY, { fetch: malformed }))).code).toBe(
+ 'agent_claim_invalid_response',
+ )
+ expect((await captureError(() => startAgentClaim(API_KEY, { fetch: unsafeDate }))).code).toBe(
+ 'agent_claim_invalid_response',
+ )
+ expect((await captureError(() => startAgentClaim(API_KEY, { fetch: oversized }))).code).toBe(
+ 'agent_claim_invalid_response',
+ )
+ })
+
+ test('reports network failures and bounded timeouts without reflecting secrets', async () => {
+ const unavailable: typeof fetch = async () => {
+ throw new Error(`failed with ${API_KEY}`)
+ }
+ const pending: typeof fetch = async (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
+ })
+
+ const networkError = await captureError(() => startAgentClaim(API_KEY, { fetch: unavailable }))
+ const timeoutError = await captureError(() =>
+ startAgentClaim(API_KEY, { fetch: pending, timeoutMs: 1 }),
+ )
+
+ expect(networkError.code).toBe('agent_claim_unavailable')
+ expect(timeoutError.code).toBe('agent_claim_timeout')
+ expect(`${networkError.message}${timeoutError.message}`).not.toContain(API_KEY)
+ })
+
+ test('keeps the timeout active while reading the response body', async () => {
+ const stalled: typeof fetch = async (_input, init) =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ init?.signal?.addEventListener('abort', () => controller.error(new Error('aborted')), {
+ once: true,
+ })
+ },
+ }),
+ )
+
+ const error = await captureError(() =>
+ startAgentClaim(API_KEY, { fetch: stalled, timeoutMs: 1 }),
+ )
+
+ expect(error.code).toBe('agent_claim_timeout')
+ })
+
+ test('checks status with the agent credential and returns the bounded public result', async () => {
+ let endpoint = ''
+ let method = ''
+ let authorization: string | null = null
+ let redirect: RequestRedirect | undefined
+ const fetchMock: typeof fetch = async (input, init) => {
+ endpoint = String(input)
+ method = init?.method ?? ''
+ authorization = new Headers(init?.headers).get('authorization')
+ redirect = init?.redirect
+ return Response.json({
+ ...VALID_STATUS,
+ agentName: '\u001b[2Jmalicious',
+ credentialName: API_KEY,
+ })
+ }
+
+ const result = await getAgentStatus(API_KEY, { fetch: fetchMock })
+
+ expect(endpoint).toBe('https://editor.pascal.app/api/auth/agent/status')
+ expect(method).toBe('GET')
+ expect(authorization).toBe(`Bearer ${API_KEY}`)
+ expect(redirect).toBe('error')
+ expect(result).toEqual(VALID_STATUS)
+ expect(JSON.stringify(result)).not.toContain(API_KEY)
+ })
+
+ test.each([
+ [401, 'agent_status_unauthorized'],
+ [403, 'agent_status_forbidden'],
+ [503, 'agent_status_failed'],
+ ])('maps status HTTP %i without exposing the API key or response body', async (status, code) => {
+ const fetchMock: typeof fetch = async () =>
+ new Response(`credential ${API_KEY} rejected`, { status })
+
+ const error = await captureError(() => getAgentStatus(API_KEY, { fetch: fetchMock }))
+
+ expect(error.code).toBe(code)
+ expect(JSON.stringify(error)).not.toContain(API_KEY)
+ expect(error.message).not.toContain(API_KEY)
+ })
+
+ test('rejects malformed and oversized status responses', async () => {
+ const malformed: typeof fetch = async () => Response.json({ ...VALID_STATUS, claimed: 'false' })
+ const oversized: typeof fetch = async () =>
+ new Response(JSON.stringify({ ...VALID_STATUS, padding: 'x'.repeat(33 * 1024) }))
+
+ expect((await captureError(() => getAgentStatus(API_KEY, { fetch: malformed }))).code).toBe(
+ 'agent_status_invalid_response',
+ )
+ expect((await captureError(() => getAgentStatus(API_KEY, { fetch: oversized }))).code).toBe(
+ 'agent_status_invalid_response',
+ )
+ })
+
+ test('reports status network failures and bounded timeouts', async () => {
+ const unavailable: typeof fetch = async () => {
+ throw new Error(`failed with ${API_KEY}`)
+ }
+ const pending: typeof fetch = async (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
+ })
+
+ expect((await captureError(() => getAgentStatus(API_KEY, { fetch: unavailable }))).code).toBe(
+ 'agent_status_unavailable',
+ )
+ expect(
+ (await captureError(() => getAgentStatus(API_KEY, { fetch: pending, timeoutMs: 1 }))).code,
+ ).toBe('agent_status_timeout')
+ })
+})
+
+async function captureError(run: () => Promise): Promise {
+ try {
+ await run()
+ throw new Error('Expected the operation to fail')
+ } catch (error) {
+ expect(error).toBeInstanceOf(CliError)
+ return error as CliError
+ }
+}
diff --git a/packages/cli/src/agent-account.ts b/packages/cli/src/agent-account.ts
new file mode 100644
index 0000000000..fbf44f603a
--- /dev/null
+++ b/packages/cli/src/agent-account.ts
@@ -0,0 +1,295 @@
+import { CliError } from './errors.js'
+
+const CLAIM_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/claim/start'
+const STATUS_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/status'
+const CLAIM_PAGE = 'https://editor.pascal.app/settings/agents/claim'
+const MAX_RESPONSE_BYTES = 32 * 1024
+const DEFAULT_TIMEOUT_MS = 15_000
+const CLAIM_CODE_PATTERN =
+ /^[23456789BCDFGHJKLMNPQRSTVWXZ]{4}(?:-[23456789BCDFGHJKLMNPQRSTVWXZ]{4}){2}$/
+
+export interface AgentClaim {
+ claimCode: string
+ claimUrl: string
+ expiresAt: string
+}
+
+export interface AgentStatus {
+ schemaVersion: 1
+ agentId: string
+ mode: 'autonomous' | 'delegated'
+ claimed: boolean
+ organizationScoped: boolean
+}
+
+export function agentClaimHandoffUrl(claim: AgentClaim): string {
+ const url = new URL(claim.claimUrl)
+ url.searchParams.set('code', claim.claimCode)
+ return url.toString()
+}
+
+interface AgentAccountRequestOptions {
+ fetch?: typeof fetch
+ timeoutMs?: number
+}
+
+export async function startAgentClaim(
+ apiKey: string,
+ options: AgentAccountRequestOptions = {},
+): Promise {
+ const credential = apiKey.trim()
+ if (!credential) {
+ throw new CliError(
+ 'agent_api_key_missing',
+ "Set PASCAL_API_KEY to this autonomous agent's API key and try again.",
+ )
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
+ try {
+ let response: Response
+ try {
+ response = await (options.fetch ?? fetch)(CLAIM_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${credential}`,
+ },
+ redirect: 'error',
+ signal: controller.signal,
+ })
+ } catch {
+ if (controller.signal.aborted) {
+ throw claimTimeout()
+ }
+ throw new CliError(
+ 'agent_claim_unavailable',
+ 'Pascal could not be reached while starting the agent claim. Try again.',
+ )
+ }
+
+ if (!response.ok) {
+ if (response.body) void response.body.cancel().catch(() => {})
+ throw claimResponseError(response.status)
+ }
+ const body = await readJsonResponse(response, controller.signal, invalidResponse, claimTimeout)
+ if (!isAgentClaim(body)) throw invalidResponse()
+ return {
+ claimCode: body.claimCode,
+ claimUrl: body.claimUrl,
+ expiresAt: body.expiresAt,
+ }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+export async function getAgentStatus(
+ apiKey: string,
+ options: AgentAccountRequestOptions = {},
+): Promise {
+ const credential = apiKey.trim()
+ if (!credential) {
+ throw new CliError(
+ 'agent_api_key_missing',
+ "Set PASCAL_API_KEY to this agent's API key and try again.",
+ )
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
+ try {
+ let response: Response
+ try {
+ response = await (options.fetch ?? fetch)(STATUS_ENDPOINT, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${credential}`,
+ },
+ redirect: 'error',
+ signal: controller.signal,
+ })
+ } catch {
+ if (controller.signal.aborted) throw statusTimeout()
+ throw new CliError(
+ 'agent_status_unavailable',
+ 'Pascal could not be reached while checking the agent status. Try again.',
+ )
+ }
+
+ if (!response.ok) {
+ if (response.body) void response.body.cancel().catch(() => {})
+ throw statusResponseError(response.status)
+ }
+ const body = await readJsonResponse(
+ response,
+ controller.signal,
+ invalidStatusResponse,
+ statusTimeout,
+ )
+ if (!isAgentStatus(body)) throw invalidStatusResponse()
+ return {
+ schemaVersion: 1,
+ agentId: body.agentId,
+ mode: body.mode,
+ claimed: body.claimed,
+ organizationScoped: body.organizationScoped,
+ }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+async function readJsonResponse(
+ response: Response,
+ signal: AbortSignal,
+ invalid: () => CliError,
+ timeout: () => CliError,
+): Promise {
+ const declaredLength = Number(response.headers.get('content-length'))
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
+ throw invalid()
+ }
+
+ if (!response.body) throw invalid()
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let bytes = 0
+ let text = ''
+ while (true) {
+ let chunk
+ try {
+ chunk = await reader.read()
+ } catch {
+ if (signal.aborted) throw timeout()
+ throw invalid()
+ }
+ if (chunk.done) break
+ bytes += chunk.value.byteLength
+ if (bytes > MAX_RESPONSE_BYTES) {
+ void reader.cancel().catch(() => {})
+ throw invalid()
+ }
+ text += decoder.decode(chunk.value, { stream: true })
+ }
+ text += decoder.decode()
+
+ try {
+ return JSON.parse(text) as unknown
+ } catch {
+ throw invalid()
+ }
+}
+
+function isAgentClaim(value: unknown): value is AgentClaim {
+ if (!isRecord(value)) return false
+ if (typeof value.claimCode !== 'string' || !CLAIM_CODE_PATTERN.test(value.claimCode)) return false
+ if (typeof value.expiresAt !== 'string') return false
+ const expiresAt = Date.parse(value.expiresAt)
+ if (Number.isNaN(expiresAt) || new Date(expiresAt).toISOString() !== value.expiresAt) return false
+ return value.claimUrl === CLAIM_PAGE
+}
+
+function isAgentStatus(value: unknown): value is AgentStatus {
+ return (
+ isRecord(value) &&
+ value.schemaVersion === 1 &&
+ typeof value.agentId === 'string' &&
+ value.agentId.length > 0 &&
+ (value.mode === 'autonomous' || value.mode === 'delegated') &&
+ typeof value.claimed === 'boolean' &&
+ typeof value.organizationScoped === 'boolean'
+ )
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function claimResponseError(status: number): CliError {
+ switch (status) {
+ case 400:
+ return new CliError(
+ 'agent_claim_not_available',
+ 'This agent credential cannot start a claim.',
+ { status },
+ )
+ case 401:
+ return new CliError('agent_claim_unauthorized', 'PASCAL_API_KEY is invalid or revoked.', {
+ status,
+ })
+ case 403:
+ return new CliError(
+ 'agent_claim_forbidden',
+ 'PASCAL_API_KEY must belong to an autonomous Pascal agent.',
+ { status },
+ )
+ case 409:
+ return new CliError('agent_already_claimed', 'This agent has already been claimed.', {
+ status,
+ })
+ case 429:
+ return new CliError(
+ 'agent_claim_rate_limited',
+ 'Too many agent claim attempts. Wait and try again.',
+ { status },
+ )
+ default:
+ return new CliError(
+ 'agent_claim_failed',
+ `Pascal could not start the agent claim (HTTP ${status}).`,
+ { status },
+ )
+ }
+}
+
+function statusResponseError(status: number): CliError {
+ switch (status) {
+ case 401:
+ return new CliError('agent_status_unauthorized', 'PASCAL_API_KEY is invalid or revoked.', {
+ status,
+ })
+ case 403:
+ return new CliError(
+ 'agent_status_forbidden',
+ 'PASCAL_API_KEY must belong to a Pascal agent.',
+ { status },
+ )
+ default:
+ return new CliError(
+ 'agent_status_failed',
+ `Pascal could not check the agent status (HTTP ${status}).`,
+ { status },
+ )
+ }
+}
+
+function invalidResponse(): CliError {
+ return new CliError(
+ 'agent_claim_invalid_response',
+ 'Pascal returned an invalid agent claim response. Try again.',
+ )
+}
+
+function invalidStatusResponse(): CliError {
+ return new CliError(
+ 'agent_status_invalid_response',
+ 'Pascal returned an invalid agent status response. Try again.',
+ )
+}
+
+function claimTimeout(): CliError {
+ return new CliError(
+ 'agent_claim_timeout',
+ 'Pascal did not respond while starting the agent claim. Try again.',
+ )
+}
+
+function statusTimeout(): CliError {
+ return new CliError(
+ 'agent_status_timeout',
+ 'Pascal did not respond while checking the agent status. Try again.',
+ )
+}
diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts
index be1649c036..9cf8e4dbfe 100755
--- a/packages/cli/src/bin/pascal.ts
+++ b/packages/cli/src/bin/pascal.ts
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { parseArgs } from 'node:util'
+import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from '../agent-account.js'
import { openBrowser } from '../browser.js'
import { installGlobalPascalCommand, isNpxInvocation } from '../command-install.js'
import { collectInfo, runDoctor } from '../diagnostics.js'
@@ -17,9 +18,10 @@ import {
import { CliError, toCliError } from '../errors.js'
import { readJsonFile } from '../json-files.js'
import { connectManagedMcp } from '../mcp-connector.js'
+import { getMcpServiceStatus } from '../mcp-service.js'
import { resolvePascalPaths } from '../paths.js'
import { listLocalProjects, projectUrl, resolveLocalProject } from '../projects.js'
-import { installBundledRuntime } from '../runtime.js'
+import { ensureWebRuntime } from '../runtime-download.js'
import { TerminalProgress } from '../terminal-progress.js'
import { version } from '../version.js'
@@ -37,8 +39,8 @@ ENABLE THE SHORT GLOBAL COMMAND:
pascal
USAGE:
- pascal editor [--foreground] [--no-open] [--port ]
- pascal start [--foreground] [--port ]
+ pascal editor [--foreground] [--no-open] [--port ] [--runtime ]
+ pascal start [--foreground] [--port ] [--runtime ]
pascal stop | restart | status
pascal open [project]
pascal resume [project]
@@ -50,15 +52,26 @@ USAGE:
pascal project list [--json]
pascal project open
pascal project resume [id-or-name]
+ pascal agent claim [--no-open] [--json]
+ pascal agent status [--json]
pascal mcp connect | status | config | setup
pascal plugin list [--json]
+THE WEB EDITOR RUNTIME:
+ The npm package holds the CLI and the MCP service. The web editor runtime is
+ downloaded once per version into ~/.pascal/runtime the first time a command
+ starts the editor, and verified against a digest published with this CLI.
+ Offline: pass --runtime . "pascal mcp connect" needs no
+ download at all.
+
Documentation: https://editor.pascal.app/docs/developers/local-editor
`
const MCP_HELP = `Pascal MCP — connect AI agents to local projects
-The authenticated MCP service starts and stops with the Pascal editor.
+The authenticated MCP service ships inside this package. It starts on demand and
+needs neither the web editor nor its downloaded runtime, so agents can read and
+write local projects on a machine that never runs the editor.
USAGE:
pascal mcp status [--json] Check the managed MCP service
@@ -73,14 +86,36 @@ dynamic loopback port without exposing Pascal's private local token.
Documentation: https://editor.pascal.app/docs/developers/mcp
`
+const AGENT_HELP = `Pascal agent — connect an autonomous agent to a person
+
+USAGE:
+ pascal agent claim [--no-open] [--json]
+ pascal agent status [--json]
+
+Set PASCAL_API_KEY to the autonomous agent's hosted Pascal API key. The CLI
+uses it once to request a 15-minute claim code and never stores it. It opens
+the claim page unless --no-open or --json is set.
+
+Use "pascal agent status" to verify whether that credential is active and
+whether its autonomous agent has been claimed.
+
+Claiming records who is accountable for the agent and lifts claim-gated
+capabilities. It does not transfer project ownership or grant access to either
+account's private projects.
+
+Documentation: https://editor.pascal.app/docs/developers/mcp
+`
+
const paths = resolvePascalPaths()
+const agentApiKey = process.env.PASCAL_API_KEY
+Reflect.deleteProperty(process.env, 'PASCAL_API_KEY')
async function main(): Promise {
const [command = 'help', ...args] = process.argv.slice(2)
if (command === '--version' || command === '-v') return print(version)
if (command === '--help' || command === '-h' || command === 'help') return print(HELP)
if (args.includes('--help') || args.includes('-h')) {
- return print(command === 'mcp' ? MCP_HELP : HELP)
+ return print(command === 'mcp' ? MCP_HELP : command === 'agent' ? AGENT_HELP : HELP)
}
switch (command) {
@@ -110,12 +145,14 @@ async function main(): Promise {
return runUpdate(args)
case 'project':
return runProject(args)
+ case 'agent':
+ return runAgent(args, agentApiKey)
case 'plugin':
return runPlugin(args)
case 'mcp':
return runMcp(args)
case '_install-runtime':
- return output(true, await installBundledRuntime(paths, undefined, { activate: false }), '')
+ return output(true, (await ensureWebRuntime({ paths, activate: false })).runtime, '')
default:
throw new CliError('unknown_command', `Unknown command: ${command}`, { command }, 2)
}
@@ -130,6 +167,7 @@ async function runStart(args: string[], shouldOpen: boolean): Promise {
open: { type: 'boolean', default: shouldOpen },
'no-open': { type: 'boolean', default: false },
port: { type: 'string' },
+ runtime: { type: 'string' },
json: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
@@ -144,7 +182,8 @@ async function runStart(args: string[], shouldOpen: boolean): Promise {
paths,
port,
foreground: values.foreground,
- onProgress: progress ? (event) => reportStartProgress(progress, event) : undefined,
+ runtimeSource: values.runtime,
+ onProgress: progress ? createStartProgressReporter(progress) : undefined,
})
} catch (error) {
progress?.stop()
@@ -170,12 +209,12 @@ async function runStart(args: string[], shouldOpen: boolean): Promise {
const commandPrefix = useShortCommand ? 'pascal' : 'npx @pascal-app/cli'
output(
values.json,
- { ...result.state, alreadyRunning: result.alreadyRunning },
+ { ...result.state, mcp: result.mcp, alreadyRunning: result.alreadyRunning },
[
result.alreadyRunning
? `Pascal is already running at ${result.state.url}`
: `Pascal is ready at ${result.state.url}`,
- `MCP is ready on port ${result.state.mcp?.port}`,
+ `MCP is ready on port ${result.mcp.port}`,
`Projects stay in ${paths.data}`,
'',
`Manage it with ${useShortCommand ? 'pascal' : 'npx'}:`,
@@ -205,11 +244,54 @@ async function runStart(args: string[], shouldOpen: boolean): Promise {
}
}
+/**
+ * Download progress arrives far more often than a non-TTY log should print, so percentages
+ * are reported per whole percent on a terminal and per tenth otherwise.
+ */
+function createStartProgressReporter(
+ progress: TerminalProgress,
+): (event: EditorStartProgress) => void {
+ const perPercent = Boolean(process.stderr.isTTY)
+ let lastReportedStep = -1
+ return (event) => {
+ if (event.step !== 'runtime-downloading') return reportStartProgress(progress, event)
+ if (event.received === 0) {
+ lastReportedStep = -1
+ progress.start(`Downloading the editor runtime from ${event.url}`)
+ return
+ }
+ const percent = event.total
+ ? Math.min(100, Math.floor((event.received / event.total) * 100))
+ : 0
+ const step = perPercent ? percent : Math.floor(percent / 10)
+ if (step === lastReportedStep) return
+ lastReportedStep = step
+ progress.update(
+ event.total
+ ? `Downloading the editor runtime ${percent}% (${formatMegabytes(event.received)} of ${formatMegabytes(event.total)})`
+ : `Downloading the editor runtime (${formatMegabytes(event.received)})`,
+ )
+ }
+}
+
+function formatMegabytes(bytes: number): string {
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
function reportStartProgress(progress: TerminalProgress, event: EditorStartProgress): void {
switch (event.step) {
case 'storage-ready':
progress.succeed(`Local data directory ready at ${event.dataDirectory}`)
return
+ case 'runtime-downloading':
+ progress.update('Downloading the editor runtime')
+ return
+ case 'runtime-verifying':
+ progress.update('Verifying the editor runtime digest')
+ return
+ case 'runtime-extracting':
+ progress.update('Extracting the editor runtime')
+ return
case 'runtime-installing':
progress.start('Installing the editor runtime')
return
@@ -244,6 +326,12 @@ function reportStartProgress(progress: TerminalProgress, event: EditorStartProgr
case 'mcp-health-checking':
progress.update('Checking that MCP is ready')
return
+ case 'mcp-ready':
+ progress.succeed(`MCP is ready on port ${event.port}`)
+ return
+ case 'mcp-already-running':
+ progress.succeed(`MCP is already running on port ${event.port}`)
+ return
case 'ready':
progress.succeed('Pascal Editor and MCP are ready')
return
@@ -273,20 +361,20 @@ async function runRestart(args: string[]): Promise {
async function runStatus(args: string[]): Promise {
const json = booleanOption(args, 'json')
- const status = await getEditorStatus(paths)
+ const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)])
output(
json,
- status,
+ { ...status, mcp },
status.healthy
? [
`Pascal ${status.state?.version} is running at ${status.state?.url}`,
- `MCP is ready on port ${status.state?.mcp?.port}`,
+ mcp.healthy ? `MCP is ready on port ${mcp.state?.port}` : 'MCP is stopped.',
].join('\n')
: status.running
? 'Pascal has a running but unhealthy process.'
: status.installed
? `Pascal ${status.runtime?.version} is installed and stopped.`
- : 'Pascal is not installed.',
+ : 'The Pascal web runtime is not installed yet.',
)
if (status.running && !status.healthy) process.exitCode = 1
}
@@ -296,13 +384,13 @@ async function runOpen(args: string[]): Promise {
args,
strict: true,
allowPositionals: true,
- options: { json: { type: 'boolean', default: false } },
+ options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } },
})
if (positionals.length > 1) {
throw new CliError('invalid_option', 'Use "pascal open [project]".', undefined, 2)
}
if (positionals[0]) return runProjectOpen(args, false)
- const status = await ensureRunningEditor()
+ const status = await ensureRunningEditor(values.runtime)
openBrowser(status.state.url)
output(values.json, { url: status.state.url }, status.state.url)
}
@@ -350,9 +438,9 @@ async function runInfo(args: string[]): Promise {
`CLI: ${version}`,
`Node: ${info.cli.node}`,
`Home: ${paths.root}`,
- `Runtime: ${info.editor.runtime?.version ?? 'not installed'}`,
+ `Web runtime: ${info.editor.runtime?.version ?? 'not installed'}`,
`Editor: ${info.editor.healthy ? info.editor.state?.url : 'stopped'}`,
- `MCP: ${info.editor.components.mcp.healthy ? `ready on port ${info.editor.state?.mcp?.port}` : 'stopped'}`,
+ `MCP: ${info.mcp.healthy ? `ready on port ${info.mcp.state?.port}` : 'stopped'}`,
`Plugins: ${info.plugins.length}`,
].join('\n'),
)
@@ -362,7 +450,11 @@ async function runUpdate(args: string[]): Promise {
const { values } = parseArgs({
args,
strict: true,
- options: { version: { type: 'string' }, json: { type: 'boolean', default: false } },
+ options: {
+ version: { type: 'string' },
+ runtime: { type: 'string' },
+ json: { type: 'boolean', default: false },
+ },
})
const target = values.version ?? 'latest'
if (!isAllowedUpdateVersion(target)) {
@@ -375,7 +467,8 @@ async function runUpdate(args: string[]): Promise {
}
let candidate
if (target === version) {
- candidate = await installBundledRuntime(paths, undefined, { activate: false })
+ candidate = (await ensureWebRuntime({ paths, runtimeSource: values.runtime, activate: false }))
+ .runtime
} else {
const spec = `@pascal-app/cli@${target}`
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
@@ -430,11 +523,15 @@ async function runUpdate(args: string[]): Promise {
async function runProject(args: string[]): Promise {
const [subcommand, ...rest] = args
if (subcommand === 'list') {
- const json = booleanOption(rest, 'json')
- const status = await ensureRunningEditor()
+ const { values } = parseArgs({
+ args: rest,
+ strict: true,
+ options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } },
+ })
+ const status = await ensureRunningEditor(values.runtime)
const projects = await listLocalProjects(status.state)
output(
- json,
+ values.json,
{ projects },
projects.length
? projects
@@ -466,7 +563,7 @@ async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promi
args,
strict: true,
allowPositionals: true,
- options: { json: { type: 'boolean', default: false } },
+ options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } },
})
if (positionals.length > 1 || (!latestWhenMissing && positionals.length !== 1)) {
throw new CliError(
@@ -476,7 +573,7 @@ async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promi
2,
)
}
- const status = await ensureRunningEditor()
+ const status = await ensureRunningEditor(values.runtime)
const projects = await listLocalProjects(status.state)
const project = resolveLocalProject(projects, positionals[0])
const url = projectUrl(status.state, project)
@@ -495,11 +592,11 @@ async function runMcp(args: string[]): Promise {
}
if (subcommand === 'status') {
const json = booleanOption(rest, 'json')
- const status = await getEditorStatus(paths)
+ const status = await getMcpServiceStatus(paths)
const result = {
- running: status.components.mcp.running,
- healthy: status.components.mcp.healthy,
- port: status.state?.mcp?.port ?? null,
+ running: status.running,
+ healthy: status.healthy,
+ port: status.state?.port ?? null,
}
output(
json,
@@ -508,7 +605,7 @@ async function runMcp(args: string[]): Promise {
? `Pascal MCP is ready on port ${result.port}.`
: result.running
? 'Pascal MCP is running but unhealthy.'
- : 'Pascal MCP is stopped.',
+ : 'Pascal MCP is stopped. It starts when an MCP client runs "pascal mcp connect".',
)
if (result.running && !result.healthy) process.exitCode = 1
return
@@ -576,6 +673,58 @@ async function runMcp(args: string[]): Promise {
)
}
+async function runAgent(args: string[], apiKey: string | undefined): Promise {
+ const [subcommand, ...rest] = args
+ if (subcommand === 'status') {
+ const json = booleanOption(rest, 'json')
+ const status = await getAgentStatus(apiKey ?? '')
+ output(
+ json,
+ status,
+ [
+ `Agent ID: ${JSON.stringify(status.agentId)}`,
+ `Mode: ${status.mode}`,
+ `Claimed: ${status.claimed ? 'yes' : 'no'}`,
+ `Organization scoped: ${status.organizationScoped ? 'yes' : 'no'}`,
+ ...(!status.claimed && status.mode === 'autonomous'
+ ? ['', 'Next: run "pascal agent claim" to link a person accountable for this agent.']
+ : []),
+ ].join('\n'),
+ )
+ return
+ }
+ if (subcommand !== 'claim') {
+ throw new CliError(
+ 'unknown_command',
+ 'Use "pascal agent claim" or "pascal agent status".',
+ undefined,
+ 2,
+ )
+ }
+ const { values } = parseArgs({
+ args: rest,
+ strict: true,
+ options: {
+ json: { type: 'boolean', default: false },
+ 'no-open': { type: 'boolean', default: false },
+ },
+ })
+ const claim = await startAgentClaim(apiKey ?? '')
+ const claimHandoffUrl = agentClaimHandoffUrl(claim)
+ if (!values['no-open'] && !values.json) openBrowser(claimHandoffUrl)
+ output(
+ values.json,
+ claim,
+ [
+ `Claim code: ${claim.claimCode}`,
+ `Claim page: ${claimHandoffUrl}`,
+ `Expires: ${claim.expiresAt}`,
+ '',
+ 'Claiming links accountability. It does not transfer project ownership or grant access to private projects.',
+ ].join('\n'),
+ )
+}
+
async function runPlugin(args: string[]): Promise {
const [subcommand, ...rest] = args
if (subcommand === 'list') {
@@ -605,10 +754,20 @@ async function runPlugin(args: string[]): Promise {
)
}
-async function ensureRunningEditor() {
+async function ensureRunningEditor(runtimeSource?: string) {
const status = await getEditorStatus(paths)
if (status.healthy && status.state) return { ...status, state: status.state }
- const started = await startEditor({ paths })
+ const progress = process.stderr.isTTY ? new TerminalProgress() : undefined
+ let started: Awaited>
+ try {
+ started = await startEditor({
+ paths,
+ runtimeSource,
+ onProgress: progress ? createStartProgressReporter(progress) : undefined,
+ })
+ } finally {
+ progress?.stop()
+ }
return {
...(await getEditorStatus(paths)),
state: started.state,
diff --git a/packages/cli/src/browser.test.ts b/packages/cli/src/browser.test.ts
new file mode 100644
index 0000000000..fee703b616
--- /dev/null
+++ b/packages/cli/src/browser.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test'
+import type { spawn } from 'node:child_process'
+import { EventEmitter } from 'node:events'
+import { openBrowser } from './browser.js'
+
+const spawnMock = mock(() => {
+ const child = new EventEmitter() as EventEmitter & { unref: () => void }
+ child.unref = mock(() => {})
+ return child
+}) as unknown as typeof spawn
+
+afterEach(() => spawnMock.mockClear())
+
+describe('browser launch', () => {
+ test('removes the Pascal API key from the spawned process environment', () => {
+ openBrowser(
+ 'https://editor.pascal.app/settings/agents/claim',
+ {
+ HOME: '/tmp/pascal-home',
+ PASCAL_API_KEY: 'sk_live_private-agent-key',
+ PATH: '/usr/bin',
+ },
+ spawnMock,
+ )
+
+ expect(spawnMock).toHaveBeenCalledTimes(1)
+ const options = spawnMock.mock.calls[0]?.[2]
+ expect(options?.env).toEqual({ HOME: '/tmp/pascal-home', PATH: '/usr/bin' })
+ expect(JSON.stringify(options)).not.toContain('sk_live_private-agent-key')
+ })
+
+ test('does not spawn when browser opening is disabled', () => {
+ openBrowser(
+ 'https://editor.pascal.app/settings/agents/claim',
+ {
+ PASCAL_API_KEY: 'sk_live_private-agent-key',
+ PASCAL_NO_OPEN: '1',
+ },
+ spawnMock,
+ )
+
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts
index 25fc8d8c26..26ed824b80 100644
--- a/packages/cli/src/browser.ts
+++ b/packages/cli/src/browser.ts
@@ -1,11 +1,20 @@
import { spawn } from 'node:child_process'
-export function openBrowser(url: string, environment: NodeJS.ProcessEnv = process.env): void {
+export function openBrowser(
+ url: string,
+ environment: NodeJS.ProcessEnv = process.env,
+ spawnProcess: typeof spawn = spawn,
+): void {
if (environment.PASCAL_NO_OPEN === '1') return
+ const { PASCAL_API_KEY: _pascalApiKey, ...browserEnvironment } = environment
const command =
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
- const child = spawn(command, args, { detached: true, stdio: 'ignore' })
+ const child = spawnProcess(command, args, {
+ detached: true,
+ env: browserEnvironment,
+ stdio: 'ignore',
+ })
child.once('error', () => {})
child.unref()
}
diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts
index af61017279..706a993321 100644
--- a/packages/cli/src/cli.test.ts
+++ b/packages/cli/src/cli.test.ts
@@ -6,6 +6,30 @@ import path from 'node:path'
const executable = path.join(import.meta.dir, 'bin/pascal.ts')
const testRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-command-test-'))
const testHome = path.join(testRoot, 'home')
+const claimFetchPreload = path.join(testRoot, 'claim-fetch-preload.mjs')
+
+await writeFile(
+ claimFetchPreload,
+ `globalThis.fetch = async (input, init) => {
+ if (String(input) !== process.env.PASCAL_AGENT_TEST_ENDPOINT) {
+ throw new Error('Unexpected agent endpoint')
+ }
+ if (init?.method !== process.env.PASCAL_AGENT_TEST_METHOD) throw new Error('Unexpected method')
+ if (init?.redirect !== 'error') throw new Error('Redirects must be disabled')
+ if (process.env.PASCAL_API_KEY !== undefined) {
+ throw new Error('PASCAL_API_KEY remained in the process environment')
+ }
+ const authorization = new Headers(init?.headers).get('authorization')
+ if (authorization !== process.env.PASCAL_AGENT_TEST_AUTHORIZATION) {
+ throw new Error('Unexpected agent authorization')
+ }
+ return new Response(process.env.PASCAL_AGENT_TEST_BODY, {
+ headers: { 'content-type': 'application/json' },
+ status: Number(process.env.PASCAL_AGENT_TEST_STATUS),
+ })
+ }
+`,
+)
afterAll(() => rm(testRoot, { recursive: true, force: true }))
@@ -28,6 +52,136 @@ describe('command parsing', () => {
expect(result.stdout).not.toContain('pascal plugin list')
})
+ test('shows focused help for hosted agent claims', async () => {
+ const result = await runCli('agent', '--help')
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('pascal agent claim')
+ expect(result.stdout).toContain('PASCAL_API_KEY')
+ expect(result.stdout).toContain('does not transfer project ownership')
+ expect(result.stdout).not.toContain('pascal plugin list')
+ })
+
+ test('requires an environment credential before starting an agent claim', async () => {
+ const result = await runCli('agent', 'claim', '--no-open', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toEqual({
+ error: 'agent_api_key_missing',
+ message: "Set PASCAL_API_KEY to this autonomous agent's API key and try again.",
+ })
+ expect(result.stdout).toBe('')
+ })
+
+ test('prints the exact successful JSON claim contract without opening a browser', async () => {
+ const claim = {
+ claimCode: 'BCDF-GHJK-LMNP',
+ claimUrl: 'https://editor.pascal.app/settings/agents/claim',
+ expiresAt: '2026-09-10T18:30:00.000Z',
+ }
+
+ const result = await runClaimCli(
+ 200,
+ { ...claim, agent: { name: '\u001b[2J', client: 'test' } },
+ '--json',
+ )
+
+ expect(result.exitCode).toBe(0)
+ expect(JSON.parse(result.stdout)).toEqual(claim)
+ expect(result.stderr).toBe('')
+ })
+
+ test('prints a terminal-safe human claim without server-controlled identity text', async () => {
+ const result = await runClaimCli(
+ 200,
+ {
+ claimCode: 'BCDF-GHJK-LMNP',
+ claimUrl: 'https://editor.pascal.app/settings/agents/claim',
+ expiresAt: '2026-09-10T18:30:00.000Z',
+ agent: { name: '\u001b[2Jmalicious', client: 'test' },
+ },
+ '--no-open',
+ )
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('Claim code: BCDF-GHJK-LMNP')
+ expect(result.stdout).toContain(
+ 'Claim page: https://editor.pascal.app/settings/agents/claim?code=BCDF-GHJK-LMNP',
+ )
+ expect(result.stdout).toContain('Claiming links accountability.')
+ expect(result.stdout).not.toContain('malicious')
+ expect(result.stdout).not.toContain('\u001b')
+ expect(result.stderr).toBe('')
+ })
+
+ test.each([
+ [401, 'agent_claim_unauthorized'],
+ [409, 'agent_already_claimed'],
+ ])('preserves the hosted HTTP %i error contract', async (status, errorCode) => {
+ const result = await runClaimCli(status, 'untrusted error', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toMatchObject({
+ details: { status },
+ error: errorCode,
+ })
+ expect(result.stdout).toBe('')
+ })
+
+ test('prints the exact successful JSON agent status contract', async () => {
+ const status = {
+ schemaVersion: 1,
+ agentId: 'agent_cli_test',
+ mode: 'autonomous',
+ claimed: false,
+ organizationScoped: true,
+ }
+
+ const result = await runStatusCli(200, { ...status, credentialName: '\u001b[2J' }, '--json')
+
+ expect(result.exitCode).toBe(0)
+ expect(JSON.parse(result.stdout)).toEqual(status)
+ expect(result.stderr).toBe('')
+ })
+
+ test('prints terminal-safe human status and an unclaimed next action', async () => {
+ const result = await runStatusCli(200, {
+ schemaVersion: 1,
+ agentId: '\u001b[2Jmalicious',
+ mode: 'autonomous',
+ claimed: false,
+ organizationScoped: false,
+ })
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('Mode: autonomous')
+ expect(result.stdout).toContain('Claimed: no')
+ expect(result.stdout).toContain('pascal agent claim')
+ expect(result.stdout).not.toContain('\u001b')
+ expect(result.stderr).toBe('')
+ })
+
+ test.each([
+ [401, 'agent_status_unauthorized'],
+ [403, 'agent_status_forbidden'],
+ ])('preserves the hosted status HTTP %i error contract', async (status, errorCode) => {
+ const result = await runStatusCli(status, 'untrusted error', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toMatchObject({
+ details: { status },
+ error: errorCode,
+ })
+ expect(result.stdout).toBe('')
+ })
+
+ test('rejects unknown agent account commands', async () => {
+ const result = await runCli('agent', 'login', '--json')
+
+ expect(result.exitCode).toBe(2)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'unknown_command' })
+ })
+
test('rejects a partially numeric port', async () => {
const result = await runCli('editor', '--port', '3000junk', '--no-open', '--json')
@@ -85,7 +239,12 @@ describe('command parsing', () => {
async function runCli(...args: string[]) {
const child = Bun.spawn([process.execPath, executable, ...args], {
- env: { ...process.env, PASCAL_HOME: testHome, PASCAL_NO_OPEN: '1' },
+ env: {
+ ...process.env,
+ PASCAL_API_KEY: '',
+ PASCAL_HOME: testHome,
+ PASCAL_NO_OPEN: '1',
+ },
stdout: 'pipe',
stderr: 'pipe',
})
@@ -96,3 +255,59 @@ async function runCli(...args: string[]) {
])
return { exitCode, stdout, stderr }
}
+
+async function runClaimCli(status: number, body: unknown, ...args: string[]) {
+ const apiKey = 'sk_live_cli-test-key'
+ const child = Bun.spawn(
+ [process.execPath, '--preload', claimFetchPreload, executable, 'agent', 'claim', ...args],
+ {
+ env: {
+ ...process.env,
+ PASCAL_API_KEY: apiKey,
+ PASCAL_AGENT_TEST_AUTHORIZATION: `Bearer ${apiKey}`,
+ PASCAL_AGENT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body),
+ PASCAL_AGENT_TEST_ENDPOINT: 'https://editor.pascal.app/api/auth/agent/claim/start',
+ PASCAL_AGENT_TEST_METHOD: 'POST',
+ PASCAL_AGENT_TEST_STATUS: String(status),
+ PASCAL_HOME: testHome,
+ PASCAL_NO_OPEN: '1',
+ },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ )
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ])
+ return { exitCode, stdout, stderr }
+}
+
+async function runStatusCli(status: number, body: unknown, ...args: string[]) {
+ const apiKey = 'sk_live_cli-status-test-key'
+ const child = Bun.spawn(
+ [process.execPath, '--preload', claimFetchPreload, executable, 'agent', 'status', ...args],
+ {
+ env: {
+ ...process.env,
+ PASCAL_API_KEY: apiKey,
+ PASCAL_AGENT_TEST_AUTHORIZATION: `Bearer ${apiKey}`,
+ PASCAL_AGENT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body),
+ PASCAL_AGENT_TEST_ENDPOINT: 'https://editor.pascal.app/api/auth/agent/status',
+ PASCAL_AGENT_TEST_METHOD: 'GET',
+ PASCAL_AGENT_TEST_STATUS: String(status),
+ PASCAL_HOME: testHome,
+ PASCAL_NO_OPEN: '1',
+ },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ )
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ])
+ return { exitCode, stdout, stderr }
+}
diff --git a/packages/cli/src/diagnostics.ts b/packages/cli/src/diagnostics.ts
index b499341b1a..7b91ffdb7d 100644
--- a/packages/cli/src/diagnostics.ts
+++ b/packages/cli/src/diagnostics.ts
@@ -2,6 +2,7 @@ import { constants } from 'node:fs'
import { access, readdir, stat } from 'node:fs/promises'
import { ensurePascalDirectories, getEditorStatus } from './editor-process.js'
import { readJsonFile } from './json-files.js'
+import { getMcpServiceStatus } from './mcp-service.js'
import type { PascalPaths } from './paths.js'
export interface DiagnosticCheck {
@@ -46,39 +47,31 @@ export async function runDoctor(paths: PascalPaths): Promise
})
}
try {
- const status = await getEditorStatus(paths)
+ const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)])
checks.push({
id: 'runtime',
status: status.installed ? 'pass' : 'warn',
message: status.runtime
- ? `Installed runtime ${status.runtime.version}`
- : 'No runtime installed yet.',
+ ? `Installed web runtime ${status.runtime.version}`
+ : 'No web runtime installed yet. It downloads when the editor first starts.',
})
checks.push({
id: 'editor',
- status: status.components.editor.healthy
- ? 'pass'
- : status.components.editor.running
- ? 'fail'
- : 'warn',
- message: status.components.editor.healthy
+ status: status.healthy ? 'pass' : status.running ? 'fail' : 'warn',
+ message: status.healthy
? `Healthy at ${status.state?.url}`
- : status.components.editor.running
+ : status.running
? 'A recorded editor process is running but unhealthy.'
: 'The editor is stopped.',
})
checks.push({
id: 'mcp',
- status: status.components.mcp.healthy
- ? 'pass'
- : status.components.mcp.running
- ? 'fail'
- : 'warn',
- message: status.components.mcp.healthy
- ? `MCP is healthy on loopback port ${status.state?.mcp?.port}.`
- : status.components.mcp.running
+ status: mcp.healthy ? 'pass' : mcp.running ? 'fail' : 'warn',
+ message: mcp.healthy
+ ? `MCP is healthy on loopback port ${mcp.state?.port}.`
+ : mcp.running
? 'The managed MCP process is running but unhealthy.'
- : 'MCP is stopped with the editor.',
+ : 'MCP is stopped. "pascal mcp connect" starts it on demand.',
})
const runtimeVersions = (await readdir(paths.runtime, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
@@ -123,14 +116,16 @@ function errorMessage(error: unknown): string {
export async function collectInfo(paths: PascalPaths) {
await ensurePascalDirectories(paths)
- const [status, runtimeVersions, pluginLock] = await Promise.all([
+ const [status, mcp, runtimeVersions, pluginLock] = await Promise.all([
getEditorStatus(paths),
+ getMcpServiceStatus(paths),
readdir(paths.runtime).catch(() => [] as string[]),
readJsonFile<{ schemaVersion?: number; plugins?: unknown[] }>(paths.pluginLock),
])
return {
cli: { node: process.versions.node, platform: process.platform, arch: process.arch },
editor: status,
+ mcp,
paths,
runtimes: runtimeVersions.filter((entry) => !entry.startsWith('.')).sort(),
plugins: pluginLock?.plugins ?? [],
diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts
index 8017105310..0c4d260bc0 100644
--- a/packages/cli/src/editor-process.ts
+++ b/packages/cli/src/editor-process.ts
@@ -1,20 +1,33 @@
-import { type ChildProcess, execFile, spawn } from 'node:child_process'
-import { randomBytes, randomUUID } from 'node:crypto'
+import { type ChildProcess, spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
import { closeSync, openSync } from 'node:fs'
-import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
-import net from 'node:net'
+import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
import path from 'node:path'
import { CliError } from './errors.js'
import { withFileLock } from './file-lock.js'
import { readJsonFile, writeJsonFile } from './json-files.js'
+import {
+ ensureMcpService,
+ type McpServiceState,
+ type McpStartProgress,
+ stopMcpService,
+} from './mcp-service.js'
import type { PascalPaths } from './paths.js'
+import {
+ errorMessage,
+ findAvailablePort,
+ isProcessRunning,
+ processCommand,
+ terminateProcess,
+ waitForSpawn,
+} from './process-control.js'
import {
type ActiveRuntime,
activateRuntime,
- installBundledRuntime,
readActiveRuntime,
readRuntimeManifest,
} from './runtime.js'
+import { ensureWebRuntime, type RuntimeProvisionProgress } from './runtime-download.js'
export interface EditorState {
schemaVersion: 1
@@ -26,14 +39,6 @@ export interface EditorState {
instanceId: string
runtimeDirectory: string
startedAt: string
- mcp?: McpState
-}
-
-export interface McpState {
- pid: number
- port: number
- host: '127.0.0.1'
- url: string
}
export interface EditorStatus {
@@ -42,35 +47,31 @@ export interface EditorStatus {
healthy: boolean
state: EditorState | null
runtime: ActiveRuntime | null
- components: {
- editor: { running: boolean; healthy: boolean }
- mcp: { running: boolean; healthy: boolean }
- }
}
export interface StartEditorOptions {
paths: PascalPaths
port?: number
foreground?: boolean
- sourceDirectory?: string
+ /** A web-runtime directory or `.tar.gz` archive to install instead of downloading one. */
+ runtimeSource?: string
onProgress?: (event: EditorStartProgress) => void
}
export type EditorStartProgress =
| { step: 'storage-ready'; dataDirectory: string }
- | { step: 'runtime-installing' }
| { step: 'runtime-ready'; version: string; installed: boolean }
| { step: 'port-ready'; port: number; preferredPort: number }
| { step: 'process-starting'; port: number }
| { step: 'health-checking'; port: number }
- | { step: 'mcp-port-ready'; port: number }
- | { step: 'mcp-starting'; port: number }
- | { step: 'mcp-health-checking'; port: number }
| { step: 'ready'; port: number }
| { step: 'already-running'; port: number }
+ | RuntimeProvisionProgress
+ | McpStartProgress
export interface StartEditorResult {
state: EditorState
+ mcp: McpServiceState
alreadyRunning: boolean
child?: ChildProcess
}
@@ -86,8 +87,8 @@ export interface RuntimeActivationResult {
export async function ensurePascalDirectories(paths: PascalPaths): Promise {
await Promise.all(
- [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs].map((directory) =>
- mkdir(directory, { recursive: true, mode: 0o700 }),
+ [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs, paths.tmp].map(
+ (directory) => mkdir(directory, { recursive: true, mode: 0o700 }),
),
)
}
@@ -98,34 +99,15 @@ export async function getEditorStatus(paths: PascalPaths): Promise
readJsonFile(paths.state),
])
if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') {
- return {
- installed: Boolean(runtime),
- running: false,
- healthy: false,
- state: null,
- runtime,
- components: {
- editor: { running: false, healthy: false },
- mcp: { running: false, healthy: false },
- },
- }
+ return { installed: Boolean(runtime), running: false, healthy: false, state: null, runtime }
}
- const editorRunning = isProcessRunning(state.pid)
- const mcpRunning = Boolean(state.mcp && isProcessRunning(state.mcp.pid))
- const [editorHealthy, mcpHealthy] = await Promise.all([
- editorRunning ? checkHealth(state) : false,
- mcpRunning ? checkMcpHealth(paths, state) : false,
- ])
+ const running = isProcessRunning(state.pid)
return {
installed: Boolean(runtime),
- running: editorRunning || mcpRunning,
- healthy: editorHealthy && mcpHealthy,
+ running,
+ healthy: running ? await checkHealth(state) : false,
state,
runtime,
- components: {
- editor: { running: editorRunning, healthy: editorHealthy },
- mcp: { running: mcpRunning, healthy: mcpHealthy },
- },
}
}
@@ -136,7 +118,6 @@ export async function startEditor(options: StartEditorOptions): Promise {
await ensurePascalDirectories(options.paths)
options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data })
- let installedRuntime = false
let currentStatus: EditorStatus
try {
currentStatus = await getEditorStatus(options.paths)
@@ -144,32 +125,35 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise {
- return withEditorLifecycleLock(paths, () => stopEditorUnlocked(paths, options))
+ const editorStopped = await withEditorLifecycleLock(paths, () =>
+ stopEditorUnlocked(paths, options),
+ )
+ const mcpStopped = await stopMcpService(paths, options)
+ return editorStopped || mcpStopped
}
async function stopEditorUnlocked(
@@ -293,34 +244,23 @@ async function stopEditorUnlocked(
options: StopEditorOptions = {},
): Promise {
const state = await readJsonFile(paths.state)
- const editorRunning = Boolean(state && isProcessRunning(state.pid))
- const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid))
- if (!state || (!editorRunning && !mcpRunning)) {
+ if (!state || !isProcessRunning(state.pid)) {
await rm(paths.state, { force: true })
- await rm(paths.mcpToken, { force: true })
return false
}
- const [editorHealthy, mcpHealthy] = await Promise.all([
- editorRunning ? checkHealth(state) : true,
- mcpRunning ? checkMcpHealth(paths, state) : true,
- ])
- const editorIdentified =
- editorHealthy ||
- (options.force && editorRunning && (await matchesRecordedEditorProcess(paths, state)))
- const mcpIdentified =
- mcpHealthy || (options.force && mcpRunning && (await matchesRecordedMcpProcess(paths, state)))
- if (!editorIdentified || !mcpIdentified) {
+ const identified =
+ (await checkHealth(state)) ||
+ (options.force && (await matchesRecordedEditorProcess(paths, state)))
+ if (!identified) {
throw new CliError(
'state_conflict',
options.force
? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal runtime.'
- : 'A Pascal process identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded commands are trusted.',
+ : 'The Pascal editor identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded command is trusted.',
)
}
- if (mcpRunning && state.mcp) await terminateProcess(state.mcp.pid)
- if (editorRunning) await terminateProcess(state.pid)
+ await terminateProcess(state.pid)
await rm(paths.state, { force: true })
- await rm(paths.mcpToken, { force: true })
return true
}
@@ -348,28 +288,21 @@ export async function activateEditorRuntime(
let previousStatus: EditorStatus
if (previousRuntimeWasInvalid) {
const state = await readJsonFile(paths.state)
- const editorRunning = Boolean(state && isProcessRunning(state.pid))
- const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid))
- const editorHealthy = Boolean(state && editorRunning && (await checkHealth(state)))
- const mcpHealthy = Boolean(state && mcpRunning && (await checkMcpHealth(paths, state)))
+ const running = Boolean(state && isProcessRunning(state.pid))
previousStatus = {
installed: false,
- running: editorRunning || mcpRunning,
- healthy: editorHealthy && mcpHealthy,
+ running,
+ healthy: Boolean(state && running && (await checkHealth(state))),
state: state ?? null,
runtime: null,
- components: {
- editor: { running: editorRunning, healthy: editorHealthy },
- mcp: { running: mcpRunning, healthy: mcpHealthy },
- },
}
} else {
previousStatus = await getEditorStatus(paths)
}
- if (previousStatus.running && !statusComponentsAreIdentified(previousStatus)) {
+ if (previousStatus.running && !previousStatus.healthy) {
throw new CliError(
'state_conflict',
- 'A recorded Pascal process is running but its identity could not be verified. Recover or stop it before updating.',
+ 'A recorded Pascal editor process is running but its identity could not be verified. Recover or stop it before updating.',
)
}
if (
@@ -386,11 +319,15 @@ export async function activateEditorRuntime(
try {
await activateRuntime(paths, candidate.version, candidate.directory)
await startEditorUnlocked({ paths, port: previousPort })
- if (!wasRunning) await stopEditorUnlocked(paths)
+ if (!wasRunning) {
+ await stopEditorUnlocked(paths)
+ await stopMcpService(paths)
+ }
return { runtime: candidate, restarted: wasRunning }
} catch (error) {
try {
await stopEditorUnlocked(paths, { force: true })
+ await stopMcpService(paths, { force: true })
} catch {}
let rollbackError: unknown
if (previousRuntime) {
@@ -478,7 +415,7 @@ export async function followLog(filePath: string): Promise {
}
}
-export async function checkHealth(state: EditorState): Promise {
+async function checkHealth(state: EditorState): Promise {
return (await probeHealth(state)) === 'healthy'
}
@@ -529,141 +466,6 @@ export async function waitForHealth(state: EditorState, timeoutMs: number): Prom
throw new CliError('health_timeout', `Pascal did not become healthy within ${timeoutMs}ms.`)
}
-export async function checkMcpHealth(paths: PascalPaths, state: EditorState): Promise {
- return (await probeMcpHealth(paths, state)) === 'healthy'
-}
-
-async function probeMcpHealth(
- paths: PascalPaths,
- state: EditorState,
-): Promise<'healthy' | 'foreign' | 'unreachable'> {
- if (!state.mcp) return 'unreachable'
- let token: string
- try {
- token = (await readFile(paths.mcpToken, 'utf8')).trim()
- } catch {
- return 'unreachable'
- }
- if (!token) return 'unreachable'
- try {
- const response = await fetch(`http://127.0.0.1:${state.mcp.port}/health`, {
- headers: { authorization: `Bearer ${token}` },
- signal: AbortSignal.timeout(1_000),
- })
- if (!response.ok) return 'foreign'
- const body = (await response.json()) as {
- status?: string
- app?: string
- version?: string
- instanceId?: string
- }
- return body.status === 'ok' &&
- body.app === 'mcp' &&
- body.version === state.version &&
- body.instanceId === state.instanceId
- ? 'healthy'
- : 'foreign'
- } catch {
- return 'unreachable'
- }
-}
-
-async function waitForMcpHealth(
- paths: PascalPaths,
- state: EditorState,
- timeoutMs: number,
-): Promise {
- if (!state.mcp) throw new CliError('start_failed', 'Pascal MCP state was not created.')
- const deadline = Date.now() + timeoutMs
- while (Date.now() < deadline) {
- const health = await probeMcpHealth(paths, state)
- if (health === 'healthy') return
- if (health === 'foreign') {
- throw new CliError(
- 'port_conflict',
- `Port ${state.mcp.port} is responding as another application. Run Pascal again to choose another port.`,
- )
- }
- if (!isProcessRunning(state.mcp.pid)) {
- throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.')
- }
- await new Promise((resolve) => setTimeout(resolve, 200))
- }
- throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`)
-}
-
-export function isProcessRunning(pid: number): boolean {
- if (!Number.isSafeInteger(pid) || pid <= 0) return false
- try {
- process.kill(pid, 0)
- return true
- } catch (error) {
- return (error as NodeJS.ErrnoException).code === 'EPERM'
- }
-}
-
-async function findAvailablePort(preferredPort: number): Promise {
- if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) {
- throw new CliError('invalid_port', `Invalid port: ${preferredPort}`)
- }
- if (preferredPort === 0) return probePort(0)
- if (!(await isPortAcceptingConnections(preferredPort))) {
- try {
- return await probePort(preferredPort)
- } catch {}
- }
- return probePort(0)
-}
-
-async function isPortAcceptingConnections(port: number): Promise {
- return new Promise((resolve) => {
- const socket = net.connect({ host: '127.0.0.1', port })
- let settled = false
- const finish = (result: boolean) => {
- if (settled) return
- settled = true
- socket.destroy()
- resolve(result)
- }
- socket.setTimeout(250)
- socket.once('connect', () => finish(true))
- socket.once('timeout', () => finish(false))
- socket.once('error', () => finish(false))
- })
-}
-
-async function probePort(port: number): Promise {
- return new Promise((resolve, reject) => {
- const server = net.createServer()
- server.unref()
- server.once('error', reject)
- server.listen({ host: '127.0.0.1', port }, () => {
- const address = server.address()
- const resolvedPort = typeof address === 'object' && address ? address.port : port
- server.close((error) => (error ? reject(error) : resolve(resolvedPort)))
- })
- })
-}
-
-async function terminateProcess(pid: number): Promise {
- try {
- process.kill(pid, 'SIGTERM')
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'ESRCH') return
- throw error
- }
- const deadline = Date.now() + 10_000
- while (Date.now() < deadline) {
- if (!isProcessRunning(pid)) return
- await new Promise((resolve) => setTimeout(resolve, 100))
- }
- try {
- process.kill(pid, 'SIGKILL')
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
- }
-}
-
async function withEditorLifecycleLock(
paths: PascalPaths,
action: () => Promise,
@@ -676,15 +478,6 @@ async function withEditorLifecycleLock(
)
}
-async function waitForSpawn(child: ChildProcess, binary: string): Promise {
- await new Promise((resolve, reject) => {
- child.once('spawn', resolve)
- child.once('error', reject)
- }).catch((error) => {
- throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`)
- })
-}
-
async function matchesRecordedEditorProcess(
paths: PascalPaths,
state: EditorState,
@@ -703,28 +496,6 @@ async function matchesRecordedEditorProcess(
return command.includes(expectedEntrypoint)
}
-async function matchesRecordedMcpProcess(paths: PascalPaths, state: EditorState): Promise {
- if (process.platform === 'win32' || !state.mcp) return false
- const runtimeDirectory = path.resolve(state.runtimeDirectory)
- if (!runtimeDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) return false
- let expectedEntrypoint: string
- try {
- const manifest = await readRuntimeManifest(runtimeDirectory)
- expectedEntrypoint = path.resolve(runtimeDirectory, manifest.mcpEntrypoint)
- } catch {
- expectedEntrypoint = path.join(runtimeDirectory, 'services/pascal-mcp.mjs')
- }
- return (await processCommand(state.mcp.pid)).includes(expectedEntrypoint)
-}
-
-async function processCommand(pid: number): Promise {
- return new Promise((resolve) => {
- execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => {
- resolve(error ? '' : stdout.trim())
- })
- })
-}
-
async function rotateEditorLog(filePath: string): Promise {
try {
if ((await stat(filePath)).size <= 10 * 1024 * 1024) return
@@ -735,14 +506,3 @@ async function rotateEditorLog(filePath: string): Promise {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error)
-}
-
-function statusComponentsAreIdentified(status: EditorStatus): boolean {
- return (
- (!status.components.editor.running || status.components.editor.healthy) &&
- (!status.components.mcp.running || status.components.mcp.healthy)
- )
-}
diff --git a/packages/cli/src/file-lock.ts b/packages/cli/src/file-lock.ts
index ffb0ddea23..75f4f1df5a 100644
--- a/packages/cli/src/file-lock.ts
+++ b/packages/cli/src/file-lock.ts
@@ -19,10 +19,11 @@ export async function withFileLock(
code: string,
message: string,
action: () => Promise,
+ options: { timeoutMs?: number } = {},
): Promise {
await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 })
const token = randomUUID()
- const deadline = Date.now() + DEFAULT_TIMEOUT_MS
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
while (!(await tryAcquire(lockPath, token))) {
if (await reclaimStaleLock(lockPath)) continue
diff --git a/packages/cli/src/http-download.ts b/packages/cli/src/http-download.ts
new file mode 100644
index 0000000000..9dd7d5a340
--- /dev/null
+++ b/packages/cli/src/http-download.ts
@@ -0,0 +1,235 @@
+import { createWriteStream } from 'node:fs'
+import http from 'node:http'
+import https from 'node:https'
+import type { Socket } from 'node:net'
+import tls from 'node:tls'
+import { CliError } from './errors.js'
+import { version } from './version.js'
+
+const DEFAULT_TIMEOUT_MS = 60_000
+const MAX_REDIRECTS = 5
+const PROGRESS_INTERVAL_MS = 200
+
+export interface DownloadProgress {
+ received: number
+ total: number | null
+}
+
+export interface DownloadOptions {
+ environment?: NodeJS.ProcessEnv
+ onProgress?: (progress: DownloadProgress) => void
+ timeoutMs?: number
+}
+
+/**
+ * Streams an HTTPS URL to disk without adding a dependency. Node's built-in `fetch` only
+ * honours `HTTPS_PROXY` when the process was started with `--use-env-proxy`, which a
+ * published CLI cannot retrofit onto its own entrypoint, so the proxy tunnel is explicit.
+ */
+export async function downloadToFile(
+ url: string,
+ destination: string,
+ options: DownloadOptions = {},
+): Promise {
+ const environment = options.environment ?? process.env
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
+ let target = parseHttpsUrl(url)
+ for (let redirect = 0; ; redirect += 1) {
+ const response = await requestOnce(target, environment, timeoutMs)
+ const status = response.statusCode ?? 0
+ if (status >= 300 && status < 400 && response.headers.location) {
+ response.resume()
+ if (redirect >= MAX_REDIRECTS) {
+ throw new CliError('download_failed', `${url} redirected more than ${MAX_REDIRECTS} times.`)
+ }
+ target = parseHttpsUrl(new URL(response.headers.location, target).toString())
+ continue
+ }
+ if (status !== 200) {
+ response.resume()
+ throw new CliError('download_failed', `${target.href} returned HTTP ${status}.`)
+ }
+ return writeResponse(response, destination, options.onProgress)
+ }
+}
+
+export function resolveProxyUrl(target: URL, environment: NodeJS.ProcessEnv): string | null {
+ if (isProxyBypassed(target.hostname, environment.NO_PROXY ?? environment.no_proxy)) return null
+ const configured =
+ environment.HTTPS_PROXY ??
+ environment.https_proxy ??
+ environment.ALL_PROXY ??
+ environment.all_proxy
+ return configured?.trim() ? configured.trim() : null
+}
+
+export function isProxyBypassed(hostname: string, noProxy: string | undefined): boolean {
+ if (!noProxy?.trim()) return false
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
+ for (const raw of noProxy.split(/[,\s]+/)) {
+ const entry = raw.trim().toLowerCase()
+ if (!entry) continue
+ if (entry === '*') return true
+ const pattern = entry.replace(/^\*/, '').replace(/^\./, '').replace(/:\d+$/, '')
+ if (!pattern) continue
+ if (host === pattern || host.endsWith(`.${pattern}`)) return true
+ }
+ return false
+}
+
+function parseHttpsUrl(value: string): URL {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ throw new CliError('download_failed', `Invalid download URL: ${value}`)
+ }
+ if (url.protocol !== 'https:') {
+ throw new CliError('download_failed', `Only https downloads are supported: ${value}`)
+ }
+ return url
+}
+
+async function requestOnce(
+ target: URL,
+ environment: NodeJS.ProcessEnv,
+ timeoutMs: number,
+): Promise {
+ const proxy = resolveProxyUrl(target, environment)
+ const agent = proxy
+ ? new TunnelAgent(
+ await openProxyTunnel(parseProxyUrl(proxy), target, timeoutMs),
+ target.hostname,
+ )
+ : undefined
+ const request = https.request({
+ hostname: target.hostname,
+ port: target.port || 443,
+ path: `${target.pathname}${target.search}`,
+ method: 'GET',
+ headers: {
+ accept: 'application/octet-stream, */*',
+ 'accept-encoding': 'identity',
+ 'user-agent': `pascal-cli/${version}`,
+ },
+ ...(agent ? { agent } : {}),
+ })
+ request.setTimeout(timeoutMs, () =>
+ request.destroy(new Error(`no response from ${target.host} within ${timeoutMs}ms`)),
+ )
+ request.end()
+ return new Promise((resolve, reject) => {
+ request.once('response', resolve)
+ request.once('error', (error) =>
+ reject(new CliError('download_failed', `Unable to reach ${target.href}: ${error.message}`)),
+ )
+ })
+}
+
+async function writeResponse(
+ response: http.IncomingMessage,
+ destination: string,
+ onProgress: ((progress: DownloadProgress) => void) | undefined,
+): Promise {
+ const declared = Number(response.headers['content-length'])
+ const total = Number.isFinite(declared) && declared > 0 ? declared : null
+ const file = createWriteStream(destination, { mode: 0o600 })
+ let received = 0
+ let lastReport = 0
+ await new Promise((resolve, reject) => {
+ const fail = (error: Error) => {
+ response.destroy()
+ file.destroy()
+ reject(error)
+ }
+ response.on('data', (chunk: Buffer) => {
+ received += chunk.byteLength
+ if (!file.write(chunk)) response.pause()
+ const now = Date.now()
+ if (onProgress && now - lastReport >= PROGRESS_INTERVAL_MS) {
+ lastReport = now
+ onProgress({ received, total })
+ }
+ })
+ file.on('drain', () => response.resume())
+ response.once('error', fail)
+ file.once('error', fail)
+ response.once('end', () => file.end(resolve))
+ })
+ onProgress?.({ received, total })
+ return received
+}
+
+function parseProxyUrl(value: string): URL {
+ const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`
+ let proxy: URL
+ try {
+ proxy = new URL(candidate)
+ } catch {
+ throw new CliError('download_failed', `Invalid proxy URL: ${value}`)
+ }
+ if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') {
+ throw new CliError('download_failed', `Unsupported proxy protocol: ${proxy.protocol}`)
+ }
+ return proxy
+}
+
+async function openProxyTunnel(proxy: URL, target: URL, timeoutMs: number): Promise {
+ const authority = `${target.hostname}:${target.port || 443}`
+ const headers: Record = { host: authority }
+ if (proxy.username) {
+ const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
+ headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`
+ }
+ const requestFn = proxy.protocol === 'https:' ? https.request : http.request
+ const request = requestFn({
+ host: proxy.hostname,
+ port: proxy.port || (proxy.protocol === 'https:' ? 443 : 80),
+ method: 'CONNECT',
+ path: authority,
+ headers,
+ })
+ request.setTimeout(timeoutMs, () =>
+ request.destroy(new Error(`proxy ${proxy.host} did not answer CONNECT within ${timeoutMs}ms`)),
+ )
+ request.end()
+ return new Promise((resolve, reject) => {
+ request.once('connect', (response, socket) => {
+ if (response.statusCode !== 200) {
+ socket.destroy()
+ reject(
+ new CliError(
+ 'download_failed',
+ `Proxy ${proxy.host} refused CONNECT ${authority} with HTTP ${response.statusCode}.`,
+ ),
+ )
+ return
+ }
+ resolve(socket)
+ })
+ request.once('error', (error) =>
+ reject(
+ new CliError('download_failed', `Unable to reach proxy ${proxy.host}: ${error.message}`),
+ ),
+ )
+ })
+}
+
+class TunnelAgent extends https.Agent {
+ private readonly tunnel: Socket
+ private readonly servername: string
+
+ constructor(tunnel: Socket, servername: string) {
+ super({ keepAlive: false, maxSockets: 1 })
+ this.tunnel = tunnel
+ this.servername = servername
+ }
+
+ override createConnection(): tls.TLSSocket {
+ return tls.connect({
+ socket: this.tunnel,
+ servername: this.servername,
+ ALPNProtocols: ['http/1.1'],
+ })
+ }
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 6134f0db67..ba86bb163b 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -5,7 +5,6 @@ export {
type EditorStatus,
ensurePascalDirectories,
getEditorStatus,
- type McpState,
type RuntimeActivationResult,
restartEditor,
type StopEditorOptions,
@@ -13,6 +12,13 @@ export {
stopEditor,
} from './editor-process.js'
export { CliError } from './errors.js'
+export {
+ ensureMcpService,
+ getMcpServiceStatus,
+ type McpServiceState,
+ type McpServiceStatus,
+ stopMcpService,
+} from './mcp-service.js'
export { type PascalPaths, resolvePascalPaths } from './paths.js'
export {
type ActiveRuntime,
@@ -21,4 +27,10 @@ export {
readActiveRuntime,
readRuntimeManifest,
} from './runtime.js'
+export {
+ ensureWebRuntime,
+ type RuntimeSource,
+ readRuntimeSource,
+ verifyArchiveDigest,
+} from './runtime-download.js'
export { version } from './version.js'
diff --git a/packages/cli/src/mcp-connector.ts b/packages/cli/src/mcp-connector.ts
index 21688e0d70..1ff5d3fed4 100644
--- a/packages/cli/src/mcp-connector.ts
+++ b/packages/cli/src/mcp-connector.ts
@@ -2,24 +2,19 @@ import { readFile } from 'node:fs/promises'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
-import { getEditorStatus, startEditor } from './editor-process.js'
import { CliError } from './errors.js'
+import { ensureMcpService } from './mcp-service.js'
import type { PascalPaths } from './paths.js'
+/**
+ * Bridges stdio to the managed MCP service. The service ships with the CLI, so this never
+ * starts the web editor and never needs the downloaded web runtime.
+ */
export async function connectManagedMcp(paths: PascalPaths): Promise {
- let status = await getEditorStatus(paths)
- if (!status.healthy) {
- await startEditor({ paths })
- status = await getEditorStatus(paths)
- }
- if (!(status.healthy && status.state?.mcp)) {
- throw new CliError('mcp_unavailable', 'Pascal MCP is not healthy. Run "pascal doctor".')
- }
-
- const token = (await readFile(paths.mcpToken, 'utf8')).trim()
- if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.')
+ const { state } = await ensureMcpService({ paths })
+ const token = await readMcpToken(paths)
- const remote = new StreamableHTTPClientTransport(new URL(status.state.mcp.url), {
+ const remote = new StreamableHTTPClientTransport(new URL(state.url), {
requestInit: { headers: { authorization: `Bearer ${token}` } },
})
const stdio = new StdioServerTransport()
@@ -42,6 +37,15 @@ export async function connectManagedMcp(paths: PascalPaths): Promise {
await stdio.start()
}
+async function readMcpToken(paths: PascalPaths): Promise {
+ let token = ''
+ try {
+ token = (await readFile(paths.mcpToken, 'utf8')).trim()
+ } catch {}
+ if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.')
+ return token
+}
+
function applyProtocolVersion(
transport: StreamableHTTPClientTransport,
message: JSONRPCMessage,
diff --git a/packages/cli/src/mcp-service.test.ts b/packages/cli/src/mcp-service.test.ts
new file mode 100644
index 0000000000..2f2c56c351
--- /dev/null
+++ b/packages/cli/src/mcp-service.test.ts
@@ -0,0 +1,165 @@
+import { afterAll, afterEach, describe, expect, test } from 'bun:test'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import {
+ ensureMcpService,
+ getMcpServiceStatus,
+ type McpServiceState,
+ stopMcpService,
+} from './mcp-service.js'
+import { type PascalPaths, resolvePascalPaths } from './paths.js'
+import { readActiveRuntime } from './runtime.js'
+import { writeFakeMcpService } from './test-support/fake-mcp-service.js'
+
+const roots: string[] = []
+const started: PascalPaths[] = []
+/** The MCP service ships with the CLI; the tests inject a stand-in for the bundled bundle. */
+const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-service-'))
+process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot)
+
+afterEach(async () => {
+ for (const paths of started.splice(0)) {
+ await stopMcpService(paths, { force: true }).catch(() => undefined)
+ }
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+afterAll(() => rm(serviceRoot, { recursive: true, force: true }))
+
+describe('managed MCP service', () => {
+ test('starts on demand without a web runtime installed', async () => {
+ const paths = await temporaryPaths()
+
+ const result = await ensureMcpService({ paths })
+
+ expect(result.alreadyRunning).toBe(false)
+ expect(result.state.editorOrigin).toBeNull()
+ expect(result.state.host).toBe('127.0.0.1')
+ expect(result.state.url).toBe(`http://127.0.0.1:${result.state.port}/mcp`)
+ expect(await readActiveRuntime(paths)).toBeNull()
+ expect(paths.mcpState.endsWith(path.join('run', 'mcp.json'))).toBe(true)
+ const status = await getMcpServiceStatus(paths)
+ expect(status).toMatchObject({ running: true, healthy: true })
+ expect(status.state?.pid).toBe(result.state.pid)
+ expect((await stat(paths.mcpToken)).mode & 0o077).toBe(0)
+ })
+
+ test('reuses a healthy service instead of starting a second one', async () => {
+ const paths = await temporaryPaths()
+ const first = await ensureMcpService({ paths })
+
+ const second = await ensureMcpService({ paths })
+
+ expect(second.alreadyRunning).toBe(true)
+ expect(second.state.pid).toBe(first.state.pid)
+ expect(second.state.instanceId).toBe(first.state.instanceId)
+ })
+
+ test('serializes concurrent starts into one service', async () => {
+ const paths = await temporaryPaths()
+
+ const [first, second] = await Promise.all([
+ ensureMcpService({ paths }),
+ ensureMcpService({ paths }),
+ ])
+
+ expect(first.state.pid).toBe(second.state.pid)
+ expect([first.alreadyRunning, second.alreadyRunning].sort()).toEqual([false, true])
+ })
+
+ test('keeps the recorded editor origin when the caller does not run the editor', async () => {
+ const paths = await temporaryPaths()
+ const editorOrigin = 'http://pascal.localhost:41234'
+ const first = await ensureMcpService({ paths, editorOrigin })
+
+ const connected = await ensureMcpService({ paths })
+
+ expect(connected.alreadyRunning).toBe(true)
+ expect(connected.state.pid).toBe(first.state.pid)
+ expect(connected.state.editorOrigin).toBe(editorOrigin)
+ expect(await reportedEditorOrigin(paths, connected.state)).toBe(editorOrigin)
+ })
+
+ test('restarts with the new origin when the editor moves to another port', async () => {
+ const paths = await temporaryPaths()
+ const first = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41234' })
+
+ const moved = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41235' })
+
+ expect(moved.alreadyRunning).toBe(false)
+ expect(moved.state.pid).not.toBe(first.state.pid)
+ expect(moved.state.editorOrigin).toBe('http://pascal.localhost:41235')
+ expect(await reportedEditorOrigin(paths, moved.state)).toBe('http://pascal.localhost:41235')
+ })
+
+ test('stops the service once and clears its state and token', async () => {
+ const paths = await temporaryPaths()
+ await ensureMcpService({ paths })
+
+ expect(await stopMcpService(paths)).toBe(true)
+ expect(await stopMcpService(paths)).toBe(false)
+ expect(await getMcpServiceStatus(paths)).toEqual({
+ running: false,
+ healthy: false,
+ state: null,
+ })
+ expect(await exists(paths.mcpState)).toBe(false)
+ expect(await exists(paths.mcpToken)).toBe(false)
+ })
+
+ test('refuses to stop a recorded process that is not the MCP service', async () => {
+ const paths = await temporaryPaths(false)
+ await writeFile(
+ paths.mcpState,
+ JSON.stringify({
+ schemaVersion: 1,
+ pid: process.pid,
+ port: 1,
+ host: '127.0.0.1',
+ url: 'http://127.0.0.1:1/mcp',
+ version: '0.0.0',
+ instanceId: 'not-the-service',
+ servicePath: path.join(serviceRoot, 'pascal-mcp.mjs'),
+ editorOrigin: null,
+ startedAt: new Date().toISOString(),
+ }),
+ )
+
+ await expect(stopMcpService(paths)).rejects.toMatchObject({ code: 'state_conflict' })
+ await expect(stopMcpService(paths, { force: true })).rejects.toMatchObject({
+ code: 'state_conflict',
+ })
+ await rm(paths.mcpState, { force: true })
+ })
+})
+
+async function temporaryPaths(tracked = true): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-test-'))
+ roots.push(root)
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await mkdir(paths.run, { recursive: true, mode: 0o700 })
+ if (tracked) started.push(paths)
+ return paths
+}
+
+/** The stand-in service echoes the origin it was started with, proving the restart repointed it. */
+async function reportedEditorOrigin(
+ paths: PascalPaths,
+ state: McpServiceState,
+): Promise {
+ const token = (await readFile(paths.mcpToken, 'utf8')).trim()
+ const response = await fetch(`http://127.0.0.1:${state.port}/health`, {
+ headers: { authorization: `Bearer ${token}` },
+ })
+ return ((await response.json()) as { editorOrigin: string | null }).editorOrigin
+}
+
+async function exists(file: string): Promise {
+ try {
+ await stat(file)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/packages/cli/src/mcp-service.ts b/packages/cli/src/mcp-service.ts
new file mode 100644
index 0000000000..af633b8bf8
--- /dev/null
+++ b/packages/cli/src/mcp-service.ts
@@ -0,0 +1,292 @@
+import type { ChildProcess } from 'node:child_process'
+import { spawn } from 'node:child_process'
+import { randomBytes, randomUUID } from 'node:crypto'
+import { closeSync, openSync } from 'node:fs'
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { CliError } from './errors.js'
+import { withFileLock } from './file-lock.js'
+import { readJsonFile, writeJsonFile } from './json-files.js'
+import type { PascalPaths } from './paths.js'
+import {
+ findAvailablePort,
+ isProcessRunning,
+ processCommand,
+ terminateProcess,
+ waitForSpawn,
+} from './process-control.js'
+import { version } from './version.js'
+
+export interface McpServiceState {
+ schemaVersion: 1
+ pid: number
+ port: number
+ host: '127.0.0.1'
+ url: string
+ version: string
+ instanceId: string
+ servicePath: string
+ editorOrigin: string | null
+ startedAt: string
+}
+
+export interface McpServiceStatus {
+ running: boolean
+ healthy: boolean
+ state: McpServiceState | null
+}
+
+export type McpStartProgress =
+ | { step: 'mcp-port-ready'; port: number }
+ | { step: 'mcp-starting'; port: number }
+ | { step: 'mcp-health-checking'; port: number }
+ | { step: 'mcp-ready'; port: number }
+ | { step: 'mcp-already-running'; port: number }
+
+export interface EnsureMcpServiceOptions {
+ paths: PascalPaths
+ /**
+ * The editor origin the MCP service should format `editorUrl` values against. Omit it when
+ * the caller does not run the web editor: a recorded origin is then kept as it is.
+ */
+ editorOrigin?: string
+ foreground?: boolean
+ onProgress?: (event: McpStartProgress) => void
+}
+
+export interface McpServiceResult {
+ state: McpServiceState
+ alreadyRunning: boolean
+ child?: ChildProcess
+}
+
+/**
+ * The MCP service is bundled with the CLI itself, not with the downloaded web runtime, so
+ * agent tools work before (and without) any editor runtime being installed.
+ */
+export function resolveMcpServicePath(environment: NodeJS.ProcessEnv = process.env): string {
+ if (environment.PASCAL_MCP_SERVICE_PATH) {
+ return path.resolve(environment.PASCAL_MCP_SERVICE_PATH)
+ }
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url))
+ return path.basename(moduleDirectory) === 'dist'
+ ? path.join(moduleDirectory, 'services/pascal-mcp.mjs')
+ : path.resolve(moduleDirectory, '../dist/services/pascal-mcp.mjs')
+}
+
+export async function getMcpServiceStatus(paths: PascalPaths): Promise {
+ const state = await readJsonFile(paths.mcpState)
+ if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') {
+ return { running: false, healthy: false, state: null }
+ }
+ const running = isProcessRunning(state.pid)
+ return { running, healthy: running ? await checkMcpHealth(paths, state) : false, state }
+}
+
+export async function ensureMcpService(
+ options: EnsureMcpServiceOptions,
+): Promise {
+ return withMcpLifecycleLock(options.paths, () => ensureMcpServiceUnlocked(options))
+}
+
+export async function stopMcpService(
+ paths: PascalPaths,
+ options: { force?: boolean } = {},
+): Promise {
+ return withMcpLifecycleLock(paths, () => stopMcpServiceUnlocked(paths, options))
+}
+
+async function ensureMcpServiceUnlocked(
+ options: EnsureMcpServiceOptions,
+): Promise {
+ const { paths } = options
+ const status = await getMcpServiceStatus(paths)
+ if (status.healthy && status.state) {
+ const originMatches =
+ options.editorOrigin === undefined || options.editorOrigin === status.state.editorOrigin
+ if (originMatches) {
+ options.onProgress?.({ step: 'mcp-already-running', port: status.state.port })
+ return { state: status.state, alreadyRunning: true }
+ }
+ }
+ if (status.running && status.state) {
+ if (!(status.healthy || (await matchesRecordedMcpProcess(status.state)))) {
+ throw new CliError(
+ 'state_conflict',
+ 'A recorded Pascal MCP process is running but its identity could not be verified. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.',
+ )
+ }
+ await terminateProcess(status.state.pid)
+ }
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+
+ const servicePath = resolveMcpServicePath()
+ const port = await findAvailablePort(0)
+ const instanceId = randomUUID()
+ const token = randomBytes(32).toString('base64url')
+ const state: McpServiceState = {
+ schemaVersion: 1,
+ pid: 0,
+ port,
+ host: '127.0.0.1',
+ url: `http://127.0.0.1:${port}/mcp`,
+ version,
+ instanceId,
+ servicePath,
+ editorOrigin: options.editorOrigin ?? null,
+ startedAt: new Date().toISOString(),
+ }
+ options.onProgress?.({ step: 'mcp-port-ready', port })
+ await Promise.all(
+ [paths.run, paths.logs, paths.data].map((directory) =>
+ mkdir(directory, { recursive: true, mode: 0o700 }),
+ ),
+ )
+ await writeFile(paths.mcpToken, `${token}\n`, { mode: 0o600 })
+
+ const environment: NodeJS.ProcessEnv = {
+ ...process.env,
+ NODE_ENV: 'production',
+ PASCAL_DATA_DIR: paths.data,
+ PASCAL_INSTANCE_ID: instanceId,
+ PASCAL_RUNTIME_VERSION: version,
+ PASCAL_MCP_HTTP_TOKEN: token,
+ ...(state.editorOrigin ? { PASCAL_EDITOR_ORIGIN: state.editorOrigin } : {}),
+ }
+ const nodeBinary = process.env.PASCAL_NODE_BINARY || 'node'
+ const logDescriptor = options.foreground ? undefined : openSync(paths.editorLog, 'a', 0o600)
+ options.onProgress?.({ step: 'mcp-starting', port })
+ const child = spawn(
+ nodeBinary,
+ [servicePath, '--http', '--host', state.host, '--port', String(port)],
+ {
+ cwd: path.dirname(servicePath),
+ env: environment,
+ detached: !options.foreground,
+ stdio: options.foreground
+ ? ['ignore', 'inherit', 'inherit']
+ : ['ignore', logDescriptor!, logDescriptor!],
+ },
+ )
+ if (logDescriptor !== undefined) closeSync(logDescriptor)
+ try {
+ await waitForSpawn(child, nodeBinary)
+ if (!child.pid) throw new CliError('start_failed', 'The Pascal MCP process did not start.')
+ state.pid = child.pid
+ await writeJsonFile(paths.mcpState, state)
+ if (!options.foreground) child.unref()
+ options.onProgress?.({ step: 'mcp-health-checking', port })
+ await waitForMcpHealth(paths, state, 20_000)
+ options.onProgress?.({ step: 'mcp-ready', port })
+ } catch (error) {
+ if (child.pid) await terminateProcess(child.pid)
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+ throw error
+ }
+ return { state, alreadyRunning: false, child: options.foreground ? child : undefined }
+}
+
+async function stopMcpServiceUnlocked(
+ paths: PascalPaths,
+ options: { force?: boolean },
+): Promise {
+ const status = await getMcpServiceStatus(paths)
+ if (!status.state || !status.running) {
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+ return false
+ }
+ if (!(status.healthy || (options.force && (await matchesRecordedMcpProcess(status.state))))) {
+ throw new CliError(
+ 'state_conflict',
+ options.force
+ ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal MCP service.'
+ : 'The Pascal MCP identity is unavailable. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.',
+ )
+ }
+ await terminateProcess(status.state.pid)
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+ return true
+}
+
+export async function checkMcpHealth(paths: PascalPaths, state: McpServiceState): Promise {
+ return (await probeMcpHealth(paths, state)) === 'healthy'
+}
+
+async function probeMcpHealth(
+ paths: PascalPaths,
+ state: McpServiceState,
+): Promise<'healthy' | 'foreign' | 'unreachable'> {
+ let token: string
+ try {
+ token = (await readFile(paths.mcpToken, 'utf8')).trim()
+ } catch {
+ return 'unreachable'
+ }
+ if (!token) return 'unreachable'
+ try {
+ const response = await fetch(`http://127.0.0.1:${state.port}/health`, {
+ headers: { authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(1_000),
+ })
+ if (!response.ok) return 'foreign'
+ const body = (await response.json()) as {
+ status?: string
+ app?: string
+ version?: string
+ instanceId?: string
+ }
+ return body.status === 'ok' &&
+ body.app === 'mcp' &&
+ body.version === state.version &&
+ body.instanceId === state.instanceId
+ ? 'healthy'
+ : 'foreign'
+ } catch {
+ return 'unreachable'
+ }
+}
+
+async function waitForMcpHealth(
+ paths: PascalPaths,
+ state: McpServiceState,
+ timeoutMs: number,
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const health = await probeMcpHealth(paths, state)
+ if (health === 'healthy') return
+ if (health === 'foreign') {
+ throw new CliError(
+ 'port_conflict',
+ `Port ${state.port} is responding as another application. Run the command again to choose another port.`,
+ )
+ }
+ if (!isProcessRunning(state.pid)) {
+ throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ }
+ throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`)
+}
+
+async function matchesRecordedMcpProcess(state: McpServiceState): Promise {
+ if (process.platform === 'win32') return false
+ const servicePath = path.resolve(state.servicePath)
+ if (path.basename(servicePath) !== 'pascal-mcp.mjs') return false
+ return (await processCommand(state.pid)).includes(servicePath)
+}
+
+async function withMcpLifecycleLock(paths: PascalPaths, action: () => Promise): Promise {
+ return withFileLock(
+ path.join(paths.run, 'mcp-lifecycle.lock'),
+ 'mcp_locked',
+ 'Another Pascal MCP lifecycle operation is active.',
+ action,
+ { timeoutMs: 30_000 },
+ )
+}
diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts
index e07529fc95..f352dd83af 100644
--- a/packages/cli/src/paths.ts
+++ b/packages/cli/src/paths.ts
@@ -8,7 +8,9 @@ export interface PascalPaths {
plugins: string
run: string
logs: string
+ tmp: string
state: string
+ mcpState: string
currentRuntime: string
pluginLock: string
database: string
@@ -25,7 +27,9 @@ export function resolvePascalPaths(environment: NodeJS.ProcessEnv = process.env)
plugins: path.join(root, 'plugins'),
run: path.join(root, 'run'),
logs: path.join(root, 'logs'),
+ tmp: path.join(root, 'tmp'),
state: path.join(root, 'run/editor.json'),
+ mcpState: path.join(root, 'run/mcp.json'),
currentRuntime: path.join(root, 'run/current-runtime.json'),
pluginLock: path.join(root, 'pascal.plugins.lock'),
database: path.join(root, 'data/pascal.db'),
diff --git a/packages/cli/src/process-control.ts b/packages/cli/src/process-control.ts
new file mode 100644
index 0000000000..f19027f48d
--- /dev/null
+++ b/packages/cli/src/process-control.ts
@@ -0,0 +1,96 @@
+import { type ChildProcess, execFile } from 'node:child_process'
+import net from 'node:net'
+import { CliError } from './errors.js'
+
+export function isProcessRunning(pid: number): boolean {
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
+ }
+}
+
+export async function terminateProcess(pid: number): Promise {
+ try {
+ process.kill(pid, 'SIGTERM')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ESRCH') return
+ throw error
+ }
+ const deadline = Date.now() + 10_000
+ while (Date.now() < deadline) {
+ if (!isProcessRunning(pid)) return
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+ try {
+ process.kill(pid, 'SIGKILL')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
+ }
+}
+
+export async function findAvailablePort(preferredPort: number): Promise {
+ if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) {
+ throw new CliError('invalid_port', `Invalid port: ${preferredPort}`)
+ }
+ if (preferredPort === 0) return probePort(0)
+ if (!(await isPortAcceptingConnections(preferredPort))) {
+ try {
+ return await probePort(preferredPort)
+ } catch {}
+ }
+ return probePort(0)
+}
+
+async function isPortAcceptingConnections(port: number): Promise {
+ return new Promise((resolve) => {
+ const socket = net.connect({ host: '127.0.0.1', port })
+ let settled = false
+ const finish = (result: boolean) => {
+ if (settled) return
+ settled = true
+ socket.destroy()
+ resolve(result)
+ }
+ socket.setTimeout(250)
+ socket.once('connect', () => finish(true))
+ socket.once('timeout', () => finish(false))
+ socket.once('error', () => finish(false))
+ })
+}
+
+async function probePort(port: number): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer()
+ server.unref()
+ server.once('error', reject)
+ server.listen({ host: '127.0.0.1', port }, () => {
+ const address = server.address()
+ const resolvedPort = typeof address === 'object' && address ? address.port : port
+ server.close((error) => (error ? reject(error) : resolve(resolvedPort)))
+ })
+ })
+}
+
+export async function processCommand(pid: number): Promise {
+ return new Promise((resolve) => {
+ execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => {
+ resolve(error ? '' : stdout.trim())
+ })
+ })
+}
+
+export async function waitForSpawn(child: ChildProcess, binary: string): Promise {
+ await new Promise((resolve, reject) => {
+ child.once('spawn', resolve)
+ child.once('error', reject)
+ }).catch((error) => {
+ throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`)
+ })
+}
+
+export function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
diff --git a/packages/cli/src/runtime-download.test.ts b/packages/cli/src/runtime-download.test.ts
new file mode 100644
index 0000000000..decae30cd9
--- /dev/null
+++ b/packages/cli/src/runtime-download.test.ts
@@ -0,0 +1,339 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { copyFile, mkdir, mkdtemp, open, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { isProxyBypassed, resolveProxyUrl } from './http-download.js'
+import { resolvePascalPaths } from './paths.js'
+import { findInstalledRuntime, installBundledRuntime, readActiveRuntime } from './runtime.js'
+import {
+ ensureWebRuntime,
+ fileSha256,
+ type RuntimeSource,
+ readRuntimeSource,
+ verifyArchiveDigest,
+} from './runtime-download.js'
+import { createRuntimeArchive } from './tar.js'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('archive digest verification', () => {
+ test('accepts a matching digest whatever case it is written in', async () => {
+ const fixture = await createFixture()
+
+ await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256)
+ await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256.toUpperCase())
+ })
+
+ test('rejects a changed archive and deletes it when asked to', async () => {
+ const fixture = await createFixture()
+ const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz'))
+
+ await expect(
+ verifyArchiveDigest(tampered, fixture.source.sha256, { deleteOnMismatch: true }),
+ ).rejects.toMatchObject({
+ code: 'runtime_digest_mismatch',
+ message: expect.stringContaining(fixture.source.sha256),
+ })
+ expect(await exists(tampered)).toBe(false)
+ })
+
+ test('keeps a caller-supplied archive that fails verification', async () => {
+ const fixture = await createFixture()
+ const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz'))
+
+ await expect(verifyArchiveDigest(tampered, fixture.source.sha256)).rejects.toMatchObject({
+ code: 'runtime_digest_mismatch',
+ })
+ expect(await exists(tampered)).toBe(true)
+ })
+})
+
+describe('published runtime source', () => {
+ test('reads the archive URL and digest committed with the CLI', async () => {
+ const fixture = await createFixture()
+
+ expect(await readRuntimeSource(fixture.sourceFile)).toEqual(fixture.source)
+ })
+
+ test.each([
+ ['is missing', null],
+ ['is not JSON', '{not-json'],
+ ['omits the digest', { version: '1.2.3', url: 'https://example.com/a.tar.gz', size: 10 }],
+ [
+ 'carries a truncated digest',
+ { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'abc123', size: 10 },
+ ],
+ [
+ 'points at a plain-http URL',
+ { version: '1.2.3', url: 'http://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 10 },
+ ],
+ [
+ 'declares an empty archive',
+ { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 0 },
+ ],
+ [
+ 'carries a path-like version',
+ {
+ version: '../escape',
+ url: 'https://example.com/a.tar.gz',
+ sha256: 'a'.repeat(64),
+ size: 10,
+ },
+ ],
+ ])('refuses a runtime source that %s', async (_label, content) => {
+ const root = await temporaryRoot()
+ const sourceFile = path.join(root, 'runtime-source.json')
+ if (content !== null) {
+ await writeFile(sourceFile, typeof content === 'string' ? content : JSON.stringify(content))
+ }
+
+ await expect(readRuntimeSource(sourceFile)).rejects.toMatchObject({
+ code: 'invalid_runtime_source',
+ message: expect.stringContaining('--runtime'),
+ })
+ })
+})
+
+describe('web runtime resolution order', () => {
+ test('prefers an explicit runtime directory over the environment override', async () => {
+ const fixture = await createFixture()
+ const other = await fakeRuntimeDirectory(fixture.root, '9.9.9')
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: fixture.sourceDirectory,
+ sourceFile: fixture.sourceFile,
+ environment: { PASCAL_BUNDLED_RUNTIME_DIR: other },
+ })
+
+ expect(result.runtime.version).toBe('1.2.3')
+ expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3')
+ expect(await findInstalledRuntime(fixture.paths, '9.9.9')).toBeNull()
+ })
+
+ test('falls back to PASCAL_BUNDLED_RUNTIME_DIR when no flag is passed', async () => {
+ const fixture = await createFixture()
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ sourceFile: fixture.sourceFile,
+ environment: { PASCAL_BUNDLED_RUNTIME_DIR: fixture.sourceDirectory },
+ })
+
+ expect(result).toMatchObject({ installed: true, runtime: { version: '1.2.3' } })
+ })
+
+ test('installs a local archive that matches the published digest', async () => {
+ const fixture = await createFixture()
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: fixture.archiveFile,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ })
+
+ expect(result.runtime.directory).toBe(path.join(fixture.paths.runtime, '1.2.3'))
+ expect(await exists(path.join(result.runtime.directory, 'apps/editor/server.js'))).toBe(true)
+ })
+
+ test('installs nothing when a local archive fails verification', async () => {
+ const fixture = await createFixture()
+ const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz'))
+
+ await expect(
+ ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: tampered,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ }),
+ ).rejects.toMatchObject({ code: 'runtime_digest_mismatch' })
+ expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull()
+ expect(await exists(tampered)).toBe(true)
+ })
+
+ test('reports a missing runtime path instead of reaching for the network', async () => {
+ const fixture = await createFixture()
+
+ await expect(
+ ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: path.join(fixture.root, 'absent.tar.gz'),
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ }),
+ ).rejects.toMatchObject({ code: 'runtime_source_missing' })
+ })
+
+ test('reuses the installed runtime for this version without downloading', async () => {
+ const fixture = await createFixture()
+ await installBundledRuntime(fixture.paths, fixture.sourceDirectory, { activate: false })
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ })
+
+ expect(result).toEqual({
+ installed: false,
+ runtime: {
+ schemaVersion: 1,
+ version: '1.2.3',
+ directory: path.join(fixture.paths.runtime, '1.2.3'),
+ },
+ })
+ expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3')
+ })
+
+ test('installs without activating so an update can health-check first', async () => {
+ const fixture = await createFixture()
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: fixture.sourceDirectory,
+ sourceFile: fixture.sourceFile,
+ activate: false,
+ environment: {},
+ })
+
+ expect(result.runtime.version).toBe('1.2.3')
+ expect(await readActiveRuntime(fixture.paths)).toBeNull()
+ expect(await findInstalledRuntime(fixture.paths, '1.2.3')).not.toBeNull()
+ })
+
+ test('names the archive, the digest and the offline escape hatch when the download fails', async () => {
+ const fixture = await createFixture()
+
+ const failure = await ensureWebRuntime({
+ paths: fixture.paths,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ }).catch((error: unknown) => error)
+
+ expect(failure).toMatchObject({ code: 'runtime_download_failed' })
+ const message = (failure as Error).message
+ expect(message).toContain(fixture.source.url)
+ expect(message).toContain(fixture.source.sha256)
+ expect(message).toContain('pascal editor --runtime')
+ expect(message).toContain('HTTPS_PROXY')
+ expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull()
+ })
+})
+
+describe('proxy configuration', () => {
+ test('prefers HTTPS_PROXY and trims the configured value', () => {
+ const target = new URL('https://github.com/pascalorg/editor')
+
+ expect(resolveProxyUrl(target, { HTTPS_PROXY: ' http://proxy:3128 ' })).toBe(
+ 'http://proxy:3128',
+ )
+ expect(resolveProxyUrl(target, { ALL_PROXY: 'http://all:3128' })).toBe('http://all:3128')
+ expect(resolveProxyUrl(target, { HTTPS_PROXY: ' ' })).toBeNull()
+ expect(resolveProxyUrl(target, {})).toBeNull()
+ })
+
+ test('honours NO_PROXY for the download host', () => {
+ const target = new URL('https://github.com/pascalorg/editor')
+
+ expect(resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: '*' })).toBeNull()
+ expect(
+ resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', no_proxy: 'github.com' }),
+ ).toBeNull()
+ expect(
+ resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: 'example.com' }),
+ ).toBe('http://proxy:3128')
+ })
+
+ test('matches NO_PROXY entries by suffix and ignores ports', () => {
+ expect(isProxyBypassed('release-assets.githubusercontent.com', '.githubusercontent.com')).toBe(
+ true,
+ )
+ expect(isProxyBypassed('github.com', 'github.com:443')).toBe(true)
+ expect(isProxyBypassed('github.com', '*.github.com')).toBe(true)
+ expect(isProxyBypassed('notgithub.com', 'github.com')).toBe(false)
+ expect(isProxyBypassed('github.com', '')).toBe(false)
+ expect(isProxyBypassed('github.com', undefined)).toBe(false)
+ })
+})
+
+interface Fixture {
+ root: string
+ paths: ReturnType
+ sourceDirectory: string
+ archiveFile: string
+ sourceFile: string
+ source: RuntimeSource
+}
+
+/** An unroutable port keeps every download test offline; the connection is refused at once. */
+const UNREACHABLE_HOST = 'https://127.0.0.1:1'
+
+async function createFixture(version = '1.2.3'): Promise {
+ const root = await temporaryRoot()
+ const sourceDirectory = await fakeRuntimeDirectory(root, version)
+ const archiveFile = path.join(root, `pascal-web-runtime-${version}.tar.gz`)
+ await createRuntimeArchive(sourceDirectory, archiveFile)
+ const source: RuntimeSource = {
+ version,
+ url: `${UNREACHABLE_HOST}/pascal-web-runtime-${version}.tar.gz`,
+ sha256: await fileSha256(archiveFile),
+ size: (await stat(archiveFile)).size,
+ }
+ const sourceFile = path.join(root, 'runtime-source.json')
+ await writeFile(sourceFile, JSON.stringify(source))
+ return {
+ root,
+ paths: resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }),
+ sourceDirectory,
+ archiveFile,
+ sourceFile,
+ source,
+ }
+}
+
+async function temporaryRoot(): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-download-test-'))
+ roots.push(root)
+ return root
+}
+
+async function fakeRuntimeDirectory(root: string, version: string): Promise {
+ const runtime = path.join(root, `source-${version}`)
+ await mkdir(path.join(runtime, 'apps/editor'), { recursive: true })
+ await writeFile(
+ path.join(runtime, 'runtime-manifest.json'),
+ JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }),
+ )
+ await writeFile(path.join(runtime, 'apps/editor/server.js'), `// pascal ${version}\n`)
+ return runtime
+}
+
+async function tamper(archiveFile: string, destination: string): Promise {
+ await copyFile(archiveFile, destination)
+ const handle = await open(destination, 'r+')
+ try {
+ const offset = Math.floor((await handle.stat()).size / 2)
+ const byte = Buffer.alloc(1)
+ await handle.read(byte, 0, 1, offset)
+ byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff
+ await handle.write(byte, 0, 1, offset)
+ } finally {
+ await handle.close()
+ }
+ return destination
+}
+
+async function exists(file: string): Promise {
+ try {
+ await stat(file)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/packages/cli/src/runtime-download.ts b/packages/cli/src/runtime-download.ts
new file mode 100644
index 0000000000..8b54b50b0d
--- /dev/null
+++ b/packages/cli/src/runtime-download.ts
@@ -0,0 +1,256 @@
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { CliError } from './errors.js'
+import { downloadToFile } from './http-download.js'
+import { readJsonFile } from './json-files.js'
+import type { PascalPaths } from './paths.js'
+import {
+ type ActiveRuntime,
+ activateRuntime,
+ findInstalledRuntime,
+ installBundledRuntime,
+ installRuntimeDirectory,
+ withRuntimeInstallLock,
+} from './runtime.js'
+import { extractTarGzip } from './tar.js'
+
+/** A peer process may be downloading the same archive; wait for it instead of duplicating it. */
+const DOWNLOAD_LOCK_TIMEOUT_MS = 20 * 60_000
+
+export interface RuntimeSource {
+ version: string
+ url: string
+ sha256: string
+ size: number
+}
+
+export interface WebRuntimeResult {
+ runtime: ActiveRuntime
+ installed: boolean
+}
+
+export type RuntimeProvisionProgress =
+ | { step: 'runtime-downloading'; url: string; received: number; total: number | null }
+ | { step: 'runtime-verifying' }
+ | { step: 'runtime-extracting' }
+ | { step: 'runtime-installing' }
+
+export interface EnsureWebRuntimeOptions {
+ paths: PascalPaths
+ /** A directory or `.tar.gz` archive from `--runtime`; archives are digest-verified. */
+ runtimeSource?: string
+ /** `false` installs the runtime without pointing the active runtime at it (used by updates). */
+ activate?: boolean
+ environment?: NodeJS.ProcessEnv
+ sourceFile?: string
+ onProgress?: (event: RuntimeProvisionProgress) => void
+}
+
+/**
+ * Resolves the web runtime for the commands that start the Next server. The npm package
+ * ships the CLI and the MCP service only; the runtime is downloaded once per version and
+ * verified against the digest committed in `dist/runtime-source.json`.
+ */
+export async function ensureWebRuntime(
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ const { paths } = options
+ const environment = options.environment ?? process.env
+ const override = options.runtimeSource ?? environment.PASCAL_BUNDLED_RUNTIME_DIR
+ if (override) return installOverride(paths, override, options)
+
+ const source = await readRuntimeSource(options.sourceFile)
+ const existing = await findInstalledRuntime(paths, source.version)
+ if (existing) return { runtime: await useInstalled(paths, existing, options), installed: false }
+ return withRuntimeInstallLock(
+ paths,
+ async () => {
+ const peerInstalled = await findInstalledRuntime(paths, source.version)
+ if (peerInstalled) {
+ return { runtime: await useInstalled(paths, peerInstalled, options), installed: false }
+ }
+ return withWorkDirectory(paths, async (workDirectory) => {
+ const archiveFile = path.join(workDirectory, `pascal-web-runtime-${source.version}.tar.gz`)
+ await download(source, archiveFile, options)
+ options.onProgress?.({ step: 'runtime-verifying' })
+ await verifyArchiveDigest(archiveFile, source.sha256, { deleteOnMismatch: true })
+ return {
+ runtime: await extractAndInstall(archiveFile, workDirectory, options, (directory) =>
+ installRuntimeDirectory(paths, directory, { activate: options.activate }),
+ ),
+ installed: true,
+ }
+ })
+ },
+ { timeoutMs: DOWNLOAD_LOCK_TIMEOUT_MS },
+ )
+}
+
+export function resolveRuntimeSourceFile(): string {
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url))
+ return path.basename(moduleDirectory) === 'dist'
+ ? path.join(moduleDirectory, 'runtime-source.json')
+ : path.resolve(moduleDirectory, '../dist/runtime-source.json')
+}
+
+export async function readRuntimeSource(sourceFile?: string): Promise {
+ const file = sourceFile ?? resolveRuntimeSourceFile()
+ let source: RuntimeSource | null
+ try {
+ source = await readJsonFile(file)
+ } catch {
+ source = null
+ }
+ if (
+ !source ||
+ typeof source.version !== 'string' ||
+ !/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(source.version) ||
+ typeof source.url !== 'string' ||
+ !source.url.startsWith('https://') ||
+ typeof source.sha256 !== 'string' ||
+ !/^[0-9a-f]{64}$/.test(source.sha256) ||
+ !Number.isSafeInteger(source.size) ||
+ source.size <= 0
+ ) {
+ throw new CliError(
+ 'invalid_runtime_source',
+ `This CLI cannot resolve the Pascal web runtime it was published with (${file}). Reinstall @pascal-app/cli, or pass "--runtime ".`,
+ )
+ }
+ return { version: source.version, url: source.url, sha256: source.sha256, size: source.size }
+}
+
+export async function fileSha256(filePath: string): Promise {
+ const hash = createHash('sha256')
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer)
+ return hash.digest('hex')
+}
+
+export async function verifyArchiveDigest(
+ archiveFile: string,
+ expectedSha256: string,
+ options: { deleteOnMismatch?: boolean } = {},
+): Promise {
+ const actual = await fileSha256(archiveFile)
+ if (actual === expectedSha256.toLowerCase()) return
+ if (options.deleteOnMismatch) await rm(archiveFile, { force: true })
+ throw new CliError(
+ 'runtime_digest_mismatch',
+ [
+ 'The Pascal web runtime archive does not match the digest published with this CLI.',
+ ` archive: ${archiveFile}`,
+ ` expected: ${expectedSha256}`,
+ ` actual: ${actual}`,
+ 'The archive was not installed. Download it again from the Pascal release page.',
+ ].join('\n'),
+ )
+}
+
+async function installOverride(
+ paths: PascalPaths,
+ override: string,
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ const resolved = path.resolve(override)
+ let info: Awaited>
+ try {
+ info = await stat(resolved)
+ } catch {
+ throw new CliError('runtime_source_missing', `No Pascal web runtime exists at ${resolved}.`)
+ }
+ if (info.isDirectory()) {
+ options.onProgress?.({ step: 'runtime-installing' })
+ return {
+ runtime: await installBundledRuntime(paths, resolved, { activate: options.activate }),
+ installed: true,
+ }
+ }
+ const source = await readRuntimeSource(options.sourceFile)
+ options.onProgress?.({ step: 'runtime-verifying' })
+ await verifyArchiveDigest(resolved, source.sha256)
+ return {
+ runtime: await withWorkDirectory(paths, (workDirectory) =>
+ extractAndInstall(resolved, workDirectory, options, (directory) =>
+ installBundledRuntime(paths, directory, { activate: options.activate }),
+ ),
+ ),
+ installed: true,
+ }
+}
+
+async function useInstalled(
+ paths: PascalPaths,
+ runtime: ActiveRuntime,
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ return options.activate === false
+ ? runtime
+ : activateRuntime(paths, runtime.version, runtime.directory)
+}
+
+async function extractAndInstall(
+ archiveFile: string,
+ workDirectory: string,
+ options: EnsureWebRuntimeOptions,
+ install: (directory: string) => Promise,
+): Promise {
+ const extracted = path.join(workDirectory, 'runtime')
+ options.onProgress?.({ step: 'runtime-extracting' })
+ await extractTarGzip(archiveFile, extracted)
+ options.onProgress?.({ step: 'runtime-installing' })
+ return install(extracted)
+}
+
+async function download(
+ source: RuntimeSource,
+ archiveFile: string,
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ options.onProgress?.({ step: 'runtime-downloading', url: source.url, received: 0, total: null })
+ try {
+ await downloadToFile(source.url, archiveFile, {
+ environment: options.environment ?? process.env,
+ onProgress: ({ received, total }) =>
+ options.onProgress?.({
+ step: 'runtime-downloading',
+ url: source.url,
+ received,
+ total: total ?? source.size,
+ }),
+ })
+ } catch (error) {
+ await rm(archiveFile, { force: true })
+ throw new CliError(
+ 'runtime_download_failed',
+ [
+ `Unable to download the Pascal web runtime ${source.version}.`,
+ ` archive: ${source.url}`,
+ ` sha256: ${source.sha256}`,
+ ` reason: ${error instanceof Error ? error.message : String(error)}`,
+ 'Download that archive on a connected machine, copy it over, then run:',
+ ` pascal editor --runtime /path/to/pascal-web-runtime-${source.version}.tar.gz`,
+ 'HTTPS_PROXY and NO_PROXY are honoured. "pascal mcp connect" needs no web runtime.',
+ ].join('\n'),
+ )
+ }
+}
+
+/**
+ * Downloads and extraction stay out of `runtime/`: `installRuntimeDirectory` deletes every
+ * `.install-*` directory there before it copies, which would race a partial extraction.
+ */
+async function withWorkDirectory(
+ paths: PascalPaths,
+ action: (directory: string) => Promise,
+): Promise {
+ await mkdir(paths.tmp, { recursive: true, mode: 0o700 })
+ const workDirectory = await mkdtemp(path.join(paths.tmp, 'runtime-'))
+ try {
+ return await action(workDirectory)
+ } finally {
+ await rm(workDirectory, { recursive: true, force: true })
+ }
+}
diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts
index 2fe7c21214..1e48a58347 100644
--- a/packages/cli/src/runtime.test.ts
+++ b/packages/cli/src/runtime.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test } from 'bun:test'
+import { afterAll, afterEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import http from 'node:http'
import os from 'node:os'
@@ -10,15 +10,22 @@ import {
stopEditor,
waitForHealth,
} from './editor-process.js'
+import { getMcpServiceStatus } from './mcp-service.js'
import { resolvePascalPaths } from './paths.js'
import { installBundledRuntime, readActiveRuntime } from './runtime.js'
+import { writeFakeMcpService } from './test-support/fake-mcp-service.js'
const roots: string[] = []
+/** The MCP service ships with the CLI, so it is injected instead of staged in the runtime. */
+const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-test-service-'))
+process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot)
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
+afterAll(() => rm(serviceRoot, { recursive: true, force: true }))
+
describe('managed runtime', () => {
test('installs a bundled runtime outside the package-runner cache', async () => {
const root = await temporaryRoot()
@@ -39,24 +46,43 @@ describe('managed runtime', () => {
await mkdir(paths.data, { recursive: true })
await writeFile(paths.database, 'persistent')
- const started = await startEditor({ paths, sourceDirectory: source })
+ const started = await startEditor({ paths, runtimeSource: source })
expect(started.alreadyRunning).toBe(false)
expect((await getEditorStatus(paths)).healthy).toBe(true)
- expect((await startEditor({ paths, sourceDirectory: source })).alreadyRunning).toBe(true)
+ expect((await startEditor({ paths, runtimeSource: source })).alreadyRunning).toBe(true)
expect(await stopEditor(paths)).toBe(true)
expect((await getEditorStatus(paths)).running).toBe(false)
expect(await Bun.file(paths.database).text()).toBe('persistent')
})
+ test('preserves a configured Mint host origin in the editor process', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const previousMintOrigin = process.env.MINT_PASCAL_HOST_ORIGIN
+ process.env.MINT_PASCAL_HOST_ORIGIN = 'https://pascal.example.com'
+
+ try {
+ const started = await startEditor({ paths, runtimeSource: source })
+ const response = await fetch(`http://127.0.0.1:${started.state.port}/mint-origin`)
+
+ expect(await response.text()).toBe('https://pascal.example.com')
+ } finally {
+ await stopEditor(paths)
+ if (previousMintOrigin === undefined) delete process.env.MINT_PASCAL_HOST_ORIGIN
+ else process.env.MINT_PASCAL_HOST_ORIGIN = previousMintOrigin
+ }
+ })
+
test('serializes concurrent starts into one managed editor', async () => {
const root = await temporaryRoot()
const source = await fakeRuntime(root, '1.2.3')
const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
const [first, second] = await Promise.all([
- startEditor({ paths, port: 0, sourceDirectory: source }),
- startEditor({ paths, port: 0, sourceDirectory: source }),
+ startEditor({ paths, port: 0, runtimeSource: source }),
+ startEditor({ paths, port: 0, runtimeSource: source }),
])
expect(first.state.pid).toBe(second.state.pid)
@@ -80,7 +106,7 @@ describe('managed runtime', () => {
const started = await startEditor({
paths,
port: address.port,
- sourceDirectory: source,
+ runtimeSource: source,
})
expect(started.state.port).not.toBe(address.port)
@@ -153,7 +179,7 @@ describe('managed runtime', () => {
const active = await installBundledRuntime(paths, source)
await rm(path.join(active.directory, 'apps/editor/server.js'))
- const started = await startEditor({ paths, port: 0, sourceDirectory: source })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
expect(started.state.version).toBe('1.2.3')
expect((await getEditorStatus(paths)).healthy).toBe(true)
@@ -168,7 +194,7 @@ describe('managed runtime', () => {
const active = await installBundledRuntime(paths, source)
await writeFile(path.join(active.directory, 'runtime-manifest.json'), '{not-json')
- const started = await startEditor({ paths, port: 0, sourceDirectory: source })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
expect(started.state.version).toBe('1.2.3')
expect((await getEditorStatus(paths)).healthy).toBe(true)
@@ -182,7 +208,7 @@ describe('managed runtime', () => {
await mkdir(paths.run, { recursive: true })
await writeFile(paths.currentRuntime, '{not-json')
- const started = await startEditor({ paths, port: 0, sourceDirectory: source })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
expect(started.state.version).toBe('1.2.3')
expect((await getEditorStatus(paths)).healthy).toBe(true)
@@ -206,7 +232,7 @@ describe('managed runtime', () => {
const root = await temporaryRoot()
const source = await fakeRuntime(root, '1.2.3')
const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
- const started = await startEditor({ paths, port: 0, sourceDirectory: source })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
await writeFile(
paths.state,
`${JSON.stringify({ ...started.state, instanceId: 'no-longer-healthy' }, null, 2)}\n`,
@@ -220,7 +246,7 @@ describe('managed runtime', () => {
const root = await temporaryRoot()
const source = await fakeRuntime(root, '1.2.3')
const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
- const started = await startEditor({ paths, port: 0, sourceDirectory: source })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
await writeFile(path.join(started.state.runtimeDirectory, 'runtime-manifest.json'), '{not-json')
await writeFile(
paths.state,
@@ -235,7 +261,7 @@ describe('managed runtime', () => {
const firstSource = await fakeRuntime(root, '1.2.3')
const brokenSource = await fakeRuntime(root, '2.0.0', false)
const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
- await startEditor({ paths, port: 0, sourceDirectory: firstSource })
+ await startEditor({ paths, port: 0, runtimeSource: firstSource })
const candidate = await installBundledRuntime(paths, brokenSource, { activate: false })
await expect(activateEditorRuntime(paths, candidate)).rejects.toMatchObject({
@@ -246,25 +272,24 @@ describe('managed runtime', () => {
await stopEditor(paths)
})
- test('upgrades a running editor state that predates managed MCP', async () => {
+ test('restarts the editor and repoints MCP when a new runtime is activated', async () => {
const root = await temporaryRoot()
const firstSource = await fakeRuntime(root, '1.2.3')
const secondSource = await fakeRuntime(root, '2.0.0')
const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
- const started = await startEditor({ paths, sourceDirectory: firstSource })
- const oldMcpPid = started.state.mcp?.pid
- if (!oldMcpPid) throw new Error('test MCP did not start')
- process.kill(oldMcpPid, 'SIGTERM')
- await waitUntilStopped(oldMcpPid)
- const legacyState = { ...started.state, mcp: undefined }
- await writeFile(paths.state, `${JSON.stringify(legacyState, null, 2)}\n`)
- await rm(paths.mcpToken, { force: true })
+ const started = await startEditor({ paths, runtimeSource: firstSource })
const candidate = await installBundledRuntime(paths, secondSource, { activate: false })
const result = await activateEditorRuntime(paths, candidate)
expect(result.restarted).toBe(true)
- expect((await getEditorStatus(paths)).healthy).toBe(true)
+ const status = await getEditorStatus(paths)
+ expect(status.healthy).toBe(true)
+ expect(status.state?.version).toBe('2.0.0')
+ expect(status.state?.pid).not.toBe(started.state.pid)
+ const mcp = await getMcpServiceStatus(paths)
+ expect(mcp.healthy).toBe(true)
+ expect(mcp.state?.editorOrigin).toBe(status.state?.url ?? '')
await stopEditor(paths)
})
@@ -274,7 +299,7 @@ describe('managed runtime', () => {
const secondSource = await fakeRuntime(root, '2.0.0')
const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
await installBundledRuntime(paths, firstSource)
- const seeded = await startEditor({ paths, port: 0, sourceDirectory: firstSource })
+ const seeded = await startEditor({ paths, port: 0, runtimeSource: firstSource })
await stopEditor(paths)
await writeFile(paths.state, `${JSON.stringify(seeded.state, null, 2)}\n`)
const candidate = await installBundledRuntime(paths, secondSource, { activate: false })
@@ -293,35 +318,13 @@ async function temporaryRoot(): Promise {
return root
}
-async function waitUntilStopped(pid: number): Promise {
- const deadline = Date.now() + 2_000
- while (Date.now() < deadline) {
- try {
- process.kill(pid, 0)
- } catch {
- return
- }
- await Bun.sleep(20)
- }
- throw new Error(`process ${pid} did not stop`)
-}
-
async function fakeRuntime(root: string, version: string, healthy = true): Promise {
const runtime = path.join(root, `source-${version}`)
const app = path.join(runtime, 'apps/editor')
- const services = path.join(runtime, 'services')
await mkdir(app, { recursive: true })
- await mkdir(services, { recursive: true })
await writeFile(
path.join(runtime, 'runtime-manifest.json'),
- JSON.stringify({
- schemaVersion: 1,
- version,
- entrypoint: 'apps/editor/server.js',
- mcpEntrypoint: 'services/pascal-mcp.mjs',
- healthPath: '/api/health',
- mcpHealthPath: '/health',
- }),
+ JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }),
)
await writeFile(
path.join(app, 'server.js'),
@@ -339,6 +342,10 @@ const server = http.createServer((request, response) => {
}))
return
}
+ if (request.url === '/mint-origin') {
+ response.end(process.env.MINT_PASCAL_HOST_ORIGIN ?? '')
+ return
+ }
response.end('{}')
})
server.listen(Number(process.env.PORT), process.env.HOSTNAME)
@@ -346,31 +353,5 @@ process.on('SIGTERM', () => server.close(() => process.exit(0)))
`
: 'process.exit(1)\n',
)
- await writeFile(
- path.join(services, 'pascal-mcp.mjs'),
- `import http from 'node:http'
-const token = process.env.PASCAL_MCP_HTTP_TOKEN
-const server = http.createServer((request, response) => {
- if (request.headers.authorization !== \`Bearer \${token}\`) {
- response.writeHead(401).end()
- return
- }
- response.setHeader('content-type', 'application/json')
- if (request.url === '/health') {
- response.end(JSON.stringify({
- status: 'ok',
- app: 'mcp',
- version: process.env.PASCAL_RUNTIME_VERSION,
- instanceId: process.env.PASCAL_INSTANCE_ID,
- }))
- return
- }
- response.writeHead(404).end('{}')
-})
-const portIndex = process.argv.indexOf('--port')
-server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1')
-process.on('SIGTERM', () => server.close(() => process.exit(0)))
-`,
- )
return runtime
}
diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts
index 004d48ad5e..495a726713 100644
--- a/packages/cli/src/runtime.ts
+++ b/packages/cli/src/runtime.ts
@@ -1,18 +1,14 @@
import { cp, mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
import path from 'node:path'
-import { fileURLToPath } from 'node:url'
import { CliError } from './errors.js'
import { withFileLock } from './file-lock.js'
import { readJsonFile, writeJsonFile } from './json-files.js'
import type { PascalPaths } from './paths.js'
export interface RuntimeManifest {
- schemaVersion: 1
+ schemaVersion: 2
version: string
entrypoint: string
- mcpEntrypoint: string
- healthPath: string
- mcpHealthPath: string
}
export interface ActiveRuntime {
@@ -21,18 +17,6 @@ export interface ActiveRuntime {
directory: string
}
-export function resolveBundledRuntimeDirectory(
- environment: NodeJS.ProcessEnv = process.env,
-): string {
- if (environment.PASCAL_BUNDLED_RUNTIME_DIR) {
- return path.resolve(environment.PASCAL_BUNDLED_RUNTIME_DIR)
- }
- const moduleDirectory = path.dirname(fileURLToPath(import.meta.url))
- return path.basename(moduleDirectory) === 'dist'
- ? path.join(moduleDirectory, 'runtime')
- : path.resolve(moduleDirectory, '../dist/runtime')
-}
-
export async function readRuntimeManifest(directory: string): Promise {
let manifest: RuntimeManifest | null
try {
@@ -41,12 +25,9 @@ export async function readRuntimeManifest(directory: string): Promise(
+ paths: PascalPaths,
+ action: () => Promise,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ return withFileLock(
+ path.join(paths.run, 'runtime-install.lock'),
+ 'install_locked',
+ 'Another Pascal runtime installation is active.',
+ action,
+ options,
+ )
+}
+
export async function installBundledRuntime(
paths: PascalPaths,
- sourceDirectory = resolveBundledRuntimeDirectory(),
+ sourceDirectory: string,
+ options: { activate?: boolean } = {},
+): Promise {
+ return withRuntimeInstallLock(paths, () =>
+ installRuntimeDirectory(paths, sourceDirectory, options),
+ )
+}
+
+/** Requires `withRuntimeInstallLock`; call `installBundledRuntime` when no lock is held. */
+export async function installRuntimeDirectory(
+ paths: PascalPaths,
+ sourceDirectory: string,
options: { activate?: boolean } = {},
): Promise {
const sourceManifest = await readRuntimeManifest(sourceDirectory)
const targetDirectory = path.join(paths.runtime, sourceManifest.version)
await mkdir(paths.runtime, { recursive: true, mode: 0o700 })
-
- return withFileLock(
- path.join(paths.run, 'runtime-install.lock'),
- 'install_locked',
- 'Another Pascal runtime installation is active.',
- async () => {
- await removeAbandonedInstallDirectories(paths.runtime)
- const installed = await readInstalledManifest(targetDirectory)
- if (
- installed?.version === sourceManifest.version &&
- (await isRuntimeValid(targetDirectory, sourceManifest.version))
- ) {
- return options.activate === false
- ? runtimeRecord(sourceManifest.version, targetDirectory)
- : activateRuntime(paths, sourceManifest.version, targetDirectory)
- }
- const temporaryDirectory = path.join(
- paths.runtime,
- `.install-${sourceManifest.version}-${process.pid}`,
- )
- await rm(temporaryDirectory, { recursive: true, force: true })
- await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false })
- await readRuntimeManifest(temporaryDirectory)
- await rm(targetDirectory, { recursive: true, force: true })
- await rename(temporaryDirectory, targetDirectory)
- return options.activate === false
- ? runtimeRecord(sourceManifest.version, targetDirectory)
- : activateRuntime(paths, sourceManifest.version, targetDirectory)
- },
+ await removeAbandonedInstallDirectories(paths.runtime)
+ const installed = await readInstalledManifest(targetDirectory)
+ if (
+ installed?.version === sourceManifest.version &&
+ (await isRuntimeValid(targetDirectory, sourceManifest.version))
+ ) {
+ return options.activate === false
+ ? runtimeRecord(sourceManifest.version, targetDirectory)
+ : activateRuntime(paths, sourceManifest.version, targetDirectory)
+ }
+ const temporaryDirectory = path.join(
+ paths.runtime,
+ `.install-${sourceManifest.version}-${process.pid}`,
)
+ await rm(temporaryDirectory, { recursive: true, force: true })
+ await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false })
+ await readRuntimeManifest(temporaryDirectory)
+ await rm(targetDirectory, { recursive: true, force: true })
+ await rename(temporaryDirectory, targetDirectory)
+ return options.activate === false
+ ? runtimeRecord(sourceManifest.version, targetDirectory)
+ : activateRuntime(paths, sourceManifest.version, targetDirectory)
}
export async function readActiveRuntime(paths: PascalPaths): Promise {
@@ -157,6 +154,14 @@ export async function activateRuntime(
return active
}
+export async function findInstalledRuntime(
+ paths: PascalPaths,
+ version: string,
+): Promise {
+ const directory = path.join(paths.runtime, version)
+ return (await isRuntimeValid(directory, version)) ? runtimeRecord(version, directory) : null
+}
+
function runtimeRecord(version: string, directory: string): ActiveRuntime {
return { schemaVersion: 1, version, directory }
}
diff --git a/packages/cli/src/tar.test.ts b/packages/cli/src/tar.test.ts
new file mode 100644
index 0000000000..566c2ec12c
--- /dev/null
+++ b/packages/cli/src/tar.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { createHash } from 'node:crypto'
+import { createWriteStream } from 'node:fs'
+import {
+ chmod,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ stat,
+ symlink,
+ utimes,
+ writeFile,
+} from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { Readable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { createGzip } from 'node:zlib'
+import { createRuntimeArchive, extractTarGzip, tarHeaderBlock } from './tar.js'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('runtime archive', () => {
+ test('writes the same bytes for the same tree regardless of timestamps', async () => {
+ const root = await temporaryRoot()
+ const source = path.join(root, 'runtime')
+ await mkdir(path.join(source, 'apps/editor/.next'), { recursive: true })
+ await writeFile(path.join(source, 'runtime-manifest.json'), '{"schemaVersion":2}')
+ await writeFile(path.join(source, 'apps/editor/server.js'), 'console.log(1)\n')
+ await writeFile(path.join(source, 'apps/editor/.next/build.txt'), 'build\n')
+ /** Longer than the 100-byte ustar name field, so the archive needs a long-name entry. */
+ const deep = path.join(source, 'apps/editor', 'a'.repeat(60), 'b'.repeat(60))
+ await mkdir(deep, { recursive: true })
+ await writeFile(path.join(deep, 'long-path.txt'), 'long\n')
+ const executable = path.join(source, 'apps/editor/run.sh')
+ await writeFile(executable, '#!/bin/sh\n')
+ await chmod(executable, 0o755)
+
+ const first = path.join(root, 'first.tar.gz')
+ const firstResult = await createRuntimeArchive(source, first)
+ await utimes(path.join(source, 'apps/editor/server.js'), new Date(0), new Date(0))
+ const second = path.join(root, 'second.tar.gz')
+ const secondResult = await createRuntimeArchive(source, second)
+
+ expect(firstResult.entryCount).toBe(secondResult.entryCount)
+ expect(await sha256(first)).toBe(await sha256(second))
+
+ const target = path.join(root, 'extracted')
+ await extractTarGzip(first, target)
+ expect(await readFile(path.join(target, 'apps/editor/server.js'), 'utf8')).toBe(
+ 'console.log(1)\n',
+ )
+ expect(
+ await readFile(path.join(target, deep.slice(source.length + 1), 'long-path.txt'), 'utf8'),
+ ).toBe('long\n')
+ expect((await stat(path.join(target, 'apps/editor/run.sh'))).mode & 0o111).not.toBe(0)
+ })
+
+ test('refuses to archive a symbolic link', async () => {
+ const root = await temporaryRoot()
+ const source = path.join(root, 'runtime')
+ await mkdir(source, { recursive: true })
+ await writeFile(path.join(source, 'real.txt'), 'real\n')
+ await symlink('real.txt', path.join(source, 'link.txt'))
+
+ await expect(createRuntimeArchive(source, path.join(root, 'out.tar.gz'))).rejects.toMatchObject(
+ {
+ code: 'archive_failed',
+ },
+ )
+ })
+})
+
+describe('runtime archive extraction safety', () => {
+ test.each([
+ ['a parent traversal', '../escaped.txt'],
+ ['a nested parent traversal', 'apps/../../escaped.txt'],
+ ['an absolute path', '/tmp/pascal-escaped.txt'],
+ ['a Windows drive path', 'C:/pascal-escaped.txt'],
+ ])('rejects %s', async (_label, name) => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'malicious.tar.gz')
+ await writeArchive(archive, fileEntry(name, 'escaped\n'))
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ expect(await exists(path.join(root, 'escaped.txt'))).toBe(false)
+ expect(await exists('/tmp/pascal-escaped.txt')).toBe(false)
+ })
+
+ test('rejects a symbolic-link entry that would point out of the target', async () => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'symlink.tar.gz')
+ await writeArchive(archive, [
+ tarHeaderBlock({ name: 'apps/editor/escape', size: 0, mode: 0o777, typeflag: '2' }),
+ ])
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ expect(await exists(path.join(root, 'target/apps/editor/escape'))).toBe(false)
+ })
+
+ test('rejects a hard-link entry', async () => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'hardlink.tar.gz')
+ await writeArchive(archive, [
+ tarHeaderBlock({ name: 'apps/editor/linked', size: 0, mode: 0o644, typeflag: '1' }),
+ ])
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ })
+
+ test('rejects a header whose checksum was rewritten', async () => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'tampered.tar.gz')
+ const [header, ...rest] = fileEntry('apps/editor/server.js', 'console.log(1)\n')
+ if (!header) throw new Error('the test archive has no header block')
+ const rewritten = Buffer.from(header)
+ rewritten.write('X', 0, 1, 'ascii')
+ await writeArchive(archive, [rewritten, ...rest])
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ })
+})
+
+async function temporaryRoot(): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-tar-test-'))
+ roots.push(root)
+ return root
+}
+
+function fileEntry(name: string, body: string): Buffer[] {
+ const data = Buffer.from(body, 'utf8')
+ const padded = Buffer.alloc(Math.ceil(data.byteLength / 512) * 512)
+ data.copy(padded)
+ return [tarHeaderBlock({ name, size: data.byteLength, mode: 0o644, typeflag: '0' }), padded]
+}
+
+async function writeArchive(file: string, blocks: Buffer[]): Promise {
+ await pipeline(
+ Readable.from([Buffer.concat([...blocks, Buffer.alloc(1024)])]),
+ createGzip(),
+ createWriteStream(file),
+ )
+}
+
+async function sha256(file: string): Promise {
+ return createHash('sha256')
+ .update(await readFile(file))
+ .digest('hex')
+}
+
+async function exists(file: string): Promise {
+ try {
+ await stat(file)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/packages/cli/src/tar.ts b/packages/cli/src/tar.ts
new file mode 100644
index 0000000000..470a4213ea
--- /dev/null
+++ b/packages/cli/src/tar.ts
@@ -0,0 +1,304 @@
+import { createReadStream, createWriteStream } from 'node:fs'
+import { mkdir, readdir, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { Readable, type Writable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { createGunzip, createGzip } from 'node:zlib'
+import { CliError } from './errors.js'
+
+const BLOCK_SIZE = 512
+const LONG_NAME_ENTRY = '././@LongLink'
+
+export interface TarHeaderFields {
+ name: string
+ size: number
+ mode: number
+ typeflag: string
+}
+
+export interface RuntimeArchiveResult {
+ size: number
+ entryCount: number
+}
+
+interface ArchiveEntry {
+ relative: string
+ absolute: string
+ directory: boolean
+ size: number
+ mode: number
+}
+
+/**
+ * Writes a byte-for-byte reproducible tar.gz: entries sorted by path, zero mtime, zero
+ * uid/gid, empty owner names and normalized modes. Two runs over the same tree therefore
+ * produce the same SHA-256, which is what `dist/runtime-source.json` pins.
+ */
+export async function createRuntimeArchive(
+ sourceDirectory: string,
+ destinationFile: string,
+): Promise {
+ const root = path.resolve(sourceDirectory)
+ const destination = path.resolve(destinationFile)
+ const entries = await collectEntries(root)
+ await mkdir(path.dirname(destination), { recursive: true })
+ await rm(destination, { force: true })
+ await pipeline(
+ Readable.from(archiveBlocks(entries), { objectMode: false }),
+ createGzip({ level: 9 }),
+ createWriteStream(destination),
+ )
+ return { size: (await stat(destination)).size, entryCount: entries.length }
+}
+
+export async function extractTarGzip(archiveFile: string, targetDirectory: string): Promise {
+ const root = path.resolve(targetDirectory)
+ await mkdir(root, { recursive: true, mode: 0o700 })
+ const source = createReadStream(archiveFile)
+ const gunzip = createGunzip()
+ source.on('error', (error) => gunzip.destroy(error))
+ const reader = new BlockReader(source.pipe(gunzip))
+ let pendingLongName: string | null = null
+ try {
+ for (;;) {
+ const header = await reader.read(BLOCK_SIZE)
+ if (!header || isZeroBlock(header)) break
+ verifyChecksum(header)
+ const typeflag = String.fromCharCode(header[156] ?? 0)
+ const size = readOctal(header, 124, 12)
+ const mode = readOctal(header, 100, 8)
+ if (typeflag === 'L') {
+ const data = await reader.read(paddedSize(size))
+ if (!data) throw invalidArchive('a long-name entry is truncated')
+ pendingLongName = data.subarray(0, size).toString('utf8').replace(/\0+$/, '')
+ continue
+ }
+ const name = pendingLongName ?? readHeaderString(header, 0, 100)
+ pendingLongName = null
+ if (typeflag !== '0' && typeflag !== '\0' && typeflag !== '5') {
+ throw invalidArchive(`entry ${JSON.stringify(name)} uses unsupported type "${typeflag}"`)
+ }
+ const destination = resolveEntryPath(root, name)
+ if (typeflag === '5') {
+ await mkdir(destination, { recursive: true, mode: 0o755 })
+ continue
+ }
+ await mkdir(path.dirname(destination), { recursive: true, mode: 0o755 })
+ await reader.writeTo(
+ createWriteStream(destination, { mode: (mode & 0o111) !== 0 ? 0o755 : 0o644 }),
+ size,
+ )
+ const padding = paddedSize(size) - size
+ if (padding > 0 && !(await reader.read(padding))) {
+ throw invalidArchive(`entry ${JSON.stringify(name)} is truncated`)
+ }
+ }
+ } finally {
+ gunzip.destroy()
+ source.destroy()
+ }
+}
+
+export function tarHeaderBlock(fields: TarHeaderFields): Buffer {
+ const block = Buffer.alloc(BLOCK_SIZE)
+ Buffer.from(fields.name, 'utf8').subarray(0, 100).copy(block, 0)
+ writeOctal(block, fields.mode & 0o7777, 100, 8)
+ writeOctal(block, 0, 108, 8)
+ writeOctal(block, 0, 116, 8)
+ writeOctal(block, fields.size, 124, 12)
+ writeOctal(block, 0, 136, 12)
+ block.write(fields.typeflag, 156, 1, 'ascii')
+ block.write('ustar\0', 257, 6, 'ascii')
+ block.write('00', 263, 2, 'ascii')
+ block.fill(0x20, 148, 156)
+ let checksum = 0
+ for (const byte of block) checksum += byte
+ block.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii')
+ return block
+}
+
+async function* archiveBlocks(entries: ArchiveEntry[]): AsyncGenerator {
+ for (const entry of entries) {
+ const name = entry.directory ? `${entry.relative}/` : entry.relative
+ const nameBytes = Buffer.from(name, 'utf8')
+ if (nameBytes.byteLength > 100) {
+ yield tarHeaderBlock({
+ name: LONG_NAME_ENTRY,
+ size: nameBytes.byteLength + 1,
+ mode: 0o644,
+ typeflag: 'L',
+ })
+ const data = Buffer.concat([nameBytes, Buffer.of(0)])
+ yield data
+ yield* paddingBlocks(data.byteLength)
+ }
+ yield tarHeaderBlock({
+ name,
+ size: entry.directory ? 0 : entry.size,
+ mode: entry.mode,
+ typeflag: entry.directory ? '5' : '0',
+ })
+ if (entry.directory) continue
+ let written = 0
+ for await (const chunk of createReadStream(entry.absolute)) {
+ const buffer = chunk as Buffer
+ written += buffer.byteLength
+ yield buffer
+ }
+ if (written !== entry.size) {
+ throw new CliError(
+ 'archive_failed',
+ `${entry.relative} changed size while the archive was being written.`,
+ )
+ }
+ yield* paddingBlocks(written)
+ }
+ yield Buffer.alloc(2 * BLOCK_SIZE)
+}
+
+function* paddingBlocks(size: number): Generator {
+ const padding = (BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE
+ if (padding > 0) yield Buffer.alloc(padding)
+}
+
+async function collectEntries(root: string): Promise {
+ const entries: ArchiveEntry[] = []
+ const walk = async (directory: string, prefix: string): Promise => {
+ for (const child of await readdir(directory, { withFileTypes: true })) {
+ const absolute = path.join(directory, child.name)
+ const relative = prefix ? `${prefix}/${child.name}` : child.name
+ if (child.isSymbolicLink()) {
+ throw new CliError('archive_failed', `Cannot archive the symbolic link ${relative}.`)
+ }
+ if (child.isDirectory()) {
+ entries.push({ relative, absolute, directory: true, size: 0, mode: 0o755 })
+ await walk(absolute, relative)
+ continue
+ }
+ if (!child.isFile()) {
+ throw new CliError('archive_failed', `Cannot archive the special file ${relative}.`)
+ }
+ const info = await stat(absolute)
+ entries.push({
+ relative,
+ absolute,
+ directory: false,
+ size: info.size,
+ mode: (info.mode & 0o111) !== 0 ? 0o755 : 0o644,
+ })
+ }
+ }
+ await walk(root, '')
+ return entries.sort((left, right) =>
+ Buffer.compare(Buffer.from(left.relative, 'utf8'), Buffer.from(right.relative, 'utf8')),
+ )
+}
+
+class BlockReader {
+ private readonly iterator: AsyncIterator
+ private pending: Buffer = Buffer.alloc(0)
+
+ constructor(stream: Readable) {
+ this.iterator = stream[Symbol.asyncIterator]() as AsyncIterator
+ }
+
+ async read(size: number): Promise {
+ if (size === 0) return Buffer.alloc(0)
+ while (this.pending.byteLength < size) {
+ const next = await this.iterator.next()
+ if (next.done) break
+ this.pending =
+ this.pending.byteLength === 0
+ ? Buffer.from(next.value)
+ : Buffer.concat([this.pending, next.value])
+ }
+ if (this.pending.byteLength < size) return null
+ const result = this.pending.subarray(0, size)
+ this.pending = this.pending.subarray(size)
+ return result
+ }
+
+ async writeTo(target: Writable, size: number): Promise {
+ let remaining = size
+ try {
+ while (remaining > 0) {
+ const chunk = await this.read(Math.min(remaining, 1024 * 1024))
+ if (!chunk) throw invalidArchive('an entry ends before its recorded size')
+ remaining -= chunk.byteLength
+ if (!target.write(chunk)) {
+ await new Promise((resolve, reject) => {
+ target.once('drain', resolve)
+ target.once('error', reject)
+ })
+ }
+ }
+ } catch (error) {
+ target.destroy()
+ throw error
+ }
+ await new Promise((resolve, reject) => {
+ target.once('error', reject)
+ target.end(resolve)
+ })
+ }
+}
+
+function resolveEntryPath(root: string, name: string): string {
+ const normalized = name.replace(/\/+$/, '')
+ const segments = normalized.split('/')
+ if (
+ !normalized ||
+ normalized.includes('\0') ||
+ normalized.startsWith('/') ||
+ path.isAbsolute(normalized) ||
+ /^[A-Za-z]:/.test(normalized) ||
+ segments.some((segment) => segment === '..' || segment === '')
+ ) {
+ throw invalidArchive(`entry ${JSON.stringify(name)} is not a safe relative path`)
+ }
+ const destination = path.resolve(root, ...segments)
+ if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) {
+ throw invalidArchive(`entry ${JSON.stringify(name)} escapes the extraction directory`)
+ }
+ return destination
+}
+
+function verifyChecksum(header: Buffer): void {
+ const expected = readOctal(header, 148, 8)
+ let checksum = 0
+ for (let index = 0; index < BLOCK_SIZE; index += 1) {
+ checksum += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0)
+ }
+ if (checksum !== expected) throw invalidArchive('an entry header checksum does not match')
+}
+
+function isZeroBlock(block: Buffer): boolean {
+ return block.every((byte) => byte === 0)
+}
+
+function paddedSize(size: number): number {
+ return size + ((BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE)
+}
+
+function readHeaderString(block: Buffer, offset: number, length: number): string {
+ const field = block.subarray(offset, offset + length)
+ const end = field.indexOf(0)
+ return field.subarray(0, end === -1 ? field.byteLength : end).toString('utf8')
+}
+
+function readOctal(block: Buffer, offset: number, length: number): number {
+ const text = readHeaderString(block, offset, length).trim()
+ if (!/^[0-7]*$/.test(text)) throw invalidArchive('an entry header field is not octal')
+ return text ? Number.parseInt(text, 8) : 0
+}
+
+function writeOctal(block: Buffer, value: number, offset: number, length: number): void {
+ block.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii')
+}
+
+function invalidArchive(reason: string): CliError {
+ return new CliError(
+ 'invalid_runtime_archive',
+ `The Pascal web runtime archive is invalid: ${reason}.`,
+ )
+}
diff --git a/packages/cli/src/test-support/fake-mcp-service.ts b/packages/cli/src/test-support/fake-mcp-service.ts
new file mode 100644
index 0000000000..7dd70fed55
--- /dev/null
+++ b/packages/cli/src/test-support/fake-mcp-service.ts
@@ -0,0 +1,47 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+/**
+ * A stand-in for the bundled `services/pascal-mcp.mjs`: it answers the authenticated health
+ * probe the CLI uses to identify its own MCP process, and records the environment it was
+ * started with so tests can assert the editor origin handed to it.
+ */
+const FAKE_MCP_SERVICE = `import { writeFileSync } from 'node:fs'
+import http from 'node:http'
+const token = process.env.PASCAL_MCP_HTTP_TOKEN
+const server = http.createServer((request, response) => {
+ if (request.headers.authorization !== \`Bearer \${token}\`) {
+ response.writeHead(401).end()
+ return
+ }
+ response.setHeader('content-type', 'application/json')
+ if (request.url === '/health') {
+ response.end(JSON.stringify({
+ status: 'ok',
+ app: 'mcp',
+ version: process.env.PASCAL_RUNTIME_VERSION,
+ instanceId: process.env.PASCAL_INSTANCE_ID,
+ editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null,
+ }))
+ return
+ }
+ response.writeHead(404).end('{}')
+})
+const portIndex = process.argv.indexOf('--port')
+server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1')
+if (process.env.PASCAL_MCP_TEST_RECORD) {
+ writeFileSync(process.env.PASCAL_MCP_TEST_RECORD, JSON.stringify({
+ pid: process.pid,
+ editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null,
+ dataDirectory: process.env.PASCAL_DATA_DIR,
+ }))
+}
+process.on('SIGTERM', () => server.close(() => process.exit(0)))
+`
+
+export async function writeFakeMcpService(directory: string): Promise {
+ await mkdir(directory, { recursive: true })
+ const servicePath = path.join(directory, 'pascal-mcp.mjs')
+ await writeFile(servicePath, FAKE_MCP_SERVICE)
+ return servicePath
+}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
index 8a0ae091f9..2cb2be14e6 100644
--- a/packages/cli/tsconfig.json
+++ b/packages/cli/tsconfig.json
@@ -9,5 +9,5 @@
"types": ["node"]
},
"include": ["src"],
- "exclude": ["node_modules", "dist", "**/*.test.ts", "scripts"]
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "src/test-support", "scripts"]
}
diff --git a/packages/core/LICENSE b/packages/core/LICENSE
new file mode 100644
index 0000000000..083fd9e323
--- /dev/null
+++ b/packages/core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pascal Group Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/core/README.md b/packages/core/README.md
index 2f39c6ff83..e9edb80cf8 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -23,6 +23,9 @@ npm install react three @react-three/fiber @react-three/drei
- **Spatial Grid** - Collision detection and placement validation
- **Event Bus** - Typed event emitter for inter-component communication
- **Asset Storage** - IndexedDB-based file storage for user-uploaded assets
+- **Capture Contracts** (`@pascal-app/core/capture`) - Versioned capture-session manifests,
+ normalized stream descriptors, packet headers, and transport-neutral static/live `CaptureSource`
+ implementations
## Usage
@@ -78,6 +81,26 @@ Load the plugin before mounting `@pascal-app/viewer`. See the
[`@pascal-app/viewer` quick start](https://github.com/pascalorg/editor/tree/main/packages/viewer#usage)
for a React example.
+## Capture Sessions
+
+Capture contracts are a self-contained subpath — no React, no Three.js, no prescribed transport:
+
+```typescript
+import { createHttpCaptureSource, type CaptureSessionLocator } from '@pascal-app/core/capture'
+
+const locator: CaptureSessionLocator = {
+ sessionId: 'capture_123',
+ manifestUrl: '/api/captures/capture_123/manifest',
+}
+
+const source = createHttpCaptureSource(locator, { credentials: 'include' })
+const descriptor = await source.describe()
+```
+
+For live producers, use `PushCaptureSource` directly or implement `CaptureSource.subscribe()` with
+the same descriptor and packet event contract. The reference renderers that consume these sources
+ship in [`@pascal-app/viewer/capture`](https://github.com/pascalorg/editor/tree/main/packages/viewer#capture-sessions).
+
## License
MIT
diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml
new file mode 100644
index 0000000000..eec7d338da
--- /dev/null
+++ b/packages/core/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["../../scripts/bun-preload-three.ts"]
+
+[test]
+preload = ["../../scripts/bun-preload-three.ts"]
diff --git a/packages/core/package.json b/packages/core/package.json
index 27bdef827f..e3dc0681fb 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/core",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0",
"description": "Core library for Pascal 3D building editor",
"type": "module",
"main": "./dist/index.js",
@@ -21,6 +21,11 @@
"import": "./dist/utils/scene-migrations.js",
"default": "./dist/utils/scene-migrations.js"
},
+ "./capture": {
+ "types": "./dist/capture/index.d.ts",
+ "import": "./dist/capture/index.js",
+ "default": "./dist/capture/index.js"
+ },
"./registry": {
"types": "./dist/registry/index.d.ts",
"import": "./dist/registry/index.js",
@@ -66,21 +71,21 @@
"dev": "tsgo --build --watch",
"test": "bun test src",
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
+ "bench:schema": "bun run src/schema/__bench__/node-parsers.bench.ts",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
- "three": "^0.185"
+ "three": "^0.186"
},
"dependencies": {
- "@pascal-app/capture-protocol": "^1.0.0-beta.4",
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
- "zod": "^4.3.5",
+ "zod": ">=4.5.4 <4.6",
"zundo": "^2.3.0",
"zustand": "^5"
},
diff --git a/packages/capture-protocol/src/index.ts b/packages/core/src/capture/index.ts
similarity index 100%
rename from packages/capture-protocol/src/index.ts
rename to packages/core/src/capture/index.ts
diff --git a/packages/capture-protocol/src/schema.test.ts b/packages/core/src/capture/schema.test.ts
similarity index 92%
rename from packages/capture-protocol/src/schema.test.ts
rename to packages/core/src/capture/schema.test.ts
index bd44354b74..3dbb6e8c7a 100644
--- a/packages/capture-protocol/src/schema.test.ts
+++ b/packages/core/src/capture/schema.test.ts
@@ -114,7 +114,7 @@ describe('capture manifests', () => {
).toThrow()
})
- test('rejects oversized or structurally inconsistent surface meshes', () => {
+ test('accepts the native 20,000-face preview budget and rejects malformed or oversized meshes', () => {
const surfaceMesh = {
version: 1,
coordinateSystem: 'arkit-world',
@@ -138,9 +138,19 @@ describe('capture manifests', () => {
streams: { surfaceMesh: { kind: 'surface-mesh', mesh } },
})
+ const atBudget = normalizeCaptureSessionManifest(
+ manifest({ ...surfaceMesh, faceCount: 20_000, indices: surfaceMesh.indices.repeat(20_000) }),
+ )
+ expect(atBudget.streams[0]?.inline).toMatchObject({ faceCount: 20_000 })
expect(() =>
- normalizeCaptureSessionManifest(manifest({ ...surfaceMesh, faceCount: 6_001 })),
- ).toThrow()
+ normalizeCaptureSessionManifest(
+ manifest({
+ ...surfaceMesh,
+ faceCount: 20_001,
+ indices: surfaceMesh.indices.repeat(20_001),
+ }),
+ ),
+ ).toThrow('<=20000')
expect(() =>
normalizeCaptureSessionManifest(manifest({ ...surfaceMesh, positions: 'AAAA' })),
).toThrow('decoded bytes')
diff --git a/packages/capture-protocol/src/schema.ts b/packages/core/src/capture/schema.ts
similarity index 99%
rename from packages/capture-protocol/src/schema.ts
rename to packages/core/src/capture/schema.ts
index 7ff16ea261..012e7b5fa2 100644
--- a/packages/capture-protocol/src/schema.ts
+++ b/packages/core/src/capture/schema.ts
@@ -52,7 +52,7 @@ export const ArkitPointCloudPayloadSchema = PointCloudPayloadSchema.safeExtend({
})
const MAX_SURFACE_MESH_VERTICES = 65_535
-const MAX_SURFACE_MESH_FACES = 6_000
+const MAX_SURFACE_MESH_FACES = 20_000
export const SurfaceMeshPayloadSchema = z
.object({
@@ -69,7 +69,10 @@ export const SurfaceMeshPayloadSchema = z
indexEncoding: z.literal('uint16x3-base64-little-endian'),
positions: z.string().min(1).max(524_280),
colors: z.string().min(1).max(262_140),
- indices: z.string().min(1).max(48_000),
+ indices: z
+ .string()
+ .min(1)
+ .max(MAX_SURFACE_MESH_FACES * 8),
})
.superRefine((payload, context) => {
if (payload.vertexCount > payload.faceCount * 3) {
diff --git a/packages/capture-protocol/src/source.test.ts b/packages/core/src/capture/source.test.ts
similarity index 100%
rename from packages/capture-protocol/src/source.test.ts
rename to packages/core/src/capture/source.test.ts
diff --git a/packages/capture-protocol/src/source.ts b/packages/core/src/capture/source.ts
similarity index 100%
rename from packages/capture-protocol/src/source.ts
rename to packages/core/src/capture/source.ts
diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts
index d00717a4f2..c71d0184c0 100644
--- a/packages/core/src/events/bus.ts
+++ b/packages/core/src/events/bus.ts
@@ -51,18 +51,25 @@ import type {
WindowNode,
ZoneNode,
} from '../schema'
-import type { AnyNode } from '../schema/types'
+import type { AnyNode, AnyNodeId } from '../schema/types'
// Base event interfaces
export interface GridEvent {
- /** World-space intersection point on the grid plane. */
+ /** World-space intersection point on the floor grid or a scene surface. */
position: [number, number, number]
/**
- * Building-local intersection point — relative to the currently selected building.
- * Equals `position` when no building is selected.
+ * Intersection in localFrameId when specified, otherwise the selected building.
+ * Equals `position` when neither frame is available.
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
*/
localPosition: [number, number, number]
+ /** Explicit scene-node coordinate frame for local fields, when provided. */
+ localFrameId?: AnyNodeId
+ /** Pointer ray in the same coordinate frame as `localPosition`. */
+ localRay?: {
+ origin: [number, number, number]
+ direction: [number, number, number]
+ }
faceIndex?: number
/**
* Optional: the hit Three.js object. Present when the grid event was
@@ -72,6 +79,20 @@ export interface GridEvent {
* the intersection to.
*/
object?: Object3D
+ /** Architectural hit in the same coordinate frame as localPosition. */
+ surfaceLocalPosition?: [number, number, number]
+ /** Outward normal in the same coordinate frame as localPosition. */
+ surfaceNormal?: [number, number, number]
+ /** The architectural surface object hit by the cursor, when available. */
+ surfaceObject?: Object3D
+ /** Semantic architectural hit for scoped placement/drafting tools. */
+ surfaceHit?: {
+ kind: 'wall' | 'ceiling' | 'slab' | 'roof'
+ hostId: AnyNodeId
+ levelId?: AnyNodeId
+ face: 'side' | 'top' | 'end' | 'unknown'
+ side?: 'front' | 'back'
+ }
nativeEvent: ThreeEvent
}
@@ -170,8 +191,32 @@ export interface CameraControlEvent {
nodeId: AnyNode['id']
}
+export interface SnapshotCapturePose {
+ position: [number, number, number]
+ quaternion: [number, number, number, number]
+ /** Vertical field of view in degrees in the final standard-size output. */
+ fov: number
+}
+
+export interface SnapshotSavedEvent {
+ requestId?: string
+ projectId?: string
+ id: string
+ url: string
+ width: number
+ height: number
+}
+
+export interface SnapshotCaptureFailedEvent {
+ requestId: string
+ error: string
+}
+
export interface ThumbnailGenerateEvent {
projectId: string
+ requestId?: string
+ /** World-space pose for a standard capture without moving the viewport camera. */
+ cameraPose?: SnapshotCapturePose
captureMode?: 'standard' | 'viewport' | 'area'
cropRegion?: { x: number; y: number; width: number; height: number }
/**
@@ -263,7 +308,8 @@ type ThumbnailEvents = {
}
type SnapshotEvents = {
- 'snapshot:saved': undefined
+ 'snapshot:saved': undefined | SnapshotSavedEvent
+ 'snapshot:capture-failed': SnapshotCaptureFailedEvent
'camera:go-to-position': { position: [number, number, number]; target: [number, number, number] }
}
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
index d96ee2f36e..f84ed61625 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
@@ -4,7 +4,7 @@ import { encodeTerrainField } from '../../lib/terrain-codec'
import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
-import type { AnyNode, AnyNodeId } from '../../schema'
+import { type AnyNode, type AnyNodeId, ItemNode, LevelNode, SlabNode, WallNode } from '../../schema'
import useLiveTerrain from '../../store/use-live-terrain'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
@@ -565,3 +565,221 @@ describe('spatial-grid sync dirty rules (terrain support)', () => {
expect(marked).toEqual(['wall_ground', 'wall_fill', 'slab_fill', 'column_a'])
})
})
+
+describe('temporal writes update slab support dependencies', () => {
+ let stop = () => {}
+ let restore = () => {}
+ beforeEach(() => {
+ restore = nodeRegistry._snapshot()
+ nodeRegistry._register({
+ kind: 'item',
+ schemaVersion: 1,
+ schema: ItemNode,
+ capabilities: {
+ floorPlaced: { footprint: () => ({ dimensions: [0.2, 1, 0.2], rotation: [0, 0, 0] }) },
+ },
+ } as never)
+ spatialGridManager.clear()
+ })
+ afterEach(() => {
+ stop()
+ restore()
+ spatialGridManager.clear()
+ clearSceneHistory()
+ })
+
+ test('undo/redo of wall thickness re-elevates an unchanged item on the former rendered slab band', async () => {
+ const level = LevelNode.parse({ id: 'level_band_history' })
+ const wall = WallNode.parse({
+ id: 'wall_band_history',
+ parentId: level.id,
+ start: [4, 0],
+ end: [4, 4],
+ thickness: 0.8,
+ })
+ const slab = SlabNode.parse({
+ parentId: level.id,
+ polygon: SQUARE,
+ elevation: 0.4,
+ thickness: 0.4,
+ })
+ const item = ItemNode.parse({
+ parentId: level.id,
+ position: [4.45, 0, 2],
+ asset: {
+ id: 'test',
+ name: 'test',
+ category: 'test',
+ thumbnail: '',
+ src: '/test.glb',
+ dimensions: [0.2, 1, 0.2],
+ },
+ })
+ const remote = { ...item, id: 'item_remote_band', position: [20, 0, 20] } as AnyNode
+ const interior = { ...item, id: 'item_interior_band', position: [2, 0, 2] } as AnyNode
+ const interiorWall = WallNode.parse({
+ id: 'wall_interior_band',
+ parentId: level.id,
+ start: [1, 1],
+ end: [2, 1],
+ })
+ const upper = LevelNode.parse({ id: 'level_other_band', level: 1 })
+ const upperItem = { ...item, id: 'item_upper_band', parentId: upper.id } as AnyNode
+ const upperWall = { ...wall, id: 'wall_upper_band', parentId: upper.id } as AnyNode
+ const upperSlab = { ...slab, id: 'slab_upper_band', parentId: upper.id } as AnyNode
+ useScene.setState({
+ nodes: nodesFor(
+ level,
+ wall,
+ slab,
+ item,
+ remote,
+ interior,
+ interiorWall,
+ upper,
+ upperItem,
+ upperWall,
+ upperSlab,
+ ),
+ dirtyNodes: new Set(),
+ readOnly: false,
+ })
+ clearSceneHistory()
+ stop = initSpatialGridSync()
+ const elevation = () =>
+ spatialGridManager.getSlabSupportForItem(level.id, item.position, [0.2, 1, 0.2], [0, 0, 0])
+ .elevation
+ expect(elevation()).toBeCloseTo(0.4)
+ useScene.setState({
+ nodes: { ...useScene.getState().nodes, [wall.id]: { ...wall, thickness: 0.1 } },
+ })
+ expect(elevation()).toBe(0)
+ expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true)
+ for (const [jump, expected] of [
+ [useScene.temporal.getState().undo, 0.4],
+ [useScene.temporal.getState().redo, 0],
+ ] as const) {
+ useScene.getState().dirtyNodes.clear()
+ jump()
+ // The support subscription runs on the write, before the temporal microtask.
+ expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true)
+ expect(elevation()).toBeCloseTo(expected)
+ await Promise.resolve()
+ expect(useScene.getState().nodes[item.id]).toBe(item)
+ expect(useScene.getState().nodes[slab.id]).toBe(slab)
+ for (const unaffected of [
+ remote,
+ interior,
+ interiorWall,
+ upper,
+ upperItem,
+ upperWall,
+ upperSlab,
+ ]) {
+ expect(useScene.getState().dirtyNodes.has(unaffected.id)).toBe(false)
+ }
+ }
+ })
+
+ test('slab reparent and undo mark covering dependents below both parent levels', async () => {
+ const levels = [0, 1, 2, 3].map((ordinal) =>
+ makeLevel(`level_${ordinal}`, ordinal, 2.5, [
+ `wall_covering_${ordinal}`,
+ `ceiling_covering_${ordinal}`,
+ ...(ordinal === 2 ? ['slab_reparent'] : []),
+ ]),
+ )
+ const consumers = levels.flatMap((level, ordinal) => [
+ {
+ ...makeChild(`wall_covering_${ordinal}`, 'wall', level.id),
+ start: [20, 0],
+ end: [24, 0],
+ } as AnyNode,
+ makeChild(`ceiling_covering_${ordinal}`, 'ceiling', level.id),
+ ])
+ const slab = makeSlab('slab_reparent', 'level_2')
+ useScene.setState({
+ nodes: nodesFor(...levels, ...consumers, slab),
+ rootNodeIds: levels.map((level) => level.id),
+ installedPlugins: [],
+ dirtyNodes: new Set(),
+ readOnly: false,
+ })
+ clearSceneHistory()
+ stop = initSpatialGridSync()
+ const coveringDirtyIds = () => dirtyIds().filter((id) => id.includes('_covering_'))
+ const expected = [
+ 'ceiling_covering_1',
+ 'ceiling_covering_2',
+ 'wall_covering_1',
+ 'wall_covering_2',
+ ]
+ useScene.getState().dirtyNodes.clear()
+ useScene.setState({
+ nodes: {
+ ...useScene.getState().nodes,
+ [slab.id]: { ...slab, parentId: 'level_3' } as AnyNode,
+ level_2: { ...levels[2]!, children: ['wall_covering_2', 'ceiling_covering_2'] } as AnyNode,
+ level_3: {
+ ...levels[3]!,
+ children: ['wall_covering_3', 'ceiling_covering_3', slab.id],
+ } as AnyNode,
+ },
+ })
+ await Promise.resolve()
+ expect(coveringDirtyIds()).toEqual(expected)
+
+ useScene.getState().dirtyNodes.clear()
+ useScene.temporal.getState().undo()
+ await Promise.resolve()
+ expect(useScene.getState().nodes[slab.id]?.parentId).toBe('level_2')
+ expect(coveringDirtyIds()).toEqual(expected)
+ })
+
+ test('slab elevation and level height subscribers fire during real temporal restoration', async () => {
+ const level = LevelNode.parse({
+ id: 'level_vertical_history',
+ children: ['wall_vertical_history'],
+ })
+ const wall = WallNode.parse({
+ id: 'wall_vertical_history',
+ parentId: level.id,
+ start: [0, 0],
+ end: [4, 0],
+ })
+ const slab = SlabNode.parse({
+ parentId: level.id,
+ polygon: SQUARE,
+ elevation: 1,
+ thickness: 0.1,
+ })
+ const item = ItemNode.parse({
+ parentId: level.id,
+ position: [2, 0, 2],
+ asset: { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb' },
+ })
+ useScene.setState({
+ nodes: nodesFor(level, wall, slab, item),
+ dirtyNodes: new Set(),
+ readOnly: false,
+ })
+ clearSceneHistory()
+ stop = initSpatialGridSync()
+ useScene.setState({
+ nodes: {
+ ...useScene.getState().nodes,
+ [slab.id]: { ...slab, elevation: 2 },
+ [level.id]: { ...level, height: 4 },
+ },
+ })
+ useScene.getState().dirtyNodes.clear()
+ useScene.temporal.getState().undo()
+ expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true)
+ expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true)
+ expect(
+ spatialGridManager.getSlabSupportForItem(level.id, item.position, [0.2, 1, 0.2], [0, 0, 0])
+ .elevation,
+ ).toBe(1)
+ await Promise.resolve()
+ })
+})
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
index d05d0d68e4..01869c84c1 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
@@ -1,3 +1,4 @@
+import { subtractPolygonsFromPolygon } from '../../lib/polygon-union'
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support'
import { nodeRegistry } from '../../registry'
@@ -111,6 +112,38 @@ export function initSpatialGridSync(): () => void {
// Subscribe to all changes
const unsubscribeScene = store.subscribe((state, prevState) => {
+ if (state.nodes === prevState.nodes) return
+ const changedSlabContextLevels = new Set()
+ const checkSlabContext = (id: string) => {
+ const previous = prevState.nodes[id as AnyNodeId]
+ const next = state.nodes[id as AnyNodeId]
+ if (previous === next) return
+ const wallChanged =
+ (previous?.type === 'wall' || next?.type === 'wall') &&
+ (previous?.type !== 'wall' ||
+ next?.type !== 'wall' ||
+ previous.parentId !== next.parentId ||
+ previous.start !== next.start ||
+ previous.end !== next.end ||
+ previous.thickness !== next.thickness ||
+ previous.curveOffset !== next.curveOffset)
+ const slabChanged =
+ (previous?.type === 'slab' || next?.type === 'slab') &&
+ (previous?.type !== 'slab' ||
+ next?.type !== 'slab' ||
+ previous.parentId !== next.parentId ||
+ previous.polygon !== next.polygon ||
+ previous.elevation !== next.elevation)
+ if (!(wallChanged || slabChanged)) return
+ if (previous) changedSlabContextLevels.add(resolveLevelId(previous, prevState.nodes))
+ if (next) changedSlabContextLevels.add(resolveLevelId(next, state.nodes))
+ }
+
+ for (const id in prevState.nodes) checkSlabContext(id)
+ for (const id in state.nodes) {
+ if (!prevState.nodes[id as AnyNodeId]) checkSlabContext(id)
+ }
+
// Detect added nodes
for (const [id, node] of Object.entries(state.nodes)) {
if (!prevState.nodes[id as AnyNode['id']]) {
@@ -140,7 +173,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is removed, mark items/walls that were on it dirty (using current state)
if (node.type === 'slab') {
- markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
+ markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty, prevState.nodes)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
@@ -183,7 +216,13 @@ export function initSpatialGridSync(): () => void {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
}
- markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty)
+ markSlabChangeDependents(
+ prev as SlabNode,
+ node as SlabNode,
+ state.nodes,
+ markDirty,
+ prevState.nodes,
+ )
} else if (node.type === 'level' && prev.type === 'level') {
if (node.height !== prev.height) {
markLevelHeightDependents(node as LevelNode, state.nodes, markDirty)
@@ -210,6 +249,47 @@ export function initSpatialGridSync(): () => void {
}
}
}
+
+ // Unchanged slabs can lose an adopted wall band or a sibling seam. Their
+ // stored polygons cannot identify objects standing on the former boundary.
+ if (changedSlabContextLevels.size === 0) return
+ const beforeContext = slabBoundaryContext(prevState.nodes, changedSlabContextLevels)
+ const afterContext = slabBoundaryContext(state.nodes, changedSlabContextLevels)
+ for (const context of afterContext.values()) {
+ for (const slab of context.slabs) {
+ const previous = prevState.nodes[slab.id]
+ if (previous?.type !== 'slab') continue
+ if (
+ slab.parentId !== previous.parentId ||
+ slab.polygon !== previous.polygon ||
+ slab.elevation !== previous.elevation ||
+ slab.holes !== previous.holes
+ )
+ continue
+ const previousContext = beforeContext.get(resolveLevelId(previous, prevState.nodes))!
+ const beforePolygon = cachedSlabPolygon(previous, previousContext)
+ const afterPolygon = cachedSlabPolygon(slab, context)
+ if (
+ beforePolygon.length === afterPolygon.length &&
+ beforePolygon.every((point, i) => arraysEqual(point, afterPolygon[i]!))
+ )
+ continue
+ // Support only changed in the gained/lost bands, not across the slab interior.
+ const changedBands = [
+ ...subtractPolygonsFromPolygon(beforePolygon, [afterPolygon]),
+ ...subtractPolygonsFromPolygon(afterPolygon, [beforePolygon]),
+ ]
+ for (const polygon of changedBands) {
+ markNodesOverlappingPolygon(
+ resolveLevelId(slab, state.nodes),
+ polygon,
+ state.nodes,
+ markDirty,
+ context.consumers,
+ )
+ }
+ }
+ }
})
// Live terrain is deliberately not written into `useScene` per dab: doing so
@@ -282,14 +362,16 @@ export function markSlabChangeDependents(
next: SlabNode,
nodes: Record,
markDirty: (id: AnyNodeId) => void,
+ previousNodes = nodes,
) {
const supportChanged =
+ next.parentId !== previous.parentId ||
next.polygon !== previous.polygon ||
next.elevation !== previous.elevation ||
next.holes !== previous.holes
if (supportChanged) {
- markNodesOverlappingSlab(previous, nodes, markDirty)
+ markNodesOverlappingSlab(previous, nodes, markDirty, previousNodes)
markNodesOverlappingSlab(next, nodes, markDirty)
}
if (next.elevation !== previous.elevation) {
@@ -300,7 +382,14 @@ export function markSlabChangeDependents(
next.thickness !== previous.thickness ||
next.recessed !== previous.recessed
) {
- markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty)
+ const nextLevelId = resolveLevelId(next, nodes)
+ markCoveringDependentsBelow(nextLevelId, nodes, markDirty)
+ if (next.parentId !== previous.parentId) {
+ const previousLevelId = resolveLevelId(previous, previousNodes)
+ if (previousLevelId !== nextLevelId) {
+ markCoveringDependentsBelow(previousLevelId, nodes, markDirty)
+ }
+ }
}
}
@@ -396,22 +485,8 @@ export function markCoveringDependentsBelow(
}
}
-/**
- * Mark all floor items and walls that may be affected by a slab change as dirty.
- */
-function markNodesOverlappingSlab(
- slab: SlabNode,
- nodes: Record,
- markDirty: (id: AnyNodeId) => void,
-) {
- if (slab.polygon.length < 3) return
+function renderableSlabPolygon(slab: SlabNode, nodes: Record) {
const slabLevelId = resolveLevelId(slab, nodes)
-
- // Walls AND floor-placed nodes follow the slab's RENDERED footprint
- // (band-adopted edges reach the wall's outer face), so the dirty gate
- // must test the same polygon the support queries re-evaluate — a stored
- // polygon that stops short of the wall body would otherwise never
- // re-elevate nodes sitting over the adopted band.
const levelWalls: WallNode[] = []
const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) {
@@ -425,9 +500,33 @@ function markNodesOverlappingSlab(
siblingSlabs.push(node as SlabNode)
}
}
- const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs })
+ return getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs })
+}
- for (const node of Object.values(nodes)) {
+/**
+ * Mark all floor items and walls that may be affected by a slab change as dirty.
+ */
+function markNodesOverlappingSlab(
+ slab: SlabNode,
+ nodes: Record,
+ markDirty: (id: AnyNodeId) => void,
+ contextNodes = nodes,
+) {
+ if (slab.polygon.length < 3) return
+ const slabLevelId = resolveLevelId(slab, contextNodes)
+ const renderedPolygon = renderableSlabPolygon(slab, contextNodes)
+
+ markNodesOverlappingPolygon(slabLevelId, renderedPolygon, nodes, markDirty)
+}
+
+function markNodesOverlappingPolygon(
+ slabLevelId: string,
+ renderedPolygon: [number, number][],
+ nodes: Record,
+ markDirty: (id: AnyNodeId) => void,
+ candidates: Iterable = Object.values(nodes),
+) {
+ for (const node of candidates) {
if (node.type === 'wall') {
const wall = node as WallNode
if (resolveLevelId(node, nodes) !== slabLevelId) continue
@@ -477,3 +576,42 @@ function markNodesOverlappingSlab(
}
}
}
+
+type SlabBoundaryContext = {
+ walls: WallNode[]
+ slabs: SlabNode[]
+ consumers: AnyNode[]
+ polygons: Map
+}
+
+function slabBoundaryContext(nodes: Record, levels: Set) {
+ const contexts = new Map()
+ for (const id in nodes) {
+ const node = nodes[id]!
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
+ if (node.type !== 'wall' && node.type !== 'slab' && !floorPlaced) continue
+ const levelId = resolveLevelId(node, nodes)
+ if (!levels.has(levelId)) continue
+ let context = contexts.get(levelId)
+ if (!context) {
+ context = { walls: [], slabs: [], consumers: [], polygons: new Map() }
+ contexts.set(levelId, context)
+ }
+ if (node.type === 'wall') context.walls.push(node)
+ if (node.type === 'slab') context.slabs.push(node)
+ if (node.type === 'wall' || floorPlaced) context.consumers.push(node)
+ }
+ return contexts
+}
+
+function cachedSlabPolygon(slab: SlabNode, context: SlabBoundaryContext) {
+ let polygon = context.polygons.get(slab.id)
+ if (!polygon) {
+ polygon = getRenderableSlabPolygon(slab, {
+ walls: context.walls,
+ siblingSlabs: context.slabs.filter((sibling) => sibling.id !== slab.id),
+ })
+ context.polygons.set(slab.id, polygon)
+ }
+ return polygon
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 5d2720fa12..5e263ce933 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -33,11 +33,15 @@ export type {
SiteEvent,
SkylightEvent,
SlabEvent,
+ SnapshotCaptureFailedEvent,
+ SnapshotCapturePose,
+ SnapshotSavedEvent,
SolarPanelEvent,
SpawnEvent,
StairEvent,
StairSegmentEvent,
StructuralGridEvent,
+ ThumbnailGenerateEvent,
WallEvent,
WindowEvent,
ZoneEvent,
@@ -144,12 +148,17 @@ export {
type RoofPlanSegment,
roofOverlapEntryOwns,
roofPlanBoundsOverlap,
+ roofPlanOverlapEntryOwns,
} from './lib/roof-overlap'
export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy'
export {
getRenderableSlabPolygon,
+ prepareSlabPolygonContext,
type SlabEdgeWallBandSnap,
type SlabPolygonContext,
+ scopeSlabPolygonContext,
+ slabPolygonContextChanges,
+ slabPolygonContextForLevel,
slabPolygonContextFromGeometry,
snapSlabEdgeToWallBand,
} from './lib/slab-polygon'
@@ -294,6 +303,7 @@ export * from './services'
export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement'
export {
acquireSceneHistoryPause,
+ activeSceneCommitNodeIds,
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
@@ -305,6 +315,7 @@ export {
type SceneSnapshot,
subscribeSceneCommits,
} from './store/history-control'
+export { getHistoryDirtyNodeIds } from './store/history-invalidation'
export {
type ControlValue,
type DoorAnimationState,
@@ -389,18 +400,32 @@ export {
isSplineFence,
sampleFenceSpline,
} from './systems/fence/fence-spline'
+export { resolveRoofElevation, resolveRoofWallTopElevation } from './systems/roof/roof-elevation'
+export { RoofElevationSystem } from './systems/roof/roof-elevation-system'
+export {
+ fitRoofFootprint,
+ type RoofFootprintTarget,
+ resolveRoomRoofFootprint,
+ resolveRoomRoofFootprintOnLevel,
+} from './systems/roof/roof-footprint'
export { resolveSlabPlacementElevation } from './systems/slab/slab-placement'
export {
clampSlabElevationForWalls,
getSlabElevationUpperBound,
type SlabElevationClamp,
} from './systems/slab/slab-support'
+export {
+ createDefaultStairSegment,
+ createStairFlightFromStair,
+ type StairFlightOverrides,
+} from './systems/stair/stair-flight'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export { resolveStairTotalRise, syncStairRises } from './systems/stair/stair-rise'
export {
+ constrainWallCurveOffsetToAvoidIntersections,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallArcData,
diff --git a/packages/core/src/lib/polygon-union.test.ts b/packages/core/src/lib/polygon-union.test.ts
index d0c872eef4..3b1a5dc785 100644
--- a/packages/core/src/lib/polygon-union.test.ts
+++ b/packages/core/src/lib/polygon-union.test.ts
@@ -13,6 +13,17 @@ function polygonArea(points: Point2D[]) {
return Math.abs(area / 2)
}
+function hasRepeatedNonAdjacentPoint(points: Point2D[]) {
+ return points.some((point, index) =>
+ points.some(
+ (candidate, candidateIndex) =>
+ candidateIndex > index + 1 &&
+ !(index === 0 && candidateIndex === points.length - 1) &&
+ Math.hypot(point[0] - candidate[0], point[1] - candidate[1]) <= 1e-7,
+ ),
+ )
+}
+
describe('unionPolygons', () => {
test('collapses a contained polygon into the containing polygon', () => {
const small: Point2D[] = [
@@ -74,6 +85,87 @@ describe('unionPolygons', () => {
expect(result).toHaveLength(2)
expect(result.map(polygonArea)).toEqual([1, 1])
})
+
+ test('does not stitch point-touching branches into a self-touching ring', () => {
+ const lowerTip: Point2D[] = [
+ [-4.4073, -1.383],
+ [-4.2663, -1.383],
+ [-4.4073, -1.22435],
+ ]
+ const upperTip: Point2D[] = [
+ [-2.1038, 0.35056],
+ [-0.9401, 1.385],
+ [-3.0233, 1.385],
+ ]
+ const connectingBand: Point2D[] = [
+ [-4.2663, -1.383],
+ [-3.0233, 1.385],
+ [-4.4073, 1.385],
+ [-4.4073, -1.22435],
+ ]
+
+ const result = unionPolygons([lowerTip, upperTip, connectingBand])
+
+ expect(result).toHaveLength(2)
+ expect(result.some(hasRepeatedNonAdjacentPoint)).toBe(false)
+ })
+
+ test('keeps point-touching branches separate in every orientation and input order', () => {
+ const polygons: Point2D[][] = [
+ [
+ [-4.4073, -1.383],
+ [-4.2663, -1.383],
+ [-4.4073, -1.22435],
+ ],
+ [
+ [-2.1038, 0.35056],
+ [-0.9401, 1.385],
+ [-3.0233, 1.385],
+ ],
+ [
+ [-4.2663, -1.383],
+ [-3.0233, 1.385],
+ [-4.4073, 1.385],
+ [-4.4073, -1.22435],
+ ],
+ ]
+ const orders = [
+ [0, 1, 2],
+ [0, 2, 1],
+ [1, 0, 2],
+ [1, 2, 0],
+ [2, 0, 1],
+ [2, 1, 0],
+ ]
+ const expectedArea = polygons.reduce((sum, polygon) => sum + polygonArea(polygon), 0)
+
+ for (const order of orders) {
+ for (let quarterTurns = 0; quarterTurns < 4; quarterTurns++) {
+ for (const reflection of [-1, 1]) {
+ const transformed = order.map((index) =>
+ polygons[index]!.map(([sourceX, sourceZ]): Point2D => {
+ let x = sourceX * reflection
+ let z = sourceZ
+ for (let turn = 0; turn < quarterTurns; turn++) {
+ const previousX = x
+ x = -z
+ z = previousX
+ }
+ return [x, z]
+ }),
+ )
+
+ const result = unionPolygons(transformed)
+
+ expect(result).toHaveLength(2)
+ expect(result.some(hasRepeatedNonAdjacentPoint)).toBe(false)
+ expect(result.reduce((sum, polygon) => sum + polygonArea(polygon), 0)).toBeCloseTo(
+ expectedArea,
+ )
+ }
+ }
+ }
+ })
})
describe('subtractPolygonsFromPolygon', () => {
diff --git a/packages/core/src/lib/polygon-union.ts b/packages/core/src/lib/polygon-union.ts
index ab2ae67755..3889c6ef56 100644
--- a/packages/core/src/lib/polygon-union.ts
+++ b/packages/core/src/lib/polygon-union.ts
@@ -261,6 +261,26 @@ function assembleRings(segments: Segment[]) {
const rings: Point2D[][] = []
+ const nextBoundarySegment = (ring: Point2D[], candidates: Segment[]) => {
+ const previous = ring[ring.length - 2]!
+ const current = ring[ring.length - 1]!
+ const incomingX = current[0] - previous[0]
+ const incomingZ = current[1] - previous[1]
+ const reverseIncomingAngle = Math.atan2(-incomingZ, -incomingX)
+ const clockwiseTurn = (segment: Segment) => {
+ const outgoingAngle = Math.atan2(segment.end[1] - current[1], segment.end[0] - current[0])
+ const turn = reverseIncomingAngle - outgoingAngle
+ return ((turn % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
+ }
+
+ return [...candidates].sort((left, right) => {
+ return (
+ clockwiseTurn(left) - clockwiseTurn(right) ||
+ pointKey(left.end).localeCompare(pointKey(right.end))
+ )
+ })[0]
+ }
+
for (const firstSegment of segments) {
if (firstSegment.used) continue
@@ -270,7 +290,8 @@ function assembleRings(segments: Segment[]) {
let currentKey = pointKey(firstSegment.end)
while (currentKey !== startKey) {
- const next = byStart.get(currentKey)?.find((segment) => !segment.used)
+ const candidates = byStart.get(currentKey)?.filter((segment) => !segment.used) ?? []
+ const next = nextBoundarySegment(ring, candidates)
if (!next) break
next.used = true
diff --git a/packages/core/src/lib/roof-overlap.test.ts b/packages/core/src/lib/roof-overlap.test.ts
index 8fc9a814c4..254ffc7114 100644
--- a/packages/core/src/lib/roof-overlap.test.ts
+++ b/packages/core/src/lib/roof-overlap.test.ts
@@ -1,6 +1,11 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not depend on @types/bun.
import { describe, expect, test } from 'bun:test'
-import { getRoofPlanBounds, roofOverlapEntryOwns, roofPlanBoundsOverlap } from './roof-overlap'
+import {
+ getRoofPlanBounds,
+ roofOverlapEntryOwns,
+ roofPlanBoundsOverlap,
+ roofPlanOverlapEntryOwns,
+} from './roof-overlap'
describe('roof overlap', () => {
test('larger segments own intersections with stable ID tie-breaking', () => {
@@ -12,6 +17,50 @@ describe('roof overlap', () => {
expect(roofOverlapEntryOwns({ ...current, width: 3 }, current)).toBe(false)
})
+ test('a declared host roof clips its mounted conical roof', () => {
+ const host = {
+ roofId: 'roof_host',
+ segmentId: 'seg_host',
+ roofType: 'gable',
+ width: 10,
+ depth: 8,
+ }
+ const conical = {
+ roofId: 'roof_tower',
+ segmentId: 'seg_tower',
+ roofType: 'conical',
+ width: 3,
+ depth: 3,
+ supportRoofId: host.roofId,
+ supportRoofSegmentId: host.segmentId,
+ }
+
+ expect(roofOverlapEntryOwns(conical, host)).toBe(false)
+ expect(roofOverlapEntryOwns(host, conical)).toBe(true)
+ expect(roofPlanOverlapEntryOwns(conical, host)).toBe(true)
+ expect(roofPlanOverlapEntryOwns(host, conical)).toBe(false)
+ })
+
+ test('a ground conical roof does not automatically cut a larger roof', () => {
+ const host = {
+ roofId: 'roof_host',
+ segmentId: 'seg_host',
+ roofType: 'gable',
+ width: 10,
+ depth: 8,
+ }
+ const groundConical = {
+ roofId: 'roof_tower',
+ segmentId: 'seg_tower',
+ roofType: 'conical',
+ width: 3,
+ depth: 3,
+ }
+
+ expect(roofOverlapEntryOwns(groundConical, host)).toBe(false)
+ expect(roofOverlapEntryOwns(host, groundConical)).toBe(true)
+ })
+
test('computes rotated world bounds and rejects distant roofs', () => {
const bounds = getRoofPlanBounds({
position: [10, 0, 4],
diff --git a/packages/core/src/lib/roof-overlap.ts b/packages/core/src/lib/roof-overlap.ts
index aaec60551e..acfb27d90c 100644
--- a/packages/core/src/lib/roof-overlap.ts
+++ b/packages/core/src/lib/roof-overlap.ts
@@ -1,6 +1,9 @@
export type RoofOverlapEntry = {
roofId: string
segmentId: string
+ supportRoofId?: string
+ supportRoofSegmentId?: string
+ roofType?: string
width: number
depth: number
}
@@ -35,6 +38,15 @@ export function roofOverlapEntryOwns(
current: RoofOverlapEntry,
epsilon = 1e-6,
): boolean {
+ const candidateIsMountedOnCurrent =
+ candidate.supportRoofId === current.roofId ||
+ candidate.supportRoofSegmentId === current.segmentId
+ if (candidateIsMountedOnCurrent) return false
+
+ const currentIsMountedOnCandidate =
+ current.supportRoofId === candidate.roofId ||
+ current.supportRoofSegmentId === candidate.segmentId
+ if (currentIsMountedOnCandidate) return true
const candidateArea = candidate.width * candidate.depth
const currentArea = current.width * current.depth
return (
@@ -44,6 +56,24 @@ export function roofOverlapEntryOwns(
)
}
+export function roofPlanOverlapEntryOwns(
+ candidate: RoofOverlapEntry,
+ current: RoofOverlapEntry,
+ epsilon = 1e-6,
+): boolean {
+ const candidateIsMountedOnCurrent =
+ candidate.supportRoofId === current.roofId ||
+ candidate.supportRoofSegmentId === current.segmentId
+ if (candidateIsMountedOnCurrent) return true
+
+ const currentIsMountedOnCandidate =
+ current.supportRoofId === candidate.roofId ||
+ current.supportRoofSegmentId === candidate.segmentId
+ if (currentIsMountedOnCandidate) return false
+
+ return roofOverlapEntryOwns(candidate, current, epsilon)
+}
+
export function getRoofPlanBounds(roof: RoofPlan): RoofPlanBounds | null {
if (roof.segments.length === 0) return null
const roofRotation = roof.rotation ?? 0
diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts
index ef89df43e4..d3bdd555e2 100644
--- a/packages/core/src/lib/slab-polygon.test.ts
+++ b/packages/core/src/lib/slab-polygon.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test'
import { SlabNode, WallNode } from '../schema'
import { pointInPolygon } from './polygon-relations'
-import { getRenderableSlabPolygon, snapSlabEdgeToWallBand } from './slab-polygon'
+import {
+ getRenderableSlabPolygon,
+ prepareSlabPolygonContext,
+ scopeSlabPolygonContext,
+ snapSlabEdgeToWallBand,
+} from './slab-polygon'
function wallOf(start: [number, number], end: [number, number], thickness = 0.1) {
return WallNode.parse({ start, end, thickness })
@@ -908,3 +913,36 @@ describe('snapSlabEdgeToWallBand', () => {
expect(snap!.edge[0][1]).toBeCloseTo(0)
})
})
+
+test('prepared bounds preserve unfiltered polygons across curves, long bands and floating seams', () => {
+ for (const angle of [0, 0.3, 1.2]) {
+ const rotate = ([x, z]: [number, number]): [number, number] => [
+ x * Math.cos(angle) - z * Math.sin(angle),
+ x * Math.sin(angle) + z * Math.cos(angle),
+ ]
+ for (const thickness of [0.1, 0.4, 2]) {
+ for (const curveOffset of [0, 0.2, -0.3]) {
+ const walls = [
+ WallNode.parse({ start: rotate([-10, 0]), end: rotate([10, 0]), thickness, curveOffset }),
+ WallNode.parse({ start: rotate([4, 0]), end: rotate([4, 6]), thickness }),
+ WallNode.parse({ start: [100, 100], end: [110, 100], thickness: 4 }),
+ ]
+ const slabs = [
+ slabOf(roomA.map(rotate)),
+ slabOf(roomB.map(rotate), false, 0.4, 0.4),
+ slabOf(roomB.map(rotate), false, 2),
+ ]
+ const prepared = prepareSlabPolygonContext({ walls, siblingSlabs: slabs })
+ for (const slab of slabs) {
+ const full = getRenderableSlabPolygon(slab, {
+ walls,
+ siblingSlabs: slabs.filter((other) => other.id !== slab.id),
+ })
+ expect(getRenderableSlabPolygon(slab, scopeSlabPolygonContext(slab, prepared))).toEqual(
+ full,
+ )
+ }
+ }
+ }
+ }
+})
diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts
index e8fded0d15..3aa8ffc894 100644
--- a/packages/core/src/lib/slab-polygon.ts
+++ b/packages/core/src/lib/slab-polygon.ts
@@ -1,5 +1,5 @@
import type { GeometryContext } from '../registry/types'
-import type { AnyNodeId, SlabNode, WallNode } from '../schema'
+import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../schema'
import { isCurvedWall, sampleWallCenterline } from '../systems/wall/wall-curve'
import { getWallThickness } from '../systems/wall/wall-footprint'
@@ -115,7 +115,9 @@ type Segment = [number, number, number, number]
/** A sibling slab edge and the direction of its polygon interior. */
type NeighborSegment = {
+ slab: SlabNode
segment: Segment
+ bounds: Bounds
elevation: number
/** Unit normal pointing into the sibling polygon at this edge. */
inwardX: number
@@ -141,27 +143,34 @@ export function slabPolygonContextFromGeometry(
): SlabPolygonContext {
if (!ctx) return { walls: [], siblingSlabs: [] }
- const siblingSlabs = ctx.siblings.filter(
- (node): node is SlabNode => node.type === 'slab',
- ) as SlabNode[]
+ return {
+ ...slabPolygonContextForLevel(ctx.parent, ctx.resolve),
+ siblingSlabs: ctx.siblings.filter((node): node is SlabNode => node.type === 'slab'),
+ }
+}
+export function slabPolygonContextForLevel(
+ parent: AnyNode | null,
+ resolve: (id: AnyNodeId) => AnyNode | undefined,
+ fallbackSlabs: SlabNode[] = [],
+): SlabPolygonContext {
const walls: WallNode[] = []
- const parentChildIds = (ctx.parent as { children?: AnyNodeId[] } | null)?.children
- if (Array.isArray(parentChildIds)) {
- for (const childId of parentChildIds) {
- const child = ctx.resolve(childId)
- if ((child as { type?: string } | undefined)?.type === 'wall') {
- walls.push(child as WallNode)
- }
- }
+ const siblingSlabs: SlabNode[] = []
+ const childIds = (parent as { children?: AnyNodeId[] } | null)?.children
+ if (!Array.isArray(childIds)) {
+ return { walls, siblingSlabs: parent ? fallbackSlabs : [] }
+ }
+ for (const id of childIds) {
+ const child = resolve(id)
+ if (child?.type === 'wall') walls.push(child)
+ else if (child?.type === 'slab') siblingSlabs.push(child)
}
-
return { walls, siblingSlabs }
}
export function getRenderableSlabPolygon(
slabNode: SlabNode,
- context: SlabPolygonContext,
+ context: SlabPolygonContext | PreparedSlabPolygonContext,
): Array<[number, number]> {
const polygon = slabNode.polygon
if (polygon.length < 3 || isFloatingSlab(slabNode)) {
@@ -171,7 +180,8 @@ export function getRenderableSlabPolygon(
const subSpans = computeEdgeSubSpans(
polygon,
slabNode.elevation ?? DEFAULT_SLAB_ELEVATION,
- context,
+ 'wallCandidates' in context ? context : prepareSlabPolygonContext(context),
+ slabNode.id,
)
if (subSpans.every((spans) => spans.length === 1 && spans[0]!.offset === 0)) {
return polygon.map(([x, z]) => [x, z] as [number, number])
@@ -375,23 +385,107 @@ type EdgeSubSpan = {
key: string
}
-/**
- * Split every polygon edge at candidate span boundaries and classify
- * each sub-span independently. Returns one non-empty span list per
- * edge, covering [0, edgeLength] without gaps.
- */
-function computeEdgeSubSpans(
- polygon: Array<[number, number]>,
- selfElevation: number,
- context: SlabPolygonContext,
-): EdgeSubSpan[][] {
- const n = polygon.length
+type Bounds = [number, number, number, number]
- // Winding sign: the outward normal of an edge with direction `dir` is
- // `s * (dirZ, -dirX)`, so a target lateral `L` measured along
- // `(dirZ, -dirX)` is `s * L` along the outward normal.
- const s = polygonWindingSign(polygon)
+function polygonBounds(polygon: Array<[number, number]>): Bounds {
+ const bounds: Bounds = [Infinity, Infinity, -Infinity, -Infinity]
+ for (const [x, z] of polygon) {
+ bounds[0] = Math.min(bounds[0], x)
+ bounds[1] = Math.min(bounds[1], z)
+ bounds[2] = Math.max(bounds[2], x)
+ bounds[3] = Math.max(bounds[3], z)
+ }
+ return bounds
+}
+
+function boundsOverlap(a: Bounds, b: Bounds, padding: number): boolean {
+ return (
+ a[0] <= b[2] + padding &&
+ a[2] >= b[0] - padding &&
+ a[1] <= b[3] + padding &&
+ a[3] >= b[1] - padding
+ )
+}
+
+export function scopeSlabPolygonContext(
+ slab: SlabNode,
+ context: PreparedSlabPolygonContext,
+): PreparedSlabPolygonContext {
+ const bounds = polygonBounds(slab.polygon)
+ const wallCandidates = context.wallCandidates.filter((candidate) =>
+ boundsOverlap(bounds, candidate.bounds, candidate.halfThickness + WALL_ADOPTION_TOLERANCE),
+ )
+ // Keep the level-wide tolerance: even an unadopted wall can widen sibling
+ // breakpoint collection, which may affect short-span fusion.
+ const neighborSegments = context.neighborSegments.filter(
+ (neighbor) =>
+ neighbor.slab.id !== slab.id &&
+ boundsOverlap(bounds, neighbor.bounds, context.siblingBreakTolerance),
+ )
+ return {
+ ...context,
+ walls: wallCandidates.map((candidate) => candidate.wall),
+ siblingSlabs: [...new Set(neighborSegments.map((neighbor) => neighbor.slab))],
+ wallCandidates,
+ neighborSegments,
+ siblingBreakTolerance: context.siblingBreakTolerance,
+ }
+}
+
+export function slabPolygonContextChanges(
+ before: PreparedSlabPolygonContext,
+ after: PreparedSlabPolygonContext,
+): (slab: SlabNode) => boolean {
+ if (before.siblingBreakTolerance !== after.siblingBreakTolerance) return () => true
+ const changedWalls = new Set()
+ const changedSlabs = new Set()
+ for (let i = 0; i < Math.max(before.walls.length, after.walls.length); i++) {
+ if (before.walls[i] === after.walls[i]) continue
+ if (before.walls[i]) changedWalls.add(before.walls[i]!)
+ if (after.walls[i]) changedWalls.add(after.walls[i]!)
+ }
+ for (let i = 0; i < Math.max(before.siblingSlabs.length, after.siblingSlabs.length); i++) {
+ if (before.siblingSlabs[i] === after.siblingSlabs[i]) continue
+ if (before.siblingSlabs[i]) changedSlabs.add(before.siblingSlabs[i]!)
+ if (after.siblingSlabs[i]) changedSlabs.add(after.siblingSlabs[i]!)
+ }
+ const walls = [...before.wallCandidates, ...after.wallCandidates].filter((candidate) =>
+ changedWalls.has(candidate.wall),
+ )
+ const neighbors = [...before.neighborSegments, ...after.neighborSegments].filter((neighbor) =>
+ changedSlabs.has(neighbor.slab),
+ )
+ return (slab) => {
+ const bounds = polygonBounds(slab.polygon)
+ return (
+ walls.some((candidate) =>
+ boundsOverlap(bounds, candidate.bounds, candidate.halfThickness + WALL_ADOPTION_TOLERANCE),
+ ) ||
+ neighbors.some(
+ (neighbor) =>
+ neighbor.slab.id !== slab.id &&
+ boundsOverlap(bounds, neighbor.bounds, after.siblingBreakTolerance),
+ )
+ )
+ }
+}
+
+type PreparedWallCandidate = WallCandidate & { bounds: Bounds }
+type PreparedSlabPolygonContext = SlabPolygonContext & {
+ wallCache: WeakMap
+ neighborCache: WeakMap
+ wallCandidates: PreparedWallCandidate[]
+ neighborSegments: NeighborSegment[]
+ siblingBreakTolerance: number
+}
+
+export function prepareSlabPolygonContext(
+ context: SlabPolygonContext,
+ previous?: PreparedSlabPolygonContext,
+): PreparedSlabPolygonContext {
+ const wallCache = previous?.wallCache ?? new WeakMap()
+ const neighborCache = previous?.neighborCache ?? new WeakMap()
const neighborSegments: NeighborSegment[] = []
for (const sibling of context.siblingSlabs) {
// A floating deck keeps its drawn polygon, so it can't be a seam
@@ -399,6 +493,12 @@ function computeEdgeSubSpans(
// deck's stays put (asymmetric seam), and the higher/lower band rules
// only describe room floors meeting under a wall.
if (isFloatingSlab(sibling)) continue
+ const cached = neighborCache.get(sibling)
+ if (cached) {
+ neighborSegments.push(...cached)
+ continue
+ }
+ const segments: NeighborSegment[] = []
const siblingPolygon = sibling.polygon
if (siblingPolygon.length < 2) continue
const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION
@@ -410,20 +510,37 @@ function computeEdgeSubSpans(
const dz = to[1] - from[1]
const length = Math.hypot(dx, dz)
if (length < 1e-9) continue
- neighborSegments.push({
+ segments.push({
+ slab: sibling,
+ bounds: polygonBounds([from, to]),
segment: [from[0], from[1], to[0], to[1]],
elevation,
inwardX: (-siblingWinding * dz) / length,
inwardZ: (siblingWinding * dx) / length,
})
}
+ neighborCache.set(sibling, segments)
+ neighborSegments.push(...segments)
}
- const wallCandidates: WallCandidate[] = context.walls.map((wall) => ({
- wall,
- segments: wallCenterlineSegments(wall),
- halfThickness: getWallThickness(wall) / 2,
- }))
+ const wallCandidates: PreparedWallCandidate[] = context.walls.map((wall) => {
+ const cached = wallCache.get(wall)
+ if (cached) return cached
+ const segments = wallCenterlineSegments(wall)
+ const candidate: PreparedWallCandidate = {
+ wall,
+ segments,
+ halfThickness: getWallThickness(wall) / 2,
+ bounds: polygonBounds(
+ segments.flatMap(([ax, az, bx, bz]) => [
+ [ax, az],
+ [bx, bz],
+ ]),
+ ),
+ }
+ wallCache.set(wall, candidate)
+ return candidate
+ })
// Sibling breakpoints also matter for legacy face-aligned polygons up
// to a full wall band away from the edge (the band-sibling interior
@@ -438,6 +555,37 @@ function computeEdgeSubSpans(
)
}
+ return {
+ ...context,
+ wallCache,
+ neighborCache,
+ wallCandidates,
+ neighborSegments,
+ siblingBreakTolerance,
+ }
+}
+
+/**
+ * Split every polygon edge at candidate span boundaries and classify
+ * each sub-span independently. Returns one non-empty span list per
+ * edge, covering [0, edgeLength] without gaps.
+ */
+function computeEdgeSubSpans(
+ polygon: Array<[number, number]>,
+ selfElevation: number,
+ context: PreparedSlabPolygonContext,
+ slabId: SlabNode['id'],
+): EdgeSubSpan[][] {
+ const n = polygon.length
+
+ // Winding sign: the outward normal of an edge with direction `dir` is
+ // `s * (dirZ, -dirX)`, so a target lateral `L` measured along
+ // `(dirZ, -dirX)` is `s * L` along the outward normal.
+ const s = polygonWindingSign(polygon)
+
+ const { wallCandidates, siblingBreakTolerance } = context
+ const neighborSegments = context.neighborSegments.filter((segment) => segment.slab.id !== slabId)
+
const subSpans: EdgeSubSpan[][] = []
for (let index = 0; index < n; index += 1) {
const a = polygon[index]!
diff --git a/packages/core/src/lib/space-detection-reconciliation.test.ts b/packages/core/src/lib/space-detection-reconciliation.test.ts
new file mode 100644
index 0000000000..f591b20c86
--- /dev/null
+++ b/packages/core/src/lib/space-detection-reconciliation.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, test } from 'bun:test'
+import { CeilingNode, SlabNode, WallNode } from '../schema'
+import {
+ detectSpacesForLevel,
+ planAutoCeilingsForLevel,
+ planAutoSlabsForLevel,
+ surfaceTouchesRooms,
+} from './space-detection'
+
+type Tuple = [number, number]
+
+function bounded(polygon: Tuple[]) {
+ return {
+ polygon: polygon.map(([x, y]) => ({ x, y })),
+ bbox: {
+ minX: Math.min(...polygon.map(([x]) => x)),
+ minY: Math.min(...polygon.map(([, y]) => y)),
+ maxX: Math.max(...polygon.map(([x]) => x)),
+ maxY: Math.max(...polygon.map(([, y]) => y)),
+ },
+ }
+}
+
+function rectangle(x: number, y: number, width: number, height: number): Tuple[] {
+ return [
+ [x, y],
+ [x + width, y],
+ [x + width, y + height],
+ [x, y + height],
+ ]
+}
+
+describe('surfaceTouchesRooms bounding-box rejection', () => {
+ const surface = bounded(rectangle(0, 0, 4, 4))
+ const cases: Array<[string, Tuple[], boolean]> = [
+ ['disjoint right', rectangle(5, 0, 4, 4), false],
+ ['disjoint left', rectangle(-5, 0, 4, 4), false],
+ ['disjoint above', rectangle(0, 5, 4, 4), false],
+ ['disjoint below', rectangle(0, -5, 4, 4), false],
+ ['touching edge', rectangle(4, 0, 4, 4), false],
+ ['touching corner', rectangle(4, 4, 4, 4), false],
+ ['overlapping', rectangle(2, 2, 4, 4), true],
+ ['contained room', rectangle(1, 1, 2, 2), true],
+ ['contained surface', rectangle(-1, -1, 6, 6), true],
+ ['identical', rectangle(0, 0, 4, 4), true],
+ [
+ 'overlapping bounds without polygon coverage',
+ [
+ [3, 6],
+ [6, 3],
+ [6, 6],
+ ],
+ false,
+ ],
+ ]
+
+ for (const [name, polygon, expected] of cases) {
+ test(name, () => {
+ const room = bounded(polygon)
+ expect(surfaceTouchesRooms(surface, [room])).toBe(expected)
+ expect(surfaceTouchesRooms(room, [surface])).toBe(expected)
+ })
+ }
+
+ test('disjoint bounds skip coverage and touching bounds retain coverage', () => {
+ for (const x of [4, 5]) {
+ const room = bounded(rectangle(x, 0, 4, 4))
+ let reads = 0
+ const measuredRoom = {
+ bbox: room.bbox,
+ get polygon() {
+ reads += 1
+ return room.polygon
+ },
+ }
+ expect(surfaceTouchesRooms(surface, [measuredRoom])).toBe(false)
+ expect(reads > 0).toBe(x === 4)
+ }
+ })
+
+ test('continues past disjoint rooms and handles no rooms', () => {
+ expect(surfaceTouchesRooms(surface, [])).toBe(false)
+ expect(surfaceTouchesRooms(surface, [bounded(rectangle(5, 5, 1, 1)), surface])).toBe(true)
+ })
+})
+
+describe('auto surface planner ring preservation', () => {
+ const vertices = rectangle(0, 0, 4, 3)
+ const walls = vertices.map((start, index) =>
+ WallNode.parse({ start, end: vertices[(index + 1) % vertices.length] }),
+ )
+ const { roomPolygons } = detectSpacesForLevel('level_rotation', walls)
+ const ring = roomPolygons[0]!.map(({ x, y }): Tuple => [x, y])
+
+ for (const kind of ['slab', 'ceiling'] as const) {
+ function plan(polygon: Tuple[]) {
+ if (kind === 'slab') {
+ const surface = SlabNode.parse({ polygon, autoFromWalls: true })
+ return { surface, result: planAutoSlabsForLevel(roomPolygons, [surface]) }
+ }
+ const surface = CeilingNode.parse({ polygon, autoFromWalls: true })
+ return { surface, result: planAutoCeilingsForLevel(roomPolygons, [surface]) }
+ }
+
+ test(`${kind}: every exact start rotation preserves the existing polygon`, () => {
+ for (let offset = 0; offset < ring.length; offset += 1) {
+ const polygon = [...ring.slice(offset), ...ring.slice(0, offset)]
+ const { surface, result } = plan(polygon)
+ const existingPolygon = surface.polygon
+ expect(result.update).toEqual([])
+ expect(result.create).toEqual([])
+ expect(result.delete).toEqual([])
+ expect(surface.polygon).toBe(existingPolygon)
+ expect(surface.polygon).toEqual(polygon)
+ }
+ })
+
+ test(`${kind}: changed geometry still updates even within signature rounding`, () => {
+ for (const delta of [0.2, 0.00001]) {
+ const polygon = ring.map(([x, y]): Tuple => [x, y])
+ polygon[0]![0] += delta
+ const { surface, result } = plan(polygon)
+ expect(result.update).toEqual([{ id: surface.id, data: { polygon: ring } }])
+ expect(result.create).toEqual([])
+ expect(result.delete).toEqual([])
+ }
+ })
+
+ test(`${kind}: reversed winding still updates`, () => {
+ const { surface, result } = plan([...ring].reverse())
+ expect(result.update).toEqual([{ id: surface.id, data: { polygon: ring } }])
+ expect(result.create).toEqual([])
+ expect(result.delete).toEqual([])
+ })
+ }
+})
diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts
index 3594b0c6cc..6a3bbc9751 100644
--- a/packages/core/src/lib/space-detection.ts
+++ b/packages/core/src/lib/space-detection.ts
@@ -281,10 +281,14 @@ function bboxOverlapArea(a: ReturnType, b: ReturnType,
+) {
if (subject.length < 3 || covers.length === 0) return 0
- const bbox = bboxOf(subject)
+ const bbox = subjectBounds ?? bboxOf(subject)
const width = bbox.maxX - bbox.minX
const height = bbox.maxY - bbox.minY
@@ -898,6 +902,18 @@ function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number,
)
}
+function sameTuplePolygonRotation(current: Array<[number, number]>, next: Array<[number, number]>) {
+ if (sameTuplePolygon(current, next)) return true
+ if (current.length !== next.length) return false
+ // Preserve the stored ring when extraction only changes its starting vertex.
+ return next.some((_, offset) =>
+ current.every((point, index) => {
+ const candidate = next[(index + offset) % next.length]!
+ return point[0] === candidate[0] && point[1] === candidate[1]
+ }),
+ )
+}
+
function sameTuplePolygons(
current: Array>,
next: Array>,
@@ -1334,13 +1350,27 @@ function buildSpace(levelId: string, room: ExtractedRoom): Space {
type RoomSurface = SlabNodeType | CeilingNodeType
-function surfaceTouchesRooms(surface: RoomSurface, rooms: ExtractedRoom[]) {
- const polygon = surface.polygon.map(pointFromTuple)
- return rooms.some(
- (room) =>
- polygonCoverageRatio(polygon, [room.polygon]) > 0 ||
- polygonCoverageRatio(room.polygon, [polygon]) > 0,
- )
+type BoundedPolygon = {
+ polygon: Point2D[]
+ bbox: ReturnType
+}
+
+export function surfaceTouchesRooms(surface: BoundedPolygon, rooms: BoundedPolygon[]) {
+ const { polygon, bbox } = surface
+ return rooms.some((room) => {
+ if (
+ bbox.maxX < room.bbox.minX ||
+ room.bbox.maxX < bbox.minX ||
+ bbox.maxY < room.bbox.minY ||
+ room.bbox.maxY < bbox.minY
+ ) {
+ return false
+ }
+ return (
+ polygonCoverageRatio(polygon, [room.polygon], bbox) > 0 ||
+ polygonCoverageRatio(room.polygon, [polygon], room.bbox) > 0
+ )
+ })
}
function roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) {
@@ -1772,7 +1802,7 @@ export function planAutoSlabsForLevel(
holeMetadata: [],
})
const data: Partial = {}
- if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon
+ if (!sameTuplePolygonRotation(slab.polygon, update.polygon)) data.polygon = update.polygon
if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes
if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) {
data.holeMetadata = openings.holeMetadata
@@ -1950,7 +1980,8 @@ export function planAutoCeilingsForLevel(
holeMetadata: [],
})
const data: Partial = {}
- if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon
+ if (!sameTuplePolygonRotation(ceiling.polygon, update.polygon))
+ data.polygon = update.polygon
if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes
if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) {
data.holeMetadata = openings.holeMetadata
@@ -2247,11 +2278,14 @@ function runIndexedSpaceDetection(
)
}
- const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms]
+ const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms].map((room) => ({
+ polygon: room.polygon,
+ bbox: bboxOf(room.polygon),
+ }))
if (scopedRooms.length > 0) {
- const unaffectedRooms = topologyDelta.allCurrentRooms.filter(
- (room) => !topologyDelta.currentRooms.includes(room),
- )
+ const unaffectedRooms = topologyDelta.allCurrentRooms
+ .filter((room) => !topologyDelta.currentRooms.includes(room))
+ .map((room) => ({ polygon: room.polygon, bbox: bboxOf(room.polygon) }))
const currentChildren = levelChildren(nodes, levelId)
const allSlabs: SlabNodeType[] = currentChildren
.filter((node: any): node is SlabNodeType => node.type === 'slab')
@@ -2259,16 +2293,16 @@ function runIndexedSpaceDetection(
const allCeilings: CeilingNodeType[] = currentChildren
.filter((node: any): node is CeilingNodeType => node.type === 'ceiling')
.map((ceiling: CeilingNodeType) => CeilingNode.parse(ceiling))
- const slabs = allSlabs.filter(
- (slab) =>
- surfaceTouchesRooms(slab, scopedRooms) &&
- (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)),
- )
- const ceilings = allCeilings.filter(
- (ceiling) =>
- surfaceTouchesRooms(ceiling, scopedRooms) &&
- (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)),
- )
+ const touchesScopedRooms = (surface: RoomSurface) => {
+ const polygon = surface.polygon.map(pointFromTuple)
+ const bounded = { polygon, bbox: bboxOf(polygon) }
+ return (
+ surfaceTouchesRooms(bounded, scopedRooms) &&
+ (!surface.autoFromWalls || !surfaceTouchesRooms(bounded, unaffectedRooms))
+ )
+ }
+ const slabs = allSlabs.filter(touchesScopedRooms)
+ const ceilings = allCeilings.filter(touchesScopedRooms)
const slabRooms = roomsEligibleForAutoSurface(
topologyDelta.beforeRooms,
topologyDelta.currentRooms,
diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts
index 434adf4aa3..5ed6e6faee 100644
--- a/packages/core/src/registry/handles.ts
+++ b/packages/core/src/registry/handles.ts
@@ -70,10 +70,17 @@ export type EditorApi = {
export type HandlePortal = 'self' | 'parent' | 'grandparent'
+export type HandlePortalTarget = (node: N, sceneApi: SceneApi) => AnyNodeId | null | undefined
+
export type HandleAxis = 'x' | 'y' | 'z'
export type HandleAnchor = 'center' | 'min' | 'max'
+/** Keyboard modifiers captured for a handle-resize tick. */
+export type HandleDragModifiers = {
+ readonly altKey: boolean
+}
+
/** 3D position + rotation of the arrow in its portal target's local space. */
export type HandlePlacement = {
/**
@@ -128,7 +135,12 @@ export type LinearResizeHandle = {
axis: HandleAxis
anchor: HandleAnchor
currentValue: (node: N) => number
- apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial
+ apply: (
+ node: N,
+ newValue: number,
+ sceneApi: SceneApi,
+ modifiers?: HandleDragModifiers,
+ ) => Partial
/**
* Additional live-only patches for geometry owned by related nodes. The
* editor publishes these during the drag and clears them on release or
@@ -139,6 +151,7 @@ export type LinearResizeHandle = {
node: N,
newValue: number,
sceneApi: SceneApi,
+ modifiers?: HandleDragModifiers,
) => ReadonlyArray]>
/** Optional live-scene visibility gate for context-dependent arrows. */
visible?: (node: N, sceneApi: SceneApi) => boolean
@@ -149,7 +162,7 @@ export type LinearResizeHandle = {
* final write here to fan the resize out to siblings / parents while keeping
* the handle UI generic.
*/
- commit?: (node: N, patch: Partial, sceneApi: SceneApi) => void
+ commit?: (node: N, patch: Partial, sceneApi: SceneApi, modifiers?: HandleDragModifiers) => void
/**
* Optional per-tick hook fired while this handle is being dragged, with the
* live (in-progress, override-merged) node. A pure side-channel for transient
@@ -182,6 +195,13 @@ export type LinearResizeHandle = {
gridSnap?: boolean
/** Kind-owned magnetic snap for the resized scalar, gated by the active snapping mode. */
magneticSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number
+ /**
+ * Kind-owned structural connection snap. Unlike alignment snapping, this is
+ * active in every snapping mode and is bypassed only by the held Alt force
+ * modifier. Use it when the snapped result changes connectivity, such as two
+ * lean-to roof edges becoming one continuous run.
+ */
+ connectionSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number
placement: HandlePlacement
/**
* Dimension this handle steers (e.g. `'height'`). When set, the editor
@@ -198,6 +218,7 @@ export type LinearResizeHandle = {
* need to ride the wall's rotation.
*/
portal?: HandlePortal
+ portalTarget?: HandlePortalTarget
cursor?: Cursor
/** Optional visual guide shown while the arrow is hovered or dragging. */
decoration?: HandleDecoration
@@ -260,6 +281,7 @@ export type RadialResizeHandle = {
max?: number | ((node: N, sceneApi: SceneApi) => number)
placement: HandlePlacement
portal?: HandlePortal
+ portalTarget?: HandlePortalTarget
/** Optional visual guide shown while the arrow is hovered or dragging. */
decoration?: HandleDecoration
}
@@ -287,8 +309,10 @@ export type ArcResizeHandle = {
/** Optional metadata for descriptors that bundle two handles per kind. */
end?: 'start' | 'end'
apply: (initialNode: N, delta: number, sceneApi: SceneApi) => Partial
+ visible?: (node: N, sceneApi: SceneApi) => boolean
placement: HandlePlacement
portal?: HandlePortal
+ portalTarget?: HandlePortalTarget
/** Optional visual guide shown while the arrow is hovered or dragging. */
decoration?: HandleDecoration
/**
@@ -334,6 +358,7 @@ export type EndpointMoveHandle