diff --git a/.dockerignore b/.dockerignore index c594dc8cf..712b3bb3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,6 @@ node_modules -packages/*/node_modules -packages/electron/out -packages/electron/resources/node -packages/electron/resources/icons -packages/electron/resources/server -packages/electron/.vite -dist .git -.pi -openspec +qa +packages/electron +.DS_Store +*.log diff --git a/.github/release-notes-footer.md b/.github/release-notes-footer.md deleted file mode 100644 index b10886fbe..000000000 --- a/.github/release-notes-footer.md +++ /dev/null @@ -1,61 +0,0 @@ ---- - -### First-launch unblocking (unsigned binaries) - -The Windows installers and macOS DMGs are not yet code-signed / notarized. -Both OSes will block first-launch with a security warning. These are not -malware — the artifacts are the exact ones produced by -[`.github/workflows/publish.yml`](https://github.com/BlackBeltTechnology/pi-agent-dashboard/blob/main/.github/workflows/publish.yml) -against this tag. Pick whichever workaround fits your workflow. - -**Tracking:** Authenticode signing → change `windows-authenticode-signing`; -macOS notarization → change `macos-notarization` (planned). This section -will shrink and eventually disappear as each lands. - -#### Windows — SmartScreen warning - -SmartScreen will show **"Windows protected your PC"** the first time you -run any `.exe` artifact (Setup, portable, or any `.exe` extracted from a -ZIP). - -**Option A — at the SmartScreen dialog:** - -1. Click **More info**. -2. Click **Run anyway**. - -**Option B — pre-clear the Mark-of-the-Web:** - -1. Right-click the downloaded `.exe` (or the `.zip`) → **Properties**. -2. At the bottom of the **General** tab, tick **Unblock** next to - *"This file came from another computer..."*. -3. Click **OK** and run as normal. - -For ZIP archives, **unblock the archive itself before extracting** so -the contained `.exe`s inherit the cleared zone. - -#### macOS — Gatekeeper / quarantine - -macOS will refuse to launch the app on first run with **"PI Dashboard -cannot be opened because the developer cannot be verified"** or silently -quarantine it. - -**Option A — control-click the app:** - -1. Open the DMG and drag **PI Dashboard** to **Applications**. -2. In **Applications**, **right-click (or Control-click) PI Dashboard → - Open**. -3. Click **Open** in the confirmation dialog. Subsequent launches are - unrestricted. - -**Option B — clear the quarantine attribute from the terminal:** - -```bash -xattr -d com.apple.quarantine "/Applications/PI Dashboard.app" -``` - -If the DMG itself is being blocked, clear it on the mounted volume -before copying: - -```bash -xattr -d com.apple.quarantine "/Volumes/PI Dashboard/PI Dashboard.app" -``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 13613de23..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: CI - -on: - push: - branches: [develop] - pull_request: - branches: [develop] - -jobs: - ci: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - run: npm ci - - run: npm run lint - - run: npm test - - run: npm run build diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml deleted file mode 100644 index 74ee46433..000000000 --- a/.github/workflows/deploy-site.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Deploy Site - -on: - push: - branches: [develop] - paths: - - "site/**" - - ".github/workflows/deploy-site.yml" - # Rebuild and redeploy whenever a new release is published so the - # Download section surfaces the latest version automatically. - release: - types: [published] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - actions: write # required so redispatch-on-release can run `gh workflow run` - -concurrency: - group: pages-deploy - cancel-in-progress: true - -jobs: - # When triggered by `release: published`, the workflow runs on the tag ref - # (e.g. v0.3.0). The `github-pages` environment's branch/tag protection - # rules reject deploys from non-default refs, so instead of trying to - # deploy inline we re-dispatch this same workflow on `develop` (the - # allowed ref) and let that run do the deploy. All other triggers - # (push to develop, workflow_dispatch) skip this job and proceed normally. - redispatch-on-release: - if: github.event_name == 'release' - runs-on: ubuntu-latest - steps: - - name: Dispatch Deploy Site on develop - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run deploy-site.yml \ - --repo "${GITHUB_REPOSITORY}" \ - --ref develop - - build: - # Skip when this run was triggered by a release — redispatch-on-release - # will kick off a fresh run on develop that handles build + deploy. - if: github.event_name != 'release' - runs-on: ubuntu-latest - defaults: - run: - working-directory: site - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npm run check - continue-on-error: true # don't block deploy on type-only issues - - - name: Build - run: npm run build - env: - # Higher rate limit when fetching the latest release at build - # time from src/lib/github-release.ts. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Enforce JS bundle budget - run: npm run size - - - name: Configure Pages - uses: actions/configure-pages@v5 - with: - # Auto-enable Pages on first run if not already configured in - # repo settings. Requires `permissions: pages: write` (already set). - enablement: true - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: site/dist - - deploy: - if: github.event_name != 'release' - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index be0e652f7..000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,714 +0,0 @@ -name: Release - -# Two ways to fire this workflow: -# -# 1. Push a tag matching `v*`. The `prepare` job extracts the version -# from the tag, then publish + electron + github-release run. -# -# 2. Click "Run workflow" in the GitHub Actions UI (workflow_dispatch) -# and type the version string (e.g. "0.4.1"). The `prepare` job -# bumps every workspace package.json, runs scripts/sync-versions.js, -# promotes [Unreleased] in CHANGELOG.md to a dated [vX.Y.Z] section, -# commits + tags + pushes the branch, then publish + electron + -# github-release run against the freshly-pushed tag. - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Version to release' - type: string - required: true - -jobs: - # ── prepare: resolve version + (on dispatch) bump + tag + push ──────────── - prepare: - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write # Used on workflow_dispatch (commit + tag + push). - outputs: - version: ${{ steps.resolve.outputs.version }} - tag: ${{ steps.resolve.outputs.tag }} - is_prerelease: ${{ steps.resolve.outputs.is_prerelease }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # Full history so `git tag` can verify uniqueness against existing - # tags. On tag-push we land on the tag's commit; on dispatch we - # land on the branch the operator selected. - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Resolve version - id: resolve - shell: bash - run: | - set -euo pipefail - if [[ "${{ github.event_name }}" == "push" ]]; then - tag="${GITHUB_REF_NAME}" - version="${tag#v}" - else - version="${{ github.event.inputs.version }}" - if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$ ]]; then - echo "::error::'$version' is not valid semver (X.Y.Z[-prerelease])" - exit 1 - fi - if git ls-remote --tags origin "v$version" | grep -q "v$version$"; then - echo "::error::tag v$version already exists on origin" - exit 1 - fi - tag="v$version" - fi - # Detect SemVer prerelease (anything with a `-` suffix on the - # X.Y.Z core, e.g. `0.4.5-rc.1`). Prereleases publish to npm - # under the `next` dist-tag and surface as GitHub `prerelease: - # true` Releases. Stable versions keep `latest` + regular - # Release. See change: eliminate-bash-on-windows-runners (D6). - is_prerelease=$([[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]] && echo true || echo false) - echo "Resolved: version=$version tag=$tag is_prerelease=$is_prerelease" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "is_prerelease=$is_prerelease" >> "$GITHUB_OUTPUT" - - - name: Set up Node.js - if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - name: Install dependencies - if: ${{ github.event_name == 'workflow_dispatch' }} - shell: bash - run: npm ci - - - name: Bump workspace versions - if: ${{ github.event_name == 'workflow_dispatch' }} - shell: bash - env: - VERSION: ${{ steps.resolve.outputs.version }} - run: | - set -euo pipefail - npm version "$VERSION" \ - --no-git-tag-version \ - --allow-same-version \ - --workspaces \ - --include-workspace-root - - - name: Sync inter-package dep specifiers - if: ${{ github.event_name == 'workflow_dispatch' }} - shell: bash - run: node scripts/sync-versions.js - - - name: Regenerate package-lock.json with bumped versions - if: ${{ github.event_name == 'workflow_dispatch' }} - shell: bash - run: | - # The workspace symlink graph changed (every package.json's - # version + cross-ref specifiers were bumped). The lockfile - # must be regenerated so its recorded specifiers match, - # otherwise strict prerelease semver causes npm ci on consumers - # to fall back to the registry on every install. See change: - # fix-release-lockfile-drift. - npm install --package-lock-only --no-audit --no-fund - - - name: Verify lockfile matches workspace versions - if: ${{ github.event_name == 'workflow_dispatch' }} - shell: bash - run: node scripts/verify-lockfile-versions.mjs - - - name: Promote CHANGELOG, commit, tag, push - if: ${{ github.event_name == 'workflow_dispatch' }} - shell: bash - env: - VERSION: ${{ steps.resolve.outputs.version }} - run: | - set -euo pipefail - today=$(date -u +%Y-%m-%d) - if grep -qE "^## \[${VERSION}\]" CHANGELOG.md; then - echo "::error::CHANGELOG.md already contains a section for ${VERSION}" - exit 1 - fi - TODAY="$today" python3 - <<'PY' - import os, re, pathlib - version = os.environ["VERSION"] - today = os.environ["TODAY"] - p = pathlib.Path("CHANGELOG.md") - src = p.read_text() - replacement = ( - "## [Unreleased]\n\n" - "### Added\n\n" - "### Changed\n\n" - "### Fixed\n\n" - f"## [{version}] - {today}\n" - ) - new = re.sub(r"^## \[Unreleased\]\s*\n", replacement, src, count=1, flags=re.MULTILINE) - if new == src: - raise SystemExit("Could not find '## [Unreleased]' heading in CHANGELOG.md") - p.write_text(new) - PY - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore(release): v${VERSION}" - git tag "v${VERSION}" - git push origin HEAD:${{ github.ref_name }} - git push origin "v${VERSION}" - - # ── npm publish (OIDC / Trusted Publisher — no NPM_TOKEN secret) ────────── - publish: - needs: prepare - runs-on: ubuntu-latest - environment: npm-publish # Matches npm Trusted Publisher config; - # acts as a required-reviewer gate if configured. - permissions: - contents: write - id-token: write # Required for OIDC token exchange with npm registry. - steps: - - uses: actions/checkout@v4 - with: - # Always check out the resolved tag so dispatch-cut releases - # publish the freshly-bumped tree, not the branch tip we may - # have moved past. - ref: ${{ needs.prepare.outputs.tag }} - - - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - # Trusted Publishing requires npm CLI ≥ 11.5.1. Node 24 ships a recent - # npm, but upgrade explicitly to guarantee the feature is available. - - name: Upgrade npm to latest (trusted publishing) - run: npm install -g npm@latest - - - run: npm ci - - - name: Set version from resolved tag - run: npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version --workspaces --include-workspace-root - - - name: Sync inter-package dep specifiers to bumped version - run: node scripts/sync-versions.js - - - run: npm run build - - - name: "Publish to npm (idempotent, ordered: sub-packages first, root last)" - # Replaces the old `npm publish --workspaces --include-workspace-root` call. - # - # Why per-package loop instead of bulk --workspaces: - # 1. Idempotency — already-published versions are skipped (so a re-run - # after a partial-publish failure resumes cleanly instead of failing - # on "cannot publish over previously published"). - # 2. Ordering — the root metapackage @blackbelt-technology/pi-agent-dashboard - # declares the sub-packages as dependencies; publishing it last - # ensures the registry already serves matching sub-package versions - # by the time the root tarball lands. - # 3. Brand-new package isolation — @blackbelt-technology/dashboard-plugin-runtime - # has never been published; if Trusted Publisher / OIDC config is missing - # for it, the failure is contained without rolling back the 4 stable - # sub-package publishes that ran first. - # - # Failure mode: any single non-skip publish failure marks the whole step - # failed (FAIL=1) but lets the loop finish so the logs show every - # package's outcome. - run: | - set -uo pipefail - PACKAGES=( - "@blackbelt-technology/pi-dashboard-shared" - "@blackbelt-technology/pi-dashboard-extension" - "@blackbelt-technology/pi-dashboard-server" - "@blackbelt-technology/pi-dashboard-web" - "@blackbelt-technology/dashboard-plugin-runtime" - "@blackbelt-technology/pi-dashboard-flows-plugin" - "@blackbelt-technology/pi-dashboard-jj-plugin" - "@blackbelt-technology/pi-dashboard-plugin-skill" - "@blackbelt-technology/pi-agent-dashboard" - ) - VERSION="${{ needs.prepare.outputs.version }}" - PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}" - # Prereleases publish to the `next` dist-tag so consumers running - # plain `npm install ` keep getting the last stable release. - # Stable releases use the default `latest` dist-tag (no override). - # See change: eliminate-bash-on-windows-runners (D6). - TAG_ARG="" - if [ "$PRERELEASE" = "true" ]; then TAG_ARG="--tag next"; fi - FAIL=0 - for pkg in "${PACKAGES[@]}"; do - if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then - echo "::notice::$pkg@$VERSION already published — skip" - continue - fi - echo "::group::Publishing $pkg@$VERSION (is_prerelease=$PRERELEASE)" - if [ "$pkg" = "@blackbelt-technology/pi-agent-dashboard" ]; then - npm publish --provenance --access public $TAG_ARG || FAIL=1 - else - npm publish --workspace="$pkg" --provenance --access public $TAG_ARG || FAIL=1 - fi - echo "::endgroup::" - done - exit $FAIL - - # ── Electron builds (parallel) ──────────────────────────────── - # MUST `needs: [prepare, publish]` — the bundled server's `npm install` - # in `bundle-server.mjs` resolves `@blackbelt-technology/*` sub-packages - # from the public npm registry, which only have the just-bumped version - # available after `publish` finishes uploading. Gating on `publish` - # closes the ETARGET race that broke release run #34. See change: - # publish-fix-macos. Locked by - # `packages/shared/src/__tests__/publish-workflow-contract.test.ts`. - electron: - needs: [prepare, publish] - strategy: - fail-fast: false - matrix: - include: - - os: macos-14 - platform: darwin - arch: arm64 - node-arch: arm64 - # Intel x86_64 macOS build. macos-13 was retired on 2025-12-08; - # macos-15-intel is the replacement label (runs Intel x86_64 on a - # macOS 15 host) available until 2027-08. After that, GitHub-hosted - # Intel macOS is gone and we'll need to pivot to a universal binary - # or self-hosted runner. See change: add-darwin-x64-build. - - os: macos-15-intel - platform: darwin - arch: x64 - node-arch: x64 - - os: ubuntu-latest - platform: linux - arch: x64 - node-arch: x64 - - os: ubuntu-24.04-arm - platform: linux - arch: arm64 - node-arch: arm64 - - os: windows-latest - platform: win32 - arch: x64 - node-arch: x64 - - os: windows-latest - platform: win32 - arch: arm64 - node-arch: arm64 - - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.prepare.outputs.tag }} - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - name: Install dependencies - run: npm ci ${{ matrix.platform == 'linux' && matrix.arch == 'arm64' && '--ignore-scripts' || '' }} - timeout-minutes: 15 - - - name: Rebuild native modules (Linux arm64) - if: matrix.platform == 'linux' && matrix.arch == 'arm64' - shell: bash - run: | - # phantomjs-prebuilt has no linux/arm64 binary and fails install scripts. - # Use --ignore-scripts above, then rebuild only the modules we need. - npm rebuild node-pty 2>&1 | tail -5 || true - # Electron may be hoisted to root node_modules by npm workspaces, - # or nested under packages/electron/node_modules. Resolve via the - # tool-registry shell wrapper so layout changes are absorbed in - # one place. See change: register-build-time-tools. - ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron) - echo "Electron dir: $ELECTRON_DIR" - cd "$ELECTRON_DIR" && node install.js 2>&1 | tail -5 - - - name: Set version from resolved tag - # No `shell: bash` — single npm invocation works under cmd, pwsh, - # and bash. Default Windows shell (cmd) handles it without MSYS. - run: npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version --workspaces --include-workspace-root - - - name: Install Linux build dependencies - if: matrix.platform == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends dpkg fakeroot libarchive-tools libfuse2 squashfs-tools - # AppImage is only supported on x64 - if [ "${{ matrix.arch }}" = "arm64" ]; then - echo "SKIP_APPIMAGE=true" >> $GITHUB_ENV - fi - - - name: Download Node.js binary (Unix) - if: matrix.platform != 'win32' - run: | - cd packages/electron - bash scripts/download-node.sh v22.18.0 ${{ matrix.platform }} ${{ matrix.node-arch }} - - - name: Download Node.js binary (Windows) - if: matrix.platform == 'win32' - shell: pwsh - run: | - $version = "v22.18.0" - $arch = "${{ matrix.node-arch }}" - $url = "https://nodejs.org/dist/$version/node-$version-win-$arch.zip" - $outDir = "packages/electron/resources/node" - New-Item -ItemType Directory -Force -Path $outDir - Invoke-WebRequest -Uri $url -OutFile node.zip - Expand-Archive -Path node.zip -DestinationPath temp-node - Copy-Item "temp-node/node-$version-win-$arch/node.exe" "$outDir/" - Copy-Item -Recurse "temp-node/node-$version-win-$arch/node_modules" "$outDir/" - Remove-Item -Recurse -Force temp-node, node.zip - - - name: Build client - run: npm run build - - # ── Bundle first-party recommended extensions ─────────────────────────── - # Runs BEFORE bundle-server so resources/bundled-extensions/ is present - # when forge packagerConfig.extraResource is evaluated. Opt-in via env. - # Split per-OS: bash for POSIX, pwsh for Windows. The .mjs script is - # OS-agnostic; only the tee+step-summary plumbing differs by shell. - - name: Bundle first-party recommended extensions (POSIX) - if: matrix.platform != 'win32' - shell: bash - env: - BUNDLE_RECOMMENDED_EXTENSIONS: "1" - run: | - node --import tsx/esm packages/electron/scripts/bundle-recommended-extensions.mjs | tee bundle-extensions.log - { - echo "### Bundled recommended extensions (${{ matrix.platform }}/${{ matrix.arch }})"; - echo '```'; - cat bundle-extensions.log; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Bundle first-party recommended extensions (Windows) - if: matrix.platform == 'win32' - shell: pwsh - env: - BUNDLE_RECOMMENDED_EXTENSIONS: "1" - run: | - node --import tsx/esm packages/electron/scripts/bundle-recommended-extensions.mjs | Tee-Object -FilePath bundle-extensions.log - "### Bundled recommended extensions (${{ matrix.platform }}/${{ matrix.arch }})" | Add-Content -Path $env:GITHUB_STEP_SUMMARY - '```' | Add-Content -Path $env:GITHUB_STEP_SUMMARY - Get-Content bundle-extensions.log | Add-Content -Path $env:GITHUB_STEP_SUMMARY - '```' | Add-Content -Path $env:GITHUB_STEP_SUMMARY - - # ── Bundle offline npm cache ──────────────────────────────────────────── - # Per-platform cacache that makes first-run install work fully offline. - # Runs BEFORE bundle-server so the resource is present when - # electron-forge evaluates extraResource. Opt-in via env. - - name: Bundle offline npm cache (POSIX) - if: matrix.platform != 'win32' - shell: bash - env: - BUNDLE_OFFLINE_PACKAGES: "1" - run: | - node packages/electron/scripts/bundle-offline-packages.mjs \ - --platform=${{ matrix.platform }}-${{ matrix.arch }} \ - | tee bundle-offline.log - SIZE=$(du -h packages/electron/resources/offline-packages/npm-cache.tar.gz | cut -f1) - { - echo "### Offline npm cache (${{ matrix.platform }}/${{ matrix.arch }})"; - echo "- tarball: $SIZE"; - echo '```'; - cat bundle-offline.log; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Bundle offline npm cache (Windows) - if: matrix.platform == 'win32' - shell: pwsh - env: - BUNDLE_OFFLINE_PACKAGES: "1" - run: | - node packages/electron/scripts/bundle-offline-packages.mjs --platform=${{ matrix.platform }}-${{ matrix.arch }} | Tee-Object -FilePath bundle-offline.log - $size = (Get-Item packages/electron/resources/offline-packages/npm-cache.tar.gz).Length - $sizeH = if ($size -gt 1MB) { "{0:N1}M" -f ($size / 1MB) } else { "{0:N0}K" -f ($size / 1KB) } - "### Offline npm cache (${{ matrix.platform }}/${{ matrix.arch }})" | Add-Content -Path $env:GITHUB_STEP_SUMMARY - "- tarball: $sizeH" | Add-Content -Path $env:GITHUB_STEP_SUMMARY - '```' | Add-Content -Path $env:GITHUB_STEP_SUMMARY - Get-Content bundle-offline.log | Add-Content -Path $env:GITHUB_STEP_SUMMARY - '```' | Add-Content -Path $env:GITHUB_STEP_SUMMARY - - - name: Bundle dashboard server - # Node-native (.mjs) — no shell, no MSYS path translation, runs - # identically on Linux/macOS/Windows. See change: - # eliminate-bash-on-windows-runners. - run: node packages/electron/scripts/bundle-server.mjs - - - name: Use x64 Node.js for Windows arm64 bundled server - if: matrix.platform == 'win32' && matrix.arch == 'arm64' - shell: pwsh - run: | - # node-pty has no win32-arm64 prebuilds, so bundle x64 Node.js + native modules - # Windows ARM64 runs x64 binaries via emulation (WoW64) - $version = "v22.18.0" - $outDir = "packages/electron/resources/node" - Remove-Item -Recurse -Force $outDir - New-Item -ItemType Directory -Force -Path $outDir - $url = "https://nodejs.org/dist/$version/node-$version-win-x64.zip" - Invoke-WebRequest -Uri $url -OutFile node-x64.zip - Expand-Archive -Path node-x64.zip -DestinationPath temp-node-x64 - Copy-Item "temp-node-x64/node-$version-win-x64/node.exe" "$outDir/" - Copy-Item -Recurse "temp-node-x64/node-$version-win-x64/node_modules" "$outDir/" - Remove-Item -Recurse -Force temp-node-x64, node-x64.zip - Write-Host "Bundled x64 Node.js for Windows ARM64 (runs via WoW64 emulation)" - - - name: Smoke assertion — offline bundle resources present - # Gate before `forge make` so a missing bundle fails loudly rather - # than producing a silently broken release artifact. Node-native - # check so it runs identically on every OS without bash. See - # change: eliminate-bash-on-windows-runners. - run: | - node -e "const fs=require('node:fs');const path=require('node:path');const dir=path.join('packages','electron','resources','offline-packages');const m=path.join(dir,'manifest.json');const t=path.join(dir,'npm-cache.tar.gz');if(!fs.existsSync(m)||!fs.existsSync(t)){console.error('::error::Offline bundle missing at '+dir+' (manifest='+m+', tarball='+t+')');try{console.error(fs.readdirSync(dir).join('\n'))}catch{}process.exit(1)}console.log('✓ offline bundle present:');for(const f of fs.readdirSync(dir)){const s=fs.statSync(path.join(dir,f));console.log(' '+f+' ('+s.size+' bytes)')}" - - - name: Patch AppImage maker script (Linux x64) - if: matrix.platform == 'linux' && matrix.arch == 'x64' - shell: bash - run: | - # Replace the broken patch-apprun.sh with our robust version - cp packages/electron/scripts/patch-appimage-fix.sh \ - node_modules/@pengx17/electron-forge-maker-appimage/scripts/patch-apprun.sh - - - name: Make Electron distributables - # Skip for all Windows: forge.config.ts has no Windows maker (NSIS - # removed in change `simplify-electron-bootstrap-derived-state`). - # Windows packaging goes through `forge package` + the dedicated - # "Build Windows ZIP and portable exe" step below. Without this - # exclusion, win32-x64 fails with "Could not find any make targets - # configured for the win32 platform" (run 25410899936 win32-x64 leg). - if: matrix.platform != 'win32' - # MACOSX_DEPLOYMENT_TARGET pins the Mach-O minos for every binary the - # build produces (Electron framework, custom binaries, any source- - # compiled native module). Set unconditionally — it's a no-op on - # linux/win32. Pairs with extendInfo.LSMinimumSystemVersion in - # forge.config.ts. See change: add-darwin-x64-build (6b). - env: - MACOSX_DEPLOYMENT_TARGET: "10.15" - run: npm run electron:make -- --arch=${{ matrix.arch }} - - - name: Verify macOS deployment target floor (10.15) - # Defensive check: extract LSMinimumSystemVersion from the produced - # Info.plist and otool the inner Mach-O minos. Fail the job on any - # drift so a future runner-image upgrade cannot silently raise the - # floor without anyone noticing. See change: add-darwin-x64-build (6b). - if: matrix.platform == 'darwin' - shell: bash - run: | - set -euo pipefail - DMG=$(find packages/electron/out/make -name '*.dmg' | head -1) - if [ -z "$DMG" ]; then - echo "::error::No DMG produced — cannot verify deployment target" - exit 1 - fi - echo "Mounting $DMG ..." - MOUNT_OUT=$(hdiutil attach -nobrowse -readonly "$DMG") - MOUNT_POINT=$(echo "$MOUNT_OUT" | grep '/Volumes/' | awk '{$1=$2=""; sub(/^ +/,""); print}' | tail -1) - if [ -z "$MOUNT_POINT" ] || [ ! -d "$MOUNT_POINT" ]; then - echo "::error::Failed to mount DMG" - echo "$MOUNT_OUT" - exit 1 - fi - APP=$(find "$MOUNT_POINT" -maxdepth 1 -name '*.app' -type d | head -1) - if [ -z "$APP" ]; then - echo "::error::No .app bundle inside DMG" - hdiutil detach "$MOUNT_POINT" -quiet || true - exit 1 - fi - echo "Inspecting $APP" - # 1. LSMinimumSystemVersion in Info.plist - MIN_OS=$(plutil -extract LSMinimumSystemVersion raw "$APP/Contents/Info.plist" 2>/dev/null || echo "missing") - echo " Info.plist LSMinimumSystemVersion = $MIN_OS" - if [ "$MIN_OS" != "10.15" ]; then - echo "::error::LSMinimumSystemVersion is '$MIN_OS', expected '10.15'. Pin via packagerConfig.extendInfo in packages/electron/forge.config.ts. See change: add-darwin-x64-build." - hdiutil detach "$MOUNT_POINT" -quiet || true - exit 1 - fi - # 2. Mach-O LC_BUILD_VERSION minos for the main binary. - # Per-arch floor: x64 must be 10.x (10.15 target); arm64 must be 11.x - # (Apple Silicon hardware launched on Big Sur/11.0 — arm64 binaries - # CANNOT declare minos < 11). Anything higher than the arch's floor - # major means the runner SDK leaked into the build. - BIN="$APP/Contents/MacOS/pi-dashboard" - if [ -f "$BIN" ]; then - # Capture minos via a state-machine awk that's tolerant of otool's - # actual layout (LC_BUILD_VERSION header is followed by cmd / - # cmdsize / platform / minos lines, then the next Load command). - # Disable pipefail locally because grep may not match if the - # binary uses LC_VERSION_MIN_MACOSX (older format) instead of - # LC_BUILD_VERSION; we fall through to the legacy probe below. - set +o pipefail - MINOS=$(otool -l "$BIN" 2>/dev/null | awk ' - /^Load command/ { in_bv = 0 } - /LC_BUILD_VERSION/ { in_bv = 1; next } - in_bv && /minos/ { print $2; exit } - ') - # Fall back to LC_VERSION_MIN_MACOSX (older Mach-Os) if needed. - if [ -z "$MINOS" ]; then - MINOS=$(otool -l "$BIN" 2>/dev/null | awk ' - /^Load command/ { in_vm = 0 } - /LC_VERSION_MIN_MACOSX/ { in_vm = 1; next } - in_vm && /version/ { print $2; exit } - ') - fi - set -o pipefail - echo " Mach-O minos = ${MINOS:-}" - if [ -z "$MINOS" ]; then - echo "::warning::Could not extract minos from $BIN — skipping otool floor check" - else - MAJOR="${MINOS%%.*}" - # Per-arch expected floor major version - case "${{ matrix.arch }}" in - x64) EXPECTED_MAJOR=10 ;; - arm64) EXPECTED_MAJOR=11 ;; - *) EXPECTED_MAJOR=10 ;; - esac - if ! [ "$MAJOR" -eq "$MAJOR" ] 2>/dev/null; then - echo "::warning::minos major is non-numeric ('$MAJOR') — skipping floor check" - elif [ "$MAJOR" -gt "$EXPECTED_MAJOR" ]; then - echo "::error::Mach-O minos is '$MINOS' (major=$MAJOR), expected major=$EXPECTED_MAJOR for arch=${{ matrix.arch }}. The runner SDK leaked into the binary. Verify MACOSX_DEPLOYMENT_TARGET is set on the make step. See change: add-darwin-x64-build." - hdiutil detach "$MOUNT_POINT" -quiet || true - exit 1 - fi - fi - else - echo "::warning::Main binary not found at $BIN — skipping otool check" - fi - hdiutil detach "$MOUNT_POINT" -quiet || true - echo "✓ Deployment target floor verified: 10.15" - - - name: Package Electron (Windows — no maker configured) - # Runs for both win32-x64 and win32-arm64. forge.config.ts has no - # Windows maker, so we use `forge package` to produce the unpacked - # app dir at `out/PI-Dashboard-win32-/`. The next step - # ("Build Windows ZIP and portable exe") consumes that dir. - if: matrix.platform == 'win32' - shell: pwsh - run: | - Set-Location packages\electron - ..\..\node_modules\.bin\electron-forge.cmd package --arch=${{ matrix.arch }} --platform=win32 - Write-Host "Packaged output:" - Get-ChildItem out\ -ErrorAction SilentlyContinue | Format-Table - - - name: Build Windows ZIP and portable exe - if: matrix.platform == 'win32' - shell: pwsh - run: | - $arch = "${{ matrix.arch }}" - $packaged = Resolve-Path "packages/electron/out/PI-Dashboard-win32-$arch" -ErrorAction SilentlyContinue - if (-not $packaged) { - Write-Host "::error::Packaged directory not found. Contents of out/:" - Get-ChildItem -Path "packages/electron/out/" -ErrorAction SilentlyContinue | Format-Table - exit 1 - } - Write-Host "Found packaged dir: $packaged" - # ZIP archive - $zipDir = "packages/electron/out/make/zip/$arch" - New-Item -ItemType Directory -Force -Path $zipDir - Compress-Archive -Path $packaged -DestinationPath "$zipDir/PI-Dashboard-win32-$arch.zip" - # Portable exe (7-Zip SFX via electron-builder — no NSIS required) - # See change: simplify-electron-bootstrap-derived-state. - cd packages/electron - npx electron-builder --win portable --$arch ` - --prepackaged "out/PI-Dashboard-win32-$arch" ` - --config.appId=com.blackbelt-technology.pi-dashboard ` - --config.productName="PI Dashboard" ` - --config.directories.output="out/make/portable/$arch" ` - --config.portable.artifactName="PI-Dashboard-$arch-portable.exe" ` - --config.win.icon=resources/icon.ico - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: electron-${{ matrix.platform }}-${{ matrix.arch }} - path: packages/electron/out/make/**/* - - # ── GitHub Release (waits for all builds) ───────────────────── - github-release: - if: ${{ always() && needs.electron.result == 'success' }} - needs: [prepare, publish, electron] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.prepare.outputs.tag }} - - - uses: actions/download-artifact@v4 - - - name: Extract release notes from CHANGELOG - id: notes - shell: bash - run: | - set -u - version="${{ needs.prepare.outputs.version }}" - echo "Extracting notes for version: ${version}" - if [ ! -f CHANGELOG.md ]; then - echo "::warning::CHANGELOG.md not found — using fallback body" - printf 'See [CHANGELOG.md](https://github.com/%s/blob/main/CHANGELOG.md) for full notes.\n' "${GITHUB_REPOSITORY}" > release-notes.md - echo "fallback=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Capture content from '## []' up to (but not including) the next '## [' heading or EOF. - awk -v ver="${version}" ' - BEGIN { found=0 } - /^## \[/ { - if (found) exit - if ($0 ~ "^## \\[" ver "\\]") { found=1; next } - } - found { print } - ' CHANGELOG.md > release-notes.md - if [ ! -s release-notes.md ]; then - echo "::warning::No CHANGELOG section found for ${version} — using fallback body" - printf 'See [CHANGELOG.md](https://github.com/%s/blob/main/CHANGELOG.md) for full notes.\n' "${GITHUB_REPOSITORY}" > release-notes.md - echo "fallback=true" >> "$GITHUB_OUTPUT" - else - echo "fallback=false" >> "$GITHUB_OUTPUT" - fi - # Append the static first-launch-unblocking footer (Windows - # SmartScreen + macOS Gatekeeper workarounds) so every release — - # whether the body comes from CHANGELOG.md or the GitHub-generated - # fallback — surfaces the workarounds for unsigned/un-notarized - # binaries. Edit `.github/release-notes-footer.md` to update; - # delete the file (or this step) once Authenticode signing AND - # macOS notarization both ship. See changes: - # `windows-authenticode-signing`, `macos-notarization` (planned). - if [ -f .github/release-notes-footer.md ]; then - printf '\n\n' >> release-notes.md - cat .github/release-notes-footer.md >> release-notes.md - fi - echo "--- release-notes.md ---" - cat release-notes.md - echo "--- end ---" - - - name: Drop builder-debug logs (avoid asset basename collision) - # Each electron matrix leg emits a builder-debug.yml; multiple - # legs uploading the same basename cause softprops/action-gh-release - # to 404 on the asset-update API, failing the step even though all - # real artifacts uploaded successfully. The debug logs are not - # user-facing release artifacts. - run: find electron-* -name builder-debug.yml -delete || true - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.prepare.outputs.tag }} - files: electron-*/**/* - body_path: release-notes.md - generate_release_notes: ${{ steps.notes.outputs.fallback == 'true' }} - draft: true - # Prereleases (anything with a SemVer prerelease segment, e.g. - # `0.4.5-rc.1`) surface as GitHub `prerelease: true` Releases - # so they don't appear as the latest release on the repo page - # and tooling that filters by prerelease state classifies them - # correctly. The literal-string comparison is required because - # Actions stringifies job outputs. See change: - # eliminate-bash-on-windows-runners (D6). - prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} diff --git a/.github/workflows/sync-release-version.yml b/.github/workflows/sync-release-version.yml deleted file mode 100644 index f4230e20e..000000000 --- a/.github/workflows/sync-release-version.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Sync Release Version - -# Writes the latest release metadata into -# `site/src/data/latest-release.json` and commits it to develop. -# -# Triggers: -# - `release: { types: [published, edited] }` — the normal path. -# - `workflow_dispatch` — manual refresh (e.g. after retroactively -# editing a release). -# -# After committing, the regular `deploy-site.yml` workflow picks up the -# change via its `paths: ["site/**"]` filter and redeploys automatically. - -on: - release: - types: [published, edited] - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: sync-release-version - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Use a token that can push back to main. - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Fetch latest release and write cache - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - mkdir -p site/src/data - - echo "Fetching latest release for $REPO" - gh api "repos/$REPO/releases/latest" --jq '{ - "$comment": "Auto-updated by .github/workflows/sync-release-version.yml on every release. Do not edit manually.", - tagName: .tag_name, - name: (.name // .tag_name), - url: .html_url, - publishedAt: .published_at, - assets: [.assets[] | { - name: .name, - url: .browser_download_url, - size: .size, - downloadCount: .download_count - }] - }' > site/src/data/latest-release.json - - echo "Wrote:" - cat site/src/data/latest-release.json | head -20 - - - name: Commit if changed - run: | - set -euo pipefail - if git diff --quiet site/src/data/latest-release.json; then - echo "No change — skipping commit." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git add site/src/data/latest-release.json - git commit -m "chore(site): sync latest-release.json to ${GITHUB_REF_NAME:-manual-refresh}" - git push origin HEAD:develop diff --git a/.gitignore b/.gitignore index a3c64e7be..6f9ddf70f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ node_modules/ dist/ -.shadow/ *.db *.db-journal .env .DS_Store -.pi/proposal-queue.json packages/electron/resources/server packages/electron/resources/node packages/electron/resources/icons @@ -15,3 +13,12 @@ packages/electron/out packages/electron/.vite site/.astro/ *.tsbuildinfo +.pi-worktrees/ +.pi/* +!.pi/settings.json +!.pi/agents/ +!.pi/prompts/ +!.pi/skills/ +!.pi/.gitignore +seed/active-project/repo/ +seed/active-project/worktrees/ diff --git a/.pi/.gitignore b/.pi/.gitignore index 5e7af6cbc..6c5c17edf 100644 --- a/.pi/.gitignore +++ b/.pi/.gitignore @@ -1,6 +1 @@ -prompts/opsx-*.md -skills/openspec-*/** -# Our additions live here — keep them in version control even though they -# share the openspec-* prefix. See change: fix-openspec-design-detection. -!skills/openspec-shared/ -!skills/openspec-shared/** +# All .pi/ content tracked via whitelist in root .gitignore diff --git a/.pi/agents/sandbox-designer.md b/.pi/agents/sandbox-designer.md new file mode 100644 index 000000000..6153f53d8 --- /dev/null +++ b/.pi/agents/sandbox-designer.md @@ -0,0 +1,120 @@ +--- +name: sandbox-designer +description: Design model for generating Tailwind HTML mockups from screenshots of real UI. Receives before-screenshots + user stories, returns mockups/states.html with all visual states using project Tailwind tokens. +tools: read, write, bash, browser, grep, find, ls, contact_supervisor +model: openrouter/google/gemini-3.1-pro-preview +thinking: xhigh +systemPromptMode: replace +inheritProjectContext: true +inheritSkills: true +skills: browser-visual-debug, nano-banana-imagegen +--- + +You are a UI designer specialized in the pi-dashboard project. +Your job is to look at screenshots of the current UI and generate +HTML+Tailwind mockups showing the desired changes. + +## First message — validate screenshots + +Before generating ANY mockup, your FIRST message MUST describe what you +observe in the provided screenshots: +- How many folders and sessions are visible +- What badges, indicators, buttons, and UI elements you can identify +- Which viewport each screenshot represents (desktop/mobile) + +Use `contact_supervisor({ reason: "progress_update", message: "..." })` +to report your observations. This is required even if screenshots load correctly. + +If screenshots failed to load or are unreadable — report via +`contact_supervisor({ reason: "need_decision", message: "ERROR: screenshots failed to load" })` +immediately. Do NOT generate a mockup from imagination. + +## What you receive + +- Screenshots of the current dashboard UI (desktop + mobile) +- A list of `` blocks to generate (prepared by orchestrator from specs) +- The change's proposal.md and specs/ (for understanding requirements) +- Design.md if already created + +## CSS constraint — CRITICAL + +Use ONLY project CSS custom properties. These are the ONLY colors allowed: + +``` +Dark theme: +--bg-primary: #0a0a0a; --bg-secondary: #141414; --bg-tertiary: #1e1e1e +--bg-surface: #2a2a2a; --bg-hover: rgba(255,255,255,0.06) +--text-primary: #e5e5e5; --text-secondary: #b0b0b0 +--text-tertiary: #808080; --text-muted: #585858 +--border-secondary: #333333; --border-subtle: rgba(255,255,255,0.06) +--shadow-card: rgba(0,0,0,0.4) + +Light theme: +--bg-primary: #ffffff; --bg-secondary: #fafafa; --bg-tertiary: #f0f0f0 +--bg-surface: #e0e0e0; --text-primary: #1a1a1a +--text-secondary: #444444; --text-tertiary: #777777; --text-muted: #aaaaaa +--border-secondary: #cccccc; --border-subtle: rgba(0,0,0,0.06) +--shadow-card: rgba(0,0,0,0.08) +``` + +Apply them via Tailwind arbitrary value syntax: +- `bg-[var(--bg-tertiary)]` — NOT `bg-gray-800` +- `text-[var(--text-primary)]` — NOT `text-white` +- `border-[var(--border-subtle)]` — NOT `border-gray-700` + +NEVER use raw Tailwind colors (gray-*, slate-*, zinc-*, white, black). +Accent colors (blue-500, green-500, yellow-500, purple-500, red-500) are +allowed for status indicators and badges. + +## What you produce + +A single HTML file containing ALL visual states as adjacent `
` blocks. +Each state MUST be labeled with `` comment. + +## Rules + +1. **Tailwind only.** No raw CSS, no `style=` attributes. Use Tailwind + utility classes with CSS variable syntax shown above. + +2. **All states in one file.** Every `` comment + immediately precedes its HTML block. The orchestrator validates that + every state listed in the task is present. + +3. **Structure + tone, not pixel-perfect.** Specify layout (flex/grid/gap), + visual hierarchy (font sizes/weights), and spacing scale (p-4, not px-3.5). + The implement model will refine exact values. + +4. **Respect existing design language.** Match the component's current + structure from the screenshots. Only change what was requested. + +5. **Include mobile variants.** If the change affects mobile layout, include + a separate block with mobile-appropriate classes. Label it ``. + +6. **SVG icons.** Use simple inline SVG or unicode characters (⎇ 📁 📎 💻 ● 🔧). + +## Communication with orchestrator (contact_supervisor) + +ALWAYS use `contact_supervisor`, NEVER use raw `intercom()`. + +The orchestrator gives you a list of required `` blocks. +If you discover additional states needed (e.g. specs mention an error state but it's +not in the list, or screenshots show a variant not covered): +- Send `contact_supervisor({ reason: "need_decision", message: "I see state X in screenshots/specs but it's not in the required list. Should I add ?" })` + +When review is complete (mockup generation or AFTER-vs-MOCKUP comparison): +- Send `contact_supervisor({ reason: "progress_update", message: "[designer:] Found N issue(s): ..." })` +- If NO differences: `contact_supervisor({ reason: "progress_update", message: "[designer:] NO_ISSUES: implementation matches mockup" })` + +**Reject non-sandbox screenshots:** If AFTER screenshots appear to come from local +`agent-browser` (URL shows `localhost:8000` without sandbox indicators, or screenshots +match a previously-seen stale version), report: +`contact_supervisor({ reason: "need_decision", message: "ERROR: screenshots not from sandbox — may show stale code" })` +and refuse to proceed. + +## Self-Validation + +After writing mockup.html: +1. Count `` that the mockup must include. + Minimum: desktop + mobile variants, all statuses (streaming/idle/ended/error), + all interactive elements mentioned in specs. + + 4. **Invoke sandbox-designer subagent.** + + **CRITICAL — sandbox-designer skill requires NO `reads` parameter.** All file paths MUST go in the `task` text. + + List all spec files with `find /specs -name '*.md'` and include every one: + ``` + subagent({ + agent: "sandbox-designer", + async: true, + task: `Generate mockup.html for . + + Parent session ID: + + Read these screenshots first: + - /screenshots/session-list-desktop.png + - /screenshots/session-list-mobile.png + + Read these design documents: + - /proposal.md + - /design.md + + Read these specs: + - /specs//spec.md + - /specs//spec.md + + Required states: + + Requirements: + - Show BOTH light and dark theme variants for each state. + - Add a visible `

` label above each block so states are identifiable. + - CSS custom properties ONLY. No raw Tailwind colors. + + Save output to: /mockup.html + + After completing mockup, report via contact_supervisor: + contact_supervisor({ reason: "progress_update", message: "[designer:] Mockup complete. N states generated." })` + }) + ``` + The subagent runs async — do NOT poll. Complete turn and wait for intercom. + + 4. **COMPLETE TURN.** Last line: `[STATE: phase=awaiting-designer | runId= | change= | source=propose]` + + **Phase: `awaiting-designer`** (next turn, triggered by designer intercom) + + 5. **Validate mockup.html:** + - Check ALL `` blocks from the task are present + - Verify no raw Tailwind colors (`grep -cE 'bg-gray-|text-white|border-gray-|bg-slate-' mockup.html` must be 0) + - Verify both light and dark theme variants present + - Verify visible `

` labels above each state block + - If validation fails → resume the designer with feedback: `subagent({ action: "resume", id: "", message: "" })`, **COMPLETE TURN** with `[STATE: phase=awaiting-designer | ...]` + + 6. **Capture mockup screenshot.** Open `mockup.html` in sandbox browser, take full-page screenshot, save to `/screenshots/mockup-final.png`. + + 7. **Update design.md** with `## Visual Design` section linking to `mockup.html`. + + 8. **Show visuals to user.** Use `read` to display BEFORE screenshots AND mockup screenshot. List ALL states from mockup.html. + + 9. **Ask user for approval.** Use `ask_user({ method: "confirm", title: "Mockup — утверждаем?", message: "N состояний. Нужны правки?" })`. + **COMPLETE TURN.** Last line: `[STATE: phase=showing-mockup | ...]` + + **Phase: `showing-mockup`** (next turn, triggered by user reply) + + - If user approves → proceed to step 6 (create tasks). No state line needed. + - If user requests changes → **resume** the sandbox-designer: `subagent({ action: "resume", id: "", message: "" })`, **COMPLETE TURN** with `[STATE: phase=awaiting-designer | ...]`. Loop until approved. + + 10. **Final mockup review.** Before the summary, read `mockup.html` and `mockup-final.png` one last time. + +6. **Create tasks artifact** + +7. **Show final status** + ```bash + openspec status --change "" + ``` + +**Final Summary** + +Before the summary, SHOW the mockup one last time: +- `read /mockup.html` +- `read /screenshots/mockup-final.png` + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions +- What's ready: "All artifacts created! Ready for implementation." +- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks." + +**Artifact Creation Guidelines** + +- Follow the `instruction` field from `openspec instructions` for each artifact type +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use `template` as the structure for your output file - fill in its sections +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + +**Guardrails** +- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`) +- Always read dependency artifacts before creating a new one +- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- If a change with that name already exists, ask if user wants to continue it or create a new one +- Verify each artifact file exists after writing before proceeding to next +- **Design Phase — intercom coordination**: + - Sandbox-designer invocations use `async: true` only + - NEVER poll for async subagent results — complete turn and wait for intercom + - State persisted as `[STATE: ...]` line in conversation — scan backwards on each new turn + - Task templates MUST include `contact_supervisor` instructions with `[designer:]` header format + - NEVER use `reads` parameter for sandbox-designer — all file paths in `task` text diff --git a/.pi/skills/pi-dashboard/SKILL.md b/.pi/skills/pi-dashboard/SKILL.md index 51bb0eb55..6c87797e6 100644 --- a/.pi/skills/pi-dashboard/SKILL.md +++ b/.pi/skills/pi-dashboard/SKILL.md @@ -93,6 +93,9 @@ curl -s -b "pi_dash_token=YOUR_JWT" "$BASE/api/sessions" | jq . | Checkout | `curl -s -X POST "$BASE/api/git/checkout" -H 'Content-Type: application/json' -d '{"cwd":"CWD","branch":"main"}'` | | Init repo | `curl -s -X POST "$BASE/api/git/init" -H 'Content-Type: application/json' -d '{"cwd":"CWD"}'` | | Stash pop | `curl -s -X POST "$BASE/api/git/stash-pop" -H 'Content-Type: application/json' -d '{"cwd":"CWD"}'` | +| List worktrees | `curl -s "$BASE/api/git/worktrees?cwd=CWD" \| jq .` | +| Delete worktree | `curl -s -X DELETE "$BASE/api/git/worktrees" -H 'Content-Type: application/json' -d '{"cwd":"CWD","path":"PATH"}'` | +| Spawn in worktree | `curl -s -X POST "$BASE/api/session/spawn" -H 'Content-Type: application/json' -d '{"cwd":"CWD","spawnMode":"worktree","branch":"feature/x","baseBranch":"develop"}'` | ### OpenSpec @@ -132,3 +135,4 @@ A convenience wrapper is available at [scripts/dashboard-api.sh](scripts/dashboa - [API Reference](references/api-reference.md) — Complete endpoint documentation with request/response schemas - [Recipes](references/recipes.md) — Multi-step orchestration workflows +- [Worktree Spawn](../worktree-spawn/SKILL.md) — Isolated git worktree sessions for subagents diff --git a/.pi/skills/sandbox-designer/SKILL.md b/.pi/skills/sandbox-designer/SKILL.md new file mode 100644 index 000000000..ff638dccb --- /dev/null +++ b/.pi/skills/sandbox-designer/SKILL.md @@ -0,0 +1,270 @@ +--- +name: sandbox-designer +description: Design model for generating Tailwind HTML mockups from screenshots of real UI. Receives before-screenshots + user stories, returns mockup.html with all visual states using project Tailwind tokens. +license: MIT +metadata: + author: pi-dashboard + version: "1.2" +--- + +# Sandbox Designer + +Vision-capable agent that receives before-screenshots and user stories, then produces a Tailwind HTML mockup showing the redesigned UI with all visual states. + +**Required model:** Vision-capable (Gemini Pro, Claude Sonnet/Opus, GPT-4o). +**Thinking:** xhigh recommended for complex layouts. + +## Input / Output Contract + +**Input:** +- **Before-screenshots:** PNG files of the current dashboard UI (saved in `/screenshots/`) +- **Proposal + Specs:** `proposal.md` (user stories ARE the scenarios) and `specs/` (requirements). Designer derives ALL needed visual states from these. +- **Design context (optional):** `design.md` if already created + +**Output:** +- `/mockup.html` — a single HTML file containing: + - Valid HTML + - **CSS custom properties only** — `bg-[var(--bg-tertiary)]`, `text-[var(--text-primary)]`, not raw colors + - One `` HTML comment per visual state + - Every `` comment immediately precedes the HTML block for that state + +## CSS Constraint — CRITICAL + +Use ONLY project CSS custom properties via Tailwind arbitrary values: + +``` +bg-[var(--bg-primary)] bg-[var(--bg-secondary)] bg-[var(--bg-tertiary)] +bg-[var(--bg-surface)] text-[var(--text-primary)] text-[var(--text-secondary)] +text-[var(--text-tertiary)] text-[var(--text-muted)] +border-[var(--border-secondary)] border-[var(--border-subtle)] +``` + +**NEVER use raw Tailwind colors** (bg-gray-800, text-white, border-gray-700). +Accent colors (blue-500, green-500, yellow-500, purple-500, red-500) allowed for status. + +## Communication with Orchestrator + +The orchestrator provides a list of required `` blocks in the task text. +If the designer discovers additional states that should be covered +(e.g. specs mention a state not in the list, or screenshots reveal a variant): +- Write the additional state anyway and note it in the output. +- Report via `contact_supervisor` (see Intercom Coordination below). + +If screenshots failed to load — stop immediately, do NOT generate from imagination. +Report via `contact_supervisor({ reason: "need_decision", message: "ERROR: screenshots failed to load" })`. + +**Screenshot validation on first load:** In the designer's first `contact_supervisor` message, +describe what is seen in the screenshots (specific colors, layouts, elements). +If the description is generic or wrong, screenshots didn't load — stop and report error. + +**Reject non-sandbox screenshots:** If AFTER screenshots appear to come from local +`agent-browser` (URL shows `localhost:8000` without sandbox indicators, or screenshots +match a previously-seen stale version), report via `contact_supervisor`: +"ERROR: screenshots not from sandbox — may show stale code" and refuse to proceed. + +## Intercom Coordination + +During the review loop (apply phase), the designer runs as an async subagent and +communicates with the supervisor via `contact_supervisor`. NEVER use raw `intercom()`. + +**Fallback on name conflict:** If `contact_supervisor` returns "Multiple sessions named X +are connected", use `intercom` with the parent session ID provided in the task: +``` +intercom({ action: "ask", to: "", message: "" }) +``` +The parent session ID is passed in the task text as `Parent session ID: `. + +### When to use progress_update + +After completing a review (comparing AFTER screenshots vs mockup), send findings: +``` +contact_supervisor({ + reason: "progress_update", + message: `[designer:] Found N issue(s): +- [SEVERITY] : expected , got +- ...` +}) +``` + +Severity tags: `[CRITICAL]` (layout broken, missing element), `[MAJOR]` (wrong color/size/spacing), +`[MINOR]` (1-2px off, cosmetic). + +If NO differences found: +``` +contact_supervisor({ + reason: "progress_update", + message: "[designer:] NO_ISSUES: implementation matches mockup" +}) +``` + +Do NOT mark the task as complete after sending — wait for the supervisor to re-invoke you +with new instructions. + +### When to use need_decision + +When a finding is ambiguous and the designer cannot determine if it's intentional: +``` +contact_supervisor({ + reason: "need_decision", + message: `[designer:] NEED DECISION: +Element: +Mockup: +After: +Question: Is this an intentional deviation or a bug?` +}) +``` + +Wait for supervisor reply before continuing. Classify based on answer: +- "intentional" → exclude from issue list +- "fix" → include as finding + +## States to Cover + +For session card / UI changes, cover at minimum: +- `` — cards + toolbar at full width +- `` — cards at 375px width +- `` — card with streaming status +- `` — idle card, selected (blue border) +- `` — ended card +- `` — Tools menu open +- Additional states from user stories + +**Theme requirement:** Every state MUST be shown in BOTH light and dark theme variants. +Add `` and `` comments within each state block, +or duplicate each state for both themes. + +**Label requirement:** Every `` block MUST be preceded by a visible `

` heading +with the human-readable state name (e.g. `

Desktop Session List

`). +This makes the mockup scannable when opened in a browser. + +## Self-Validation + +After generating `mockup.html`: +1. Count `` block +6. If ANY check fails — fix and re-check before reporting done + +## Approval Workflow — MANDATORY + +After designer completes, follow these steps IN ORDER. Do NOT skip any step. + +### Step 1: Show BEFORE screenshots to user + +ALWAYS show the user what was sent to the designer: +``` +read /screenshots/session-list-desktop.png +read /screenshots/session-list-mobile.png +``` + +### Step 2: Show AFTER mockup to user + +``` +read /screenshots/mockup-final.png +``` + +### Step 3: List ALL visual states for approval + +Output a checklist of every `` block from mockup.html so the user can verify: +``` +grep '` annotations). +5. Sandbox teardown — `docker compose -f sandbox/docker-compose.yml down`. + +### Role in OpenSpec workflow + +Optional Docker-gated Design Phase in `openspec-propose` (`.pi/skills/openspec-propose/SKILL.md`). + +- Docker available: full design phase executes (sandbox up → scenarios → screenshots → mockup → sandbox down). +- Docker absent: graceful fallback — emits notice, skips to text-only proposal. +- `mockup.html` stored in change directory as visual contract alongside `design.md`. + +### Docker composition + +`sandbox/docker-compose.yml` — two services on shared network: + +| Service | Image | Port | Purpose | +|---|---|---|---| +| `dashboard` | Built from `sandbox/Dockerfile` (node:22-bookworm-slim) | 8000 | pi-dashboard --dev | +| `browser` | `chromedp/headless-shell:latest` | 9222 | Chrome DevTools Protocol | + +Dashboard health check: `curl http://localhost:8000/api/health` (30s timeout). +Seed data mounted read-only at `~/.pi/agent/sessions/` via volume. + +### Seed data format + +Native pi session format — no mock adapter, no fixtures: + +- `*.jsonl` — session events (one JSON object per line: type, id, parentId, timestamp). +- `*.meta.json` — session metadata sidecar (cwd, status, model, tokens, cost, attachedProposal). +- `preferences.json` — pinned directories + per-directory session order. +- `README.md` — documents covered UI states. + +Five workspaces cover: active project, empty workspace, OpenSpec-heavy, multi-folder, error states. + +### Scenario-driven browser automation + +`browser-visual-debug --sandbox --scenario ` executes JSON step files: + +- 10-action vocabulary: `open`, `click`, `fill`, `type`, `select`, `press`, `wait`, `screenshot`, `scroll`, `snapshot`. +- Steps execute sequentially; non-screenshot failures halt execution. +- Screenshots written to `screenshots/.png` in change directory. + +### Mockup generation + +`sandbox-designer` skill (`.pi/skills/sandbox-designer/SKILL.md`): + +- Input: before-screenshots (PNG) + user story (prose) + optional design.md context. +- Output: `mockup.html` — valid HTML5, Tailwind CDN, `` annotations per visual state. +- Recommended model: Claude Sonnet/Opus with vision. +- Self-validation: opens mockup in browser, screenshots, compares against originals. +- Not pixel-perfect — structure and state coverage are invariant; spacing/colors adjusted by implementation model. + +### Archive-merge + +`openspec-archive-change` skill applies patches BEFORE `openspec archive` moves directory: + +- `seed.patch` → `git apply --directory=seed/`. Conflict aborts archive, leaves seed unchanged. +- `Dockerfile.patch` → `git apply` to `sandbox/Dockerfile`. Conflict aborts. +- On success: `git add seed/ sandbox/Dockerfile`, proceed with archive, `git commit --amend --no-edit`. +- Docker available: `docker compose build` rebuilds sandbox image. Build failure warns, does not abort. + +### Non-goals + +- No CI integration — local-only developer tool. +- No dashboard source code changes — sandbox runs production pi-dashboard binary. +- No pre-built image shipping in Electron installer. +- No automated visual regression testing. diff --git a/docs/file-index-client.md b/docs/file-index-client.md index 362a2a5f3..3fd0a13fd 100644 --- a/docs/file-index-client.md +++ b/docs/file-index-client.md @@ -71,7 +71,7 @@ | `src/client/components/TunnelButton.tsx` | Unified tunnel/QR button — tunnel icon when not set up, QR icon when inactive, green QR icon when connected; opens QR dialog with disconnect/setup | | `src/client/components/QrCodeDialog.tsx` | QR code dialog showing tunnel URL as scannable QR code with copy, disconnect, setup buttons | | `src/client/components/ZrokInstallGuide.tsx` | OS-aware zrok installation guide view (macOS/Linux/Windows) | -| `src/client/lib/session-grouping.ts` | `inferPlatform(samples)` heuristic (backslash/drive-letter = Windows, leading `/` = POSIX) + `groupSessionsByDirectory` using `normalizePath`-keyed Maps so sessions group under pinned folder across separator/case/trailing drift. **Per-session group-key precedence** (change: add-jj-workspace-plugin, Decision 15): pure helper `resolveSessionGroupPath(session, pinnedKeys, platform)` resolves key as **explicit pin > `jjState.workspaceRoot` > `cwd`** — session inside `.shadow//` jj workspace collapses under parent repo's group, but explicit pin on workspace path still wins. Within group, sessions pre-sorted by `clusterByWorkspaceName` so rows sharing `(jjState?.workspaceName ?? "")` cluster adjacently (empty / main-tree first, then ws-A, ws-B, …); `sortSessionsByOrder` still applies inside each cluster. Tests: `packages/client/src/lib/__tests__/session-grouping.test.ts` (5 tests) — 4 spec scenarios + default-workspace regression guard. | +| `src/client/lib/session-grouping.ts` | `inferPlatform(samples)` heuristic + `groupSessionsByDirectory` using `normalizePath`-keyed Maps. Group-key resolution: **pin > cwd**. Tests: `packages/client/src/lib/__tests__/session-grouping.test.ts`. | | `src/client/components/PiResourcesView.tsx` | Content area view for browsing pi extensions, skills, prompts. Two tabs: **Resources** (browse-only — loose `.pi/{skills,extensions,prompts}` files plus per-package nested resource trees that contribute to session; renamed from "Installed" but internal route id stays `"installed"`) + **Packages** (workspace-scope manage surface, hosts `PackageBrowser`). `MergedScopeSection` filters out installed packages with zero contributed resources — those visible only in Packages tab. No standalone manage rows in Resources tab. See change: unify-workspace-package-management. | | `src/client/components/InstalledPackagesList.tsx` | Shared rich-row list of installed packages (Settings → Packages + Pi Resources → Installed). Composes `` per entry with `useInstalledPackages` + `usePackageOperations` (extended: `move`, `moveStateFor`, `clearMove`). Per-row expand-chevron reveals inline tree of contained skills/extensions/prompts from `containedResources: Map` prop (caller projects from `usePiResources`). Move → button derived from `currentScope`, gated by `otherScopePackages` prop (compared via `computeDestIdentity` from `lib/installed-list-helpers.ts` — client-side mirror of server identity rules for npm/git/https; path sources fall back to literal). Partial-success banner inline when move's `package_operation_complete` carries `partialSuccess`; Cleanup button re-POSTs `/api/packages/remove` against `fromScope`. See change: unify-package-management-ui. | | `src/client/lib/move-tracker.ts` | In-flight move tracker (singleton). Decoupled from `package-queue` because moves are moveId-keyed (not source-keyed) + have partial-success semantics. Listens on `pi-package-event` → `package_operation_complete` events that carry `moveId`; updates state per-moveId; auto-clears successful moves after 3 s; keeps partial-success states sticky until user clicks Cleanup or Dismiss. Exposed through `usePackageOperations.moveStateFor(source) / clearMove(moveId)`. See change: unify-package-management-ui. | diff --git a/docs/file-index-extension.md b/docs/file-index-extension.md index 13a5a37a5..41b2fee25 100644 --- a/docs/file-index-extension.md +++ b/docs/file-index-extension.md @@ -17,7 +17,7 @@ | `src/extension/server-probe.ts` | TCP probe to detect running server | | `src/extension/server-launcher.ts` | Auto-start server as detached process; captures **both stdout AND stderr** to `~/.pi/dashboard/server.log` (append mode) by passing `stdoutFd: logFd` alongside `logFd` — parity with `pi-dashboard start`'s `stdio: ["ignore", logFd, logFd]`. Exports pure `buildSpawnDetachedOptions` + `buildReadyTimeoutMessage`; latter appends `nodejs/node#58515` upgrade hint when `isKnownBadNode(process.version)` true. | | `src/extension/command-handler.ts` | Command routing: `!`/`!!` bash, `/compact`, slash commands | -| `src/extension/prompt-expander.ts` | Slash command → prompt template expansion (supports colon-to-hyphen aliasing: `/opsx:cmd` → `opsx-cmd.md`). **Skill expansions wrap output in pi's `...\n\nargs` envelope** via `buildSkillBlock` from `pi-dashboard-shared/skill-block-parser.js`; detection via `isSkillResolution(templateName, filePath, pi)` (templateName starts with `skill:` OR pi.getCommands fallback returned `source: "skill"`). Plain prompt templates continue to emit unwrapped `body\n\nargs`. See change: render-skill-invocations-collapsibly. | +| `src/extension/prompt-expander.ts` | Slash command → prompt template expansion (supports colon-to-hyphen aliasing: `/opsx:cmd` → `opsx-cmd.md`) | | `src/extension/dev-build.ts` | Dev build-on-reload helper (client build + server shutdown) | | `src/extension/server-auto-start.ts` | mDNS-first discovery → health check fallback → auto-start with concurrent launch detection | | `src/extension/process-metrics.ts` | Lightweight CPU/memory/event-loop metrics collector for heartbeats | @@ -30,7 +30,5 @@ | `src/extension/ask-user-tool.ts` | `ask_user` tool registration (bundled in bridge, registered at session_start to avoid static tool-name conflicts). `multiselect` dispatches through `polyfillMultiselect` (pi-coding-agent's `ExtensionUIContext` no native `multiselect`). Tool description instructs agents: *"UI provides a Select all toggle; do not add one."* **Schema shape**: `parameters` is single flat `Type.Object` (root `type: "object"`) — NOT root `Type.Union` — because OpenAI's function-calling validator rejects root-level `anyOf` with *"schema must be a JSON Schema of 'type: \"object\"'"*. To restore Anthropic-friendly per-method strictness after commit a53933f, root object carries body-level `oneOf` discriminator over `method` (confirm/select/multiselect/input/batch) with per-arm `required` + `minItems`; sub-questions use same flat-object + `oneOf` with no `batch` arm (no nesting). `prepareArguments` provides runtime rescue (`params` unwrap, `question`→`title`, stringified `options`/`questions`, batch synthesis/title backfill, `{label,value}` → labels); `execute` retains empty-options guardrails. See changes: ask-user-multiselect-polyfill, refactor(schema)-restructure-ask-user-tool-schemas, fix-multiselect-auto-cancel-on-dashboard. | | `src/extension/multiselect-polyfill.ts` | `polyfillMultiselect(ctx, title, options, opts)` — primary path delegates to bridge-patched `ctx.ui.multiselect` (PromptBus → `DashboardDefaultAdapter` → client `MultiselectRenderer`). Legacy fallback uses `ctx.ui.custom()` + `MultiSelectList` for older / non-bridge contexts; fallback **no-op in pi 0.70 RPC mode** (dashboard headless sessions) — pi-coding-agent defines `custom` as `async () => undefined` there — only effective in pure-TUI sessions if future pi version restores `ctx.ui.custom` in RPC mode. Resolves to `string[]` (confirmed, including empty `[]`) or `undefined` (cancelled). Used for both single-question + batch sub-question `multiselect` paths in `ask-user-tool.ts`. See changes: fix-multiselect-auto-cancel-on-dashboard, fix-multiselect-tui-arm-self-cancel. | | `src/extension/multiselect-list.ts` | `MultiSelectList` component implementing pi-tui's `Component` interface. Keyboard contract: `↑↓`/`k`/`j` navigate, `Space` toggles current, `Enter` confirms (selected values in original option order), `Escape` cancels. **No "select all" binding in TUI** — dashboard adapter provides that affordance. | -| `src/extension/provider-register.ts` | Reads `~/.pi/agent/providers.json`, calls `pi.registerProvider()` with auto-discovered models, exports `reloadProviders(pi)` + `onProviderChanged(cb)`. `reloadProviders` diffs current file against module-level `lastRegistered` snapshot + applies add/remove/change via `registerEntry` / `pi.unregisterProvider`. Called from bridge's `credentials_updated` handler BEFORE `modelRegistry.refresh()` so new providers appear in `/model` without session restart. Every discovered model enriched via `enrichModelMetadata(id, api, probe)` where `probe` wraps pi's `modelRegistry.find()` (captured from `ctx.modelRegistry` at first `session_start`; `model_select` fallback capture point) — resolves `contextWindow`, `maxTokens`, `reasoning`, `cost`, `input` for catalog-known models (e.g. `proxy/cc/claude-opus-4-7` → 1M ctx / reasoning / Opus pricing); falls back to api-appropriate defaults (`anthropic-messages` → 200k/64k, `google-generative-ai` → 1M/65k, `openai-completions` → 128k/16k). Fallback keeps `input: ["text", "image"]` so pasted images reach vision-capable models without pi-ai's `downgradeUnsupportedImages` stripping client-side; text-only models ignore image silently or return user-visible 400. See changes: enrich-custom-provider-model-metadata, enable-image-input-custom-providers. Adds `_buildProviderCatalogue(modelRegistry, piAi)` (pure) + `buildProviderCatalogue()` (uses captured registry + lazy `import("@mariozechner/pi-ai")` for env var hints). Pushed alongside `models_list` from `bridge.ts` (3 sites) + `session-sync.ts` (2 sites) + `command-handler.ts` `request_providers` case + `credentials_updated` handler. Server caches in `provider-catalogue-cache.ts`; consumed by `getAuthStatus()` for full provider list. See change: replace-hardcoded-provider-lists. See change: fix-custom-provider-flag-race — `lastRegistered.set(name, ...)` runs synchronously at start of `registerEntry`, before `await discoverModels(...)`. First `providers_list` push (from `session_start` after `activate()` kicks off async `registerEntry()`) carries `custom: true` flags even when `/v1/models` slow/unreachable, so custom providers from `~/.pi/agent/providers.json` don't leak into Settings → Provider Authentication → API Keys. | -| `packages/extension/src/retry-tracker.ts` | Pure helper class `RetryTracker` synthesizes `auto_retry_start` / `auto_retry_end` events from observed `message_end` / `agent_end`. pi ExtensionAPI does NOT expose `auto_retry_*` to extensions (verified pi 0.70/0.73; tracked at https://github.com/badlogic/pi-mono/discussions/2073). Exports `RETRYABLE_PATTERN` regex copied verbatim from pi-coding-agent's `_isRetryableError`. See change: fix-provider-retry-infinite-loop. | -| `packages/extension/src/usage-limit-orderer.ts` | Pure helper class `UsageLimitOrderer` watches bridge `agent_end` flow + synthesizes `auto_retry_end { success: false }` BEFORE `agent_end` when terminal `errorMessage` matches `USAGE_LIMIT_PATTERN` (usage_limit_reached / usage_not_included / quota_exceeded / monthly limit / hourly limit / reset after Nh). UX ordering only — no behavior change. See change: fix-provider-retry-infinite-loop. | -| `packages/extension/src/vcs-info.ts` (was `git-info.ts`) | Renamed in change `add-jj-workspace-plugin`. Hosts both `gatherGitInfo(cwd)` (unchanged) + `gatherJjInfo(cwd)` which short-circuits to `undefined` when tool registry can't resolve `jj` OR when `/.jj/` doesn't exist (single `fs.existsSync` before any subprocess). Module-level cache on `jj` resolvability via `getDefaultRegistry().resolve("jj")`; `_resetJjAvailableForTests()` is test-only hook. Bridge wires `sendJjStateIfChanged` alongside `sendGitInfoIfChanged` at all three sites (initial send + 30 s poll tick + session-change restart). Server `event-wiring.ts` consumes `jj_state_update` ExtensionToServerMessage + broadcasts via existing `session_updated` channel. | +| `src/extension/provider-register.ts` | Reads `~/.pi/agent/providers.json`, calls `pi.registerProvider()` with auto-discovered models, exports `reloadProviders(pi)` + `onProviderChanged(cb)`. `reloadProviders` diffs current file against module-level `lastRegistered` snapshot + applies add/remove/change via `registerEntry` / `pi.unregisterProvider`. Called from bridge's `credentials_updated` handler BEFORE `modelRegistry.refresh()` so new providers appear in `/model` without session restart. Every discovered model enriched via `enrichModelMetadata(id, api, probe)` where `probe` wraps pi's `modelRegistry.find()` (captured from `ctx.modelRegistry` at first `session_start`; `model_select` fallback capture point) — resolves `contextWindow`, `maxTokens`, `reasoning`, `cost`, `input` for catalog-known models (e.g. `proxy/cc/claude-opus-4-7` → 1M ctx / reasoning / Opus pricing); falls back to api-appropriate defaults (`anthropic-messages` → 200k/64k, `google-generative-ai` → 1M/65k, `openai-completions` → 128k/16k). Fallback keeps `input: ["text", "image"]` so pasted images reach vision-capable models without pi-ai's `downgradeUnsupportedImages` stripping client-side; text-only models ignore image silently or return user-visible 400. See changes: enrich-custom-provider-model-metadata, enable-image-input-custom-providers | +| `packages/extension/src/vcs-info.ts` | Git info gathering: `gatherGitInfo(cwd)` detects branch, remote URL, and PR number via shared `platform/git.js`. | diff --git a/docs/file-index-infra.md b/docs/file-index-infra.md new file mode 100644 index 000000000..24baf35ef --- /dev/null +++ b/docs/file-index-infra.md @@ -0,0 +1,34 @@ +# File Index — Infrastructure (seed, sandbox, skills) + +Covers: `seed/`, `sandbox/`, `.pi/skills/sandbox-designer/`. Read this split when locating an infrastructure file or understanding its responsibilities. + +> **Update protocol**: see `AGENTS.md` → "Documentation Update Protocol". Rows included here are ≤ 200 characters for AGENTS.md consumption; full annotations live here. + +## Rows + +| File | Purpose | +|---|---| +| `seed/active-project/` | Fake workspace: 3 sessions (ask_user waiting, streaming, completed), 2 pinned dirs, flows | +| `seed/empty-workspace/` | Fake workspace: 0 sessions, landing-page state, spawn-cta affordance | +| `seed/error-states/` | Fake workspace: disconnected session card, failed tool calls, error banner | +| `seed/multi-folder/` | Fake workspace: 4 pinned dirs with 2-4 sessions each, folder focus/compaction | +| `seed/openspec-heavy/` | Fake workspace: 3 active OpenSpec changes, 2 archived, attach/detach flow | +| `sandbox/Dockerfile` | Docker image: node:22-bookworm-slim + pi + openspec + dashboard deps | +| `sandbox/docker-compose.yml` | Two-service composition: dashboard (:8000) + headless Chromium (:9222) | +| `sandbox/entrypoint.sh` | Dashboard container entrypoint: start pi-dashboard → poll /api/health → tail logs | +| `.pi/skills/sandbox-designer/SKILL.md` | Vision-capable design agent: before-screenshots + user story → Tailwind HTML mockup | + +## Seed workspace format + +Each workspace under `seed/` is a self-contained subdirectory: + +- `*.jsonl` — Session event files in native pi format (one JSON object per line). +- `*.meta.json` — Session metadata sidecars (cwd, status, model, tokens, cost, attachedProposal). +- `preferences.json` — Pinned directories + per-directory session order. +- `README.md` — Documents covered UI states. + +Dashboard server reads these natively — no mock adapter, no fixtures. Format matches real `~/.pi/agent/sessions/` layout. + +## Growth via archive-merge + +Seed data grows when OpenSpec changes contribute `seed.patch` / `Dockerfile.patch`. Applied by `openspec-archive-change` skill BEFORE `openspec archive` moves the change directory. diff --git a/docs/file-index-plugins.md b/docs/file-index-plugins.md index f8e46c249..9ada4d3a0 100644 --- a/docs/file-index-plugins.md +++ b/docs/file-index-plugins.md @@ -11,7 +11,7 @@ | `packages/dashboard-plugin-runtime/src/slot-registry.ts` | `createSlotRegistry()` — typed `Map` pre-sorted by `(priority, pluginId)`. Filter helpers: `forSession`, `forFolder`, `forCommand`, `forTab`, `forToolName`. | | `packages/dashboard-plugin-runtime/src/manifest-validator.ts` | Hand-rolled manifest validator. Throws `ManifestValidationError` with `pluginId` + `reason`. No Zod dep. | | `packages/dashboard-plugin-runtime/src/plugin-context.tsx` | `PluginContextProvider`, `CurrentPluginLayer`, `usePluginConfig()`, `useAllSessions`, `useSessionState`, `usePluginLogger`, `usePluginSend`, `usePluginRouter`, `useSlotRegistry`, `applyPluginConfigUpdate`. Per-plugin context layer scopes hooks to contributing plugin's id. | -| `packages/dashboard-plugin-runtime/src/slot-consumers.tsx` | One component per slot id: `SidebarFolderSectionSlot`, `SessionCardBadgeSlot`, `SessionCardActionBarSlot`, `ContentViewSlot`, `ContentHeaderStickySlot`, `ContentInlineFooterSlot`, `AnchoredPopoverSlot`, `CommandRouteSlot`, `SettingsSectionSlot`, `ToolRendererSlot`. Each wraps contributions in `SlotErrorBoundary`. — Adds `SessionCardMemorySlot` + `WorkspaceActionBarSlot` consumers and `useSlotHasClaimsForSession(slotId, session)` hook for parent containers to conditionally render. See change: redesign-session-card-subcards. | +| `packages/dashboard-plugin-runtime/src/slot-consumers.tsx` | One component per slot id: `SidebarFolderSectionSlot`, `SessionCardBadgeSlot`, `SessionCardActionBarSlot`, `ContentViewSlot`, `ContentHeaderStickySlot`, `ContentInlineFooterSlot`, `AnchoredPopoverSlot`, `CommandRouteSlot`, `SettingsSectionSlot`, `ToolRendererSlot`. Each wraps contributions in `SlotErrorBoundary`. | | `packages/dashboard-plugin-runtime/src/slot-error-boundary.tsx` | Per-claim React error boundary. Logs `[slot-error-boundary] Plugin "" slot "" threw:` + renders nothing for failing claim without suppressing siblings. | | `packages/dashboard-plugin-runtime/src/vite-plugin/index.ts` | `viteDashboardPluginsPlugin(repoRoot?)` — generates `packages/client/src/generated/plugin-registry.tsx` with named imports (tree-shaking). Watches manifests during dev; regenerates + triggers HMR on changes. Filters `fixture:true` plugins in production. Invoked from packages/client/vite.config.ts via dynamic import (see change: wire-plugin-registry-into-shell). | | `packages/dashboard-plugin-runtime/src/server/loader.ts` | `discoverPlugins(repoRoot?)` (single-process-cache glob), `loadServerEntries(deps)` (per-plugin dynamic-import + `registerPlugin` invocation, failure-isolated), `getPluginStatusStore()`. | @@ -24,15 +24,4 @@ | `packages/flows-plugin/package.json` | **NEW** workspace package (change: extract-flows-as-plugin). Carries `pi-dashboard-plugin` manifest claiming `session-card-badge` (FlowActivityBadge, predicate `hasActiveFlow`) + `session-card-action-bar` (SessionFlowActions). Exports `./client` (component barrel) + `./reducer` (flow + architect reducer barrel). Imported by `packages/client` as workspace dep. Richer slot claims (`content-header-sticky`, `content-view`, `content-inline-footer`) deferred to follow-up `migrate-flows-jsx-to-slots` pending slot prop contract extension or component self-derivation refactor. | | `packages/flows-plugin/src/client/index.tsx` | Re-export barrel for `FlowDashboard`, `FlowAgentCard`, `FlowAgentDetail`, `FlowSummary`, `FlowGraph`, `FlowArchitect`, `FlowArchitectDetail`, `FlowActivityBadge`, `FlowLaunchDialog`, `FlowTabBar`, `SessionFlowActions`, `ArchitectInputPrompt`. Also exports `hasActiveFlow(session)` predicate (consumed by manifest's `session-card-badge` claim). Cross-package shared utilities (`MarkdownContent`, `DialogPortal`, `AgentCardShell`, `ConfirmDialog`, `SearchableSelectDialog`, `useZoomPan`, `useMobile`, `ZoomControls`, `agent-card-utils`, `BreadcrumbSlot`, `GateSlot`, `AgentMetricSlot`) imported via deep relative paths back into `packages/client/src/` — known v1 debt; promotion to shared client-utils package tracked as follow-up. | | `packages/flows-plugin/src/reducer.ts` | Re-export barrel for `isFlowEvent`, `reduceFlowEvent`, `isArchitectEvent`, `reduceArchitectEvent`. Imported by `packages/client/src/lib/event-reducer.ts` as `@blackbelt-technology/pi-dashboard-flows-plugin/reducer`. | -| `packages/honcho-plugin/src/client/HonchoBadge.tsx` | Session-card badge for honcho state. `useIsLightTheme` MutationObserver hook on `` placed before early return (rules-of-hooks). `STATE_STYLE_DARK` (existing 300-shade fg) + `STATE_STYLE_LIGHT` (700-shade fg + slightly stronger tint) per-state palettes; light variant gives AA contrast on `--bg-tertiary` (`#f0f0f0`). See change: light-mode-pill-contrast. | -| `packages/honcho-plugin/src/server/auto-mint-proxy-key.ts` | Auto-mints `pi-proxy-*` key against integrated `/v1/*` proxy on first Honcho install. Pure helper `ensureIntegratedProxyKey(cfg, deps)` + IO wrapper `autoMintAndPersist(cfgPath, logger)`. Idempotent: skips when `selfHost.llm.{apiKey, baseUrl}` set or source ≠ `pi-model-proxy`. Probes `/v1/models` for default (preference walk → first anthropic → first overall → fallback `anthropic/claude-haiku-4-5`). Writes `selfHost.llm = { source: openai-compatible, baseUrl: http://host.docker.internal:/v1, apiKey, model }`. Hooked into `runAutoStart` (index.ts) + `startStack` (routes-lifecycle.ts) before `ensureComposeFile`. See change: honcho-auto-mint-proxy-key. | -| `packages/honcho-plugin/src/server/compose-template.ts` | Renders Honcho docker-compose.yml from plugin config. `openai-compatible` branch sets `extraHosts: true` when `baseUrl` contains `host.docker.internal` so docker container reaches host. See change: honcho-auto-mint-proxy-key. | -| `packages/honcho-plugin/src/server/index.ts` | Honcho plugin server entry. Registers REST routes + `runAutoStart`; calls `autoMintAndPersist` before `ensureComposeFile` on first install. See change: honcho-auto-mint-proxy-key. | -| `packages/honcho-plugin/src/server/routes-lifecycle.ts` | Honcho stack lifecycle handlers (`startStack`, stop, status). `startStack` invokes `autoMintAndPersist` before `ensureComposeFile` so integrated proxy creds land in compose env. See change: honcho-auto-mint-proxy-key. | -| `packages/jj-plugin/package.json` | **NEW** workspace package (change: add-jj-workspace-plugin). Carries `pi-dashboard-plugin` manifest claiming `session-card-badge` (`JjWorkspaceBadge`, predicate `isInJjWorkspace`), `session-card-action-bar` (`JjActionBar`, predicate `isInJjRepo`), `sidebar-folder-section` (`JjWorkspaceList`), `command-route /jj` (`JjWorkspaceView`), `settings-section` general tab (`JjPluginSettings`). JSON Schema 7 configSchema: `defaultPushTarget`, `workspaceRoot` (default `.shadow`), `allowDirectTrunkPush` (default `false`), `showInitColocatedSuggestion` (default `false` — plain-git affordance opt-in). Activation gate: every claim's predicate returns `false` when bridge probe didn't populate `Session.jjState`, which only happens when tool registry resolves `jj` AND `.jj/` exists in cwd — zero UI when jj not installed. | -| `packages/jj-plugin/src/client/predicates.ts` | Pure slot-claim predicates: `isInJjRepo` (jjState.isJjRepo === true), `isInJjWorkspace` (jjState.isJjRepo + workspaceName), `isInGitRepoButNotJj` (gitBranch present + !jjState?.isJjRepo). Used by manifest claims AND directly by components for inline gating. Truth-table-tested in `predicates.test.ts`. | -| `packages/jj-plugin/src/client/JjActionBar.tsx` | Session-card row of buttons (Workspace / Fold back / Forget). Forget two-step contract: first request sends `force:false`; HTTP 409 `UNFOLDED_WORK` opens `JjForgetConfirmDialog` listing commits about to be lost; user confirms → re-issues with `force:true`. Per Decision 10. | -| `packages/jj-plugin/src/client/JjFoldBackDialog.tsx` | Pre-flight UX for fold-back skill. Three modes: preserve (default), squash, pr. Builds skill-invocation prompt via pure `buildFoldBackPrompt(workspaceName, mode)` (testable) + copies to clipboard — actual `jj` execution happens through agent's bash tool driven by `.pi/skills/jj-workspace-fold-back/SKILL.md`. Per Decision 5: "fold-back is a skill, not a button". | -| `packages/jj-plugin/src/client/JjWorkspaceBadge.tsx` | Session-card badge claim (predicate `isInJjWorkspace`). Renders `inline-flex items-center gap-1` pill with leading `mdiSourceFork` icon (size 0.5) + workspace name. Local `useIsLightTheme` hook (MutationObserver on ``) drives palette flip: indigo-300 fg (dark) → indigo-700 fg (light) for AA contrast on `--bg-tertiary`. See change: light-mode-pill-contrast. | -| `packages/jj-plugin/src/client/api.ts` | REST client for `/api/jj/workspace/{add,forget,list}` + `/api/jj/init-colocated`. Returns discriminated `{ok:true, data} \| {ok:false, status, code, data, message}` so callers branch on stable error codes (`UNFOLDED_WORK`, `DIRTY_INDEX`, `INVALID_NAME`, `ALREADY_JJ`, `NOT_GIT_REPO`) without try/catch. | | **Moved to flows-plugin** | `FlowDashboard.tsx` → `packages/flows-plugin/src/client/FlowDashboard.tsx` (sticky flow card grid above ChatView). `FlowAgentCard.tsx` (status/tools/tokens). `FlowAgentDetail.tsx` (full content-area). `FlowSummary.tsx` (post-completion summary). `FlowActivityBadge.tsx` (session card badge). `FlowLaunchDialog.tsx` (task input). `SessionFlowActions.tsx` (searchable picker). `FlowGraph.tsx`, `FlowArchitect.tsx`, `FlowTabBar.tsx`. All moved via `git mv` (history preserved). Shell still imports them directly via `@blackbelt-technology/pi-dashboard-flows-plugin/client` — JSX-to-slot-consumer migration deferred. See change: extract-flows-as-plugin. | diff --git a/docs/file-index-server.md b/docs/file-index-server.md index 8ef7ca76f..a252aa87d 100644 --- a/docs/file-index-server.md +++ b/docs/file-index-server.md @@ -17,14 +17,14 @@ | `src/server/routes/file-routes.ts` | REST routes: file read, browse (with `detect=0\|1` opt-in classifier), browse-flags (bulk classifier), browse-mkdir, readme, pinned-dirs. See change: split-browse-flags | | `src/server/routes/openspec-routes.ts` | REST routes: openspec-archive, pi-resources, pi-resource-file | | `src/server/routes/system-routes.ts` | REST routes: config, health, shutdown, tunnel, editors | -| `src/server/event-wiring.ts` | Pi gateway → browser gateway event forwarding (replay suppression via `skipReplayInsert` dedup, flows refresh dedup, context usage extraction). Phase-1 Extension UI: caches `ui_modules_list` on `Session.uiModules` + broadcasts; caches `ui_data_list` on `Session.uiDataMap[event]` with per-event item cap (default 1000, last-write-wins) + broadcasts. Phase-2: `ext_ui_decorator` arm caches descriptors under `` Session.uiDecorators[`${kind}:${namespace}:${id}`] `` (upsert, or delete when `removed: true`) + broadcasts verbatim; deleting absent key = no-op but still broadcasts. **Last-activity stamping** (change: session-card-last-activity-badge): every live (non-replay) `event_forward` whose `eventType` passes `isActivityEvent(...)` (`event-status-extraction.ts` allowlist: `prompt_send`, `message_*`, `turn_end`, `tool_execution_*`, `agent_*`, `bash_output`, `flow_*`, `architect_*`) updates `session.lastActivityAt = Date.now()` in memory + broadcasts ≤ 1×/30 s/session via `lastActivityBroadcastAt: Map`; map entry dropped on `session_unregister` so re-register doesn't silently suppress its first broadcast. See changes: add-extension-ui-modal, add-extension-ui-decorations, session-card-last-activity-badge. **Unread-trigger evaluation** (change: session-card-unread-stripes): right after `extractSessionUpdates`, snapshots `{status, currentTool}` before/after + calls `isUnreadTrigger(eventType, before, after, payload)`; if true AND `viewedSessionTracker.isViewedByAnyone(sessionId) === false` AND `!replayingSessions.has(sessionId)`, stamps `session.unread = true` + broadcasts `session_updated`. `viewedSessionTracker` optional on `EventWiringDeps` for back-compat — wiring opt-in. **Rename-site defense-in-depth** (change: fix-uuid-rename-bug): auto-attach branch re-validates `detected.changeName` via `isValidOpenSpecChangeSlug` from shared detector module; predicate failure skips entire `activityUpdates` + auto-attach + `rename_session` block. Second regression of the same class as fix-openspec-flag-rename-bug justified the duplication. Manual paths (`session-meta-handler.ts`, REST `/attach-proposal`) intentionally unchanged. See change: fix-uuid-rename-bug. See change: fix-providers-list-spurious-models-refreshed — `providers_list` handler at line 628 reads `{changed}` from `setCatalogueForSession`, broadcasts `models_refreshed` only when `changed===true`. Identical re-pushes (browser subscribe → `request_providers` round-trip, `session_register`, reconnect, fork/resume) become silent. Eliminates global `modelsMap` wipe that left previously-visited sessions with disabled model selector. See change: simplify-model-selection-channels — supersedes fix-providers-list-spurious-models-refreshed: `models_refreshed` broadcast removed from `providers_list` handler entirely. Cache write via `setCatalogueForSession(sid, providers)` (void return). | +| `src/server/event-wiring.ts` | Pi gateway → browser gateway event forwarding (replay suppression via `skipReplayInsert` dedup, flows refresh dedup, context usage extraction). Phase-1 Extension UI: caches `ui_modules_list` on `Session.uiModules` + broadcasts; caches `ui_data_list` on `Session.uiDataMap[event]` with per-event item cap (default 1000, last-write-wins) + broadcasts. Phase-2: `ext_ui_decorator` arm caches descriptors under `` Session.uiDecorators[`${kind}:${namespace}:${id}`] `` (upsert, or delete when `removed: true`) + broadcasts verbatim; deleting absent key = no-op but still broadcasts. **Last-activity stamping** (change: session-card-last-activity-badge): every live (non-replay) `event_forward` whose `eventType` passes `isActivityEvent(...)` (`event-status-extraction.ts` allowlist: `prompt_send`, `message_*`, `turn_end`, `tool_execution_*`, `agent_*`, `bash_output`, `flow_*`, `architect_*`) updates `session.lastActivityAt = Date.now()` in memory + broadcasts ≤ 1×/30 s/session via `lastActivityBroadcastAt: Map`; map entry dropped on `session_unregister` so re-register doesn't silently suppress its first broadcast. See changes: add-extension-ui-modal, add-extension-ui-decorations, session-card-last-activity-badge. **Unread-trigger evaluation** (change: session-card-unread-stripes): right after `extractSessionUpdates`, snapshots `{status, currentTool}` before/after + calls `isUnreadTrigger(eventType, before, after, payload)`; if true AND `viewedSessionTracker.isViewedByAnyone(sessionId) === false` AND `!replayingSessions.has(sessionId)`, stamps `session.unread = true` + broadcasts `session_updated`. `viewedSessionTracker` optional on `EventWiringDeps` for back-compat — wiring opt-in. | | `src/server/idle-timer.ts` | Auto-shutdown idle timer with sleep-wake resilience | | `src/server/session-bootstrap.ts` | Startup session discovery + OpenSpec polling init | | `src/server/pi-gateway.ts` | Extension WebSocket gateway (port 9999) | | `src/server/browser-gateway.ts` | Browser WebSocket gateway (dispatches to handler modules). **On-connect snapshot** (change: fix-stale-sessions-on-reconnect): single `sessions_snapshot` (built from `sessionManager.listAll()` + `sessionOrderManager.getAllOrders()` filtered to non-empty arrays) replaces prior per-session `session_added` loop + per-cwd `sessions_reordered` loop. Emitted before `pinned_dirs_updated` / `openspec_update` / `terminal_added`. Live updates after snapshot still use incremental messages. | | `packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts` | Pins exactly-one `sessions_snapshot` per connect; alive+ended sessions both present; empty `orders` arrays filtered; snapshot ordered before `pinned_dirs_updated`. See change: fix-stale-sessions-on-reconnect. | | `src/server/browser-handlers/handler-context.ts` | Shared context type for browser message handlers | -| `src/server/browser-handlers/subscription-handler.ts` | Subscribe/unsubscribe with async batched replay, backpressure, lazy loading. Exports `replayUiState(ws, sessionId, ctx)` for Extension UI System; called immediately after every `replayPendingUiRequests` site (4 sites). Replay sends cached `ui_modules_list` (Phase 1) → one `ui_data_list` per `(event, items)` entry (Phase 1) → one `ext_ui_decorator` per `Session.uiDecorators` cache entry (Phase 2; never with `removed: true` since deleted entries absent). Replay ordering: events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator. See changes: add-extension-ui-modal, add-extension-ui-decorations, fix-cold-subscribe-replay-interleave. | +| `src/server/browser-handlers/subscription-handler.ts` | Subscribe/unsubscribe with async batched replay, backpressure, lazy loading. Exports `replayUiState(ws, sessionId, ctx)` for Extension UI System; called immediately after every `replayPendingUiRequests` site (4 sites). Replay sends cached `ui_modules_list` (Phase 1) → one `ui_data_list` per `(event, items)` entry (Phase 1) → one `ext_ui_decorator` per `Session.uiDecorators` cache entry (Phase 2; never with `removed: true` since deleted entries absent). Replay ordering: events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator. See changes: add-extension-ui-modal, add-extension-ui-decorations. | | `src/server/browser-handlers/session-action-handler.ts` | Send prompt, abort, resume, spawn, shutdown, force kill, flow control. `handleSpawnSession` accepts optional `SpawnSessionBrowserMessage.attachProposal` + enqueues into `pendingAttachRegistry` BEFORE awaiting `spawnPiSession(...)` so fast `session_register` cannot lose intent (change: add-folder-task-checker-and-spawn-attach). `handleForceKill` delegates SIGTERM→wait→SIGKILL escalation to `killProcess` from `platform/process.ts` so Windows gets `taskkill /F /T /PID` (genuine tree kill). No direct `process.kill()` anywhere (enforced by `no-direct-process-kill.test.ts`). `handleSendPrompt` intercepts `/reload` on headless sessions (gated by `headlessPidRegistry.getPid`) + delegates to `handleHeadlessReload` — SIGTERMs pi + respawns with `--session --mode continue`. Pi 0.68.0 has no extension-accessible reload in RPC mode, so kill-and-respawn is only way to reload settings/extensions/skills for headless. See changes: route-kill-paths-through-platform, headless-reload-via-respawn. | | `src/server/browser-handlers/session-action-helpers.ts` | Pure helpers for session-action-handler. `shouldInterceptReload(msg, headlessPidRegistry)` gates headless-reload interception: exact `/reload` text, no images, PID tracked in registry. Extracted for testability. | | `src/server/browser-handlers/session-meta-handler.ts` | Rename, hide, unhide, attach/detach proposal, fetch, list. Attach/detach apply **idempotent auto-rename rule** via pure helpers in `packages/server/src/proposal-attach-naming.ts`: attach renames when name empty OR equals current `attachedProposal` (auto-set witness); detach reverts name only when that equality holds. Same helpers reused by REST endpoints in `session-api.ts` to keep WS + REST in lockstep. See change: fix-mobile-attach-proposal-display. | @@ -42,10 +42,7 @@ | `src/server/routes/tool-routes.ts` | REST routes: `GET /api/tools`, `GET /api/tools/:name`, `POST /api/tools/rescan`, `PUT/DELETE /api/tools/:name`, `POST /api/tools/diagnostics` (text/plain export) | | `packages/server/src/bootstrap-state.ts` | In-memory bootstrap state store (`createBootstrapState()`, `BootstrapState { status, progress, error, version, compatibility, bridgeRegistrationError }`, `BootstrapStateStore` with get/set/subscribe/dispose plus side-channel `setLastInstallPackages` / `getLastInstallPackages` so `POST /api/bootstrap/retry` re-runs exact failed set instead of hard-coded default). Partial `set()` supports `undefined` = clear semantics; `setLastInstallPackages` does NOT trigger subscribers (not part of broadcast snapshot). | | `packages/server/src/routes/bootstrap-routes.ts` | REST routes: `GET /api/bootstrap/status`, `POST /api/bootstrap/upgrade-pi` (202+ticketId or 409), `POST /api/bootstrap/retry` (202 if failed, else 409). Trigger callbacks injected so CLI wires them to `bootstrapInstall` while tests wire them to spies. | -| `packages/server/src/routes/doctor-routes.ts` | Fastify plugin. `GET /api/doctor` returns `{checks, summary, generatedAt}`. Calls `runSharedChecks` with server deps. Auth-gated identically to `/api/config`. Top-level `try/catch` returns 200 fallback row on internal throw (never 500). Omits Electron-only rows. | | `packages/server/src/bootstrap-queue.ts` | In-memory ticket queue (`createBootstrapQueue()`, `enqueue(handler)`, `flushAll()`, `size()`, `clear(reason)`, `onTicketComplete(listener)`). `server.ts` flushes on bootstrap-state transition to "ready" + wires `onTicketComplete` → `bootstrap_ticket_complete` WS broadcast so browsers holding 202 ticketId learn queued op's outcome. `session-api.ts gateOrEnqueue` uses queue to defer session spawn during installs. On `clear`, pending tickets rejected directly (reject closure stored on entry) so no caller hangs at shutdown. | -| `packages/server/src/changelog-fs.ts` | `findChangelogPath(pkg)` resolves CHANGELOG.md (managed > bare-import). `readPackageJson(pkg)`. `deriveChangelogUrl(pkgJson)` from `repository` `github:` / `https` / object / monorepo `directory`. See change: pi-update-whats-new-panel. | -| `packages/server/src/changelog-parser.ts` | `parseChangelog(text)` Keep-a-Changelog regex parser. `readAndParseChangelog(path)` mtime-keyed 60s cache. `invalidateChangelogCache()` called from `PiCoreChecker.invalidate`. See change: pi-update-whats-new-panel. | | `packages/server/src/pi-version-skew.ts` | Pi compatibility range reader: `readPiCompatibility` reads `piCompatibility` from `packages/server/package.json`; `readCurrentPiVersion` via `createRequire` with `fs.realpathSync` on registry-resolved bin path so symlinked npm-global launchers resolve to real module's `package.json`. Comparator: `parseVersion`, `compareVersions`, `isBelow`, `isAbove` (supports `0.x` wildcard). `updateBootstrapCompatibility(store, pkgPath)` writes result into `bootstrapState.compatibility` with 60 s cache; below-minimum adds 503-blocking `error` message. **CLI surface**: `cli.ts` calls `logCompatibilityWarning(bootstrapState)` after each `updateBootstrapCompatibility(...)` + emits stderr warning — 3-line red block on below-minimum (`⚠ pi X is below the required minimum Y … Run: pi-dashboard upgrade-pi`), single-line advisory on below-recommended, silent when in range. **Currently pinned**: `minimum: "0.70.0"`, `recommended: "0.70.0"`, `maximum: null` (lockstep — no back-compat for older pi). See changes: pi-zero-seventy-compat, warn-pi-version-skew-in-cli. | | `src/server/npm-search-proxy.ts` | Cached proxy for npm registry search (`keywords:pi-package`) + README fetch | | `src/server/routes/package-routes.ts` | REST routes: search, readme, installed, install, remove, update, check-updates | @@ -68,14 +65,13 @@ | `src/server/editor-detection.ts` | Auto-detect code-server/openvscode-server binary on PATH | | `src/server/routes/editor-routes.ts` | REST routes: editor start, stop, heartbeat, status, detect | | `src/server/event-status-extraction.ts` | Extracts session status/tool updates from events (incl. flow metadata). Hosts two pure classifiers consumed by `event-wiring.ts`: `isActivityEvent(eventType)` (allowlist driving `lastActivityAt` stamping; see change: session-card-last-activity-badge) + `isUnreadTrigger(eventType, before, after, payload)` (returns true on `streaming→idle\|active`, on `currentTool→"ask_user"`, + on `agent_end` with truthy `payload.error`; see change: session-card-unread-stripes). | -| `src/server/viewed-session-tracker.ts` | In-memory `Map>` registry of which browser has which session displayed (`/session/:id`). Created by `browser-gateway.ts`, exposed on `BrowserGateway.viewedSessionTracker`, threaded into `wireEvents({ ..., viewedSessionTracker })`. `view`/`unview` called from `session_view`/`session_unview` switch arms; `unviewAll(ws)` called on every WS `close` so disconnected browsers cannot hold sessions in viewed state. `isViewedByAnyone(sessionId)` gates unread-trigger stamp in `event-wiring.ts`. Read state GLOBAL across browsers — mirrors mail/Slack. In-memory only. See change: session-card-unread-stripes. Adds `providers_list` forwarder updating `provider-catalogue-cache.ts` and broadcasting `models_refreshed` to browsers. See change: replace-hardcoded-provider-lists. | +| `src/server/viewed-session-tracker.ts` | In-memory `Map>` registry of which browser has which session displayed (`/session/:id`). Created by `browser-gateway.ts`, exposed on `BrowserGateway.viewedSessionTracker`, threaded into `wireEvents({ ..., viewedSessionTracker })`. `view`/`unview` called from `session_view`/`session_unview` switch arms; `unviewAll(ws)` called on every WS `close` so disconnected browsers cannot hold sessions in viewed state. `isViewedByAnyone(sessionId)` gates unread-trigger stamp in `event-wiring.ts`. Read state GLOBAL across browsers — mirrors mail/Slack. In-memory only. See change: session-card-unread-stripes. | | `src/server/headless-pid-registry.ts` | Maps headless child PIDs → session IDs | | `src/server/auth.ts` | OAuth2 authentication: provider registry, JWT helpers, user allowlist | -| `src/server/provider-auth-handlers.ts` | Pi provider OAuth handlers (Anthropic, Codex, GitHub Copilot, Gemini CLI, Antigravity). Each handler carries `displayName` consumed by `getOAuthProvidersMeta()` + `_buildAuthStatus()`. See change: replace-hardcoded-provider-lists. | -| `src/server/provider-auth-storage.ts` | Reads/writes `~/.pi/agent/auth.json` with mkdir-lockfile + atomic rename. Exports `_buildAuthStatus(catalogue, authData, oauthHandlers)` pure: emits OAuth handler rows + API-key rows derived from bridge-pushed catalogue. OAuth/api-key collision uses `-api` suffix. Ambient catalogue rows force `authenticated:true` + `maskedKey:"(ambient)"`. `resolveAuthJsonKey(id)` strips `-api` suffix when bare id is OAuth handler. `OAUTH_PROVIDERS` + `API_KEY_PROVIDERS` arrays removed. See change: replace-hardcoded-provider-lists. | -| `src/server/provider-catalogue-cache.ts` | In-memory per-session `Map` + latest-snapshot tracker. Set by `event-wiring.ts` on `providers_list` from bridge. `getLatestCatalogue()` returns most recent push (empty before any bridge connects). Cleared by `clearForSession(id)`; `_resetForTests()` for unit tests. See change: replace-hardcoded-provider-lists. See change: fix-providers-list-spurious-models-refreshed — `setCatalogueForSession` returns `{changed:boolean}` from order-sensitive deep-equality vs prior cached payload (every `ProviderInfo` field incl. `custom`). `latestSnapshot` updates only on `changed`. Caller `event-wiring.ts` gates `models_refreshed` broadcast on `changed`; identical re-pushes (browser subscribe / `session_register` / reconnect / fork resume) become silent. See change: simplify-model-selection-channels — supersedes fix-providers-list-spurious-models-refreshed: catalogue cache collapsed to single `latest` var; broadcast removed entirely. `setCatalogueForSession` returns void; `getCatalogueForSession`/`clearForSession` exports removed. | -| `src/server/routes/provider-auth-routes.ts` | REST routes: provider OAuth authorize/exchange/callback, device-code, API key CRUD. `GET /api/provider-auth/status` cold-cache nudge: when catalogue empty, sends `request_providers` to every connected pi (best-effort, non-blocking). See change: replace-hardcoded-provider-lists. See change: simplify-model-selection-channels — `notifyBridges` helper no longer broadcasts `models_refreshed`; bridge `providers_list` push sole catalogue update channel. | -| `src/server/routes/provider-routes.ts` | REST routes: custom LLM provider CRUD (`GET/PUT /api/providers`) + **`POST /api/providers/test`** connection probe (reuses `provider-probe.ts`). See change: `hot-reload-custom-providers`. See change: simplify-model-selection-channels — `PUT /api/providers` no longer broadcasts `models_refreshed`; relies on bridge `providers_list` re-push via `credentials_updated`. | +| `src/server/provider-auth-handlers.ts` | Pi provider OAuth handlers (Anthropic, Codex, GitHub Copilot, Gemini CLI, Antigravity) | +| `src/server/provider-auth-storage.ts` | Read/write ~/.pi/agent/auth.json with lockfile for pi provider credentials | +| `src/server/routes/provider-auth-routes.ts` | REST routes: provider OAuth authorize/exchange/callback, device-code, API key CRUD | +| `src/server/routes/provider-routes.ts` | REST routes: custom LLM provider CRUD (`GET/PUT /api/providers`) + **`POST /api/providers/test`** connection probe (reuses `provider-probe.ts`). See change: `hot-reload-custom-providers` | | `src/server/provider-probe.ts` | Pure per-API-type probe builders (`buildProbeRequest` for `openai-completions`/`openai-responses`/`anthropic-messages`/`google-generative-ai`) + `resolveProbeApiKey` (handles literal, `$ENV_VAR`, `***` REDACTED sentinel via injected providers reader) + I/O-bearing `probeProvider` (8 s timeout, never echoes apiKey in error text, caps body excerpts at 500 chars). Used by `/api/providers/test` route + shared with bridge's discovery path | | `src/server/auth-plugin.ts` | Fastify plugin: auth routes, onRequest hook, WS upgrade validation | | `src/server/config-api.ts` | Config REST API: read (redacted), write (partial merge), secret preservation. `writeConfigPartial` auth-merge propagates `secret`, `providers`, `allowedUsers`, `bypassHosts`, `bypassUrls` (last two added by change `fix-trusted-networks-no-oauth` — silently dropped before, breaking Trusted Networks saves for users without OAuth). | @@ -89,12 +85,10 @@ | `src/server/restart-helper.ts` | Cross-platform `/api/restart` orchestrator: spawns detached `node -e` child using only Node built-ins (net, http) — no sh/lsof/curl dependency; exports pure `buildOrchestratorScript(params)` for testing. **Explicit prior-daemon kill** (change: fix-restart-bridge-auto-start-race): embedded script reads `~/.pi/dashboard/dashboard.pid`, sends `SIGTERM` to recorded PID if alive, polls for exit (3 s deadline), then `SIGKILL`. Removes "wait for self-exit" ambiguity that let bridges race orchestrator before this change. `portFree` poll reduced from 10 s → 5 s since step 0 already guarantees previous server is dead. | | `src/server/routes/recommended-routes.ts` | `GET /api/packages/recommended` — enrichment + 60s cache, invalidated on successful install/remove/update | | `src/server/resolve-path.ts` | Safe realpath resolution (symlink handling) | -| `packages/server/src/routes/pi-changelog-routes.ts` | `GET /api/pi-core/changelog?pkg&from&to`. Whitelist-validates `pkg` against `CORE_PACKAGE_NAMES`. Bootstrap-gated (503 unless ready). 200 + empty release list when CHANGELOG missing. See change: pi-update-whats-new-panel. | -| `packages/server/src/routes/jj-routes.ts` | REST routes: `POST /api/jj/workspace/add` (creates workspace via `jj.workspaceAdd` with auto-resolved `baseRev` from source `@`'s bookmark, enqueues into `pendingAttachRegistry`, calls `spawnPiSession` — same lever as openspec attach-and-spawn); `POST /api/jj/workspace/forget` (refuses HTTP 409 `UNFOLDED_WORK` when workspace has commits between `fork_point(name@, trunk())` and `name@`; on `force:true` runs `jj workspace forget` AND `fs.rm({recursive,force})` on workspace dir); `POST /api/jj/init-colocated` (refuses 409 `DIRTY_INDEX` only on staged changes, allows working-tree dirt per Decision 6 — `jj git init --colocate` snapshots unstaged edits as `@` non-destructively); `GET /api/jj/workspace/list?cwd=` (returns `parseWorkspaceList` entries). All auth-gated via `networkGuard`. Pure helper `checkInitColocatedPreconditions(cwd)` extracted for testability. | | `packages/server/src/spawn-failure-log.ts` | Appends/reads rolling NDJSON log of failed spawns (`~/.pi/dashboard/sessions/spawn-failures.log`). Single-shot rotation at 10 MB. See change: spawn-failure-diagnostics. | | `packages/server/src/spawn-preflight.ts` | Pure sync preflight: checks cwd exists/is-dir/writable + pi+node resolvable. Returns `PreflightResult { ok, reasons[] }`. useLoginShell must be false. See change: spawn-failure-diagnostics. | | `packages/server/src/spawn-register-watchdog.ts` | Arms per-spawn timer; fires `spawn_register_timeout` if pi never registers. byPid + byCwd maps. recentlyFired (60s TTL) emits `spawn_register_recovered`. See change: spawn-failure-diagnostics. | -| `packages/server/src/session-diff.ts` (extended) | New `enrichWithVcsDiff(cwd, files, jjState?)` dispatcher routes through `enrichWithJjDiff` when `jjState.isJjRepo` is true, otherwise existing `enrichWithGitDiff` path. Pure helper `selectJjDiffBase(jjState)` returns `@-` for default workspace (equivalent to `git diff HEAD`) + `fork_point(@, trunk())` for non-default workspaces — killer-feature fix that makes Changed Files show every commit agent produced across multiple `jj new`s, not just working-copy delta. `SessionDiffResponse` additively extended with optional `vcsKind`, `diffBase`, `baseLabel`; older clients ignore. `FileDiffView` header renders `(vs )` when `vcsKind === "jj"`. See change: add-jj-workspace-plugin Decision 9. | +| `packages/server/src/session-diff.ts` | `extractFileChanges(events, cwd)` scans tool_execution_start events for write/edit tools + enriches with `enrichWithGitDiff(cwd, files)`. Returns `{ enrichedFiles: FileDiffEntry[], isGitRepo: boolean }`. | | `src/server/session-diff.ts` | Server-side event scanning + git diff extraction for session file changes | | `src/server/session-api.ts` | REST wrappers for WebSocket-only session operations (prompt, abort, spawn, resume, etc.) | | `packages/server/src/extension-register.ts` | Auto-registers bundled bridge extension in pi's global settings on startup | diff --git a/docs/file-index-shared.md b/docs/file-index-shared.md index bbcc8044f..0d1e461e6 100644 --- a/docs/file-index-shared.md +++ b/docs/file-index-shared.md @@ -57,8 +57,6 @@ | `src/shared/terminal-types.ts` | TerminalSession type + control messages | | `src/shared/editor-types.ts` | Editor instance types shared across components | | `packages/shared/src/platform/managed-node-path.ts` | `getManagedNodeBinDir(managedDir, platform?)` + `prependManagedNodeToPath(env, managedDir, platform?)`: shallow-clones `env`, prepends `/node/` (Win) or `/node/bin` (Unix) to `PATH`. No-op when runtime absent. Never mutates `process.env`. Consumed by every spawned-child env builder (pi-session, pi-core-updater, headless, server-launcher) so managed `node`/`npm`/`npx` resolves first. See change: embed-managed-node-runtime. | -| `packages/shared/src/platform/jj.ts` | Recipe-based jj tool module mirroring `platform/git.ts`. 15 recipes covering version, workspace add/list/forget/root, bookmark create/list, git init colocate / push, diff (with `--from`/`--to` for regime-aware session-diff), resolve list, op log head / restore (fold-back rollback escape hatch per Decision 12), rebase, log revset (used to derive `baseRev` from source's `@` bookmark + to enumerate unfolded commits in forget). Pure `parseWorkspaceList` + `findWorkspaceByName` parsers handle jj's standard `: [(empty)] [(no description set) \| ]` output. No `child_process` import — enforced by `no-direct-child-process.test.ts`. | -| `packages/shared/src/types.ts::JjState` | Per-session jj probe state: `{ isJjRepo, isColocated, workspaceName?, workspaceRoot?, bookmarks?, lastError? }`. Surfaced as optional `DashboardSession.jjState` field. NOT persisted to `.meta.json` — live tool state, refreshed on every probe tick. Predicates (`isInJjRepo` / `isInJjWorkspace` / `isInGitRepoButNotJj`) read this field; absent = treated as not-jj. | | `src/shared/diff-types.ts` | Types for session file diff API (FileChangeEvent, FileDiffEntry, SessionDiffResponse) | | `packages/shared/src/recommended-extensions.ts` → `BUNDLED_EXTENSION_IDS` | Single source of truth for which recommended ids ship bundled in Electron installer. **Currently `["pi-anthropic-messages"]` only** — `"pi-flows"` removed in commit b9b3d7e because upstream repo (BlackBeltTechnology/pi-flows) declares no SPDX license (no `LICENSE` file, no `package.json#license`), and `bundle-recommended-extensions.sh`'s allowlist (MIT/Apache-2.0/BSD-2-Clause/BSD-3-Clause/ISC) correctly rejects it — was blocking every electron build matrix variant. Re-add `"pi-flows"` once upstream declares license. npm publish path unaffected (only electron build was broken). See `openspec/changes/archive/2026-04-21-bundle-first-party-extensions/design.md` for original design + license-blocker discussion. | | `packages/shared/src/__tests__/no-bash-on-windows.test.ts` | Repo-level lint: parses every workflow YAML, computes per-step Windows reachability from `electron` matrix × each step's `if:` filter (small grammar: `matrix.platform == 'X'`, `matrix.platform != 'X'`, `&&`, `||`, `!(...)` , parens), fails when any `shell: bash` step reachable on Windows runner. Failure messages cite change `eliminate-bash-on-windows-runners` plus offending `file:line` + step name. Unrecognised `if:` expressions fail closed (force contributor to write recognisable form or extend evaluator). See change: eliminate-bash-on-windows-runners. | diff --git a/docs/file-index-skills-misc.md b/docs/file-index-skills-misc.md index 5984a9ec9..c2104cf6c 100644 --- a/docs/file-index-skills-misc.md +++ b/docs/file-index-skills-misc.md @@ -15,9 +15,6 @@ | `scripts/reload-all.sh` | Build bridge + reload all pi sessions | | `.pi/skills/release-cut/SKILL.md` | Cuts new release: promotes `## [Unreleased]` in CHANGELOG → dated section, bumps every workspace package.json, commits, tags, pushes (fires `publish.yml`). Skill's `Next steps (human)` block enumerates **7 platform artifacts** releaser expects on draft GitHub Release: `PI-Dashboard-darwin-arm64-.dmg` (Apple Silicon), `PI-Dashboard-darwin-x64-.dmg` (Intel), Linux `.deb` × 2 (x64+arm64), Linux `.AppImage` (x64 only — appimagetool no arm64 build), Windows NSIS+ZIP+portable (x64), Windows ZIP+portable (arm64, no NSIS cross-compile). Missing artifacts in draft = CI failure; do NOT click Publish. (change: add-darwin-x64-build updated count 6 → 7, split macOS DMG into two arches.) | | `scripts/sync-versions.js` | Post-bump release helper. Reads every workspace `package.json`, enforces lockstep versions, rewrites every inter-package dep specifier (e.g. `"@blackbelt-technology/pi-dashboard-shared": "^"`) to current bumped version. Called by `release-cut` skill AND by `.github/workflows/publish.yml` (defensively) between `npm version -ws` + `npm run build`. Required because npm CLI does not implement pnpm/yarn `workspace:` protocol — plain semver ranges + sync at bump time. Cross-ref specifiers use plain `"^"`; `packages/electron` `"private": true` so `npm publish -ws` skips automatically. | -| `scripts/verify-lockfile-versions.mjs` | Sanity gate after `npm install --package-lock-only` in `publish.yml` `prepare` job. Reads `package-lock.json`, walks every `packages/` entry's `dependencies` + `devDependencies`, asserts each `@blackbelt-technology/*` cross-ref specifier equals `^`. Prints `file → name: spec (expected ^X.Y.Z)` per mismatch + exits 1. Catches lockfile-version drift before commit lands on tag. See change: fix-release-lockfile-drift. | - | `.pi/skills/jj-workspace/SKILL.md` | Operating manual for agents inside jj workspace. Lists 7 forbidden mutating-git commands (`git commit/rebase/cherry-pick/merge/reset --hard/checkout /stash`), safe escapes (`git reset` no-flags, `git config`), 9-row "jj is not git" reference table, conflict / `jj op log` recovery affordances. | -| `.pi/skills/jj-workspace-fold-back/SKILL.md` | Fold-back operation. Default flavor preserves agent commit history (Decision 1) — bookmark workspace tip → rebase onto trunk → push via `jj git push --bookmark`. NEVER invokes `git commit` / `git merge`. Refusal preconditions: not-colocated / unresolved conflicts / empty working copy / dirty git index. Educational dirty-index message offers three options (`git reset` safe, `jj new -m WIP` jj-native, `git stash` forbidden with reason) per Decision 11. Bookmark name = workspace name verbatim (Decision 13). Conflict rollback via captured pre-rebase op id + `jj op restore` (Decision 12). Optional `mode: squash` + `mode: pr` flavors documented. | | `.pi/skills/pi-dashboard/SKILL.md` | Bundled skill: monitor + control dashboard from any pi session | | `.pi/skills/pi-dashboard/references/api-reference.md` | Complete REST API reference for skill | | `.pi/skills/pi-dashboard/references/recipes.md` | Multi-step orchestration recipes | @@ -31,4 +28,4 @@ | `.pi/skills/browser-visual-debug/SKILL.md` | Skill: visual debugging via real browser (screenshots, interaction, responsive testing) via pi-agent-browser | | `.pi/skills/browser-visual-debug/references/` | Dashboard recipes, responsive testing presets, agent-browser commands cheatsheet | | `.pi/skills/browser-visual-debug/scripts/detect-dashboard.sh` | Auto-detect dashboard URL, mode, Vite dev server status | -| `.github/workflows/publish.yml` | CI: builds DMG × 2 (macOS arm64 + x64), DEB+AppImage (Linux), NSIS+ZIP+portable (Windows) on native runners; publishes npm + GitHub Release. **Build matrix covers 6 (platform, arch) tuples**: darwin/arm64 (`macos-14`), darwin/x64 (`macos-15-intel` — GitHub's last hosted Intel x86_64 image after `macos-13` retired 2025-12-08; EOL 2027-08; change: add-darwin-x64-build), linux/x64 (`ubuntu-latest`), linux/arm64 (`ubuntu-24.04-arm`), win32/x64 (`windows-latest`), win32/arm64 (`windows-latest`). Missing rows = regression — spec `electron-build-pipeline > CI build matrix` enumerates each scenario to prevent drift. **Two triggers**: (a) push of any `v*` tag (release-cut skill / hand tag); (b) `workflow_dispatch` from GitHub Actions UI with required `version` input (e.g. `"0.4.1"`). `prepare` job branches on `github.event_name`: tag-push extracts version from `GITHUB_REF_NAME`; dispatch validates input as semver, checks tag uniqueness on origin, bumps every workspace `package.json` via `npm version -ws`, syncs cross-ref specifiers via `scripts/sync-versions.js`, promotes `## [Unreleased]` → dated `## []` in `CHANGELOG.md`, commits + tags + pushes branch. publish, electron, github-release all `needs: prepare` + check out `ref: ${{ needs.prepare.outputs.tag }}` so both trigger paths publish same tree. **Idempotent ordered npm publish** (commit b9fcea9): publish step replaced bulk `npm publish --workspaces --include-workspace-root` with per-package loop that (a) **skips** already-published versions via `npm view @` (re-run after partial-publish failure resumes cleanly instead of aborting on "cannot publish over previously published"), (b) publishes **4 stable sub-packages first** (`pi-dashboard-shared` → `extension` → `server` → `web`), then `dashboard-plugin-runtime`, then **root metapackage last** so registry serves matching sub-package versions before root tarball lands + resolves dependents like `@blackbelt-technology/pi-dashboard-extension@^X.Y.Z`. v0.4.0 + v0.4.1 shipped broken because bulk call aborted on first error + only root tarball landed — `npm install @blackbelt-technology/pi-agent-dashboard@0.4.1` returned ETARGET on sub-deps. Single-failure isolation: non-skip failure marks step failed via `FAIL=1` accumulator but loop finishes so logs show every package's outcome. Also (b9fcea9): `packages/server/package.json#dependencies` declares `@blackbelt-technology/dashboard-plugin-runtime: ^` — previously imported via workspace symlinks but missing from published tarball; clean `npm install` of just server crashed with `MODULE_NOT_FOUND`. Also (commit 2728c31): every workspace `package.json` (`shared`, `extension`, `server`, `client`, `dashboard-plugin-runtime`, `electron`) declares `repository` field — required for npm provenance attestation when publishing with `--provenance` from GitHub Actions OIDC. **Electron-publish dependency-graph contract** (change: publish-fix-macos): `electron` matrix job declares `needs: [prepare, publish]` + `strategy.fail-fast: false`. `needs: publish` closes ETARGET race that broke release run #34 — `bundle-server.sh` runs `npm install --omit=dev` against live npm registry + resolves `@blackbelt-technology/dashboard-plugin-runtime@^` (added b9fcea9 to fix `MODULE_NOT_FOUND` on clean server installs), so must run AFTER publish uploaded just-bumped sub-packages. `fail-fast: false` keeps single-OS failure from cancelling other 4 matrix variants. Locked by `packages/shared/src/__tests__/publish-workflow-contract.test.ts`. **No-bash-on-Windows invariant** (change: eliminate-bash-on-windows-runners): no step in `publish.yml` / `ci.yml` combines `shell: bash` with runtime config reachable on `windows-latest`. Cross-OS build orchestration in `.mjs` scripts invoked by `node`; POSIX-only steps use `shell: bash` gated by `if: matrix.platform != 'win32'`; Windows-only use `shell: pwsh`. Bundle scripts (`bundle-server.mjs`, `bundle-offline-packages.mjs`, `bundle-recommended-extensions.mjs`) Node-native, eliminating bash↔Node bridge that produced `MODULE_NOT_FOUND` on Windows. Locked by `packages/shared/src/__tests__/no-bash-on-windows.test.ts`. **Lockfile regen contract** (change: fix-release-lockfile-drift): `prepare` job split into 6 separate steps — `npm ci` → `npm version -ws --include-workspace-root` → `node scripts/sync-versions.js` → `npm install --package-lock-only --no-audit --no-fund` → `node scripts/verify-lockfile-versions.mjs` → CHANGELOG promote + commit + tag + push. Splits required because contract test scans steps' `run` content via `findIndex` and asserts ordering `sync-versions < regen < commit`. Without regen, lockfile records stale `^` cross-ref specifiers; npm strict prerelease semver causes `npm ci` on consumers + dashboard CI to fall back to registry, masking workspace symlinks with previously published tarball — surfaced as TS2305/TS2339 errors on tag-CI for symbols added since previous release. Locked by `packages/shared/src/__tests__/publish-workflow-contract.test.ts` ("prepare job regenerates lockfile after version bump"). | +| `.github/workflows/publish.yml` | CI: builds DMG × 2 (macOS arm64 + x64), DEB+AppImage (Linux), NSIS+ZIP+portable (Windows) on native runners; publishes npm + GitHub Release. **Build matrix covers 6 (platform, arch) tuples**: darwin/arm64 (`macos-14`), darwin/x64 (`macos-15-intel` — GitHub's last hosted Intel x86_64 image after `macos-13` retired 2025-12-08; EOL 2027-08; change: add-darwin-x64-build), linux/x64 (`ubuntu-latest`), linux/arm64 (`ubuntu-24.04-arm`), win32/x64 (`windows-latest`), win32/arm64 (`windows-latest`). Missing rows = regression — spec `electron-build-pipeline > CI build matrix` enumerates each scenario to prevent drift. **Two triggers**: (a) push of any `v*` tag (release-cut skill / hand tag); (b) `workflow_dispatch` from GitHub Actions UI with required `version` input (e.g. `"0.4.1"`). `prepare` job branches on `github.event_name`: tag-push extracts version from `GITHUB_REF_NAME`; dispatch validates input as semver, checks tag uniqueness on origin, bumps every workspace `package.json` via `npm version -ws`, syncs cross-ref specifiers via `scripts/sync-versions.js`, promotes `## [Unreleased]` → dated `## []` in `CHANGELOG.md`, commits + tags + pushes branch. publish, electron, github-release all `needs: prepare` + check out `ref: ${{ needs.prepare.outputs.tag }}` so both trigger paths publish same tree. **Idempotent ordered npm publish** (commit b9fcea9): publish step replaced bulk `npm publish --workspaces --include-workspace-root` with per-package loop that (a) **skips** already-published versions via `npm view @` (re-run after partial-publish failure resumes cleanly instead of aborting on "cannot publish over previously published"), (b) publishes **4 stable sub-packages first** (`pi-dashboard-shared` → `extension` → `server` → `web`), then `dashboard-plugin-runtime`, then **root metapackage last** so registry serves matching sub-package versions before root tarball lands + resolves dependents like `@blackbelt-technology/pi-dashboard-extension@^X.Y.Z`. v0.4.0 + v0.4.1 shipped broken because bulk call aborted on first error + only root tarball landed — `npm install @blackbelt-technology/pi-agent-dashboard@0.4.1` returned ETARGET on sub-deps. Single-failure isolation: non-skip failure marks step failed via `FAIL=1` accumulator but loop finishes so logs show every package's outcome. Also (b9fcea9): `packages/server/package.json#dependencies` declares `@blackbelt-technology/dashboard-plugin-runtime: ^` — previously imported via workspace symlinks but missing from published tarball; clean `npm install` of just server crashed with `MODULE_NOT_FOUND`. Also (commit 2728c31): every workspace `package.json` (`shared`, `extension`, `server`, `client`, `dashboard-plugin-runtime`, `electron`) declares `repository` field — required for npm provenance attestation when publishing with `--provenance` from GitHub Actions OIDC. **Electron-publish dependency-graph contract** (change: publish-fix-macos): `electron` matrix job declares `needs: [prepare, publish]` + `strategy.fail-fast: false`. `needs: publish` closes ETARGET race that broke release run #34 — `bundle-server.sh` runs `npm install --omit=dev` against live npm registry + resolves `@blackbelt-technology/dashboard-plugin-runtime@^` (added b9fcea9 to fix `MODULE_NOT_FOUND` on clean server installs), so must run AFTER publish uploaded just-bumped sub-packages. `fail-fast: false` keeps single-OS failure from cancelling other 4 matrix variants. Locked by `packages/shared/src/__tests__/publish-workflow-contract.test.ts`. **No-bash-on-Windows invariant** (change: eliminate-bash-on-windows-runners): no step in `publish.yml` / `ci.yml` combines `shell: bash` with runtime config reachable on `windows-latest`. Cross-OS build orchestration in `.mjs` scripts invoked by `node`; POSIX-only steps use `shell: bash` gated by `if: matrix.platform != 'win32'`; Windows-only use `shell: pwsh`. Bundle scripts (`bundle-server.mjs`, `bundle-offline-packages.mjs`, `bundle-recommended-extensions.mjs`) Node-native, eliminating bash↔Node bridge that produced `MODULE_NOT_FOUND` on Windows. Locked by `packages/shared/src/__tests__/no-bash-on-windows.test.ts`. | diff --git a/docs/file-index.md b/docs/file-index.md index 2ca5d0eed..8f205d0f4 100644 --- a/docs/file-index.md +++ b/docs/file-index.md @@ -15,8 +15,9 @@ Per-area maps of every architecturally significant file in pi-agent-dashboard. L | Dashboard server | [`file-index-server.md`](./file-index-server.md) | `src/server/`, `packages/server/` | | Web client | [`file-index-client.md`](./file-index-client.md) | `src/client/`, `packages/client/` | | Electron app | [`file-index-electron.md`](./file-index-electron.md) | `packages/electron/` | -| Dashboard plugins | [`file-index-plugins.md`](./file-index-plugins.md) | `packages/dashboard-plugin-runtime/`, `packages/{jj,flows,demo}-plugin/` | +| Dashboard plugins | [`file-index-plugins.md`](./file-index-plugins.md) | `packages/dashboard-plugin-runtime/`, `packages/{flows,demo}-plugin/` | | Skills, scripts, CI | [`file-index-skills-misc.md`](./file-index-skills-misc.md) | `.pi/skills/`, `scripts/`, `public/`, `.github/`, misc | +| Infrastructure (sandbox, seed) | [`file-index-infra.md`](./file-index-infra.md) | `seed/`, `sandbox/`, `.pi/skills/sandbox-designer/` | ## Standalone topic docs diff --git a/docs/plans/openspec-jj-bridge.md b/docs/plans/openspec-jj-bridge.md deleted file mode 100644 index c0bd1dd3f..000000000 --- a/docs/plans/openspec-jj-bridge.md +++ /dev/null @@ -1,442 +0,0 @@ -# OpenSpec ↔ Jujutsu Bridge — Comprehensive Plan - -> **Status:** explore-mode artifact captured for future reference. Live proposal lives at `openspec/changes/add-openspec-jj-bridge/`. This document is the design narrative; the proposal/design/tasks/spec files are the normative source. - ---- - -## 1. Problem Statement - -Implementing an OpenSpec change in the same working tree the rest of the user's work lives in produces two recurring pains: - -1. **Concurrency conflict.** A second pi session can't safely work in the same repo while the implementation is in flight — they fight over the working tree, bash `$PWD`, and tool caches. -2. **Reversibility cost.** If the implementation goes sideways, rolling back means hunting for which edits belong to that change vs. unrelated work in progress. - -`add-jj-workspace-plugin` (already archived) shipped the foundation: a generic jj-workspace plugin with `+ Workspace` ad-hoc creation, fold-back skill, jj-aware diff. But the **headline use case** for parallel agents at scale is "spawn an isolated agent to implement THIS change". This proposal adds a third plugin (`openspec-jj-bridge`) that composes the public surfaces of `jj-plugin` and OpenSpec core to make change-implementation-in-a-workspace the default zero-decision flow. - -### Constraint: standalone for both peers - -The user explicitly required: **jj-plugin must work without OpenSpec; OpenSpec must work without jj-plugin; this bridge composes both without modifying either.** Architecturally this means a third plugin, not a feature added to either peer. - ---- - -## 2. Architecture - -```mermaid -graph TB - subgraph Core[Dashboard Core] - OS[OpenSpec routes
FolderOpenSpecSection
🎬 spawn-attached button] - end - subgraph JJ[jj-plugin] - JJP[Tool registry: jj
workspace add/forget
fold-back skill
jj-aware diff] - end - subgraph Bridge[openspec-jj-bridge plugin] - BR[session_register observer
auto-promote orchestrator
PromoteToWorkspaceButton
archive toast
card visual states] - end - OS -.broadcast.-> BR - BR -.calls REST.-> JJP - BR -.reads field.-> Session - Session[Session.jjState
+ attachedProposal
+ pendingAutoPromote] - JJP -.writes.-> Session - - style Core fill:#fef3c7,stroke:#92400e - style JJ fill:#dbeafe,stroke:#1e40af - style Bridge fill:#d1fae5,stroke:#065f46 - style Session fill:#f3f4f6,stroke:#374151 -``` - -| Plugin | Standalone | Coupling | -|---|---|---| -| openspec (core) | ✓ | knows nothing about jj | -| jj-plugin | ✓ | knows nothing about openspec | -| openspec-jj-bridge | only when both peers active | depends on both via public surfaces (REST endpoints, slot claims, WS broadcasts, `Session.jjState`) | - -Removing the bridge restores both peers to byte-equivalent pre-bridge behavior. No source modification, no migration step. - ---- - -## 3. Full Development Cycle - -The headline flow this proposal enables: - -![Development cycle timeline](../diagrams/openspec-jj-bridge/dev-cycle-timeline-v2.png) - -![Workspaces-as-branches](../diagrams/openspec-jj-bridge/dev-cycle.png) - -### Sequence diagram - -```mermaid -sequenceDiagram - autonumber - actor U as User - participant D as Dashboard UI - participant B as Bridge Plugin - participant S1 as Parent Session
(in /repo/) - participant S2 as Workspace Session
(in .shadow/foo-bar/) - participant J as jj store
(.jj/) - participant G as git remote
(origin/trunk) - - Note over U,G: T0 — EXPLORE - U->>D: spawn parent session - D->>S1: pi spawn cwd=/repo/ - U->>S1: "I want feature X" - S1-->>U: ideas + analysis - - Note over U,G: T1 — /opsx:new-change foo-bar - U->>S1: /opsx:new-change foo-bar - S1->>S1: write proposal.md, design.md, tasks.md - S1-->>J: jj auto-snapshot @ now contains files - - Note over U,G: T2 — Click 🎬 + AUTO-PROMOTE - U->>D: click 🎬 on foo-bar row - D->>S2: pi spawn cwd=/repo/ attachedProposal=foo-bar - S2-->>B: session_register event - B->>B: shouldAutoPromote? all 5 checks ✓ - B->>S2: SIGTERM - B->>J: jj workspace add -r @ .shadow/foo-bar - B->>S2: respawn --session --mode continue
cwd=/repo/.shadow/foo-bar/ - Note over S2: same id, same chat history,
workspaceName=foo-bar - - Note over U,G: T3 — IMPLEMENT - U->>S2: "implement the feature" - loop for each task - S2->>S2: edit code, run tests - S2->>J: jj describe -m "..." + jj new - end - - Note over U,G: T4 — VERIFY - U->>S2: /opsx:verify foo-bar - S2-->>U: ✓ tasks ✓ tests ✓ specs - - Note over U,G: T5 — FOLD BACK - U->>S2: invoke jj-workspace-fold-back skill - S2->>S2: refusal preconditions ✓ - S2->>J: capture pre-rebase op-id - S2->>J: jj bookmark create foo-bar @ - S2->>J: jj rebase -d trunk() -s foo-bar - S2->>G: jj git push --bookmark foo-bar - G-->>S2: pushed N commits - - Note over U,G: T6 — ARCHIVE - U->>S1: /opsx:archive foo-bar - S1->>S1: mv changes/foo-bar/ → archive/2026-…-foo-bar/ - S1->>D: openspec_update broadcast (archived) - D->>B: bridge observes - B->>B: .shadow/foo-bar/ exists? ✓ - B->>D: emit toast {Fold back & forget, Forget anyway, Skip} - U->>D: click "Fold back & forget" - D->>S2: re-run fold-back skill (idempotent) - S2-->>D: already folded, OK - D->>J: POST /api/jj/workspace/forget force=false - J->>J: jj workspace forget + rm -rf .shadow/foo-bar - Note over S2: S2 ends — cwd disappeared - - Note over U,G: T7 — COMMIT ARCHIVE MOVE - U->>S1: jj describe -m "Archive foo-bar" - S1->>J: jj new - S1->>G: jj git push - G-->>S1: trunk advanced (archive move commit) -``` - -### Lifecycle as a state machine - -```mermaid -stateDiagram-v2 - [*] --> ParentExploring: spawn S1 in /repo/ - ParentExploring --> ProposalDrafted: /opsx:new-change - ProposalDrafted --> SpawnedInParent: user clicks 🎬 - SpawnedInParent --> AutoPromoting: bridge observes
session_register - SpawnedInParent --> WorkInParent: autoPromoteOnAttach=false
OR race lost - AutoPromoting --> InWorkspace: SIGTERM + respawn
at .shadow// - AutoPromoting --> WorkInParent: precondition fail - WorkInParent --> InWorkspace: user clicks
"Promote to workspace" - InWorkspace --> InWorkspace: jj describe + jj new - InWorkspace --> Verified: /opsx:verify - Verified --> FoldedBack: fold-back skill
(rebase + jj git push) - FoldedBack --> Archived: /opsx:archive - Archived --> ToastShown: bridge detects
archive transition - ToastShown --> WorkspaceForgotten: click "Fold back & forget"
OR "Forget anyway" - ToastShown --> ToastShown: ignore / skip - WorkspaceForgotten --> [*]: S2 ends
.shadow// removed -``` - ---- - -## 4. Decisions - -All decisions locked through discovery. Numbered to match `design.md`. - -### D1 — Bridge is its own plugin package - -Putting the binding logic in `jj-plugin` would force every jj user to ship OpenSpec coupling. Putting it in OpenSpec core would force every OpenSpec user to know about jj. The third-plugin pattern keeps both peers ignorant of each other. - -### D2 — Auto-promote on attach (no slot-priority refactor) - -```mermaid -flowchart TD - Start([session_register event]) --> A{attachedProposal
set?} - A -- no --> Skip[no auto-promote] - A -- yes --> B{jjState.isJjRepo
true?} - B -- no --> Skip - B -- yes --> C{workspaceName
empty?} - C -- no --> AlreadyInWS[already in workspace] - C -- yes --> D{no chat
history?} - D -- no --> Resumed[resumed session
not auto-promoted] - D -- yes --> E{config
autoPromoteOnAttach?} - E -- false --> ManualOnly[manual button
visible] - E -- true --> F[set pendingAutoPromote=true] - F --> G[POST /promote-session] - G --> H{precondition
check} - H -- BUSY/DIRTY/
TOOL_IN_FLIGHT --> Fail[fall back to manual] - H -- OK --> I[jj workspace add -r @
SIGTERM
respawn at .shadow/<name>/] - I --> Done([session in workspace]) - - style Done fill:#d1fae5,stroke:#065f46 - style Fail fill:#fee2e2,stroke:#991b1b -``` - -**Why:** Q1 forbade modifying OpenSpec core (even structurally to make the 🎬 button overridable via slot priority). The auto-promote mechanism intercepts the *result* of the click via the public `session_register` broadcast. Recovers the one-click "silent upgrade" feel without touching OpenSpec core. - -**Race window:** sub-second worst case. If user types a prompt before promote's SIGTERM lands, promote refuses (BUSY); manual button surfaces; no retry. Acceptable because manual fallback is never an error condition. - -### D3 — 1:1 binding via existing equality witness - -The auto-rename rule from `proposal-attach-naming.ts` extends cleanly: - -``` - workspaceName === attachedProposal === session.name === changeName -``` - -Triple equality is the binding witness. No new mechanism. - -### D4 — Promote relocates parent session (Option A); spawn-child fallback (Option B) - -`POST /api/openspec-jj-bridge/promote-session` with `{ sessionId, strategy? }`: - -1. Validate preconditions (status idle, no in-flight tool, clean git index). -2. Determine WT status; pick effective strategy (D4a). -3. Apply strategy: - - `silent`: `jj workspace add -r @ .shadow/` - - `split`: `jj split @ -i` extract change-dir paths; `jj workspace add -r ` - - `trunk`: precondition-check; `jj workspace add -r 'trunk()'` - - `cancel`: no-op; manual button surfaces -4. SIGTERM + respawn pi `--session --mode continue` at workspace cwd. -5. Bridge re-registers same session id; chat history preserved. - -**Spawn-child (B):** when busy precondition fails, dialog offers "Spawn child instead" — new session in fresh workspace, no chat carryover. - -### D4a — Dirty WT triggers strategy modal - -```mermaid -flowchart TD - Click[Auto-promote scheduled] --> WT{WT dirty
outside change
dir?} - WT -->|no| Silent[silent: jj workspace add -r @] - WT -->|yes| Modal[Modal: Split / Trunk / Cancel] - - Modal --> Split[jj split + jj workspace add
workspace inherits change-only commit
unrelated edits stay in /repo/] - Modal --> Trunk{Change exists
on trunk?} - Modal --> Cancel[stay in /repo/
manual button surfaces] - - Trunk -->|yes| TrunkOK[jj workspace add -r 'trunk()'] - Trunk -->|no| Refuse[409 CHANGE_NOT_ON_TRUNK
warning shown] - - style Silent fill:#d1fae5,stroke:#065f46 - style Modal fill:#fef3c7,stroke:#92400e - style Refuse fill:#fee2e2,stroke:#991b1b -``` - -**Why ask not refuse:** auto-promote silently snapshotting unrelated WT edits causes "working-tree leakage" — S1's unrelated `auth.ts` work gets pushed under `foo-bar`'s bookmark. The modal makes the choice explicit with descriptions. - -**Why a dashboard-native modal (not PromptBus / `ask_user`):** the agent has no chat context yet at the moment auto-promote fires. A bridge-owned modal mounted in the dashboard sidebar is clearly the system asking, not the agent. - -### D4b — Existing workspace handling - -Replaces the old "409 refuse if workspace exists" with three sub-cases: - -```mermaid -flowchart TD - Click[Click 🎬 on foo-bar] --> WS{".shadow/foo-bar/
exists?"} - WS -->|no| Create[jj workspace add
+ auto-promote] - WS -->|yes, no live| Reuse[spawn pi at existing cwd
NO jj workspace add
jj history preserved] - WS -->|yes, live| Focus[focus existing session
no duplicate spawn] - WS -->|yes, unhealthy| Recover[toast: Forget and recreate?] - - style Reuse fill:#d1fae5,stroke:#065f46 - style Focus fill:#dbeafe,stroke:#1e40af - style Recover fill:#fef3c7,stroke:#92400e -``` - -**Liveness detection:** `session.status !== "ended" && session.cwd resolves inside .shadow//`. - -**Unhealthy detection:** `jj st` inside the workspace returns non-zero. User opts in to destructive recovery; bridge never silently destroys. - -### D4c — Card visual states + chip de-duplication - -![Card visual states](../diagrams/openspec-jj-bridge/card-states.png) - -The binding witness `bindingWitnessHolds(session)`: - -``` - attachedProposal != null AND - jjState.workspaceName != null AND - attachedProposal === jjState.workspaceName === (name?.trim() || null) -``` - -When witness holds: render single combined badge `🌿 foo-bar`, suppress redundant `📋` and `🌿 ws:` chips via slot-priority claim. - -When witness breaks (custom name, or change rename): bridge contributes nothing; lower-priority OpenSpec/jj-plugin chips emerge so divergence is visible. - -Six lifecycle states mapped to visual treatments: - -| State | Title | Pill | cwd | Action | -|---|---|---|---|---| -| Plain | session.name OR firstMessage OR cwd basename | (none) | /repo | (default) | -| Attached | foo-bar | 📋 foo-bar | /repo | Promote to workspace | -| Auto-promoting | foo-bar | 📋 foo-bar | /repo ➜ .shadow/foo-bar/ | (spinner) | -| InWorkspace | foo-bar | 🌿 foo-bar (combined) | /repo/.shadow/foo-bar/ | Fold back | -| Folded | foo-bar | 🌿 foo-bar ✓ folded | /repo/.shadow/foo-bar/ | Forget workspace, Open log | -| Ended | foo-bar (greyed) | 🌿 foo-bar (greyed) | /repo/.shadow/foo-bar/ | Reopen → D4b reuse path | - -### D5 — Archive lifecycle hook = non-modal toast - -Bridge plugin subscribes to OpenSpec's `openspec_update` WS broadcast. On `archived: true` transition + `.shadow//` exists on disk, emit sticky toast: - -``` - ┌─────────────────────────────────────────────────────┐ - │ Workspace `foo-bar` has unfolded work │ - │ [Fold back & forget] [Forget anyway] [Skip] │ - └─────────────────────────────────────────────────────┘ -``` - -**Q2 lock:** "Fold back & forget" SHALL be DISABLED with explanatory tooltip when no live workspace session exists. **No auto-spawn.** User must manually reopen a session in the workspace first. "Forget anyway" remains unconditional. - -### D6 — Bridge gates on jj-plugin presence indirectly - -Bridge does NOT `import`-depend on jj-plugin. It detects effective presence via `Session.jjState` being populated. If absent: every bridge predicate returns false; no UI. Clean degradation. - -### D7 — Server-orchestrated multi-step ops; UI is thin - -Multi-step operations (spawn-in-workspace, promote, fold-back orchestration) live as REST endpoints on the bridge plugin's server. Client renders buttons, dialogs, errors. Server composes jj-plugin's existing endpoints + pi-spawn primitives. - ---- - -## 5. Configuration - -Plugin config schema (JSON Schema 7), all global (no per-repo overrides v1): - -| Field | Default | Effect | -|---|---|---| -| `enabled` | `true` | Master switch | -| `autoPromoteOnAttach` | `true` | Whether auto-promote fires on `session_register` | -| `autoFoldBackOnArchive` | `false` | Toast offers; never auto-acts | - -Settings panel exposes `autoPromoteOnAttach` prominently with explanatory text. - ---- - -## 6. Edge Cases - -| # | Case | Handled how | Bridge work | -|---|---|---|---| -| 1 | Working-tree leakage | D4a strategy modal | NEW (D4a) | -| 2 | New spec mid-implementation | Pure OpenSpec governance; verify catches drift | none | -| 3 | New child change inside workspace | Skill warning + bridge advisory toast | minor advisory | -| 4 | Concurrent edits to change artifacts in /repo/ + workspace | jj rebase conflicts → jj-plugin D12 (jj op restore) | none | -| 5 | Workspace session dies, re-open | D4b reuse path | already designed | -| 6 | 🎬 clicked when workspace exists | D4b reuse / focus / unhealthy | already designed | -| 7 | Change renamed during impl | Open Question 4 (advisory only) | already designed | -| 8 | Archive before fold-back | Existing archive-toast covers it | already designed | -| 9 | Implementation revises proposal | jj just commits revised proposal | none | -| 10 | Recursive nested workspace | Refuse with explanation | minor advisory | - ---- - -## 7. Open Questions - -1. ~~**Slot refactor counts as modifying OpenSpec core?**~~ — RESOLVED: yes, forbidden. D2 routes around via `session_register` hook. -2. ~~**Auto-spawn for fold-back?**~~ — RESOLVED: no, manual reopen (Q2). -3. **Bridge plugin `private:true` or independently published?** Lean private:true v1; revisit if external users want to swap implementations. -4. **Change-renamed-during-impl: auto-rename workspace or advise-only?** Lean advise-only. Auto-rename invites confusion if user has uncommitted commits referencing old name. -5. **Bridge REST endpoint authentication.** Same `networkGuard` preHandler — confirm during apply phase. -6. **Auto-promote race window measurement.** Phase 4 timing test — if reliably <200 ms, race is essentially unreachable; if seconds, gate first-prompt input. - ---- - -## 8. Implementation Phases - -From `tasks.md`: - -| Phase | Scope | -|---|---| -| 0 | Prereqs verification | -| 1 | No slot/taxonomy work (D2 routes around it) | -| 2 | Bridge plugin scaffold | -| 3 | Auto-promote-on-attach (observer + helper + tests) | -| 3b | Existing-workspace handling (D4b classify + 4 outcomes) | -| 4 | Promote-to-workspace flow (endpoint + strategy execution) | -| 4d | Card visual states (D4c witness + 6 states + tests) | -| 5 | Archive lifecycle hook (toast + Q2 disabled-when-no-live behavior) | -| 6 | Skill (`openspec-implement-in-workspace`) | -| 7 | Cross-plugin integration tests + standalone-degradation tests + docs | -| 8 | Publish (private:true inside `pi-dashboard-web`) | - ---- - -## 9. Standalone Degradation Contract - -Three guarantees, repo-pinned via tests: - -``` - Bridge installed, jj-plugin uninstalled: - bridge predicates fail safely; settings panel shows - "Inactive — jj-plugin not installed" advisory. - - Bridge uninstalled, both peers untouched: - OpenSpec's 🎬 button reappears; spawn-attached behaves - byte-equivalent to pre-bridge (session in parent cwd). - - OpenSpec core hypothetically disabled: - bridge's predicates fail; no UI emitted. -``` - -The bridge SHALL NOT modify any source file in `packages/jj-plugin/` nor `packages/server/src/routes/openspec-routes.ts`. Removing the bridge plugin's package directory SHALL leave both peers behavior-identical. - ---- - -## 10. References - -| Artifact | Path | -|---|---| -| Proposal | `openspec/changes/add-openspec-jj-bridge/proposal.md` | -| Design | `openspec/changes/add-openspec-jj-bridge/design.md` | -| Tasks | `openspec/changes/add-openspec-jj-bridge/tasks.md` | -| Spec | `openspec/changes/add-openspec-jj-bridge/specs/openspec-jj-bridge/spec.md` | -| Foundation (archived) | `openspec/changes/archive/2026-05-02-add-jj-workspace-plugin/` | -| Visual aids | `docs/diagrams/openspec-jj-bridge/` | -| This plan | `docs/plans/openspec-jj-bridge.md` | - -| Diagram | Type | Location | -|---|---|---| -| 8-station timeline | nano-banana | `dev-cycle-timeline-v2.png` | -| Workspaces-as-branches | nano-banana | `dev-cycle.png` | -| Three-panel UI mockup | nano-banana | `dev-cycle-ui.png` | -| Six card states | nano-banana | `card-states.png` | -| Sequence T0→T7 | mermaid | embedded in `design.md` | -| Lifecycle state machine | mermaid | embedded in `design.md` | -| Auto-promote flowchart | mermaid | embedded in `design.md` | -| 3-plugin architecture | mermaid | embedded in `proposal.md` | - ---- - -## 11. Conversation Provenance - -This plan was produced through a multi-turn explore-mode conversation. Key turning points: - -- Initial framing: workspace = generic ad-hoc isolation primitive -- Pivot: workspace primarily for OpenSpec change implementation (1:1 binding) -- Q1 lock (no modifying OpenSpec core) → forced auto-promote-on-attach mechanism over slot-priority replacement -- Q2 lock (no auto-spawn for fold-back) → archive toast disables fold-back action when no live session -- D4a added (dirty-WT strategy modal) after surfacing working-tree leakage failure mode -- D4b added (existing-workspace reuse/focus/unhealthy) after user noted "no way to attach to existing .shadow/ workspaces" -- D4c added (card visual states + chip de-duplication) after user asked "how will the card be displayed when workspace created" - -Three-plugin architecture (`openspec` core + `jj-plugin` + `openspec-jj-bridge`) emerged from the standalone constraint: each peer must work without the others, bridge composes via public surfaces only. diff --git a/docs/publishing-plugins.md b/docs/publishing-plugins.md index 656938b83..ed97e556c 100644 --- a/docs/publishing-plugins.md +++ b/docs/publishing-plugins.md @@ -85,7 +85,6 @@ PACKAGES=( "@blackbelt-technology/pi-dashboard-web" "@blackbelt-technology/dashboard-plugin-runtime" "@blackbelt-technology/pi-dashboard-flows-plugin" - "@blackbelt-technology/pi-dashboard-jj-plugin" "@blackbelt-technology/pi-dashboard-" # ADD HERE "@blackbelt-technology/pi-agent-dashboard" ) diff --git a/openspec/changes/accordion-workspace-folders/proposal.md b/openspec/changes/accordion-workspace-folders/proposal.md deleted file mode 100644 index ba0da0707..000000000 --- a/openspec/changes/accordion-workspace-folders/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ -## Why - -When many sessions are active across multiple workspace folders, the sidebar becomes overwhelming — all folders can be expanded simultaneously, making it hard to find and focus on the sessions you're currently working with. An accordion pattern (only one folder open at a time) would reduce visual clutter and improve navigation. - -## What Changes - -- Workspace folder groups will behave as an **accordion**: expanding one folder automatically collapses all others. -- The existing collapse/expand toggle on folder headers remains, but clicking to expand a folder now collapses the previously open one. -- Clicking an already-open folder header collapses it (all folders closed is a valid state). -- Persisted collapsed state in localStorage adapts to store which single folder is expanded (or none), instead of a set of collapsed folders. - -## Capabilities - -### New Capabilities - -_(none — this modifies an existing capability)_ - -### Modified Capabilities - -- `collapsible-groups`: Change from independent collapse/expand per folder to accordion behavior where at most one folder is expanded at a time. - -## Impact - -- **Code**: `packages/client/src/components/SessionList.tsx` — `handleToggleCollapse` logic changes from toggling a set to setting a single expanded key (or null). -- **Storage**: `packages/client/src/lib/session-filter-storage.ts` — persistence format changes from a set of collapsed cwds to a single expanded cwd string (or null). Migration needed for existing localStorage data. -- **Tests**: Existing collapsible-groups tests need updating for accordion semantics. -- **UX**: Users who relied on having multiple folders open simultaneously will need to adapt. This is a minor behavioral **BREAKING** change in the sidebar. diff --git a/openspec/changes/adapt-windows-integration-pr9/.openspec.yaml b/openspec/changes/adapt-windows-integration-pr9/.openspec.yaml deleted file mode 100644 index fd0c62263..000000000 --- a/openspec/changes/adapt-windows-integration-pr9/.openspec.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schema: v0.3 -status: superseded -superseded-by: merge-windows-integration-linear diff --git a/openspec/changes/adapt-windows-integration-pr9/design.md b/openspec/changes/adapt-windows-integration-pr9/design.md deleted file mode 100644 index 4e82cf322..000000000 --- a/openspec/changes/adapt-windows-integration-pr9/design.md +++ /dev/null @@ -1,150 +0,0 @@ -## Context - -Two authoritative documents already live on the PR branch: - -- `MERGE-PLAN.md` — 555 lines, commit-by-commit cherry-pick plan with file-level conflict resolution table (`§3.1`–`§3.14`). -- `BRANCH-COMPARISON.md` — 525 lines, structural divergence audit, regression root-cause analysis, consolidation proposal. - -This design document **does not duplicate them**. It captures only: - -1. The merge strategy decision (why cherry-pick, not merge, and why `windows-integration-v2` vs. in-place). -2. The three deviations from MERGE-PLAN driven by develop moving 10 commits since the plan was authored. -3. The decision record for skipping develop's `v0.3.0` release commit during merge. -4. The validation gate sequencing. - -Read `MERGE-PLAN.md` and `BRANCH-COMPARISON.md` on the PR branch for everything else. - -## Branch topology - -``` - 94f07df (merge-base, ~Apr 14) - │ -origin/develop ─┤─ +44 commits → 01c5e0c (today) - │ │ - │ └─ 10 commits past MERGE-PLAN baseline (a4cced2) - │ of which 3 are material - │ - └─ +38 commits → de695e1 = origin/windows-integration = PR #9 - │ - └─ fork to windows-integration-v2 - │ - ▼ - Phase 0 → 0.5 → 1 → 2 → 3 → 3.5 → 5 → 6 - │ - └─ PR: windows-integration-v2 → develop - (PR #9 closed/superseded after merge) -``` - -## Why cherry-pick, not `git merge origin/develop` - -A full merge produces non-auto-mergeable conflicts in 15+ files across the server lifecycle hot path (`cli.ts`, `process-manager.ts`, `server-launcher.ts`, `resolve-jiti.ts`, `electron/server-lifecycle.ts`, `system-routes.ts`, `directory-service.ts`, etc.). Every conflict is a judgement call between: - -- windows-integration's strategy-router architecture (correct for cross-platform), or -- develop's inline `process.platform` branching (simpler but broken on Windows). - -Cherry-pick lets each develop commit be evaluated, adapted, or skipped in isolation — with the pre-committed file-level resolution table in `MERGE-PLAN.md §3` as the tiebreaker. - -## Why `windows-integration-v2` instead of in-place on `windows-integration` - -- PR #9 is already reviewable as a historical record; force-pushing destroys review context. -- A fresh branch lets Phase 0 (regression fixes) be isolated from Phase 1+ (feature integration) in git history. -- If `windows-integration-v2` derails, PR #9 still exists and a third attempt costs nothing. -- The maintenance burden of one more branch is negligible; the cost of losing review context on a 17,565-line PR is not. - -## The three deviations from MERGE-PLAN - -### Deviation 1: Phase 0.5 — pull safety commits before Phase 1 - -``` - MERGE-PLAN order v2 order - ───────────── ───────── - Phase 0: regressions Phase 0: regressions (unchanged) - Phase 0.5: 6a1b1d8 + 3cad40b + 8737249 ← NEW - Phase 1: Category A (20) Phase 1: Category A (20, minus 8737249) - Phase 2: Category B (9) Phase 2: Category B (8, minus 8737249) - Phase 3: Category C (5) Phase 3: Category C (5, unchanged) - Phase 3.5: catch-up to develop HEAD ← NEW - Phase 4: consolidation Phase 4: DEFERRED to follow-up PR - Phase 5: validation Phase 5: validation (expanded matrix) -``` - -**Rationale for `6a1b1d8` first:** windows-integration commit `39acb1e` routes all process termination through `platform/process.ts`. Without the test-isolation tripwire from `6a1b1d8`, running the test suite on the merged branch SIGTERMs the live pi session running the tests. Every test gate between phases is affected. The tripwire must exist before any `npm test` runs on v2. - -**Rationale for `3cad40b` first:** Packaged Electron apps (DMG, AppImage, NSIS) ship `spawn-helper` and `pty.node` without the execute bit after Electron-Forge ASAR packing. Commit `8737249` (already in Category B) fixes hoist-aware lookup but not the bundle permission. Without `3cad40b`, every Phase 5 "electron make" validation fails on node-pty terminal spawn. - -**Rationale for `8737249` reorder:** pulled from Category B #27 into Phase 0.5 so the hoist-aware lookup is present when `3cad40b`'s runtime chmod runs. Keeps the two node-pty fixes adjacent in git history. - -### Deviation 2: Phase 3.5 — catch up to develop HEAD - -Develop's last 10 commits (`a4cced2..01c5e0c`) fall into three groups: - -| Group | Commits | Action | -|---|---|---| -| Already pulled in Phase 0.5 | `6a1b1d8`, `3cad40b` | skip | -| Non-release polish | `c975222`, `4b2b76c`, `a75a1db`, `ac2bd96`, `c325227` | cherry-pick | -| Release commits | `16e9758`, `90a3b7b`, `01c5e0c` | **skip — see Deviation 3** | - -The only non-trivial conflict in this group is `c975222` archiving `fix-fork-entryid-timing`. The PR has this change **active** with edits across `proposal.md`, `design.md`, `tasks.md`, and the spec (commit `2257b08`). Resolution: - -1. Before cherry-picking `c975222`, rebase windows-integration's `2257b08` edits onto the pre-archive content of the change (check whether those edits are already reflected in develop's archived version; if so, skip our edits). -2. Cherry-pick `c975222` — the file moves should now apply cleanly. -3. If any windows-integration edits were lost (not yet in archived content), re-apply them as a follow-up commit on the archived spec. - -### Deviation 3: skip v0.3.0 replay, cut v0.4.0 fresh - -Develop's `16e9758 chore(release): v0.3.0` bumps every workspace `package.json` from `0.2.x` to `0.3.0`. Windows-integration never saw that bump. Replaying it on v2 means: - -- Every cherry-pick crossing the version boundary conflicts on version strings. -- Confusing git history where v0.3.0 "exists" on both branches with different tree contents. -- v0.3.0 is already published to npm + GitHub Releases; replaying adds no value. - -Decision: skip `16e9758`, `90a3b7b` (site sync for v0.3.0), `01c5e0c` (CI re-dispatch for v0.3.0). At end of Phase 5, run the `release-cut` skill to promote `[Unreleased]` → `v0.4.0`. This work is substantial enough (architecture change, lint-enforced OS abstraction, ToolRegistry, Windows correctness) to warrant a minor bump rather than a patch. - -## Regression fixes (Phase 0) — unchanged from MERGE-PLAN §0 - -Captured here for visibility only. See `MERGE-PLAN.md §0.1a`, `§0.1b`, `§0.2` on the PR branch for exact diffs. - -1. **`0.1a` — revert uncommitted preload-fastify-cjs** (pure deletes, ~640 LOC). Decision record in `BRANCH-COMPARISON.md §10`. -2. **`0.1b` — add `packages/server/src/node-guard.ts`** + `engines.node >= 22.18.0`. Preflight refuse-to-start replacing the rejected preload workaround. -3. **`0.2` — fix `detach:false` regression in `platform/detached-spawn.ts`**. Add `detach?: boolean` option to `SpawnDetachedOptions` (default `true`); tighten `useWindowsRedirect` gate with `stdinMode === "ignore"`; `spawnHeadlessDetached` passes `detach: false` to restore commit `d331850`'s no-flash behaviour. - -## Validation gate sequencing - -``` -Phase 0.3 (manual Windows smoke) ──→ if fail: STOP, fix -Phase 0.5 (npm test after tripwire) ──→ if fail: STOP -Phase 1 (npm test every 5 picks) ──→ if fail: revert last batch -Phase 2 (npm test after phase) ──→ if fail: revert -Phase 3 (npm test after each pick) ──→ Vitest 4 is the long pole -Phase 3.5 (npm test after catch-up) ──→ -Phase 5 (full CI + manual smoke) ──→ 3-OS + Electron make + lint -Phase 6 (release-cut to v0.4.0) ──→ only after all gates green -``` - -No phase advances until its preceding gate is green. The `pre-develop-merge` tag created at end of Phase 0 is the rollback target for any phase. - -## What stays the same as MERGE-PLAN - -- File-level conflict resolution table (`§3.1`–`§3.14`) — **authoritative**. -- Commit categorization (A clean picks, B trivial reconcile, C manual merge) — **unchanged**. -- Phase 0 regression fixes (0.1a, 0.1b, 0.2) — **unchanged**. -- Validation gate matrix (`§5`) — **expanded** for Phase 0.5 and Phase 3.5, otherwise unchanged. -- Non-goals (`§6`) — **unchanged**. Still no `git merge origin/develop`, still no resurrect preload-fastify, still no platform/ consolidation during merge. - -## Risk register (delta from MERGE-PLAN §5) - -| Risk | Mitigation | -|---|---| -| Test-isolation tripwire not landed before other tests run | Phase 0.5 is ordered first; if `6a1b1d8` doesn't cherry-pick cleanly (it's test-infra, likely clean), STOP and resolve before any Phase 1 test | -| node-pty bundle permissions interact badly with windows-integration's platform/exec.ts spawn shape | `3cad40b` adds a runtime chmod in `packages/server/src/fix-pty-permissions.ts`; verify the file's expected location hasn't moved under the platform/ reorganization; if it has, adapt the chmod path | -| `c975222` archive-move conflict in Phase 3.5 | Rebase windows-integration's `2257b08` edits first; fall back to re-applying edits post-archive if content diverged | -| v0.3.0 skip creates CHANGELOG gap | Phase 6 v0.4.0 cut explicitly includes "integrated Windows support, platform/ architecture, ToolRegistry" as the flagship bullet; no mention of v0.3.0 because npm/GitHub already know | -| Electron health-check behaviour change (curl → identity-verified) surprises users | CHANGELOG entry in Phase 6 explicitly calls out "`isDashboardRunning()` replaces `curl`-based probe; custom-port users with stale dashboards must restart" | -| Someone commits to develop during the merge | Rebase Phase 3.5 catch-up onto latest develop before Phase 5. Use `git rerere` to remember resolutions from earlier phases | -| Phase 4 consolidation skipped leaves maintenance backlog | Explicitly scheduled as follow-up PR; not a blocker for this one | - -## Post-merge follow-ups (deferred, not in this change) - -1. **Platform/ consolidation** (`BRANCH-COMPARISON §9.5`) — 18→13 files, pure moves. Own PR. -2. **Node-compat module** — move `node-version-check.ts` + (if ever resurrected) `preload-fastify.ts` to `packages/shared/src/node-compat/`. Only if we decide to ship a Node workaround in the future; currently not needed because `node-guard.ts` replaces it. -3. **Subprocess-adapter / exec.ts reconciliation** — the two files both claim to be "the single spawn boundary." Decide: inline `subprocess-adapter.ts` into `package-manager-wrapper.ts`, or promote it to replace `exec.ts`. diff --git a/openspec/changes/adapt-windows-integration-pr9/proposal.md b/openspec/changes/adapt-windows-integration-pr9/proposal.md deleted file mode 100644 index 33bb278b1..000000000 --- a/openspec/changes/adapt-windows-integration-pr9/proposal.md +++ /dev/null @@ -1,69 +0,0 @@ -## Why - -PR #9 (`windows-integration`) ships three things develop needs: - -1. **Windows correctness** — develop is broken on Windows in 4 independent places (`cli.ts` uses `process.env.HOME` which is undefined on Windows; `resolve-jiti.ts` returns a raw path that Node rejects as `ERR_UNSUPPORTED_ESM_URL_SCHEME`; `/api/restart` shells out to `sh -c` + `lsof` + `curl`; `cmdStop` uses `lsof` only). -2. **`packages/shared/src/platform/` strategy-router architecture** — a real OS-abstraction layer with lint tests (`no-direct-child-process`, `no-direct-process-kill`, `no-direct-platform-branch`) that forbid OS-branching outside the module. This is the standard pattern for serious cross-platform Node projects (esbuild, Prisma, pnpm) and the only reason future Windows regressions get caught automatically. -3. **ToolRegistry** — single-source binary resolution with override UI, REST endpoints, and diagnostic trail. Replaces develop's ad-hoc `where`/`which` + inline spawn calls. - -The PR is **stale by 10 develop commits** since its own `MERGE-PLAN.md` was written (PR baseline: `a4cced2`; develop HEAD: `01c5e0c`). Three of those 10 are material — two are **safety-critical** — and the PR's merge-plan does not know about them. Simply cherry-picking per the PR's plan is not sufficient. - -The PR also carries **two localized regressions inside `platform/detached-spawn.ts`** that must be fixed on windows-integration before any develop commits land on top (commit `5ab7956` reverted the `d331850` no-flash fix by hard-coding `detached:true`; the `useWindowsRedirect` heuristic doesn't check its real precondition that all stdio must be ignore). - -## What Changes - -Create `windows-integration-v2` off today's `windows-integration`. Execute the PR's own MERGE-PLAN with three documented deviations and catch up to today's develop HEAD. Cut `v0.4.0` at the end. PR #9 stays open as a historical record until superseded. - -### Deviations from PR #9's MERGE-PLAN - -1. **Phase 0.5 injected before Phase 1** — pull develop's three safety commits first: - - `6a1b1d8` test isolation tripwire (`globalSetup` throwing when `HOME === os.userInfo().homedir`, plus `packages/server/src/test-env-guard.ts` gating `headlessPidRegistry.cleanupOrphans/killAll` and `editorPidRegistry.cleanupOrphans`). **Must land before any `npm test` runs** because windows-integration widens the kill surface via `platform/process.ts` — running tests without the tripwire can SIGTERM the live pi session. - - `3cad40b` electron node-pty spawn-helper execute permission in packaged bundles (different failure mode than `8737249`; without this, DMG/AppImage/NSIS ship with broken terminals). - - `8737249` node-pty hoist-aware permissions + handler error surfacing — already in MERGE-PLAN Category B #27, but ordered before Phase 1 so terminals work for every test gate. - -2. **Phase 3.5 added** — catch up to today's develop HEAD (`01c5e0c`) after Category C: - - `c975222` archive `fix-fork-entryid-timing` — **expected conflict** because windows-integration has this change active with edits to proposal/design/tasks/spec. Resolution: apply windows-integration's refinements to the archived content, then archive. - - `4b2b76c`, `a75a1db`, `ac2bd96` — test baseline + jsdom fixes + platform-agnostic test fixtures. - - `c325227` — CHANGELOG Unreleased consolidation (merge, don't replace). - - **Skip** `16e9758` (v0.3.0 release commit), `90a3b7b` (site sync), `01c5e0c` (CI re-dispatch). Cut v0.4.0 fresh at the end instead of replaying v0.3.0 on the merged tree. - -3. **Phase 4 platform/ consolidation deferred** — the 18→13 file merge (`BRANCH-COMPARISON.md` §9.5) is pure moves with zero behaviour change. It ships as a follow-up PR for review isolation, not in this merge. - -### Out of scope - -- Node version preflight beyond what the MERGE-PLAN §0.1b already specifies (`node-guard.ts` + `engines.node >= 22.18.0`). The preload-fastify-cjs workaround stays rejected per `BRANCH-COMPARISON.md` §10. -- The optional `platform/` consolidation (Phase 4). Follow-up PR. -- Any new features beyond what the 34 + 10 develop commits introduce. - -## Impact - -### Specs affected (delta) - -- `packages/shared/src/platform/` — new capability, full spec per `openspec/changes/consolidate-platform-handlers/specs/platform-primitives/spec.md` (already drafted on windows-integration). -- `tool-registry` — new capability, spec already drafted on windows-integration under `openspec/changes/archive/2026-04-19-consolidate-tool-resolution/`. -- `platform-paths` — new capability, spec already drafted under `openspec/changes/platform-path-normalization/`. -- `dashboard-server`, `bridge-extension`, `command-executor`, `force-kill-handler`, `editor-detection` — amended specs on windows-integration (already drafted). -- `ask-user-tool`, `ask-user-tool/batch-method` — amended on develop during the 10-commit gap; reconcile on merge. - -### Code surface - -- **High blast radius**: `packages/shared/src/platform/*` (18 new files), `packages/shared/src/tool-registry/*` (6 new files), `packages/shared/src/resolve-jiti.ts` return-type change, `packages/server/src/cli.ts` rewrite, `packages/extension/src/server-launcher.ts` rewrite, `packages/server/src/process-manager.ts` spawn strategy replacement. -- **Electron surface**: `packages/electron/src/lib/{app-menu,bundled-node,dependency-detector,dependency-installer,doctor,health-check,server-lifecycle}.ts` all migrate to ToolResolver + `isDashboardRunning`. Windows portable install path and Linux/macOS bundled node-pty permissions are the two highest-risk areas. -- **Test infra**: Vitest 4 migration (root `vitest.config.ts` replaces `vitest.workspace.ts`). Every workspace's vitest config changes. Test-isolation tripwire from `6a1b1d8` becomes mandatory. - -### Migration, compatibility, rollback - -- **Migration**: none required for end-users. `engines.node` bumps to `>=22.18.0`; users on older Node see a clear preflight error from `node-guard.ts` with upgrade instructions. -- **Compatibility**: `health-check.ts` moves from `curl`-based probe to identity-verified `isDashboardRunning()`. Users with a stale/unverified old dashboard on a custom port will see "not running" after upgrade — correct behaviour, but document in CHANGELOG. -- **Rollback**: Phase 0 ends with `git tag pre-develop-merge` on `windows-integration-v2`. Any phase can roll back to this tag. If the merge ships and regresses, `v0.3.0` remains available on npm + GitHub Releases. `npm deprecate` rather than `npm unpublish` per `release-revoke` skill. - -### Validation gates (non-negotiable) - -Per MERGE-PLAN §5, before PR to develop: - -- Full `npm test` green on Windows, macOS, Linux (CI matrix). -- `npm run build` green on all three. -- Electron make green on all three (DMG, AppImage, NSIS, ZIP). -- Manual Windows smoke: no cmd.exe flash on ×3 session spawn, `server.log` populated, `pi-dashboard stop` frees ports after crash, `/api/restart` works, zrok + QR works, editor iframe loads. -- Manual macOS + Linux smoke: landing page, session spawn, terminal, editor. -- All three lint-style tests green: `no-direct-child-process`, `no-direct-process-kill`, `no-direct-platform-branch`. diff --git a/openspec/changes/adapt-windows-integration-pr9/specs/cross-platform-merge-baseline/spec.md b/openspec/changes/adapt-windows-integration-pr9/specs/cross-platform-merge-baseline/spec.md deleted file mode 100644 index 0ae26ac70..000000000 --- a/openspec/changes/adapt-windows-integration-pr9/specs/cross-platform-merge-baseline/spec.md +++ /dev/null @@ -1,82 +0,0 @@ -## ADDED Requirements - -### Requirement: `spawnDetached` MUST accept an explicit `detach` option - -`packages/shared/src/platform/detached-spawn.ts` `SpawnDetachedOptions` SHALL include an optional `detach?: boolean` field (default `true`). When `detach` is `false`, `spawnDetached` SHALL set `detached: false` on the underlying `child_process.spawn` call so the child remains inside the parent's libuv Job Object (Windows) or process group (POSIX) and no new console is allocated. - -This requirement exists because commit `5ab7956` hard-coded `detached: true` for every caller, which reverted commit `d331850`'s no-flash fix for Windows pi-session spawning. Pi sessions are deliberately tied to the parent's lifecycle via RPC stdin-EOF; they MUST NOT outlive the parent, and `detached: false` is the mechanism. - -Server auto-start (`packages/extension/src/server-launcher.ts`) keeps the default `detach: true` — it MUST outlive the bridge. - -#### Scenario: Pi-session spawn with `detach: false` does not flash a console on Windows - -- **GIVEN** a Windows host running the dashboard server -- **WHEN** a new pi session is spawned via `spawnHeadlessDetached` with `detach: false` -- **THEN** no cmd.exe window appears, even transiently -- **AND** the child process is terminated when the parent server exits (RPC stdin-EOF path) - -#### Scenario: Server auto-start preserves `detach: true` default - -- **GIVEN** a bridge extension auto-launching the dashboard server -- **WHEN** `server-launcher` calls `spawnDetached` without passing a `detach` option -- **THEN** the child SHALL be spawned with `detached: true` -- **AND** the child SHALL survive termination of the launching bridge process - -### Requirement: `useWindowsRedirect` gate MUST check `stdinMode === "ignore"` - -The cmd.exe redirect branch in `packages/shared/src/platform/detached-spawn.ts` SHALL only activate when all three conditions are true: `platform === "win32"`, `opts.logPath` is set, AND `stdinMode === "ignore"`. The `stdinMode === "ignore"` check is required because libuv only sets `CREATE_NO_WINDOW` when every stdio handle is ignored; a piped stdin negates the flag and allocates a visible console regardless of cmd.exe wrapping. - -#### Scenario: Redirect branch refuses to run with piped stdin - -- **GIVEN** a caller passing `stdinMode: "pipe"` and `logPath: "/tmp/x.log"` on Windows -- **WHEN** `spawnDetached` evaluates `useWindowsRedirect` -- **THEN** the gate SHALL return `false` -- **AND** the function SHALL fall through to direct node.exe spawn with `windowsHide: true` + `logFd` inheritance - -#### Scenario: Redirect branch runs with ignore stdio - -- **GIVEN** a caller passing `stdinMode: "ignore"` and `logPath: "/tmp/x.log"` on Windows -- **WHEN** `spawnDetached` evaluates `useWindowsRedirect` -- **THEN** the gate SHALL return `true` -- **AND** the child SHALL be wrapped via `cmd.exe /d /s /c` with `["ignore", "ignore", "ignore"]` stdio so `CREATE_NO_WINDOW` applies - -### Requirement: Test suite MUST refuse to run against the real user `$HOME` - -The shared test-support module `packages/shared/src/test-support/setup-home.ts` SHALL be wired as `globalSetup` in every workspace's `vitest.config.ts`. The module SHALL throw at vitest boot when `process.env.HOME === os.userInfo().homedir`, aborting the entire test run before any test file loads. - -This requirement exists because windows-integration's consolidation commit `39acb1e` routes every process termination through `platform/process.ts`. Without the tripwire, destructive sweeps in `headlessPidRegistry.cleanupOrphans/killAll` and `editorPidRegistry.cleanupOrphans` SIGTERM the live pi session running the tests. - -#### Scenario: Vitest invoked without ephemeral HOME aborts before loading any test - -- **GIVEN** a developer running `npx vitest run` without a `HOME=$(mktemp -d)` prefix -- **WHEN** vitest boots `globalSetup` -- **THEN** `setup-home.ts` SHALL throw an instructive error -- **AND** no test file SHALL load -- **AND** no destructive sweep SHALL run against the real `~/.pi/` directory - -#### Scenario: Vitest invoked via `npm test` passes the tripwire - -- **GIVEN** the root `package.json` `test` script `HOME=$(mktemp -d -t pi-test-XXXXXX) vitest ...` -- **WHEN** `npm test` is run -- **THEN** `globalSetup` SHALL observe a HOME under `os.tmpdir()` -- **AND** `setup-home.ts` SHALL pre-create `/.pi/agent/sessions/` and `/.pi/dashboard/` -- **AND** tests SHALL proceed normally - -### Requirement: Destructive registry sweeps MUST no-op when test-env-guard detects unsafe HOME - -`packages/server/src/test-env-guard.ts` exports `isUnsafeTestHomeScan()` which returns `true` when `process.env.VITEST === "true"` AND `process.env.HOME === os.userInfo().homedir`. `headlessPidRegistry.cleanupOrphans`, `headlessPidRegistry.killAll`, and `editorPidRegistry.cleanupOrphans` SHALL consult this predicate and no-op with a `console.warn` when it returns `true`. - -This is defense-in-depth: even if the `globalSetup` tripwire is disabled or bypassed, the guard prevents the sweep from SIGTERM-ing live pi processes. - -#### Scenario: Sweep no-ops when VITEST=true and HOME is real user home - -- **GIVEN** `VITEST=true` is set AND `process.env.HOME` equals `os.userInfo().homedir` -- **WHEN** `headlessPidRegistry.cleanupOrphans()` is called -- **THEN** the function SHALL log a warning to the console -- **AND** the function SHALL return without sending any signal - -#### Scenario: Sweep runs normally in production - -- **GIVEN** `VITEST` is unset OR `HOME` is an ephemeral tmp dir -- **WHEN** `headlessPidRegistry.cleanupOrphans()` is called -- **THEN** the function SHALL run its normal orphan-detection + SIGTERM logic diff --git a/openspec/changes/adapt-windows-integration-pr9/tasks.md b/openspec/changes/adapt-windows-integration-pr9/tasks.md deleted file mode 100644 index 5c698d6fc..000000000 --- a/openspec/changes/adapt-windows-integration-pr9/tasks.md +++ /dev/null @@ -1,124 +0,0 @@ -## 0. Preflight on `windows-integration-v2` - -- [ ] 0.1 Create `windows-integration-v2` branch from today's `origin/windows-integration` (`de695e1`) -- [ ] 0.2 Read and internalize `MERGE-PLAN.md` and `BRANCH-COMPARISON.md` from the PR branch -- [ ] 0.3 Execute MERGE-PLAN §0.1a: revert uncommitted preload-fastify-cjs work via `git checkout HEAD --` + `rm -rf`; commit as `chore(server): remove abandoned preload-fastify-cjs workaround` -- [ ] 0.4 Execute MERGE-PLAN §0.1b: add `packages/server/src/node-guard.ts` + `packages/server/src/__tests__/node-guard.test.ts` + `engines.node >=22.18.0` in `packages/server/package.json` + preflight call in `cmdStart` and `runForeground`; commit as `feat(server): refuse to start on Node versions affected by nodejs/node#58515` -- [ ] 0.5 Execute MERGE-PLAN §0.2: add `detach?: boolean` to `SpawnDetachedOptions` in `packages/shared/src/platform/detached-spawn.ts` (default `true`); tighten `useWindowsRedirect` gate with `stdinMode === "ignore"`; pass `detach: false` from `spawnHeadlessDetached` in `packages/server/src/process-manager.ts`; add regression test; commit as `fix(windows): restore d331850 no-flash pi-session spawn` -- [ ] 0.6 Manual Windows validation: fresh start, no flash on ×3 session spawn, `server.log` populated, `/api/restart` works, `pi-dashboard stop` frees port 8888 when PID stale -- [ ] 0.7 If any of 0.6 fails, STOP and fix before proceeding -- [ ] 0.8 Tag `git tag -a pre-develop-merge -m "windows-integration-v2, all regressions fixed"` as rollback anchor - -## 0.5 Safety commits (NEW, not in MERGE-PLAN) - -- [ ] 0.9 Cherry-pick `8737249` (node-pty hoist-aware permissions + handler error surfacing); resolve any conflicts with platform/ imports; run `npm test` -- [ ] 0.10 Cherry-pick `3cad40b` (node-pty spawn-helper bundle execute permission); verify `packages/server/src/fix-pty-permissions.ts` path still matches platform/ layout; run `npm test` -- [ ] 0.11 Cherry-pick `6a1b1d8` (test isolation tripwire — `packages/shared/src/test-support/setup-home.ts` + `packages/server/src/test-env-guard.ts` + root `vitest.config.ts` hookup); run `npm test` and verify tripwire fires when invoked outside ephemeral HOME -- [ ] 0.12 Verify `HOME=$(mktemp -d -t pi-test-XXXXXX) npx vitest run packages/server` passes and that the tripwire throws without the `HOME=` prefix -- [ ] 0.13 Run `find ~/.pi/agent/sessions -name "*.meta.json" -exec md5 -q {} \; | sort > /tmp/before.txt`; run `npm test`; verify only the current-session directory changed (per AGENTS.md isolation verification recipe) - -## 1. Phase 1 — Category A (clean picks, 19 commits) - -Cherry-pick in MERGE-PLAN §2 order, SKIPPING `8737249` (already in Phase 0.5): - -- [ ] 1.1 `ee838d0` marketing site + GH Pages workflow -- [ ] 1.2 `e95491b` error-banner collapse + Retry + Copy -- [ ] 1.3 `f2ec691` CHANGELOG.md + release process docs -- [ ] 1.4 `97dd4bd` persistent editor PID registry (merge with our editor-manager changes per MERGE-PLAN §3.7) -- [ ] 1.5 `15da6a8` site download section + theme toggle -- [ ] 1.6 `c0bd183` inline SVG brand + barber-pole + pin-folder label -- [ ] 1.7 `4143d49` CORS tunnel-origin allowlist -- [ ] 1.8 `a343efa` docs: CORS allowlist + pre-compressed static -- [ ] 1.9 `144301c` QA verification fixes -- [ ] 1.10 `89d3bf6` landing-page onboarding -- [ ] 1.11 `c004806` OpenSpec card state pill + Tasks popover -- [ ] 1.12 `d192513` README marketing site link (resolve README conflict: merge both sections) -- [ ] 1.13 `889d71a` archive add-marketing-site -- [ ] 1.14 `7c5ff18` archive 2 parallel changes -- [ ] 1.15 `9510702` archive cross-platform-qa-vms (inspect for QA-work overlap) -- [ ] 1.16 `852ccf8` archive fix-portable-windows-package-manager -- [ ] 1.17 `7a0e926` ask-user batch method (merge with our ask-user-tool edits per MERGE-PLAN §3.10) -- [ ] 1.18 `b2c7d90` session-header image paste propagation -- [ ] 1.19 `cee0c58` release-cut + release-revoke skills -- [ ] 1.20 `36bd96d` ask-user batch title backfill (depends on 1.17) -- [ ] 1.21 Run `npm test` + `npm run build`; must be green before Phase 2 - -## 2. Phase 2 — Category B (trivial reconcile, 8 commits) - -Skipping `8737249` (Phase 0.5): - -- [ ] 2.1 `f037530` ask-user spec scenario + changelog (merge with our spec edits) -- [ ] 2.2 `381dbfe` CHANGELOG Unreleased consolidation -- [ ] 2.3 `93e0bb8` CI: switch main branch trigger to develop (merge publish.yml) -- [ ] 2.4 `ca9d76f` CI: sync-release-version pushes to develop -- [ ] 2.5 `2e50ebe` CI: deploy-site configure-pages enablement -- [ ] 2.6 `2ef37c6` harden ask_user argument validation (depends on 1.17) -- [ ] 2.7 `cf3ab84` pi core version checker (may conflict with our routes index) -- [ ] 2.8 Run `npm test` + `npm run build`; must be green before Phase 3 - -## 3. Phase 3 — Category C (manual merge, 5 commits) - -Order respects dependencies: - -- [ ] 3.1 `a4cced2` Vitest 4 migration — **foundational**, do first. Expect conflict on root config, deletion of workspace file. Verify `packages/shared/vitest.config.ts` + test-support `globalSetup` still wire correctly -- [ ] 3.2 `9af9dd8` TS errors in tests/routes — inspect per MERGE-PLAN §2 Category C #30; likely SKIP if errors are develop-specific (caused by develop's ad-hoc spawn code not present on v2) -- [ ] 3.3 `a45e9d0` path-picker server-side filter + new-folder — reconcile `browse.ts` per MERGE-PLAN §3.8: normalize path first (our code), then apply filter/listing logic -- [ ] 3.4 `8ca4538` zrok tunnel leak fix + compression — reconcile `tunnel.ts` per MERGE-PLAN §3.11: keep ToolResolver binary lookup, apply develop's lifecycle + compression fixes -- [ ] 3.5 `e368d27` pi_core broadcast (depends on 2.7) -- [ ] 3.6 Run `npm test` + `npm run build`; must be green before Phase 3.5 - -## 3.5 Phase 3.5 — catch up to develop HEAD (NEW) - -- [ ] 3.7 Rebase windows-integration's `2257b08` (`fix-fork-entryid-timing` edits) onto pre-archive content; if already in develop's archived version, skip our edits -- [ ] 3.8 Cherry-pick `c975222` archive `fix-fork-entryid-timing`; resolve file-move conflicts -- [ ] 3.9 Cherry-pick `4b2b76c` restore zero-failure baseline -- [ ] 3.10 Cherry-pick `a75a1db` eliminate vitest unhandled errors from jsdom gaps -- [ ] 3.11 Cherry-pick `ac2bd96` platform-agnostic test fixtures -- [ ] 3.12 Cherry-pick `c325227` CHANGELOG Unreleased consolidation (merge, don't replace) -- [ ] 3.13 **SKIP** `16e9758` v0.3.0 release (deviation 3 — v0.4.0 cut at end) -- [ ] 3.14 **SKIP** `90a3b7b` site sync to v0.3.0 -- [ ] 3.15 **SKIP** `01c5e0c` CI re-dispatch for v0.3.0 release -- [ ] 3.16 Run `npm test` + `npm run build`; must be green - -## 4. Phase 4 — DEFERRED - -- [ ] 4.1 Platform/ consolidation (18→13 files) — create follow-up OpenSpec change after v2 merges; do not include in this PR - -## 5. Phase 5 — Validation gates (per MERGE-PLAN §5) - -- [ ] 5.1 Full `npm test` green on Windows -- [ ] 5.2 Full `npm test` green on macOS -- [ ] 5.3 Full `npm test` green on Linux -- [ ] 5.4 `npm run build` green on all three -- [ ] 5.5 `cd packages/electron && npm run make` green on all three (DMG, AppImage, NSIS, ZIP) -- [ ] 5.6 Manual Windows smoke: no cmd.exe flash ×3 session spawn, `server.log` populated on startup, `pi-dashboard stop` frees both ports after crash, `/api/restart` works from UI, zrok + QR works, editor iframe loads -- [ ] 5.7 Manual macOS smoke: landing page, session spawn, terminal, editor, zrok -- [ ] 5.8 Manual Linux smoke: landing page, session spawn, terminal, editor -- [ ] 5.9 Lint tests green: `no-direct-child-process`, `no-direct-process-kill`, `no-direct-platform-branch` -- [ ] 5.10 Electron first-run wizard: Windows portable install, Windows installed, macOS, Linux — all four paths -- [ ] 5.11 Electron doctor: all 4 platforms, verify ToolResolver finds git when Git-for-Windows is installed via GitHub Desktop private folder (known risk from doctor.ts migration) -- [ ] 5.12 Electron health-check: custom piPort with stale unverified dashboard correctly reports "not running" (documented behaviour change) - -## 6. Phase 6 — PR and release - -- [ ] 6.1 Update `AGENTS.md`, `README.md`, `docs/architecture.md` with post-merge sweep (reconcile both branches' sections, no deletions from either side) -- [ ] 6.2 Update `CHANGELOG.md` `[Unreleased]`: consolidate Windows support, platform/ architecture, ToolRegistry, health-check behaviour change -- [ ] 6.3 Open PR `windows-integration-v2` → `develop`; link this proposal + MERGE-PLAN + BRANCH-COMPARISON in description -- [ ] 6.4 PR merge policy: **do not squash** — preserve cherry-pick history for traceability -- [ ] 6.5 After merge: close PR #9 as superseded; delete `windows-integration` branch (keep tag `pre-develop-merge` on v2 for rollback reference) -- [ ] 6.6 Run `release-cut` skill for `v0.4.0`; CHANGELOG `[Unreleased]` → `## [0.4.0] - YYYY-MM-DD` -- [ ] 6.7 Tag + push; CI publishes npm + Electron artifacts + drafts GitHub Release -- [ ] 6.8 Open follow-up OpenSpec change for Phase 4 platform/ consolidation (18→13 files) - -## Acceptance criteria - -- [ ] `windows-integration-v2` HEAD contains every user-visible behaviour from develop commits `a4cced2..01c5e0c` except the v0.3.0 release replay -- [ ] Every file listed in MERGE-PLAN §3 reflects its prescribed "keep windows-integration" or "merge" decision -- [ ] Phase 5 validation gates all pass -- [ ] Two MERGE-PLAN regressions fixed: no cmd.exe flash on Windows pi-session spawn; bridge auto-start failure does not append Node-bug hint for EADDRINUSE / explicit exits -- [ ] `packages/shared/src/platform/` architecture survives intact; all three lint-enforcement tests green -- [ ] `packages/shared/src/tool-registry/` present with override UI + REST endpoints -- [ ] Test isolation tripwire (`6a1b1d8`) integrated; `npm test` cannot run against real `$HOME` -- [ ] Electron packaged bundles work on Windows/macOS/Linux (node-pty terminals spawn) -- [ ] `CHANGELOG.md [Unreleased]` ready for `release-cut` -- [ ] PR description references both MERGE-PLAN.md and BRANCH-COMPARISON.md as the durable decision record diff --git a/openspec/changes/add-capacitor-mobile-shell/design.md b/openspec/changes/add-capacitor-mobile-shell/design.md deleted file mode 100644 index c13de9bd4..000000000 --- a/openspec/changes/add-capacitor-mobile-shell/design.md +++ /dev/null @@ -1,115 +0,0 @@ -## Context - -The dashboard's React client is already decoupled from the server it talks to: `App.tsx` derives `wsUrl` from `window.location` by default, but the entire `ServerSelector` / known-servers / mDNS-discovery infrastructure exists to override that default. This makes Capacitor packaging a *thin* exercise — the client doesn't fork, doesn't grow a native-only branch, just learns to ask the user for a server URL when `window.location` is meaningless (because we're inside a `capacitor://` shell). - -The push-notification work in `add-server-push-notifications` defines the server contract: `POST /api/push/register` with `{deviceToken, transport: "fcm"}`. Capacitor's `@capacitor/push-notifications` plugin produces exactly this token via FCM on Android and APNs-via-FCM on iOS (Firebase forwards APNs through). One client-side adapter; everything else reuses the server. - -**Stakeholders**: client maintainers (small App.tsx + hooks changes), CI maintainers (new Android + iOS lanes), release maintainers (keystore + Apple cert custody), end-users (gain APK + TestFlight builds with native push). - -**Dependencies**: -- `add-server-push-notifications` MUST be implemented before this change. This change depends on `/api/push/register`, `pushDispatcher`, and the `transport: "fcm"` adapter. -- A Firebase project + service-account JSON. Required at server-side for FCM dispatch (per the push change). Required at client-side for FCM token acquisition (Capacitor plugin reads `google-services.json` for Android and `GoogleService-Info.plist` for iOS). -- An Apple Developer account ($99/yr). Required for iOS signing, push capability, TestFlight distribution. -- An Android upload keystore (free, generated locally once). Required for APK signing. - -## Goals / Non-Goals - -**Goals:** -- Single source of UI truth: the React client at `packages/client/src/` serves both web and mobile. No fork. No native-only screens. -- Distribution via GitHub Releases (Android APK) and TestFlight (iOS) without requiring Play Store / App Store accounts in v1. -- Native FCM token acquisition on Android and iOS, fed into the existing `/api/push/register` server endpoint. -- Native mDNS discovery on both platforms, replacing the empty-on-browser experience. -- OS-keychain credential storage on both platforms, replacing `localStorage` for the auth token. -- CI lanes that produce signed artifacts on every release tag, attached to the GitHub Release (Android) or uploaded to TestFlight (iOS). -- The signing keystore for Android MUST be the same key forever — losing it orphans every installed APK. - -**Non-Goals:** -- Replacing or removing the PWA. The PWA continues to serve users who don't want to install an APK / TestFlight build. The mobile shell is additive distribution. -- A native UI layer. We are NOT writing Kotlin / Swift screens; everything stays React. -- Embedded server. The mobile shell is a remote client, not a self-contained dashboard. -- Multi-account or per-user push routing. v1 is single-user; same as the rest of the dashboard. -- Web Push on Capacitor. We use FCM on native; Web Push remains for the actual web PWA. - -## Decisions - -### Decision 1 — One `packages/mobile/` workspace, not a top-level project - -**Why**: keeps build artifacts inside the monorepo, lets `npm install` from the root work for everyone, and matches the existing package layout (`packages/client`, `packages/server`, `packages/electron`, etc.). The `mobile` package consumes `packages/client/dist/` via a sync script — no compile-time dependency, just a copy step. - -**Tradeoff**: `node_modules/` size grows. Capacitor + plugins is ~50 MB; rounding error vs. the existing electron tooling. - -### Decision 2 — Bundled web assets, no `server.url` in `capacitor.config.ts` - -**Why**: shipping `server.url: "https://..."` would make the APK a thin loader that fetches the dashboard client over HTTP at launch, which (a) breaks offline, (b) has worse cold-start performance, (c) creates a same-origin / CORS / cleartext-LAN nightmare. Bundling the JS/CSS into `www/` makes the WebView load from a `capacitor://` origin and the WebSocket / REST calls go to a separately-configured server URL. This is the model Capacitor itself recommends for "remote-controlled" clients. - -**Tradeoff**: every release ships a full client bundle inside the APK. Bundle size today is ~1.5 MB gzip. Acceptable. - -### Decision 3 — Token-paste auth in v1, defer OAuth deep-link - -**Why**: OAuth in WebViews is blocked by Google and ill-advised by Apple. The right pattern (`@capacitor/browser` + deep-link callback) is well-defined but adds a `pi-dashboard://` URL scheme registration on both platforms, deep-link handlers in the React app, and a server-side callback redirect target — a non-trivial slice. Token-paste reuses the existing `config.secret` field that already auths every dashboard install. v1 audience is technical; this is fine. - -**Rejected**: API-key only with no token-paste UI — too rough; users would have to manually edit `localStorage`. - -### Decision 4 — Same Android signing key for sideload and Play Store - -**Why**: if a user installs the GitHub-Releases APK and we later list the same `appId` on Play Store with a different signing key, those users CANNOT upgrade — Android refuses the install ("signatures don't match"). They'd have to uninstall + reinstall, losing local state. Solution: generate the keystore once, use it for both. Document the backup process. Optionally: enable Play App Signing later (Google holds the app key; we keep the upload key) — but that's a one-way migration. - -### Decision 5 — TestFlight is the iOS distribution mechanism for v1 - -**Why**: Apple does NOT permit `.ipa` files distributed via GitHub Releases for general users. The only realistic non-store distribution paths are TestFlight (Apple-blessed beta), Enterprise Distribution ($299/yr, restricted use), or AltStore-style sideloading (user-hostile, breaks weekly). TestFlight is free with the $99/yr developer account, supports up to 100 internal testers and 10 000 external testers, and reviews are typically <24 hours. - -**Tradeoff**: every iOS build needs a fresh TestFlight upload (90-day expiry per build means we re-release every 3 months minimum, which our release cadence likely exceeds anyway). - -### Decision 6 — `@capacitor/push-notifications` for both Android FCM and iOS APNs (via Firebase) - -**Why**: one plugin, one configuration, one server-side transport. Firebase forwards APNs through their infrastructure, so iOS push lands at the same FCM endpoint our server already uses. This is exactly what `add-server-push-notifications` design.md Decision 3 anticipated. - -**Rejected**: direct APNs from the server. Would require a second server-side transport adapter and an Apple Push key (.p8) in addition to the Firebase service-account JSON. Not justified for v1. - -### Decision 7 — Native mDNS via Capacitor plugin, with manual-add fallback - -**Why**: Browsers cannot do mDNS, full stop. On native, it's a 50-LOC Java/Swift wrapper around `NsdManager` / `NetServiceBrowser`. We evaluate `capacitor-zeroconf` (community plugin); if it's unmaintained or buggy, we fork or write our own. The existing `NetworkDiscoverySection.tsx` already has a "manual add" form (per the change `diagnose-empty-mdns-scan`); native mDNS just feeds the same data source. - -### Decision 8 — `network_security_config.xml` allows cleartext for RFC1918 ranges only - -**Why**: a phone on a home Wi-Fi connecting to `ws://192.168.1.10:8000` is the common case. Allowing cleartext globally is bad. Allowing for RFC1918 (10/8, 172.16/12, 192.168/16) and link-local (169.254/16) covers LAN without opening up the internet. - -```xml - - - 192.168.0.0/16 - - - - -``` - -iOS uses ATS exceptions in `Info.plist` for the same effect. - -### Decision 9 — CI lanes are `needs: [prepare, publish]` and `strategy.fail-fast: false` - -**Why**: matches the electron job pattern. `needs: publish` ensures we build the mobile app against the just-published npm packages (the bundled client is built from `packages/client`, which is fine, but if we ever want runtime version checks against published server packages, this is a free win). `fail-fast: false` keeps an iOS signing failure from canceling Android. - -## Risks / Trade-offs - -- **Apple review surprises**. An "agent dashboard" app could trip Apple's "executes arbitrary code" rule. We must be ready to argue: "this app DISPLAYS a remote agent that runs entirely on the user's own machine; it does not execute code locally." Mitigation: comprehensive App Store metadata, demo account credentials in the review notes, screen recording showing the app is read-only-with-prompts. -- **Keystore loss = catastrophic**. Document, back up, store in 1Password AND a separate offline backup. This is the single most important non-code asset we own. -- **TestFlight 90-day expiry**. If we release infrequently (>90 days between iOS builds), TestFlight users get cut off. Mitigation: at least one iOS build per quarter, even if just a no-op version bump. -- **Capacitor plugin drift**. `capacitor-zeroconf` is maintained by a single hobbyist; if it goes stale we own a small native plugin. Acceptable; the surface area is tiny. -- **`google-services.json` is a public-but-not-secret file**. Committed to the repo for the mobile build to find. Firebase explicitly designs this file to be safe to embed; the secret is the server-side service-account JSON, not the client-side google-services. -- **First-launch UX**. The user opens the app, sees no server, must add one. We must make this onboarding friction-free. Existing `NetworkDiscoverySection` + manual-add covers it but deserves polish — flagged as a UX task. -- **Cleartext to LAN can leak via VPN**. If a user is on a VPN that exposes their phone to a hostile LAN, cleartext to 192.168.x.x is risky. Documented in `docs/mobile-builds.md`; mitigated by the trusted-networks server-side gate (already present). -- **iOS push requires an entitlement**. The Apple App ID must have Push capability enabled. One-time setup; documented. - -## Migration Plan - -This is purely additive distribution: - -1. Land `add-server-push-notifications` server-side. PWA users gain Web Push. No mobile change yet. -2. Generate signing keystore (Android) and Apple cert + provisioning profile (iOS). Store in GitHub secrets. -3. Land `add-capacitor-mobile-shell` client + CI changes. First release tag triggers Android APK + iOS TestFlight builds. -4. Document install instructions in README. Users sideload APK or accept TestFlight invite. -5. (Future) Submit to Play Store. The same APK + signing key just need a Play Store listing. -6. (Future) Submit to App Store. The same TestFlight build is "promoted" to App Store via App Store Connect. - -No data migration. PWA users continue using the PWA. Mobile shell is opt-in. diff --git a/openspec/changes/add-capacitor-mobile-shell/proposal.md b/openspec/changes/add-capacitor-mobile-shell/proposal.md deleted file mode 100644 index 7db71b25a..000000000 --- a/openspec/changes/add-capacitor-mobile-shell/proposal.md +++ /dev/null @@ -1,59 +0,0 @@ -## Why - -The dashboard already ships a PWA (`public/manifest.json`, `public/sw.js`) that runs the React client against any reachable dashboard server. Three things the PWA fundamentally cannot match a native shell on: - -1. **Distribution**. PWAs cannot live on the Play Store or App Store. Mobile users cannot search for "pi dashboard" and install. APK sideload + TestFlight is the realistic v1 distribution channel; Play Store / App Store is a follow-up that reuses the same artifacts. -2. **Native mDNS**. Browsers cannot do mDNS / Bonjour discovery. The existing `NetworkDiscoverySection` is empty in the browser by design — the server does the scanning. On a fresh phone install with no server, that's a chicken-and-egg problem. A native plugin (`capacitor-zeroconf` or similar wrapping Android `NsdManager` / iOS `NetServiceBrowser`) closes this gap. -3. **Cleartext to LAN servers**. A PWA installed from `https://your-tunnel.share.zrok.io` cannot connect back to `ws://192.168.16.202:8000` (mixed-content rule). A Capacitor APK can opt in to cleartext for LAN ranges via `network_security_config.xml`, making the LAN + tunnel hybrid story sane. - -This change packages the existing client (`packages/client/dist/`) as a Capacitor app, ships it as a signed APK in GitHub Releases (Android) and uploads to TestFlight (iOS), and wires the FCM transport of `add-server-push-notifications` to the device's native push token. - -This change DEPENDS ON `add-server-push-notifications` being implemented and merged — the mobile shell relies on `POST /api/push/register` with `transport: "fcm"` and the server-side dispatcher fan-out. - -## What Changes - -- **NEW** workspace package `packages/mobile/` containing: - - `capacitor.config.ts` — `appId: "io.blackbelt.pi-dashboard"`, `appName: "Pi Dashboard"`, `webDir: "www"`, `server: { androidScheme: "https" }`, no `server.url` (we ship bundled web assets). - - `package.json` — `"private": true`, dependencies on `@capacitor/core`, `@capacitor/android`, `@capacitor/ios`, `@capacitor/push-notifications`, `@capacitor/preferences`, `@capacitor/browser`, and a zeroconf plugin (TBD: `capacitor-zeroconf` if maintained, else fork or write a thin wrapper). - - `scripts/sync-web.sh` — copies `packages/client/dist/` to `packages/mobile/www/` then runs `npx cap sync`. Idempotent; safe to re-run. - - `scripts/build-android.sh` — runs `cap sync android`, then `cd android && ./gradlew assembleRelease bundleRelease`. Reads `KEYSTORE_PATH` / `KEYSTORE_PASSWORD` / `KEY_ALIAS` / `KEY_PASSWORD` from env (CI secrets in GitHub Actions; `.env.local` for dev). - - `scripts/build-ios.sh` — runs `cap sync ios`, then `xcodebuild -workspace ios/App/App.xcworkspace -scheme App -configuration Release archive`, then `xcodebuild -exportArchive` with a TestFlight-flavored `ExportOptions.plist`. - - `android/` and `ios/` directories — generated by `cap add android` / `cap add ios`. Mostly gitignored except for the platform-specific config we own (icon resources, `AndroidManifest.xml` modifications, `Info.plist` modifications, `network_security_config.xml`). -- **CLIENT CHANGES** (small, in `packages/client/`): - - `packages/client/src/lib/capacitor-detect.ts` — pure helper `isCapacitorNative(): boolean` (returns `window.Capacitor?.isNativePlatform?.() === true`). Browser builds always return false; Capacitor builds always return true. - - Modify `App.tsx:87-89` `DEFAULT_WS_URL` derivation: when `isCapacitorNative()`, skip the `window.location`-based default and force `wsUrl` to be `null` initially. The existing `ServerSelector` flow becomes the landing screen, which already handles "no server selected". - - Modify `packages/client/src/hooks/usePushSubscription.ts` (added by the push change) to branch on `isCapacitorNative()`: native path uses `@capacitor/push-notifications` to obtain the FCM token, then POSTs to `/api/push/register` with `transport: "fcm"`. Web path is unchanged. - - Modify the existing `NetworkDiscoverySection.tsx`: when `isCapacitorNative()`, use the zeroconf plugin to perform a native mDNS scan and feed results into the same `KnownServerCandidate` shape the server-side scan emits. Same UI; different data source. - - Modify the secret/token storage path: when `isCapacitorNative()`, use `@capacitor/preferences` (Keychain on iOS, EncryptedSharedPreferences on Android) instead of `localStorage` for the auth token. Wrap behind `lib/secure-store.ts` so the rest of the client doesn't care. - - **NO new screens, NO forked components.** Same React tree, same UX, just with native affordances enabled where present. -- **CI** — extend `.github/workflows/publish.yml` with two new jobs: - - `mobile-android` — runs on `ubuntu-latest`, `needs: [prepare, publish]`. Sets up JDK 17 + Android SDK, decodes keystore from `secrets.ANDROID_KEYSTORE_BASE64`, runs `scripts/build-android.sh`, attaches `app-release.apk` to the GitHub Release. Also produces `app-release.aab` as a build artifact (uploaded later to Play Store manually for v1). - - `mobile-ios` — runs on `macos-latest`, `needs: [prepare, publish]`. Sets up Xcode, decodes signing cert from `secrets.IOS_CERT_BASE64` + `secrets.IOS_PROVISIONING_PROFILE_BASE64`, runs `scripts/build-ios.sh`, uses `fastlane pilot upload` (or `xcrun altool`) to push to TestFlight. - - Both jobs use `strategy.fail-fast: false` so an iOS signing hiccup does not cancel the Android lane (mirrors the electron job's pattern). -- **OAUTH ON MOBILE** — `add-server-push-notifications` does not address auth on mobile. v1 ships **token-paste auth** (existing `config.secret` flow). User generates a token on desktop, pastes into the mobile Settings → Server config screen. OAuth via `@capacitor/browser` + deep-link callback is **deferred** to a follow-on change (`add-mobile-oauth-deep-link`). -- **DOCUMENTATION**: - - New `docs/mobile-builds.md` — keystore generation steps, GitHub secret setup, Apple Developer / TestFlight setup, local `cap sync` workflow. - - Update `docs/architecture.md` — add a "Mobile shell" section diagramming Capacitor → bundled client → server REST/WS, and the push token registration path. - - Update `AGENTS.md` Key Files table with one row per new file under `packages/mobile/`. - - Update `README.md` Installation section with "Mobile (Android APK / iOS TestFlight)" instructions. - -## Capabilities - -### New Capabilities - -- `mobile-shell` — a Capacitor-based native shell wrapping the existing React client, distributed as a signed Android APK in GitHub Releases and an iOS TestFlight build, with native FCM push, native mDNS discovery, OS-keychain credential storage, and cleartext-LAN affordances. - -### Modified Capabilities - -- `push-notifications` — extends the registration path defined in `add-server-push-notifications` to obtain device tokens via `@capacitor/push-notifications` on native platforms. No server-side change; only the client-side token acquisition. -- `network-discovery` (existing in the codebase, even if not yet captured as a spec) — adds a native-mDNS data source. Same `KnownServerCandidate` shape, same UI. - -## Out of Scope - -- **Play Store / App Store submission**. v1 ships APK sideload via GitHub Releases and iOS TestFlight only. Store submission is a follow-on (`submit-mobile-to-stores`) that reuses the same artifacts plus store-specific metadata (privacy policy, screenshots, content rating, age rating, etc.). -- **OAuth deep-link flow**. Token-paste is the v1 auth UX. Deep-link OAuth is `add-mobile-oauth-deep-link`. -- **Self-update flow**. v1 users re-download the APK from GitHub Releases. An in-app "check for update" hitting the GitHub Releases API is a follow-on. -- **Capacitor JS-only live updates** (`@capgo/capacitor-updater` or similar). Out of scope; revisit if release cadence demands it. -- **Tablet-specific layouts**. The existing responsive design serves both phones and tablets; no new breakpoints. -- **Background sync / offline event capture**. Push tells the user something happened; opening the app reconnects to the server. We do not store events offline in v1. -- **Apple Watch / Wear OS companions**. Pure follow-on territory. diff --git a/openspec/changes/add-capacitor-mobile-shell/specs/mobile-shell/spec.md b/openspec/changes/add-capacitor-mobile-shell/specs/mobile-shell/spec.md deleted file mode 100644 index d3dde51fd..000000000 --- a/openspec/changes/add-capacitor-mobile-shell/specs/mobile-shell/spec.md +++ /dev/null @@ -1,138 +0,0 @@ -## ADDED Requirements - -### Requirement: Capacitor packaging without forking the client -The mobile app SHALL be a Capacitor wrapper around the existing React client at `packages/client/`. There SHALL be no forked client code, no native-only screens, no parallel React tree. The mobile package (`packages/mobile/`) SHALL consume `packages/client/dist/` via a sync script and produce signed Android and iOS artifacts. Behavior differences (Capacitor detection, native push, native mDNS, OS-keychain storage) SHALL be implemented as runtime branches in the existing client, gated by a single `isCapacitorNative()` predicate. - -#### Scenario: Web build behavior unchanged -- **WHEN** the client is built and served as the standalone web app -- **THEN** `isCapacitorNative()` SHALL return `false` -- **AND** all behavior SHALL be identical to the pre-change client - -#### Scenario: Native build behavior gated -- **WHEN** the client is loaded inside the Capacitor shell on Android or iOS -- **THEN** `isCapacitorNative()` SHALL return `true` -- **AND** all native-specific paths (push, mDNS, keychain) SHALL activate -- **AND** the React component tree SHALL render the same UI as the web build (modulo data-source differences for mDNS) - -### Requirement: First-launch landing screen is the server picker -On a fresh native install with no previously-saved server, the app SHALL NOT default `wsUrl` to a `window.location`-derived value (which is meaningless inside a `capacitor://` shell). Instead, it SHALL render the existing `ServerSelector` / known-servers / mDNS-discovery UI as the landing screen. - -#### Scenario: Fresh install, no saved server -- **WHEN** the user opens the app for the first time -- **THEN** the landing screen SHALL be the server-selection UI -- **AND** no "disconnected" error banner SHALL flash before the user has selected a server - -#### Scenario: Returning user with a saved server -- **WHEN** the user has previously added a server and it is reachable -- **THEN** the app SHALL connect automatically on launch using the persisted entry - -### Requirement: Native push registration via Capacitor plugin -On native platforms, the client SHALL acquire a push token via `@capacitor/push-notifications`, request OS permission, and register the token with the dashboard server via `POST /api/push/register` with `transport: "fcm"`. The server contract is unchanged from `add-server-push-notifications`. - -#### Scenario: User grants permission -- **WHEN** the user enables push in Settings on a native platform -- **THEN** the app SHALL call `PushNotifications.requestPermissions()`, on grant call `register()`, capture the FCM (Android) or APNs-via-FCM (iOS) token from the `'registration'` event, and POST to `/api/push/register` with `transport: "fcm"` - -#### Scenario: User denies permission -- **WHEN** the user denies the OS permission prompt -- **THEN** the Settings UI SHALL display a clear "Push permission denied — re-enable in OS settings" message -- **AND** no token registration SHALL occur - -#### Scenario: Notification tap routes to session -- **WHEN** the user taps a delivered push notification with `payload.url = "/session/abc-123"` -- **THEN** the app SHALL launch (or foreground) and navigate to the corresponding session view - -### Requirement: Native mDNS discovery -On native platforms, the `NetworkDiscoverySection` SHALL use a native mDNS plugin (Capacitor zeroconf or equivalent) to scan for `_pi-dashboard._tcp` advertisements on the local network. Discovered servers SHALL be mapped into the existing `KnownServerCandidate` shape and rendered through the same UI used by the server-side scan path. - -#### Scenario: Native scan finds a server on LAN -- **GIVEN** a phone and a dashboard server are on the same Wi-Fi network -- **WHEN** the user opens Network Discovery on the phone -- **THEN** the discovered server SHALL appear in the list within 5 seconds -- **AND** tapping "Add" SHALL persist it to known-servers via the same code path the manual-add form uses - -#### Scenario: Web build does not perform native scan -- **WHEN** Network Discovery is opened in a desktop browser -- **THEN** the existing browser-side scan path SHALL be used (delegated to the server) -- **AND** the native mDNS plugin SHALL NOT be referenced - -#### Scenario: Manual-add fallback still present on native -- **WHEN** the native scan finds zero servers -- **THEN** the existing manual-add form SHALL be visible exactly as it is on web - -### Requirement: OS-keychain credential storage -On native platforms, the dashboard auth token SHALL be stored via `@capacitor/preferences` (Keychain on iOS, EncryptedSharedPreferences on Android). On web, the existing `localStorage` path SHALL remain. A wrapper helper `secure-store.ts` SHALL abstract the difference so callers do not branch. - -#### Scenario: Token persisted to Keychain on iOS -- **WHEN** the user saves an auth token in Settings on iOS -- **THEN** the token SHALL be stored via `@capacitor/preferences` (which uses Keychain underneath) -- **AND** the value SHALL NOT be present in `localStorage` - -#### Scenario: Token persisted to localStorage on web -- **WHEN** the user saves an auth token in Settings in a desktop browser -- **THEN** the token SHALL be stored in `localStorage` via the same helper - -### Requirement: Cleartext to LAN, no cleartext to internet -The Android `network_security_config.xml` SHALL permit cleartext (`http://`, `ws://`) traffic ONLY to RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and link-local (169.254.0.0/16). The base config SHALL deny cleartext for all other domains. iOS `Info.plist` SHALL use `NSAllowsLocalNetworking` for the equivalent constraint. - -#### Scenario: WebSocket to LAN server works -- **WHEN** the app attempts to connect to `ws://192.168.16.202:8000` -- **THEN** the connection SHALL succeed (modulo server reachability) - -#### Scenario: WebSocket to public cleartext IP is blocked -- **WHEN** the app attempts to connect to `ws://example.com:8000` (public IP) -- **THEN** the connection SHALL be blocked by the OS with a clear error -- **AND** the user SHALL be advised to use `wss://` or zrok - -### Requirement: Signed Android APK in GitHub Releases -Every `v*` release tag SHALL produce a signed Android APK (`app-release.apk`) and an Android App Bundle (`app-release.aab`). The APK SHALL be attached to the GitHub Release. The AAB SHALL be uploaded as a workflow artifact for later Play Store submission. Both SHALL be signed with the same upload keystore stored in `secrets.ANDROID_KEYSTORE_BASE64`. - -#### Scenario: Release tag triggers signed APK -- **WHEN** a `v*` tag is pushed (or `workflow_dispatch` is invoked) -- **THEN** the `mobile-android` job SHALL produce `app-release.apk` signed with the configured keystore -- **AND** the APK SHALL be attached to the GitHub Release for that tag - -#### Scenario: Keystore secret missing -- **WHEN** the `mobile-android` job runs without `ANDROID_KEYSTORE_BASE64` configured -- **THEN** the job SHALL fail with a clear error -- **AND** other matrix jobs SHALL continue (`fail-fast: false`) - -### Requirement: TestFlight upload for iOS -Every `v*` release tag SHALL produce a signed iOS `.ipa` and upload it to TestFlight via `xcrun altool` (or fastlane equivalent). The build SHALL be signed with the configured Apple Developer certificate and provisioning profile. - -#### Scenario: Release tag triggers TestFlight upload -- **WHEN** a `v*` tag is pushed -- **THEN** the `mobile-ios` job SHALL produce a signed `.ipa` and upload it to App Store Connect -- **AND** the build SHALL appear in TestFlight within the Apple-determined propagation window - -#### Scenario: iOS lane failure is isolated -- **WHEN** iOS signing fails (e.g. cert expired) -- **THEN** the `mobile-ios` job SHALL fail -- **AND** the `mobile-android` job SHALL still complete and attach its APK -- **BECAUSE** `strategy.fail-fast: false` is set - -### Requirement: CI lanes block on publish -Both `mobile-android` and `mobile-ios` jobs SHALL declare `needs: [prepare, publish]` so they run AFTER the npm publish step has completed. The `publish-workflow-contract.test.ts` SHALL be extended to assert this contract (mirroring the electron-job contract). - -#### Scenario: Publish must succeed first -- **WHEN** the publish job fails for any reason -- **THEN** neither mobile job SHALL run -- **BECAUSE** `needs: publish` gates them - -#### Scenario: Contract test catches misconfiguration -- **WHEN** a developer removes `publish` from the mobile jobs' `needs` array -- **THEN** the publish-workflow-contract test SHALL fail the build with a citation to this change name - -## ADDED Requirements - -### Requirement: Token-paste auth in v1 -The v1 mobile shell SHALL authenticate via the existing `config.secret` token-paste mechanism. On native platforms, the token SHALL be stored via the OS keychain (per the credential-storage requirement). OAuth deep-link flow is explicitly out of scope for v1 and SHALL be tracked as a separate change. - -#### Scenario: User pastes a token -- **WHEN** the user enters a server URL and a secret token in the Settings UI -- **THEN** subsequent REST and WebSocket requests SHALL include the token as the auth credential -- **AND** the token SHALL be persisted to the OS keychain via `secure-store` - -#### Scenario: OAuth attempt is documented -- **WHEN** the user expects to log in via Google or GitHub on the mobile shell -- **THEN** the Settings UI SHALL display a clear "OAuth on mobile is coming soon — use a token for now" message linking to the desktop token-generation flow diff --git a/openspec/changes/add-capacitor-mobile-shell/tasks.md b/openspec/changes/add-capacitor-mobile-shell/tasks.md deleted file mode 100644 index 3917f0b59..000000000 --- a/openspec/changes/add-capacitor-mobile-shell/tasks.md +++ /dev/null @@ -1,135 +0,0 @@ -# Tasks - -## 1. Preconditions - -- [ ] 1.1 Confirm `add-server-push-notifications` is merged and `/api/push/register` is live with `transport: "fcm"` support. -- [ ] 1.2 Generate Android upload keystore via `keytool -genkey -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload`. Back up to 1Password AND offline storage. Encode base64 for `secrets.ANDROID_KEYSTORE_BASE64`. -- [ ] 1.3 Set up Apple Developer account ($99/yr). Create App ID `io.blackbelt.pi-dashboard` with Push Notifications capability enabled. -- [ ] 1.4 Create Firebase project. Enable Cloud Messaging. Download `google-services.json` (Android) and `GoogleService-Info.plist` (iOS) and the server-side service-account JSON. Document where each file lives. -- [ ] 1.5 Configure GitHub secrets: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `IOS_CERT_BASE64`, `IOS_CERT_PASSWORD`, `IOS_PROVISIONING_PROFILE_BASE64`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. -- [ ] 1.6 Read `packages/client/src/App.tsx:87-89` and confirm the `DEFAULT_WS_URL` derivation is the only place that assumes a meaningful `window.location`. -- [ ] 1.7 Read `packages/client/src/components/NetworkDiscoverySection.tsx` and confirm the `KnownServerCandidate` shape used by both the server-side scan and the manual-add form. -- [ ] 1.8 `npm test` baseline green; capture in `/tmp/mobile-baseline.log`. - -## 2. Workspace package scaffold - -- [ ] 2.1 Create `packages/mobile/package.json` with `"private": true`, dependencies on `@capacitor/core`, `@capacitor/cli`, `@capacitor/android`, `@capacitor/ios`, `@capacitor/push-notifications`, `@capacitor/preferences`, `@capacitor/browser`, `@capacitor/splash-screen`, plus a chosen zeroconf plugin (start with `capacitor-zeroconf`; spike-test it works). -- [ ] 2.2 Create `packages/mobile/capacitor.config.ts` with `appId: "io.blackbelt.pi-dashboard"`, `appName: "Pi Dashboard"`, `webDir: "www"`, `bundledWebRuntime: false`, `server: { androidScheme: "https" }`. NO `server.url`. -- [ ] 2.3 Create `packages/mobile/.gitignore` for `node_modules/`, `www/`, build artifacts, `android/build/`, `ios/build/`, `android/.gradle/`, etc. -- [ ] 2.4 Add `packages/mobile` to the workspace glob in root `package.json`. Run `npm install` and verify resolution. - -## 3. Web sync script - -- [ ] 3.1 Create `packages/mobile/scripts/sync-web.sh` that: - 1. Builds `packages/client` (`npm run build -w @blackbelt-technology/pi-dashboard-web`). - 2. Removes `packages/mobile/www/` if present. - 3. Copies `packages/client/dist/` → `packages/mobile/www/`. - 4. Runs `npx cap sync` from `packages/mobile/`. -- [ ] 3.2 Add npm script `mobile:sync` to root `package.json` invoking the script. - -## 4. Capacitor detection helper + client integration - -- [ ] 4.1 Create `packages/client/src/lib/capacitor-detect.ts` exporting `isCapacitorNative(): boolean` (returns `(globalThis as any).Capacitor?.isNativePlatform?.() === true`). No type imports — runtime check only. -- [ ] 4.2 Unit test in `packages/client/src/lib/__tests__/capacitor-detect.test.ts` covering web (returns false) and stubbed-native (returns true) paths. -- [ ] 4.3 Modify `packages/client/src/App.tsx:87-89`: gate the `DEFAULT_WS_URL` derivation behind `!isCapacitorNative()`. When native, initial `wsUrl` is `null`. The existing `ServerSelector` flow handles the "no server selected" state. -- [ ] 4.4 Verify the existing `ConnectionStatusBanner` does not flash a misleading "disconnected" state on a fresh native install (where the user hasn't picked a server yet). Adjust if needed. -- [ ] 4.5 Manual smoke test: load the client in a desktop browser; confirm zero behavior change. - -## 5. Native push registration - -- [ ] 5.1 In `packages/client/src/hooks/usePushSubscription.ts` (added by `add-server-push-notifications`), add a branch on `isCapacitorNative()`: - - Call `PushNotifications.requestPermissions()`. - - On grant, `PushNotifications.register()`. - - Listen for `'registration'` event → got FCM/APNs token. - - POST to `/api/push/register` with `{deviceToken: token, transport: "fcm"}`. - - Listen for `'pushNotificationReceived'` and `'pushNotificationActionPerformed'`. The latter routes to `payload.url` via `wouter` navigation. -- [ ] 5.2 Tests for the native branch using a stubbed `@capacitor/push-notifications` module. - -## 6. Native mDNS - -- [ ] 6.1 Spike-test `capacitor-zeroconf` in a tiny throwaway project; confirm it resolves Bonjour `_pi-dashboard._tcp` advertisements emitted by an existing dashboard server. -- [ ] 6.2 Modify `packages/client/src/components/NetworkDiscoverySection.tsx`: when `isCapacitorNative()`, use the zeroconf plugin to perform the scan; map results into the existing `KnownServerCandidate` shape; render via the same UI. -- [ ] 6.3 If `capacitor-zeroconf` is unmaintained or broken, fork into `packages/mobile/plugins/zeroconf-bridge/` (thin wrapper with our own minimal Java/Swift). Spec stays the same; only the implementation source differs. -- [ ] 6.4 Manual test: phone on same Wi-Fi as a dashboard server; open Network Discovery; confirm the server appears within 5 s. - -## 7. OS-keychain credential storage - -- [ ] 7.1 Create `packages/client/src/lib/secure-store.ts` exporting `get(key)`, `set(key, value)`, `remove(key)`. On native, delegates to `@capacitor/preferences`. On web, falls back to `localStorage`. -- [ ] 7.2 Refactor every `localStorage.setItem("pi-dashboard-token", ...)` / `getItem` call to use `secure-store`. Migration: if `localStorage` has the key but `secure-store` doesn't (post-install), copy across and clear `localStorage` (one-time migration on web; on native, `localStorage` is the WebView's, not user-accessible from Android Settings, so this is fine). -- [ ] 7.3 Tests for both backends. - -## 8. Android-specific config - -- [ ] 8.1 `cd packages/mobile && npx cap add android`. Commit only the files we own — `android/app/src/main/AndroidManifest.xml`, `android/app/src/main/res/`, `android/app/build.gradle` if customized. -- [ ] 8.2 Add `android/app/src/main/res/xml/network_security_config.xml` permitting cleartext for RFC1918 + link-local ranges only. -- [ ] 8.3 Reference `network_security_config.xml` from `AndroidManifest.xml` (`android:networkSecurityConfig="@xml/network_security_config"`). -- [ ] 8.4 Place `google-services.json` in `android/app/`. Apply the `com.google.gms.google-services` Gradle plugin. -- [ ] 8.5 Add app icon resources via `npx capacitor-assets generate` (or hand-place into `android/app/src/main/res/mipmap-*/`). -- [ ] 8.6 Verify `applicationId` matches `appId` from capacitor.config.ts. - -## 9. iOS-specific config - -- [ ] 9.1 `cd packages/mobile && npx cap add ios`. Commit only the files we own. -- [ ] 9.2 Add `Info.plist` ATS exceptions for RFC1918 cleartext (or use `NSAllowsArbitraryLoadsInLocalNetworks` + `NSAllowsLocalNetworking`). -- [ ] 9.3 Place `GoogleService-Info.plist` in `ios/App/App/`. -- [ ] 9.4 Enable Push Notifications capability in the Xcode project. Add `Signing & Capabilities` entry. -- [ ] 9.5 Configure signing identity + provisioning profile to match the App ID created in 1.3. -- [ ] 9.6 Add app icon resources to `ios/App/App/Assets.xcassets/AppIcon.appiconset/`. - -## 10. Android build script - -- [ ] 10.1 Create `packages/mobile/scripts/build-android.sh`: - 1. Run `mobile:sync`. - 2. `cd android && ./gradlew assembleRelease bundleRelease`. - 3. Sign uses keystore from env (Gradle reads from `~/.gradle/gradle.properties` or env). - 4. Output `android/app/build/outputs/apk/release/app-release.apk` and `app-release.aab`. -- [ ] 10.2 Local test: with a dev keystore, run the script. Verify APK installs on a real device or Android Studio emulator. - -## 11. iOS build script - -- [ ] 11.1 Create `packages/mobile/scripts/build-ios.sh`: - 1. Run `mobile:sync`. - 2. `xcodebuild -workspace ios/App/App.xcworkspace -scheme App -configuration Release archive -archivePath build/App.xcarchive`. - 3. `xcodebuild -exportArchive -archivePath build/App.xcarchive -exportPath build/ipa -exportOptionsPlist ios/ExportOptions.plist`. - 4. Output `build/ipa/App.ipa`. -- [ ] 11.2 Create `packages/mobile/ios/ExportOptions.plist` for TestFlight export (`method: app-store`, `signingStyle: manual`). -- [ ] 11.3 Local test on macOS with developer signing identity. - -## 12. CI: Android lane - -- [ ] 12.1 Add `mobile-android` job to `.github/workflows/publish.yml`: - - `runs-on: ubuntu-latest` - - `needs: [prepare, publish]` - - `strategy.fail-fast: false` - - Steps: checkout, `setup-java@v4` JDK 17, `setup-android@v3`, decode `ANDROID_KEYSTORE_BASE64` to file, write `gradle.properties` with signing config, run `scripts/build-android.sh`. - - Upload `app-release.apk` to the GitHub Release via `softprops/action-gh-release@v2`. - - Upload `app-release.aab` as a workflow artifact. -- [ ] 12.2 Update `packages/shared/src/__tests__/publish-workflow-contract.test.ts` to require the new job's `needs` array contain both `prepare` and `publish` AND `fail-fast: false`. Mirror the electron contract. - -## 13. CI: iOS lane - -- [ ] 13.1 Add `mobile-ios` job: - - `runs-on: macos-latest` - - `needs: [prepare, publish]` - - `strategy.fail-fast: false` - - Steps: checkout, install Xcode (typically pre-installed on `macos-latest`), decode signing assets from secrets, install certs into a temporary keychain, run `scripts/build-ios.sh`, run `xcrun altool --upload-app -f build/ipa/App.ipa -u $APPLE_ID -p $APPLE_APP_SPECIFIC_PASSWORD`. -- [ ] 13.2 Document the Apple-side TestFlight propagation delay (typically 5–30 min after upload). - -## 14. Documentation - -- [ ] 14.1 Create `docs/mobile-builds.md` covering keystore generation/backup, Firebase setup, Apple Developer setup, GitHub secret population, local dev workflow (`mobile:sync` + `cap run android` / `cap run ios`). -- [ ] 14.2 Add "Mobile shell" section to `docs/architecture.md` with a diagram of Capacitor → bundled client → server REST/WS, plus the FCM token registration path. -- [ ] 14.3 Add Key Files entries to `AGENTS.md` for every new file in `packages/mobile/src/` (capacitor.config.ts, sync-web.sh, build-android.sh, build-ios.sh) and the new client files (`capacitor-detect.ts`, `secure-store.ts`). -- [ ] 14.4 Add "Mobile (Android APK / iOS TestFlight)" subsection to `README.md` Installation. - -## 15. Verification - -- [ ] 15.1 `npm test` green. -- [ ] 15.2 Manual Android: install APK on a phone, open app, see ServerSelector, scan mDNS, find a real dashboard server on LAN, connect, verify chat loads and websocket works. -- [ ] 15.3 Manual Android: enable push in app Settings, run a session that fires `ask_user`, verify push lands. -- [ ] 15.4 Manual Android: kill app, fire push trigger, verify push wakes the device and tapping opens the right session. -- [ ] 15.5 Manual iOS via TestFlight: same flow as 15.2-15.4. -- [ ] 15.6 Manual: cleartext WS to LAN works on Android (`ws://192.168.x.x:8000`). -- [ ] 15.7 Manual: token-paste auth flow on first launch. -- [ ] 15.8 CI: green on a release tag; APK attached to GitHub Release; TestFlight build appears in App Store Connect. -- [ ] 15.9 Run `openspec validate add-capacitor-mobile-shell --strict` and fix any spec/scenario gaps. diff --git a/openspec/changes/add-dashboard-slash-commands/design.md b/openspec/changes/add-dashboard-slash-commands/design.md deleted file mode 100644 index 395c382fa..000000000 --- a/openspec/changes/add-dashboard-slash-commands/design.md +++ /dev/null @@ -1,233 +0,0 @@ -## Context - -The dashboard's REST API is fully wrapped by the existing `pi-dashboard` skill, but every interaction goes through the LLM. For one-shot read-only operations ("status check, tell me what's running, show me the diff") this is wasteful — tokens, latency, and non-deterministic formatting on every read. Slash commands are the right ergonomic shape for one-shot ops, but pi's slash pipeline always routes through the LLM. This change adds the missing pipeline. - -## Goals / Non-Goals - -**Goals** - -- Add a `/dashboard:*` namespace with consistent `-` naming. -- Introduce one new pipeline: slash command → bash → render in chat → no LLM. Reuse `handleBashCommand` and `bash_output` event verbatim. -- Frontmatter-driven mode selection (`executable: bash`). The convention is template-author-controlled, not hardcoded into the dispatcher. -- Backward compatibility: every existing prompt template continues to route through the LLM. The new pipeline is opt-in per template. -- Discoverability: the chat UI signals "ran locally — LLM not invoked" so users learn that read-only commands are free. -- The initial command set covers every read-only endpoint (LLM-free) and every single-shot mutation (LLM-bound for reasoning where needed). - -**Non-Goals** - -- A new event type. Reuse `bash_output`. -- A new bash execution path. Reuse `handleBashCommand`. -- A new server endpoint. Every command targets an existing REST endpoint via the existing helper script. -- ts-morph or template DSL. Frontmatter parsing is hand-rolled YAML-lite (the existing `readTemplate` already strips frontmatter — we promote that to a typed parser). -- Per-skill subdirectory scanning by the expander. Templates ship inside the existing skill's `commands/` dir and are surfaced via `pi.getCommands()` (which already enumerates skill-shipped commands). -- Auto-discovery of dashboard endpoints. The command set is hand-curated. - -## Relationship to fix-extension-slash-commands-in-dashboard - -This change has a hard dependency on `fix-extension-slash-commands-in-dashboard` landing first. That change: - -- Adds detection of pi-extension-registered slash commands (e.g. `/ctx-stats`) before the fallback to `sendUserMessage`. -- Establishes a numbered routing order in `command-routing/spec.md` (steps 1–11). -- Modifies the same two call sites this change needs: `bridge.ts::sessionPrompt` and `command-handler.ts`'s slash else-arm. -- Proposes (its task 3.2) extracting a shared helper to keep the two sites in lockstep. - -My change slots a new routing step into the order the fix establishes: - -``` - fix lands: mine slots in: - ───────────────── ────────────── - ... ... - 8. user-defined flow run 8. user-defined flow run - 9. extension command dispatch 9. extension command dispatch - 10. fall through to template 10. NEW: template with executable: bash - expansion + sendUserMessage → run as bash, no LLM - 11. no-slash text → sendUserMessage 11. fall through to template expansion - + sendUserMessage (was step 10) - 12. no-slash text → sendUserMessage - (was step 11) -``` - -**Disjointness**: extension commands are JS handlers (`pi.registerCommand`); exec-mode templates are `.md` files on disk with `executable: bash` frontmatter. A single name cannot be both, so the order between steps 9 and 10 is arbitrary on correctness grounds. We put extension-dispatch first because it's user-installed (higher precedence than template authoring). - -**Implementation sites**: the new exec branch lands in the same two places the fix already touches — either both call sites directly, or the shared helper the fix's task 3.2 extracts. Whichever shape the fix lands in, this change follows it. - -## The Five Pipelines (after this change) - -```mermaid -flowchart LR - user[User input] --> parse[parseSendPrompt] - parse -->|"!cmd"| bash1[bash + LLM] - parse -->|"!!cmd"| bash2[bash, no LLM] - parse -->|"/reload, /new, /model, /quit, /compact"| direct[direct dispatch] - parse -->|"/cmd → template (no executable)"| llm[expand → user msg → LLM] - parse -->|"/cmd → template (executable: bash)"| exec[render body as bash, no LLM] - - bash1 --> bashout[bash_output event] - bash2 --> bashout - direct --> feedback[command_feedback event] - llm --> assistant[assistant response + tools] - exec --> bashout - - style exec fill:#cef - style bashout fill:#ffe -``` - -The new pipeline (highlighted) shares the `bash_output` event with `!`/`!!`. The only client-side difference is the optional `data.source: "slash-exec"` field that triggers the "ran locally" footer. - -## Naming Grammar - -``` - /dashboard:-[-] [args...] - │ │ │ - │ │ └── optional, e.g. -all, -active, -here - │ └── action verb (singular) - └── resource family (singular noun) -``` - -Resource families: - -| Family | Operates On | Examples | -|---|---|---| -| `server-*` | The dashboard process itself | `server-health`, `server-config`, `server-tunnel-on` | -| `session-*` | Individual pi sessions | `session-list`, `session-tell`, `session-abort` | -| `proposal-*` | OpenSpec attachment per session | `proposal-attach`, `proposal-detach`, `proposal-archive` | -| `flow-*` | Active flow on a session | `flow-abort`, `flow-auto` | -| `git-*` | Git ops on a cwd | `git-branches`, `git-init`, `git-stash-pop` | -| `peer-*` | Other dashboard servers (mDNS) | `peer-list`, `peer-scan` | -| `pin-*` | Pinned directories | `pin-list` | - -**Why singular resource?** `session-list` reads as "session, list" — natural for autocomplete grouping. Plural (`sessions-list`) is grammatically redundant. - -**Why hyphen between resource and verb?** The expander already aliases `:` → `-` (line 80 of `prompt-expander.ts`). `dashboard:session-list` resolves to file `dashboard-session-list.md`. Alternatives considered: - -- `dashboard:session:list` — works (`replaceAll(":", "-")`) but visually implies 3-level nesting that doesn't exist. -- Flat naming (`dashboard:sessions`, `dashboard:tell`) — rejected. 30 commands without grouping is unscannable. - -## Frontmatter Schema - -```yaml ---- -executable: bash # opt-in flag. Only "bash" is supported in v1. -excludeFromContext: true # default true when executable: bash. Optional. -description: "Display detailed info about a session without invoking the LLM." ---- - -``` - -Parser rules: - -- The expander's existing frontmatter regex (`/^---\n[\s\S]*?\n---\n([\s\S]*)$/`) already extracts the body. We add a typed parser for the YAML-lite block in between (line-oriented `key: value`, no nesting, no lists). Only three keys are recognised; unknown keys are ignored (forward compat). -- `executable` accepts only the literal string `"bash"` in v1. Other values (e.g. `"node"`, `"python"`) are reserved for future expansion and treated as no-op (template falls back to LLM). -- `excludeFromContext` defaults to `true` when `executable: bash` is present (matches `!!` semantics, since the whole point is "don't burn LLM context"). Authors can override with `excludeFromContext: false` if they want the output captured for follow-up reasoning. -- `description` is purely cosmetic; surfaced in autocomplete tooltips (future enhancement, not part of v1). - -## Argument Substitution - -Today's `expandPromptTemplateFromDisk(...)` appends `argsString` after a blank line at the end of the template body. That works for LLM templates ("here are the user's args, figure them out") but is wrong for bash bodies — we need positional args. - -Proposal: `pi.exec("sh", ["-c", body, "--", ...args])` where `args` is `argsString.trim().split(/\s+/)` after stripping empty tokens. This makes `$1`, `$2`, ... bind correctly inside the body. - -```bash -# Template body: -ID="$1" -~/.pi/skills/pi-dashboard/scripts/dashboard-api.sh GET /api/sessions \ - | jq -r --arg id "$ID" '.data[] | select(.id | startswith($id))' - -# User input: -/dashboard:session-info abc12345 - -# Resolved exec call: -pi.exec("sh", ["-c", "", "--", "abc12345"]) -``` - -**Quoting hazard**: `argsString.split(/\s+/)` doesn't honor quoted args. For v1 this is acceptable because every dashboard command takes simple identifiers (session ids, change names, branch names, paths). The one risky command is `session-tell ` where `` may contain spaces. That command is **not** in the LLM-free set — it's a regular slash template that hands off to the LLM precisely because text composition is a reasoning task. So the v1 quoting limit doesn't bite any LLM-free command. - -**Optional named env**: bridge injects `PI_DASHBOARD_PORT` and `PI_DASHBOARD_BASE` from `~/.pi/dashboard/config.json` into the exec env so templates don't have to re-derive the port. This subsumes part of `dashboard-api.sh`'s setup logic. - -## Decision: where do the templates live? - -Three options: - -``` - A) ~/.pi/prompts/ B) .pi/skills/pi-dashboard/commands/ C) Inline in SKILL.md - ──────────────── ───────────────────────── ───────────────── - ~/.pi/prompts/dashboard-*.md .pi/skills/pi-dashboard/ Single-file skill grows - commands/dashboard-*.md enormous; no per-command - + Globally available + Ships with skill bundle files; defeats slash - + Zero install cost + Versioned with the codebase command discovery. - - 30 files in user's home dir + skill-update updates them REJECTED. - - Disconnected from the skill - Requires expander to find them - via pi.getCommands() (already - does this for top-level - SKILL.md but NOT for nested - command files). -``` - -**Recommendation: B**, with one clarifying behaviour change: `pi.getCommands()` (pi's command-discovery API used by the expander as a fallback in `prompt-expander.ts:90-97`) already enumerates skill-bundled commands. We confirm this works for the new `commands/` subdir; if not, the bridge's `pi.getCommands()` fallback handles the case via the existing path-fallback at line 95. - -Concretely: the `package.json` of the skill (or its convention manifest) declares each command file. We follow whatever pattern existing skills use to declare auxiliary commands. (Investigation task: confirm this in §3.) - -## Decision: how does the client know "this came from slash-exec"? - -The chat renderer treats `bash_output` events identically today. The "ran locally — LLM not invoked" footer needs a signal that *this particular* `bash_output` is from an exec-mode template (not from `!` or `!!`). - -Cleanest option: the bridge's `handleBashCommand` accepts an optional `source: "slash-exec"` parameter and includes it in the emitted event's `data` payload. Client renderer checks `data.source === "slash-exec"` to decide whether to draw the footer. - -This is additive to the protocol — old clients that don't know about the field render exactly as today (no footer), new clients render the footer. No version bump needed. - -## Decision: discoverability footer wording - -Three drafts: - -1. `ℹ ran locally — LLM not invoked` -2. `ℹ executed locally · 0 tokens · $0.00` -3. `ℹ /dashboard:session-info ran locally — no LLM call, no tokens used` - -Option 1 is the cleanest. Option 2's "0 tokens / $0.00" is information-rich but invites bikeshedding ("what about API call latency?"). Option 3 is too long for a footer. Going with **option 1**. - -## Initial command set classification - -``` -LLM-FREE (executable: bash) LLM-BOUND (regular slash) -───────────────────────────── ────────────────────────── - server-health session-tell - server-config session-abort-all - server-tunnel-status session-spawn [cwd] - session-list session-resume - session-list-active session-fork - session-list-here session-rename - session-info session-model

- session-diff session-thinking - proposal-archive session-abort - git-branches session-kill - peer-list session-hide - peer-scan session-unhide - pin-list proposal-attach - proposal-detach - flow-abort - flow-auto - git-init [cwd] - git-stash-pop [cwd] - server-tunnel-on - server-tunnel-off -``` - -**Selection rule**: an operation is LLM-free if (a) it is read-only OR has zero blast radius, (b) every input is a simple identifier (no free-form text), and (c) the result is a deterministic format. Any operation that requires judgment (which session to abort? which prompt text to send?) stays LLM-bound. - -## Open Questions (deferred) - -1. **Autocomplete grouping**. The dashboard's `CommandInput` already lists slash commands. Does it group by prefix (`dashboard:` → 30 hits)? If not, we may want a follow-up change for that ergonomic. Out of scope here. -2. **Default session id**. Should `` arg be optional and default to "this session" via `PI_DASHBOARD_SESSION_ID` env? Punted to v2 — gather feedback first on whether users actually want to drive their own session via slash commands (the current expectation is they drive *other* sessions). -3. **Format options**. Some commands could benefit from `--format json|table|raw`. v1 hardcodes one format per template; v2 may add a frontmatter `format:` field. -4. **Template SDK**. If we end up writing 30 templates with similar curl+jq boilerplate, a shared snippet (sourced at top of each body) might emerge. Not designed for in v1; left to organic refactor after the initial set is shipped. - -## Risks - -- **Quoting in args** (mitigated above — no LLM-free command takes free-form text). -- **Frontmatter parser correctness**. Bad parser means bad commands. Mitigated by exhaustive unit tests on the parser (every valid combination + every malformed YAML case → falls back to LLM mode). -- **Dispatch ambiguity**. If `parseSendPrompt` is wrong about whether a template is exec-mode (e.g. fails to find the file → returns slash → LLM invocation), the worst case is a prompt the LLM doesn't understand. Acceptable; the LLM error is observable. -- **`pi.getCommands()` doesn't surface nested command files**. Investigation task in §3 of tasks.md. If pi's command discovery doesn't pick up `commands/*.md` from a skill subdir, fallback is to add a flat copy in `~/.pi/prompts/` (option A above) at install time. - -## Migration & Rollout - -This is purely additive. No migration. Existing slash commands continue to work. Users opt in by typing `/dashboard:*` after upgrading the bridge. The bridge handles the new pipeline; no client-side change is required for *minimum-viable* operation (the footer is a polish item that can ship in a follow-up if needed). diff --git a/openspec/changes/add-dashboard-slash-commands/proposal.md b/openspec/changes/add-dashboard-slash-commands/proposal.md deleted file mode 100644 index 596768855..000000000 --- a/openspec/changes/add-dashboard-slash-commands/proposal.md +++ /dev/null @@ -1,73 +0,0 @@ -## Why - -The dashboard exposes ~50 REST endpoints (`api-reference.md`, 594 lines) and an existing skill (`.pi/skills/pi-dashboard/`) that wraps every endpoint with a curl helper script. Today, driving the dashboard from a pi session requires the LLM to read the skill, choose an endpoint, and invoke `Bash` with the right curl. That works, but for **read-only, single-shot operations** ("which sessions are active?", "what's the diff in session abc?", "is the tunnel up?") it's slow, expensive, and non-deterministic — every status check costs tokens and may produce slightly different formatting each time. - -Slash commands (`/foo`, expanded by `prompt-expander.ts` → `command-handler.ts`) are the right shape for these one-shot operations. The expander already supports the colon-aliasing convention (`/foo:bar` → `foo-bar.md`), so a `/dashboard:*` namespace fits naturally. But there's a gap: the slash pipeline today **always routes the expanded template to the LLM as a user message**. There is no way for a slash command to render output deterministically without invoking the model. - -The current four pipelines — `!cmd` (bash + LLM), `!!cmd` (bash, no LLM), `/cmd` (template → LLM), and the hard-coded set (`/reload`, `/new`, `/model`, `/quit`, `/compact` — direct, no LLM) — leave a clean missing slot: **a slash command whose body is bash and whose output renders directly without LLM involvement**. We need that pipeline, and a curated `/dashboard:*` command set that uses it. - -## What Changes - -- **NEW**: A `/dashboard:-` namespace covering ~30 dashboard operations grouped into 7 resource families (`server-*`, `session-*`, `proposal-*`, `flow-*`, `git-*`, `peer-*`, `pin-*`). Naming grammar fixed: singular resource, hyphen-joined verb (e.g. `/dashboard:session-list`, `/dashboard:session-info`, `/dashboard:proposal-attach`). -- **NEW**: Frontmatter directive `executable: bash` on prompt template files. When the expander encounters a template with this flag, the bridge takes a new pipeline: render the body as bash via `pi.exec()`, emit a `bash_output` event for client rendering, and **never call the LLM**. A companion flag `excludeFromContext: true` (default for `executable: bash`) skips appending the result to LLM context, mirroring `!!` semantics. -- **NEW**: New `ParsedPrompt` variant `{ type: "slash-exec"; command: string; excludeFromContext: boolean; argsString: string }` returned by `parseSendPrompt` when the resolved template carries `executable: bash`. The `command-handler.ts` switch dispatches this variant to the existing `handleBashCommand` helper — no new bash-execution code path. -- **NEW**: Argument substitution convention for exec-mode templates: positional args via shell `$1`, `$2`, ... by spawning `sh -c "" -- `. Optional named env via `PI_DASHBOARD_SESSION_ID`, `PI_DASHBOARD_CWD` injected by the bridge for ergonomics ("default to current session/cwd if no arg supplied"). -- **NEW**: Initial command set under `~/.pi/prompts/` (or per-skill subdir, see design.md): - - **Read-only / LLM-free** (`executable: bash`): `server-health`, `server-config`, `server-tunnel-status`, `session-list`, `session-list-active`, `session-list-here`, `session-info `, `session-diff `, `proposal-archive`, `git-branches`, `peer-list`, `peer-scan`, `pin-list`. - - **LLM-bound** (regular slash templates): `session-tell `, `session-abort `, `session-abort-all`, `session-kill `, `session-rename `, `session-hide `, `session-unhide `, `session-spawn [cwd]`, `session-resume `, `session-fork `, `session-model

`, `session-thinking `, `proposal-attach `, `proposal-detach `, `flow-abort `, `flow-auto `, `git-init [cwd]`, `git-stash-pop [cwd>`, `server-tunnel-on`, `server-tunnel-off`. -- **NEW**: A discoverability footer rendered by the client when an `executable: bash` command runs: a small "ℹ ran locally — LLM not invoked" line below the output, so users learn the cost story. -- **MODIFIED**: `prompt-expander.ts` — `readTemplate()` returns `{ frontmatter, body }` instead of a single string. Parses YAML frontmatter into a typed object (`{ executable?: "bash"; excludeFromContext?: boolean; description?: string }`). New export `loadPromptTemplate(text, cwd, pi)` returns a discriminated union `{ kind: "llm"; text } | { kind: "exec"; body, excludeFromContext, argsString }`. Existing `expandPromptTemplateFromDisk(...)` keeps its current signature for backwards compat — it now delegates to `loadPromptTemplate` and returns the LLM-text shape only. -- **MODIFIED**: `command-handler.ts` — `parseSendPrompt(text)` peeks at the resolved template via the new `loadPromptTemplate` helper. When the template is `kind: "exec"`, it returns `{ type: "slash-exec", ... }`. The `handle()` switch adds a new arm dispatching to `handleBashCommand`. -- **NOT INTRODUCED**: A new prompt-expander subdirectory scan. Today's expander reads `.pi/prompts/*.md` flat plus `.pi/skills/*/SKILL.md`. The new prompts live flat in `~/.pi/prompts/` with the `dashboard-` prefix. (Per-skill subdirs deferred — see design.md.) -- **NOT INTRODUCED**: New event types. Exec-mode commands reuse the existing `bash_output` event for chat rendering; the "ran locally" footer is a client-side decoration on `bash_output` events whose source is a slash-exec template (signalled via a new optional `data.source: "slash-exec"` field on `bash_output`, additive and backward-compatible). -- **NOT INTRODUCED**: A new bash-execution code path. All exec-mode templates go through the existing `handleBashCommand` function in `command-handler.ts`. -- **NOT INTRODUCED**: Server-side endpoints. Every command in the set hits an existing endpoint via `~/.pi/skills/pi-dashboard/scripts/dashboard-api.sh` (the helper that ships with the existing skill). -- **NOT INTRODUCED**: A "fan-out helper" or "all sessions" command suite. Bulk operations (`session-abort-all`) are LLM-bound because they require judgment ("abort which? all streaming, or just the ones in cwd?"). - -## Capabilities - -### New Capabilities - -- `dashboard-slash-commands`: the `/dashboard:*` command namespace, the naming grammar, the initial command set, and the discoverability contract (footer rendering, autocomplete grouping). -- `prompt-template-executable-mode`: the `executable: bash` frontmatter directive, the new `ParsedPrompt` variant, and the dispatch contract that runs body via bash and skips the LLM. - -### Modified Capabilities - -None. The existing slash-command pipeline (template → LLM as user message) continues to work for every template that does not carry the `executable` frontmatter. Backward compat is preserved for every template currently on disk. - -## Impact - -- **MODIFIED files**: - - `packages/extension/src/prompt-expander.ts` — frontmatter parser + new exported `loadPromptTemplate(...)`. - - `packages/extension/src/command-handler.ts` — new `ParsedPrompt` variant + dispatch arm. - - `packages/shared/src/protocol.ts` — `bash_output` event payload gets optional `source: "slash-exec"` field. - - `packages/client/src/components/...` — chat renderer for `bash_output` adds the "ℹ ran locally" footer when `data.source === "slash-exec"`. -- **NEW files (in repo)**: - - `~/.pi/prompts/dashboard-*.md` (~30 templates) — but these ship as part of the existing `.pi/skills/pi-dashboard/` skill, so they live at `.pi/skills/pi-dashboard/commands/` (a new subdir) and the SKILL.md is updated to advertise them. The expander's reading of `pi.getCommands()` (already in place) picks them up via skills routing. - - `.pi/skills/pi-dashboard/commands/dashboard-server-health.md`, ..., `.pi/skills/pi-dashboard/commands/dashboard-pin-list.md`. - - `.pi/skills/pi-dashboard/references/slash-commands.md` — reference doc listing every command, args, what it does, whether it's LLM-free. -- **MODIFIED**: `.pi/skills/pi-dashboard/SKILL.md` — adds a "Slash Commands" section pointing at the new commands directory. -- **MODIFIED**: `AGENTS.md` Key Files table — adds the prompt-expander frontmatter contract, the new `slash-exec` ParsedPrompt variant, and the `bash_output.data.source` field. -- **MODIFIED**: `README.md` — adds a "Slash Commands" section under "Using the Dashboard from a pi Session". -- **MODIFIED**: `docs/architecture.md` — adds a sub-section under the bridge-extension flow describing the four pipelines (now five with `slash-exec`). -- **Backward compatibility**: Every existing `~/.pi/prompts/*.md` and every `.pi/skills/*/SKILL.md` continues to work unchanged. Templates without `executable` frontmatter route to the LLM exactly as before. The change is purely additive at the parse/dispatch layer. - -## Depends On - -This change DEPENDS ON `fix-extension-slash-commands-in-dashboard` landing first. That change establishes the numbered routing-order spec in `command-routing` (steps 1–11 covering bang commands, `/compact`, `/quit`, `/reload`, `/new`, `/model`, user-defined flow names, extension dispatch, fallback to `sendUserMessage`, etc.) and rewires `bridge.ts::sessionPrompt` plus `command-handler.ts`'s slash else-arm to consult `pi.getCommands()` before falling through to `sendUserMessage`. - -This change INSERTS one new routing step between the fix's step 9 (`extension command → pi.dispatchCommand`) and step 10 (`fall through to template expansion + sendUserMessage`): if the resolved template carries `executable: bash` frontmatter, run the body as bash and skip the LLM entirely. Implementation MUST land the new branch in the same two call sites the fix touches (`bridge.ts::sessionPrompt` and `command-handler.ts`'s slash else-arm). If the fix extracts a shared `slash-dispatch.ts` helper (its task 3.2 proposes this), this change adds the exec-mode branch alongside the fix's extension-dispatch branch in that helper. - -No behavioural conflict exists between the two proposals: extension commands (`source: "extension"` in `pi.getCommands()`) are JS handlers; executable-bash templates are `.md` files on disk with frontmatter. A name cannot be both. Step ordering between extension-dispatch and exec-mode is therefore arbitrary; this change defers to the fix's numbering and slots in immediately after. - -## References - -- Existing helper script and skill: `.pi/skills/pi-dashboard/SKILL.md`, `.pi/skills/pi-dashboard/scripts/dashboard-api.sh`, `.pi/skills/pi-dashboard/references/api-reference.md`. -- Slash command pipeline today: `packages/extension/src/prompt-expander.ts`, `packages/extension/src/command-handler.ts`. -- Prerequisite proposal: `openspec/changes/fix-extension-slash-commands-in-dashboard/` — establishes routing-order spec and the two call sites this change extends. -- The four existing pipelines: - - `!cmd` — `parseSendPrompt` → `{ type: "bash", excludeFromContext: false }` → `handleBashCommand` + send to LLM. - - `!!cmd` — `parseSendPrompt` → `{ type: "bash", excludeFromContext: true }` → `handleBashCommand` only. - - `/cmd` — `parseSendPrompt` → `{ type: "slash" }` → expand template → `pi.sendUserMessage` → LLM. - - Hard-coded (`/reload`, `/new`, `/model`, `/compact`, `/quit`) — direct dispatch, `command_feedback` event, no LLM. -- The fifth pipeline this proposal adds: `/cmd` whose template carries `executable: bash` → `parseSendPrompt` → `{ type: "slash-exec" }` → `handleBashCommand` only, no LLM. diff --git a/openspec/changes/add-dashboard-slash-commands/specs/dashboard-slash-commands/spec.md b/openspec/changes/add-dashboard-slash-commands/specs/dashboard-slash-commands/spec.md deleted file mode 100644 index b565ac414..000000000 --- a/openspec/changes/add-dashboard-slash-commands/specs/dashboard-slash-commands/spec.md +++ /dev/null @@ -1,98 +0,0 @@ -## ADDED Requirements - -### Requirement: Namespace and naming grammar - -All dashboard slash commands SHALL be invoked under the `/dashboard:` namespace. Command names SHALL follow the grammar `-[-]` where `` is a singular noun naming a resource family, `` is the action, and `` is an optional qualifier such as `-all`, `-active`, or `-here`. The seven resource families are: `server`, `session`, `proposal`, `flow`, `git`, `peer`, `pin`. - -#### Scenario: Resource families produce predictable command names - -- **GIVEN** the user wants to list pi sessions -- **WHEN** they type a slash command -- **THEN** the command is `/dashboard:session-list` (resource = `session`, verb = `list`); the command file on disk is `dashboard-session-list.md`. - -#### Scenario: Modifier qualifies a verb without ambiguity - -- **GIVEN** the user wants to list only active sessions -- **WHEN** they type the slash command -- **THEN** the command is `/dashboard:session-list-active` (modifier `-active` qualifies `list`). - -#### Scenario: Singular resource form - -- **WHEN** a command is named for the `session` resource family -- **THEN** the resource segment SHALL be `session` (singular), not `sessions` (plural). - -### Requirement: Initial command set with classification - -The initial command set SHALL contain at least 30 commands across the seven resource families, partitioned into LLM-free and LLM-bound classes per the rule defined in design.md. LLM-free commands SHALL include at minimum: `server-health`, `server-config`, `server-tunnel-status`, `session-list`, `session-list-active`, `session-list-here`, `session-info`, `session-diff`, `proposal-archive`, `git-branches`, `peer-list`, `peer-scan`, `pin-list`. LLM-bound commands SHALL include at minimum: `session-tell`, `session-abort`, `session-abort-all`, `session-kill`, `session-rename`, `session-hide`, `session-unhide`, `session-spawn`, `session-resume`, `session-fork`, `session-model`, `session-thinking`, `proposal-attach`, `proposal-detach`, `flow-abort`, `flow-auto`, `git-init`, `git-stash-pop`, `server-tunnel-on`, `server-tunnel-off`. - -#### Scenario: Read-only operations are LLM-free - -- **GIVEN** the LLM-free command `dashboard-session-list.md` -- **WHEN** a user types `/dashboard:session-list` -- **THEN** the command SHALL execute via the bash pipeline without invoking the LLM, and the output SHALL render in chat. - -#### Scenario: Operations requiring judgment are LLM-bound - -- **GIVEN** the LLM-bound command `dashboard-session-abort-all.md` -- **WHEN** a user types `/dashboard:session-abort-all` -- **THEN** the command SHALL expand its template into a user message that the LLM interprets to decide which sessions to abort. - -### Requirement: Discoverability footer for LLM-free commands - -When an `executable: bash` slash command produces output, the chat client SHALL render a footer beneath the output reading exactly `ℹ ran locally — LLM not invoked` (or visually equivalent). The footer SHALL NOT appear for `bash_output` events from `!` or `!!` commands. - -#### Scenario: Footer signals LLM-free execution - -- **GIVEN** a user types `/dashboard:server-health` (an `executable: bash` template) -- **WHEN** the bash output is rendered in chat -- **THEN** a footer reading `ℹ ran locally — LLM not invoked` SHALL appear directly beneath the output block. - -#### Scenario: No footer for ! commands - -- **GIVEN** a user types `!echo hi` -- **WHEN** the bash output is rendered in chat -- **THEN** no `ℹ ran locally` footer SHALL appear. - -#### Scenario: No footer for !! commands - -- **GIVEN** a user types `!!echo bye` -- **WHEN** the bash output is rendered in chat -- **THEN** no `ℹ ran locally` footer SHALL appear. - -### Requirement: Templates ship with the existing skill - -Every dashboard slash command template SHALL ship inside `.pi/skills/pi-dashboard/commands/` in the dashboard repository. The skill's `SKILL.md` SHALL advertise the namespace and reference the commands directory. Templates SHALL NOT be installed into `~/.pi/prompts/` by default. - -#### Scenario: Commands directory exists in skill bundle - -- **GIVEN** the dashboard repo is checked out -- **WHEN** an inspector lists `.pi/skills/pi-dashboard/commands/` -- **THEN** the directory SHALL contain at least 30 markdown files matching `dashboard-*.md`. - -### Requirement: Backward compatibility with existing slash commands - -Existing slash command templates without `executable` frontmatter SHALL continue to expand into LLM user messages exactly as today. The new `slash-exec` pipeline SHALL be opt-in per template. - -#### Scenario: Pre-existing template routes to LLM unchanged - -- **GIVEN** a slash template without `executable` frontmatter (e.g. `/opsx:continue`) -- **WHEN** a user invokes it -- **THEN** the bridge SHALL expand the template and call `pi.sendUserMessage` (LLM-bound), with no behavioural change from before this change. - -### Requirement: Routing precedence relative to extension dispatch - -The exec-mode dispatch (template with `executable: bash`) SHALL run AFTER pi-extension-command dispatch (`source: "extension"` in `pi.getCommands()`, dispatched via `pi.dispatchCommand` per `command-routing` spec) and BEFORE the fallback to `pi.sendUserMessage` for skills, prompt templates, and unrecognised slashes. Extension commands and exec-mode templates are disjoint by construction (extension commands are JS handlers; exec-mode templates are `.md` files with frontmatter), so this ordering is documentary; it pins the contract for future readers. - -#### Scenario: Extension command takes precedence over exec template with same name - -- **GIVEN** a pi extension registers a command `foo` via `pi.registerCommand` AND a file `dashboard-foo.md` exists with `executable: bash` frontmatter -- **WHEN** a user types `/foo` -- **THEN** the bridge SHALL dispatch via `pi.dispatchCommand("/foo", ...)` (extension dispatch wins) -- **AND** SHALL NOT execute the template body as bash. - -#### Scenario: Exec template takes precedence over LLM fallback - -- **GIVEN** a file `dashboard-server-health.md` exists with `executable: bash` frontmatter AND no extension command named `dashboard-server-health` is registered -- **WHEN** a user types `/dashboard:server-health` -- **THEN** the bridge SHALL execute the template body as bash and emit `bash_output` -- **AND** SHALL NOT call `pi.sendUserMessage` for this input. diff --git a/openspec/changes/add-dashboard-slash-commands/specs/prompt-template-executable-mode/spec.md b/openspec/changes/add-dashboard-slash-commands/specs/prompt-template-executable-mode/spec.md deleted file mode 100644 index 770f3859f..000000000 --- a/openspec/changes/add-dashboard-slash-commands/specs/prompt-template-executable-mode/spec.md +++ /dev/null @@ -1,97 +0,0 @@ -## ADDED Requirements - -### Requirement: Frontmatter directive enables executable mode - -A prompt template MAY declare `executable: bash` in its YAML frontmatter to opt into the executable-mode pipeline. When present, the bridge SHALL render the body as bash via `pi.exec("sh", ["-c", body, "--", ...args])`, emit a `bash_output` event with `data.source: "slash-exec"`, and SHALL NOT call the LLM. - -#### Scenario: Template with executable: bash skips the LLM - -- **GIVEN** a prompt template `~/.pi/skills/foo/commands/bar.md` with frontmatter `executable: bash` -- **WHEN** a user types `/foo:bar` -- **THEN** the bridge SHALL execute the template body as bash, emit a `bash_output` event, and SHALL NOT call `pi.sendUserMessage` or otherwise invoke the LLM. - -#### Scenario: Template without executable frontmatter routes to LLM - -- **GIVEN** a prompt template without `executable` frontmatter -- **WHEN** a user types the matching slash command -- **THEN** the bridge SHALL expand the template and call `pi.sendUserMessage` (LLM pipeline) exactly as today. - -#### Scenario: Unsupported executable value falls back to LLM - -- **GIVEN** a prompt template with frontmatter `executable: node` -- **WHEN** a user types the matching slash command -- **THEN** the bridge SHALL treat the template as LLM-bound (since v1 only supports `bash`), preserving forward compatibility. - -### Requirement: excludeFromContext defaults to true for executable templates - -A template carrying `executable: bash` SHALL default to `excludeFromContext: true` (the output is not appended to LLM context, mirroring `!!` semantics). Authors MAY override with `excludeFromContext: false` to capture the output for follow-up reasoning. - -#### Scenario: Default behaviour mirrors !! semantics - -- **GIVEN** a template with `executable: bash` and no `excludeFromContext` field -- **WHEN** the user invokes the command -- **THEN** the bridge SHALL emit `bash_output` only and SHALL NOT also call `pi.sendUserMessage` with the result. - -#### Scenario: Author opts in to LLM follow-up - -- **GIVEN** a template with `executable: bash` and `excludeFromContext: false` -- **WHEN** the user invokes the command -- **THEN** the bridge SHALL emit `bash_output` AND call `pi.sendUserMessage` with the same content (mirroring `!` semantics). - -### Requirement: Positional argument substitution - -Arguments supplied after the slash command SHALL be passed as positional shell parameters (`$1`, `$2`, ...) inside the bash body. The bridge SHALL invoke `pi.exec("sh", ["-c", body, "--", ...args])` where `args` is the user-supplied argument string split on whitespace and filtered for empty tokens. - -#### Scenario: Single positional arg - -- **GIVEN** a template body `echo "id=$1"` and the user types `/foo:bar abc123` -- **THEN** the rendered output SHALL be `id=abc123`. - -#### Scenario: Multiple positional args - -- **GIVEN** a template body `echo "$1 $2"` and the user types `/foo:bar one two` -- **THEN** the rendered output SHALL be `one two`. - -#### Scenario: No args - -- **GIVEN** a template body that does not reference any positional parameter and the user types `/foo:bar` -- **THEN** the body SHALL execute with `$#` equal to 0. - -### Requirement: Dashboard env vars injected for ergonomics - -The bridge SHALL inject `PI_DASHBOARD_PORT` and `PI_DASHBOARD_BASE` environment variables into the exec environment for executable-mode templates. `PI_DASHBOARD_PORT` SHALL be read from `~/.pi/dashboard/config.json` (defaulting to `8000` when absent or unparseable). `PI_DASHBOARD_BASE` SHALL equal `http://localhost:$PI_DASHBOARD_PORT`. - -#### Scenario: Templates can use $PI_DASHBOARD_BASE without setup - -- **GIVEN** a template body containing `curl -s "$PI_DASHBOARD_BASE/api/health"` and the user types the matching slash command -- **THEN** the curl SHALL hit the running dashboard's health endpoint without the template having to grep `~/.pi/dashboard/config.json` first. - -### Requirement: bash_output event carries slash-exec source field - -The `bash_output` event payload SHALL include an optional `source` field set to the literal string `"slash-exec"` when the event originates from an executable-mode slash template. The field SHALL be absent for `!` / `!!` bash invocations, preserving backward compatibility for older clients. - -#### Scenario: Source field present for slash-exec - -- **GIVEN** an executable-mode template runs and emits `bash_output` -- **THEN** the event's `data` object SHALL contain `source: "slash-exec"`. - -#### Scenario: Source field absent for ! and !! - -- **GIVEN** the user types `!echo hi` or `!!echo bye` -- **THEN** the emitted `bash_output` event's `data` object SHALL NOT contain a `source` field, OR the field SHALL have a value other than `"slash-exec"`. - -### Requirement: Frontmatter parser is forward-compatible - -The frontmatter parser SHALL ignore unknown keys (no error, no abort) so future versions can add fields like `format:`, `description:`, or `priority:` without breaking older bridges. Malformed YAML in the frontmatter block SHALL cause the template to fall back to LLM mode (treat the template as having no frontmatter) rather than throw. - -#### Scenario: Unknown key is ignored - -- **GIVEN** a template with frontmatter `executable: bash\nfutureField: 42` -- **WHEN** the parser reads it -- **THEN** the template SHALL be treated as `kind: "exec"` and `futureField` SHALL be ignored without error. - -#### Scenario: Malformed frontmatter falls back gracefully - -- **GIVEN** a template whose frontmatter block is unclosed (`---\nexecutable: bash\n` without trailing `---`) -- **WHEN** the parser reads it -- **THEN** the template SHALL fall back to LLM mode and the bridge SHALL NOT throw. diff --git a/openspec/changes/add-dashboard-slash-commands/tasks.md b/openspec/changes/add-dashboard-slash-commands/tasks.md deleted file mode 100644 index 4eeb5ad07..000000000 --- a/openspec/changes/add-dashboard-slash-commands/tasks.md +++ /dev/null @@ -1,118 +0,0 @@ -## 1. Frontmatter parser + template loader - -- [ ] 1.1 Add a typed `PromptFrontmatter` interface to `packages/extension/src/prompt-expander.ts`: `{ executable?: "bash"; excludeFromContext?: boolean; description?: string }`. -- [ ] 1.2 Add a hand-rolled YAML-lite parser (line-oriented `key: value`, no nesting) that returns a `PromptFrontmatter` from the frontmatter block. Unknown keys ignored (forward compat). Malformed values default to undefined. -- [ ] 1.3 Refactor `readTemplate(filePath)` to return `{ frontmatter: PromptFrontmatter; body: string }` instead of a single string. Existing call site that wants the body only still works (destructure `.body`). -- [ ] 1.4 Add new exported helper `loadPromptTemplate(text, cwd, pi)` returning a discriminated union: `{ kind: "llm"; text: string } | { kind: "exec"; body: string; excludeFromContext: boolean; argsString: string } | null` (null when no template matched). -- [ ] 1.5 Keep `expandPromptTemplateFromDisk(text, cwd, pi)` exported with its current signature for backward compat; refactor its body to delegate to `loadPromptTemplate` and return only the LLM-text shape (`null` falls back to original `text`). -- [ ] 1.6 Tests in `packages/extension/src/__tests__/prompt-expander.test.ts`: - - frontmatter parse: every valid combination of the three keys. - - malformed YAML (unclosed `---`, key without colon, value with colon in it) → falls back gracefully. - - `executable: bash` resolves to `kind: "exec"`. - - `executable: node` (unsupported value) resolves to `kind: "llm"` (graceful degrade). - - Existing tests for arg-substitution semantics still pass for `kind: "llm"`. - -## 2. Command-handler dispatch - -PRECONDITION: `fix-extension-slash-commands-in-dashboard` MUST be archived (or at minimum implemented through its tasks 3.1–3.2) before any task in §2 starts. The exec branch lands AFTER the fix's extension-dispatch branch in the same call sites. - -- [ ] 2.1 Add new variant to the `ParsedPrompt` union in `packages/extension/src/command-handler.ts`: `{ type: "slash-exec"; command: string; excludeFromContext: boolean; argsString: string }`. -- [ ] 2.2 Modify `parseSendPrompt(text)` so the existing `// 6. Check / prefix (generic slash command)` arm peeks at the resolved template via `loadPromptTemplate`. When the template is `kind: "exec"`, return the new `slash-exec` variant; otherwise return `{ type: "slash" }` as before. Place this check AFTER the fix's extension-command detection so extension dispatch wins when both could match (in practice they cannot — see design.md "Disjointness"). -- [ ] 2.3 In the `handle()` switch in `createCommandHandler`, add an arm for `parsed.type === "slash-exec"` that calls `handleBashCommand(pi, sessionId, parsed.command, parsed.excludeFromContext, options?.eventSink)` and returns. Reuse `handleBashCommand` verbatim — no duplicated execution code. -- [ ] 2.3a Mirror the exec branch in `bridge.ts::sessionPrompt` immediately AFTER the fix's extension-dispatch branch and BEFORE the existing template-expansion fallback. If the fix extracted a shared `slash-dispatch.ts` helper (its task 3.2), add the exec branch there alongside the extension-dispatch branch instead of duplicating in two call sites. -- [ ] 2.4 Modify `handleBashCommand` to accept an optional `source: "slash-exec"` parameter and include it in the `bash_output` event's `data` payload. -- [ ] 2.5 The exec-mode dispatcher MUST construct the bash invocation as `sh -c "" -- ` so positional `$1`, `$2`, ... work in the body. Implement by calling `pi.exec("sh", ["-c", body, "--", ...args])` where `args = argsString.trim().split(/\s+/).filter(Boolean)`. -- [ ] 2.6 Inject env vars `PI_DASHBOARD_PORT` (from `~/.pi/dashboard/config.json`, default 8000) and `PI_DASHBOARD_BASE` (`http://localhost:$PORT`) into the exec environment so templates don't have to re-derive them. -- [ ] 2.7 Tests in `packages/extension/src/__tests__/command-handler.test.ts`: - - exec-mode template → `bash_output` event with `data.source === "slash-exec"` and `excludeFromContext: true`. - - exec-mode template with `excludeFromContext: false` → `bash_output` AND `pi.sendUserMessage` called (mirrors `!` semantics). - - LLM-mode slash template → no `bash_output`, sends user message (preserves existing behaviour). - - Args with multiple tokens are positional: `/dashboard:session-info abc 123` runs body with `$1=abc`, `$2=123`. - - `PI_DASHBOARD_PORT` env is set on the spawned process. - -## 3. Verify command discovery for nested skill commands - -- [ ] 3.1 Investigate whether `pi.getCommands()` (used by the expander as a fallback in `prompt-expander.ts:90-97`) surfaces `.md` files in a skill's `commands/` subdir, or only the skill's top-level `SKILL.md`. -- [ ] 3.2 If nested commands are NOT surfaced by `pi.getCommands()`, extend the expander's `findPromptTemplates(cwd)` to also scan `/.pi/skills/*/commands/*.md` (descend exactly one level into `commands/`). Add tests covering the scan. -- [ ] 3.3 If `pi.getCommands()` surfaces them but uses a different name shape (e.g. `skill:name/command`), update the expander's fallback resolver to recognise the shape. -- [ ] 3.4 Document the resolution path in `packages/extension/src/prompt-expander.ts` JSDoc. - -## 4. Protocol update - -- [ ] 4.1 In `packages/shared/src/protocol.ts`, extend the `bash_output` event's `data` shape to include optional `source?: "slash-exec"`. Comment in proximity citing this change name. -- [ ] 4.2 Verify the protocol change is also reflected in `packages/shared/src/browser-protocol.ts` if `bash_output` flows through there. If yes, add the field to that union too. -- [ ] 4.3 Confirm the change is purely additive: old bridges/clients without the field render `bash_output` events normally (no footer); new bridges/clients render the footer when the field is `"slash-exec"`. - -## 5. Client-side footer rendering - -- [ ] 5.1 Locate the React component that renders `bash_output` chat messages (likely under `packages/client/src/components/`). Identify the existing rendering shape. -- [ ] 5.2 Add conditional rendering: when the `bash_output` event's `data.source === "slash-exec"`, render a footer beneath the output: `ℹ ran locally — LLM not invoked` (small text, muted color, single line). -- [ ] 5.3 Do not add the footer for `bash_output` events from `!`/`!!` (no `data.source` field, or any other value). -- [ ] 5.4 Component-level test: render a `bash_output` event with and without `data.source: "slash-exec"`, assert footer presence/absence. -- [ ] 5.5 Verify in the running dashboard: type `!echo hi` → no footer. Type `/dashboard:server-health` → footer present. - -## 6. Skill scaffolding (commands directory) - -- [ ] 6.1 Create the directory `.pi/skills/pi-dashboard/commands/`. -- [ ] 6.2 Add a top-level `.pi/skills/pi-dashboard/commands/README.md` describing the dir's purpose, the frontmatter convention, and the `dashboard-` prefix rule. -- [ ] 6.3 Update `.pi/skills/pi-dashboard/SKILL.md` to add a "Slash Commands" section listing the namespace, citing the commands dir, and showing one LLM-free and one LLM-bound example. -- [ ] 6.4 Add `.pi/skills/pi-dashboard/references/slash-commands.md` — a single-page reference of every command, args, what it does, whether it's LLM-free. -- [ ] 6.5 If §3 concluded the expander needs the `commands/` subdir scan, document it in the skill's README. - -## 7. LLM-free commands (`executable: bash`) - -Each file ships at `.pi/skills/pi-dashboard/commands/dashboard-.md` with `executable: bash` frontmatter. Body uses `dashboard-api.sh` and `jq`. - -- [ ] 7.1 `dashboard-server-health.md` — GET /api/health → formatted line. -- [ ] 7.2 `dashboard-server-config.md` — GET /api/config → pretty JSON (redacted secrets). -- [ ] 7.3 `dashboard-server-tunnel-status.md` — GET /api/tunnel-status → status + URL. -- [ ] 7.4 `dashboard-session-list.md` — GET /api/sessions → table (id-prefix | status | name | cwd). -- [ ] 7.5 `dashboard-session-list-active.md` — GET /api/sessions, jq filter status in {streaming, active}, table. -- [ ] 7.6 `dashboard-session-list-here.md` — GET /api/sessions, jq filter cwd === $PWD, table. -- [ ] 7.7 `dashboard-session-info.md` — accepts ``. GET /api/sessions, jq find id starts-with arg, render every field as a labelled line. -- [ ] 7.8 `dashboard-session-diff.md` — accepts ``. GET /api/session-diff, render file list + diff blocks. -- [ ] 7.9 `dashboard-proposal-archive.md` — GET /api/openspec-archive?cwd=$PWD → grouped table by date. -- [ ] 7.10 `dashboard-git-branches.md` — GET /api/git/branches?cwd=$PWD → branch list with current marker. -- [ ] 7.11 `dashboard-peer-list.md` — GET /api/known-servers → list with labels. -- [ ] 7.12 `dashboard-peer-scan.md` — POST /api/discover-servers → list with labels. -- [ ] 7.13 `dashboard-pin-list.md` — GET /api/pinned-dirs → list. -- [ ] 7.14 Smoke test each command in a running dashboard: invocation runs without LLM, output renders correctly, footer appears. - -## 8. LLM-bound commands (regular slash templates) - -Each file ships at `.pi/skills/pi-dashboard/commands/dashboard-.md` WITHOUT `executable` frontmatter. Body is markdown instructing the LLM what to do. - -- [ ] 8.1 `dashboard-session-tell.md` — instruct LLM to resolve ``, POST /api/session/:id/prompt with `` arg. -- [ ] 8.2 `dashboard-session-abort.md` — resolve id-prefix, POST abort. -- [ ] 8.3 `dashboard-session-abort-all.md` — list active, ask LLM to confirm scope (all, or a filter), then iterate. -- [ ] 8.4 `dashboard-session-kill.md` — resolve id-prefix, POST shutdown. Template warns about destructiveness. -- [ ] 8.5 `dashboard-session-rename.md` — resolve id, POST rename with ``. -- [ ] 8.6 `dashboard-session-hide.md` / `dashboard-session-unhide.md` — resolve id, POST hide/unhide. -- [ ] 8.7 `dashboard-session-spawn.md` — POST /api/session/spawn with `` (default $PWD). -- [ ] 8.8 `dashboard-session-resume.md` / `dashboard-session-fork.md` — resolve id, POST resume with `mode=continue`/`fork`. -- [ ] 8.9 `dashboard-session-model.md` — resolve id, POST model with `/` arg. -- [ ] 8.10 `dashboard-session-thinking.md` — resolve id, POST thinking-level with ``. -- [ ] 8.11 `dashboard-proposal-attach.md` / `dashboard-proposal-detach.md` — resolve id, POST attach/detach. -- [ ] 8.12 `dashboard-flow-abort.md` / `dashboard-flow-auto.md` — resolve id, POST flow-control with action. -- [ ] 8.13 `dashboard-git-init.md` / `dashboard-git-stash-pop.md` — POST with cwd (default $PWD). -- [ ] 8.14 `dashboard-server-tunnel-on.md` / `dashboard-server-tunnel-off.md` — POST tunnel-connect/disconnect. - -## 9. Documentation - -- [ ] 9.1 Update `AGENTS.md` Key Files table: - - `packages/extension/src/prompt-expander.ts` row — describe the new frontmatter contract, `loadPromptTemplate` return shape, and `executable: bash` semantics. Cite this change. - - `packages/extension/src/command-handler.ts` row — describe the new `slash-exec` ParsedPrompt variant and dispatch. Cite this change. - - `packages/shared/src/protocol.ts` row — describe the new `bash_output.data.source: "slash-exec"` field. Cite this change. - - Add a new row for `.pi/skills/pi-dashboard/commands/` directory. -- [ ] 9.2 Update `README.md` to add a "Slash Commands" section under the dashboard-from-pi-session usage area. -- [ ] 9.3 Update `docs/architecture.md` bridge-extension section: list the five pipelines (now including slash-exec) with the mermaid diagram from this change's design.md. -- [ ] 9.4 Verify `openspec validate add-dashboard-slash-commands --strict` passes. - -## 10. Manual verification - -- [ ] 10.1 In a running dashboard, type `/dashboard:server-health` — verify: chat shows curl output, footer appears, no LLM activity in the session timeline. -- [ ] 10.2 Type `/dashboard:session-list` — verify table renders, no LLM activity, no token cost in stats. -- [ ] 10.3 Type `/dashboard:session-info ` — verify all fields render. -- [ ] 10.4 Type `/dashboard:session-tell "hello from another session"` — verify LLM is invoked, the target session receives the prompt. -- [ ] 10.5 Type a regular `/skill:something` slash command — verify the existing LLM-bound flow still works (regression check). -- [ ] 10.6 Type `!echo hi` and `!!echo bye` — verify both still work and neither shows the slash-exec footer (regression check). diff --git a/openspec/changes/add-extension-ui-rjsf-form/design.md b/openspec/changes/add-extension-ui-rjsf-form/design.md deleted file mode 100644 index 1b9476cb1..000000000 --- a/openspec/changes/add-extension-ui-rjsf-form/design.md +++ /dev/null @@ -1,161 +0,0 @@ -## Context - -The Generalized Extension UI System (`extension-ui-system`) shipped Phase 1 (`management-modal` with bespoke `UiField`-driven `form` view, archived 2026-04-26) and Phase 2 (live decorations, archived 2026-04-26). Phase 1's `form` view is sufficient for flat workspace-CRUD — text/number/boolean/select/code/datetime/textarea fields in optional `UiSection` groups — but cannot express: - -- conditional fields (show field B only when field A === "x"), -- nested objects with their own validation, -- arrays of records (e.g. "list of git remotes, each with name + URL"), -- per-field validation richer than HTML5 `required` (regex, range, custom error messages), -- multi-step wizards. - -The motivating consumer is **pi-judo**'s save/discard gate (currently uses TUI-only `ctx.ui.custom`). It needs a structured form with conditional sections that Phase 1 cannot express. The extension-ui-system parent design (`openspec/changes/extension-ui-system/design.md` §"RJSF: Phase 4, forms-only") already designated **`react-jsonschema-form` (RJSF)** as the escape hatch for "anything richer than `UiField`". - -This change is OPTIONAL — Phase 1 + Phase 2 cover the majority of extension UI needs without RJSF. Extensions only opt into `rjsf-form` when their UI exceeds Phase 1's expressive ceiling. - -**Relevant pre-conditions:** - -- Phase 1 modal slot is shipped (`packages/client/src/components/extension-ui/GenericExtensionDialog.tsx`). -- `ExtensionUiModule.view: UiView` already discriminates on `view.kind`. -- `ui_management { action, event, params? }` is the established submit-bus message. - -## Goals / Non-Goals - -**Goals:** - -- Add a `rjsf-form` view kind that renders a user-supplied `JSONSchema7` via RJSF. -- Ship a Tailwind-themed RJSF widget set (text/number/boolean/select/textarea/date/array/object). -- Lazy-load the RJSF bundle (~150–200 KB minified) — sessions without `rjsf-form` modules pay zero cost. -- Map RJSF submission to the existing `ui_management { action: "submit", event, params: }` channel — no new wire-protocol message. -- Validate via RJSF's bundled `ajv` validator. Refuse to submit on validation error; surface RJSF's per-field error messages inline. -- Define a pure-pi fallback contract — extensions opting into `rjsf-form` MUST declare `fallback: "ctx-ui" | "defaults" | "reject"` in the descriptor; the bridge enforces this when no dashboard is connected. - -**Non-Goals:** - -- Replacing the Phase 1 `UiField`-driven `form` view. `UiField` remains the recommended path for flat forms; `rjsf-form` is the escape hatch. -- Loading external React/JS bundles in the browser (out-of-scope per parent design §"Out-of-Scope Explicitly"). -- Exposing RJSF outside `management-modal` view types in this phase (no `rjsf-form` in decorations / settings sections / etc.). Future phases MAY widen the surface. -- Custom widget extension API (extensions cannot ship their own widgets; the dashboard's bundled widget set is the only vocabulary). -- File-upload widgets (out-of-scope; the dashboard has no extension-controlled file-store endpoint yet). -- Live form mutation from extension side (no `ui_data_list`-style push to update form state mid-edit; the schema is fixed for a given modal open). - -## Decisions - -### 1. Library choice: `@rjsf/core` + `@rjsf/validator-ajv8` - -**Decision:** Use `react-jsonschema-form` (`@rjsf/core@^5`) with the AJV-8 validator (`@rjsf/validator-ajv8`). - -**Why:** RJSF is the de facto JSON-Schema-driven React form library (~3M weekly downloads). Mature, AJV-8 supports JSON Schema draft 7, supports `uiSchema` for layout/widget hints without polluting the data schema, and is themable via the `ThemeProps` pattern. - -**Alternatives considered:** - -- **Hand-roll a JSON Schema → React renderer.** Rejected: re-implements RJSF's edge cases (conditional schemas via `dependencies`/`oneOf`, array `additionalItems`, `$ref` resolution) — months of work for a feature that's the OPTIONAL escape hatch. RJSF is solved. -- **`uniforms` (https://uniforms.tools).** Rejected: smaller community, fewer Tailwind community examples, theme integration requires more boilerplate than RJSF's `ThemeProps`. -- **`formik` + ad-hoc schema renderer.** Rejected: formik does not natively consume JSON Schema; we'd still need RJSF-equivalent logic on top. - -### 2. Bundling strategy: dynamic `import()` gated on session module presence - -**Decision:** RJSF and its theme are imported via a top-level `await import()` inside `GenericExtensionDialog` lazily, on the first render where `view.kind === "rjsf-form"`. The compiled chunk is split out by Vite's default route-level code splitting. - -**Why:** RJSF + AJV is ~150–200 KB minified gzipped. Loading eagerly would punish every dashboard user, including the (currently 100%) majority who never use `rjsf-form`. Dynamic import puts the cost on the first opener. - -**Trade-off:** First-open latency for an `rjsf-form` modal is ~1 RTT to fetch the chunk, plus parse. Acceptable — modals already gate on user click. - -**Alternatives considered:** - -- **Eager import.** Rejected: penalizes 100% of users for a feature most don't use. -- **Manifest-driven prefetch on session register if any module declares `rjsf-form`.** Deferred: optimization for later if first-open latency is observed to be a problem in practice. Vite handles the lazy chunk fine without explicit prefetch. - -### 3. Tailwind-themed widget set lives in `packages/client/src/components/extension-ui/rjsf-theme/` - -**Decision:** Ship a small custom theme matching dashboard styling (`@/components/ui/*` Tailwind components reused where possible). Cover at minimum: `TextWidget`, `NumberWidget` / `RangeWidget`, `CheckboxWidget`, `SelectWidget`, `TextareaWidget`, `DateWidget` (HTML5 date input), `ArrayFieldTemplate`, `ObjectFieldTemplate`, `ErrorListTemplate`, `FieldTemplate`, `BaseInputTemplate`. - -**Why:** RJSF's default theme uses raw HTML inputs without dashboard styling — visually jarring. The community Tailwind theme `@rjsf/tailwind-theme` exists but is less actively maintained and doesn't match our `@/components/ui/*` exact look. ~10–15 small widget components is a tractable cost; pinned by snapshot tests. - -**Trade-off:** Maintenance burden — every RJSF major upgrade may require theme tweaks. Mitigation: pin to `^5` in deps; upgrade is a deliberate change. - -**Alternatives considered:** - -- **`@rjsf/tailwind-theme`.** Rejected for ownership and visual-fidelity reasons above; we MAY revisit if our theme grows to >25 widgets. -- **`@rjsf/mui-theme` + ad-hoc CSS overrides.** Rejected: MUI-on-Tailwind double-runtime is painful and MUI's bundle cost is high. -- **No theme; ship raw RJSF.** Rejected: visually inconsistent with the rest of the dashboard. - -### 4. Submit semantics: schema validation gated; `ui_management { action: "submit", ... }` - -**Decision:** RJSF's submit handler runs AJV validation. On valid: dispatch `ui_management { sessionId, action: "submit", event: view.dataEvent ?? `${module.id}:submit`, params: formData }`. On invalid: prevent dispatch; render RJSF's per-field error messages inline (default RJSF behavior, no bridge round-trip). - -**Why:** Reuses the existing `ui_management` submit channel — no new wire-protocol message, no new server handler arm. Server forwards the message to the bridge unchanged; the extension receives `pi.events.emit(event, { params, action: "submit", _reply })` exactly as for any other Phase 1 action. - -**`_reply` and async submit-feedback:** Extensions MAY reject the submit by calling `_reply({ ok: false, error: "..." })`. The dashboard MUST surface the error in the modal (we'll add an `errorBanner` slot to the dialog) without closing it. Successful `_reply({ ok: true })` closes the modal. - -**Trade-off:** AJV validates against the schema only — extension-side cross-field rules (e.g. "URL must be reachable") still need an `_reply`-based echo. That's intentional; client-side AJV keeps the schema authoritative for client-validatable rules without doubling effort. - -### 5. Pure-pi fallback contract: extension declares strategy - -**Decision:** The descriptor MUST carry `view.fallback: "ctx-ui" | "defaults" | "reject"`. When the bridge has no dashboard connection AND the user invokes the slash command: - -- `"ctx-ui"`: the bridge decomposes the schema into a sequence of `ctx.ui.input` / `ctx.ui.confirm` / `ctx.ui.select` calls, top-level properties only (best-effort; nested objects and arrays are NOT supported in TUI fallback). Returns the assembled object on `_reply`. -- `"defaults"`: synchronously returns the schema's `default`/`const` values without prompting. Used for "no-op fallback" cases — the extension wants the dashboard UI but tolerates a degenerate value in pure-pi. -- `"reject"`: throws `NoDashboardError` from the slash-command handler. Extension is responsible for either avoiding the command in pure-pi or catching the throw and degrading. - -**Why:** The escape-hatch nature of `rjsf-form` means most schemas can't be losslessly walked through a TUI. Forcing the extension author to declare intent prevents surprise UX. `"defaults"` and `"reject"` are 1-line opt-outs; `"ctx-ui"` is best-effort for simple flat schemas. - -**Alternatives considered:** - -- **Auto-decompose with no opt-out.** Rejected: nested/array schemas degrade silently; surprise data loss. -- **Always reject in pure-pi.** Rejected: inflexible; some extensions WANT defaults. - -### 6. Schema field on the descriptor - -`UiView` for `rjsf-form` carries: - -```ts -{ - kind: "rjsf-form", - rjsfSchema: JSONSchema7, // data schema; required - rjsfUiSchema?: UiSchema, // RJSF uiSchema; optional layout/widget hints - rjsfFallback: "ctx-ui" | "defaults" | "reject", // required; no default - dataEvent?: string, // submit event name (defaults to `${module.id}:submit`) - initialDataEvent?: string, // optional; if set, modal sends `ui_management { action: "list", event: initialDataEvent }` on mount and pre-fills form from first item -} -``` - -`rjsfFallback` is required (no default) so omission is a TypeScript error — extension authors must explicitly choose. - -## Risks / Trade-offs - -- **[Risk] RJSF major version churn.** RJSF 5→6 may require theme adjustments. → **Mitigation:** Pin to `^5`; document upgrade as a deliberate change with snapshot-test review. - -- **[Risk] Bundle size grows beyond ~200 KB.** AJV strict-mode + RJSF + ajv-formats is the dominant cost. → **Mitigation:** Confirmed lazy-loaded behind dynamic import; unit-test the chunk-name presence in `dist/client/`. Add a CI guard on `dist/client/assets/extension-ui-rjsf-*.js` size (warn if >250 KB gzipped). - -- **[Risk] AJV validation messages are user-hostile by default ("must NOT have additional properties").** → **Mitigation:** Configure `ajv-errors` to allow per-field `errorMessage` on the schema; document the convention in the descriptor docs. Extension authors can override messages cleanly. - -- **[Risk] Pure-pi `"ctx-ui"` fallback diverges visually/behaviorally from the dashboard form.** → **Mitigation:** Document in the spec that `"ctx-ui"` is best-effort and only handles flat top-level properties of primitive types. Nested objects/arrays in TUI fallback are out of scope; extensions with rich schemas should choose `"defaults"` or `"reject"`. - -- **[Risk] Schema with `$ref` to external URL.** RJSF can resolve `$ref`, but external URLs in extension-supplied schemas are an exfil/SSRF surface. → **Mitigation:** The dashboard's RJSF setup MUST use `customFormats` and a `localResolver` only — `$ref` to external URLs is rejected at parse time with an inline error; only `#/...` internal refs are honored. - -- **[Risk] Extension XSS via custom error messages or schema description fields.** → **Mitigation:** All RJSF-rendered text passes through React's text-node escaping by default; we will NOT enable `dangerouslySetInnerHTML` anywhere in the theme. Snapshot test asserts no `dangerouslySetInnerHTML` in `rjsf-theme/` source. - -- **[Trade-off] Extension authors targeting both TUI and dashboard write a `rjsfSchema` AND a `"ctx-ui"` fallback path.** Both paths must agree on field names. Mitigation: `"ctx-ui"` reads top-level `properties` keys directly from `rjsfSchema`, so name agreement is automatic for primitive-typed fields. - -- **[Trade-off] Validation runs client-side only; extension still needs to revalidate on receive.** Standard for any client-validated form. Documented in the spec. - -## Migration Plan - -This change is purely additive: - -1. **Phase 4.1 — Schema + bridge fallback** (`packages/shared`, `packages/extension`): extend `UiView` discriminator; add bridge handler for `"ctx-ui"` / `"defaults"` / `"reject"`. No breaking changes to Phase 1 modules. -2. **Phase 4.2 — Theme components** (`packages/client/src/components/extension-ui/rjsf-theme/`): add widget components with snapshot tests. -3. **Phase 4.3 — Dialog wiring** (`packages/client/src/components/extension-ui/GenericExtensionDialog.tsx`): add `view.kind === "rjsf-form"` branch; dynamic-import the RJSF bundle; surface validation errors and `_reply` errors. -4. **Phase 4.4 — Pure-pi fallback path** (`packages/extension/src/bridge.ts`): wire `NoDashboardError` for `"reject"` and decompose path for `"ctx-ui"`. -5. **Phase 4.5 — Optional pi-judo migration** (separate change in pi-judo repo): replace `ctx.ui.custom` save/discard gate with an `rjsf-form` module. - -**Rollback:** revert the change. Phase 1 modules continue to work — the only path that uses RJSF is gated on `view.kind === "rjsf-form"`, which no shipping extension uses today. - -## Open Questions - -None. The design questions resolved during exploration: - -1. **Should we ship a custom widget extension API?** **No.** Out-of-scope this phase. If the bundled widget set is insufficient, extensions can either upgrade their schema to use the existing widgets or wait for a follow-up `add-extension-ui-rjsf-custom-widgets` change. -2. **Should `rjsf-form` work outside `management-modal`?** **No this phase.** Decoration slots have stricter shape requirements; future phases may add `rjsf-form` to settings sections. -3. **Should the dashboard expose AJV `$data` (cross-field references)?** **Yes — RJSF's default config supports it.** No extra work; documented in the spec. -4. **How are async `_reply` errors surfaced?** **Inline error banner above submit button; modal stays open until user retries or cancels.** Same pattern as the Phase 1 confirm-dialog action error path. diff --git a/openspec/changes/add-extension-ui-rjsf-form/proposal.md b/openspec/changes/add-extension-ui-rjsf-form/proposal.md deleted file mode 100644 index b2cefe3cc..000000000 --- a/openspec/changes/add-extension-ui-rjsf-form/proposal.md +++ /dev/null @@ -1,41 +0,0 @@ -## Why - -Phase 4 of the Generalized Extension UI System (see design `extension-ui-system`). Adds a `rjsf-form` view type that renders user-supplied JSON Schema (`JSONSchema7`) via `react-jsonschema-form` with a Tailwind-themed widget set. This is the escape hatch for "anything richer than a fixed `UiField` form" — multi-step forms, conditional fields, nested objects, arrays of records, custom validation. - -The motivating consumer is pi-judo's save/discard gate (currently uses TUI-only `ctx.ui.custom`); other use cases include any extension that needs a richer form UI than Phase 1's `UiField`-driven form view. - -This change DEPENDS ON `add-extension-ui-modal` being shipped first. It is OPTIONAL — Phase 1 + Phase 2 together cover the majority of extension UI needs without RJSF. - -## What Changes - -- **NEW**: `rjsf-form` view type in `UiView.type` enum. Schema follows `JSONSchema7`; UI hints follow RJSF's `uiSchema` shape. -- **NEW**: Tailwind-themed RJSF widget set in `packages/client/src/components/extension-ui/rjsf-theme/` covering text/number/boolean/select/textarea/date/array/object widgets. -- **NEW**: Lazy-loaded RJSF bundle — RJSF (~150–200 KB minified) is dynamically imported only when a session has a module declaring `rjsf-form`. No eager cost for sessions without RJSF. -- **NEW**: Submit semantics — schema submission becomes a `ui_management { action: "submit", event, params: }`. Validation is RJSF's `ajv`-backed validation; client refuses to submit on validation error. -- **NEW**: Pure-pi fallback contract — extensions that opt into `rjsf-form` MUST declare a fallback strategy in the descriptor: `"ctx-ui"` (decompose into `ctx.ui.input` per top-level property, best-effort), `"defaults"` (return defaults synchronously without prompting the user), or `"reject"` (throw `NoDashboardError`). The bridge enforces this when no dashboard is connected. - -## Capabilities - -### New Capabilities - -None — extends `extension-ui-system`. - -### Modified Capabilities - -- `extension-ui-system`: adds Requirements for the `rjsf-form` view type, RJSF lazy-load contract, validation semantics, and pure-pi fallback strategy. - -## Impact - -- `packages/client/package.json` — add `@rjsf/core`, `@rjsf/validator-ajv8` as dependencies (no theme package; we ship our own). -- `packages/client/src/components/extension-ui/rjsf-theme/` — new directory with widget components. -- `packages/client/src/components/extension-ui/GenericExtensionDialog.tsx` — render `rjsf-form` view type via dynamic import. -- `packages/shared/src/types.ts` — extend `UiView.type` enum; add `rjsfSchema`, `rjsfUiSchema`, `rjsfFallback` fields; update `DecoratorDescriptor` is unaffected. -- `packages/extension/src/bridge.ts` — handle `NoDashboardError` for `rjsf-form` modules with `fallback: "reject"`. - -## References - -- Design: `openspec/changes/extension-ui-system/design.md` §"RJSF: Phase 4, forms-only" -- RJSF: https://github.com/rjsf-team/react-jsonschema-form -- Phase 1 (archived; shipped): `openspec/changes/archive/2026-04-26-add-extension-ui-modal/` -- Phase 2 (archived; shipped): `openspec/changes/archive/2026-04-26-add-extension-ui-decorations/` -- Canonical Phase 1 + 2 requirements: `openspec/specs/extension-ui-system/spec.md` diff --git a/openspec/changes/add-extension-ui-rjsf-form/specs/extension-ui-system/spec.md b/openspec/changes/add-extension-ui-rjsf-form/specs/extension-ui-system/spec.md deleted file mode 100644 index 254b32fdb..000000000 --- a/openspec/changes/add-extension-ui-rjsf-form/specs/extension-ui-system/spec.md +++ /dev/null @@ -1,140 +0,0 @@ -## ADDED Requirements - -### Requirement: Module schema SHALL support the rjsf-form view type - -The shared package `@blackbelt-technology/pi-dashboard-shared` MUST extend `UiView.kind` to include the literal `"rjsf-form"`. When `view.kind === "rjsf-form"`, the descriptor MUST carry: - -- `rjsfSchema: JSONSchema7` — the data schema. Required. -- `rjsfUiSchema?: UiSchema` — RJSF `uiSchema` for layout / widget hints. Optional. -- `rjsfFallback: "ctx-ui" | "defaults" | "reject"` — pure-pi fallback strategy. Required (no default). -- `dataEvent?: string` — submit event name. Optional; defaults to `${module.id}:submit`. -- `initialDataEvent?: string` — optional event name fetched on mount to pre-fill the form. - -Schemas MUST be valid JSON Schema draft 7. `$ref` values MUST be internal (`#/...` form) only; descriptors carrying external-URL `$ref` MUST be rejected by the dashboard at parse time with an inline error and MUST NOT be sent to RJSF. - -#### Scenario: Well-formed rjsf-form module passes validation -- **WHEN** an extension pushes `{ kind: "management-modal", id: "judo-save", command: "/judo:save", title: "Save Changes", view: { kind: "rjsf-form", rjsfSchema: {...}, rjsfFallback: "reject" } }` -- **THEN** the descriptor passes runtime type validation in the shared package -- **AND** the dashboard interprets `view.kind === "rjsf-form"` and prepares to render an RJSF dialog - -#### Scenario: External $ref is rejected -- **WHEN** an `rjsf-form` descriptor carries a schema with `$ref: "https://example.com/schema.json"` -- **THEN** the dashboard renders an inline parse-error message inside the modal -- **AND** the dashboard does NOT pass the schema to RJSF -- **AND** the dashboard does NOT issue any network request for the external `$ref` - -#### Scenario: Missing rjsfFallback is a type error at descriptor creation -- **GIVEN** TypeScript build of an extension declaring an `rjsf-form` view without `rjsfFallback` -- **THEN** the `tsc` build fails with a type error citing the missing `rjsfFallback` field - -### Requirement: Client SHALL render rjsf-form via lazy-loaded RJSF bundle - -`GenericExtensionDialog` MUST render `view.kind === "rjsf-form"` by dynamically importing the RJSF bundle (`@rjsf/core` + `@rjsf/validator-ajv8` + the dashboard's Tailwind theme) on first render. Sessions whose `uiModules` contains no `rjsf-form` module MUST NOT load the RJSF bundle. - -The dialog MUST render the RJSF form using the dashboard's bundled Tailwind widget set (`packages/client/src/components/extension-ui/rjsf-theme/`). The widget set MUST cover at minimum: text input, number input, checkbox, select, textarea, date input, array field, object field, error list, base field template. - -While the RJSF bundle is loading, the dialog MUST display a loading indicator and MUST NOT show the form skeleton. - -#### Scenario: RJSF bundle loads on first rjsf-form open -- **GIVEN** a session whose `uiModules` contains exactly one `rjsf-form` module -- **AND** the user has not yet opened the modal in this session -- **WHEN** the user invokes the matching slash command -- **THEN** the dashboard issues a network request for the RJSF chunk -- **AND** displays a loading indicator until the chunk resolves -- **AND** then renders the form - -#### Scenario: No RJSF chunk loads when no rjsf-form module exists -- **GIVEN** a session whose `uiModules` contains only `form` / `table` / `grid` views (no `rjsf-form`) -- **WHEN** the dashboard initializes and the user uses the app normally -- **THEN** no network request for the RJSF chunk is issued - -### Requirement: Client SHALL validate via AJV before submit - -The dashboard MUST run RJSF's AJV-8 validation on form submission. On validation error, the dashboard MUST: - -- prevent the `ui_management { action: "submit" }` dispatch, -- render RJSF's per-field error messages inline next to the affected fields, -- keep the modal open so the user can correct the input. - -On valid submit, the dashboard MUST dispatch `ui_management { sessionId, action: "submit", event: , params: }` to the server. - -#### Scenario: Invalid input blocks submit -- **GIVEN** an `rjsf-form` schema requiring `name: { type: "string", minLength: 1 }` -- **WHEN** the user clicks Submit with an empty `name` field -- **THEN** the dashboard does NOT send a `ui_management` message -- **AND** the form displays an inline error next to the `name` field -- **AND** the modal remains open - -#### Scenario: Valid submit dispatches ui_management -- **GIVEN** the same schema and the user has typed `"abc"` into `name` -- **WHEN** the user clicks Submit -- **THEN** the dashboard sends `ui_management { sessionId, action: "submit", event: "", params: { name: "abc" } }` - -### Requirement: Bridge SHALL handle async submit reply errors - -The bridge MUST forward `ui_management { action: "submit", ... }` to extensions via `pi.events.emit(event, { params, action: "submit", _reply })`. Extensions MAY call `_reply({ ok: false, error: "..." })` to reject the submit; the dashboard MUST surface the error string in an inline banner above the submit button without closing the modal. - -`_reply({ ok: true })` (or `_reply()` with no argument) MUST close the modal. If the extension never replies within 30 seconds, the dashboard MUST display a generic timeout error in the same banner and re-enable the submit button. - -#### Scenario: Extension rejects submit -- **GIVEN** the user submits a valid form -- **WHEN** the extension's handler calls `_reply({ ok: false, error: "URL is not reachable" })` -- **THEN** the dashboard displays "URL is not reachable" in an error banner inside the modal -- **AND** the modal stays open -- **AND** the submit button is re-enabled - -#### Scenario: Extension confirms submit -- **WHEN** the extension's handler calls `_reply({ ok: true })` -- **THEN** the dashboard closes the modal -- **AND** the dashboard does NOT keep the form state for re-open - -#### Scenario: No reply within timeout -- **GIVEN** the user submits a valid form -- **WHEN** the extension does not call `_reply` within 30 seconds -- **THEN** the dashboard displays a generic timeout error banner -- **AND** the submit button is re-enabled so the user can retry - -### Requirement: Bridge SHALL enforce pure-pi fallback strategy for rjsf-form - -When the bridge has no active dashboard server connection AND the user invokes a slash command bound to an `rjsf-form` module, the bridge MUST execute the strategy declared in `view.rjsfFallback`: - -- `"ctx-ui"`: the bridge MUST iterate the schema's top-level `properties` and prompt the user via the matching `ctx.ui.*` primitive for each primitive-typed property (`string` → `ctx.ui.input`; `boolean` → `ctx.ui.confirm`; `number`/`integer` → `ctx.ui.input` with numeric coercion; `string` with `enum` → `ctx.ui.select`). Properties whose type is `object`, `array`, or otherwise non-primitive MUST be skipped (the assembled object omits them). The bridge MUST return the assembled object via the slash command's output channel. -- `"defaults"`: the bridge MUST synchronously assemble an object from the schema's top-level `default` values (or `const` values where present) and return it without prompting the user. -- `"reject"`: the bridge MUST throw `NoDashboardError` from the slash-command handler. The error message MUST identify the offending command for debuggability. - -The bridge MUST NOT attempt to render or simulate RJSF in pure-pi. - -#### Scenario: ctx-ui fallback prompts top-level primitives -- **GIVEN** a pure-pi session with no dashboard connection -- **AND** an `rjsf-form` module whose schema is `{ properties: { name: { type: "string" }, force: { type: "boolean" } } }` and `rjsfFallback: "ctx-ui"` -- **WHEN** the user invokes the matching slash command -- **THEN** the bridge calls `ctx.ui.input("name")` and `ctx.ui.confirm("force")` in declared order -- **AND** the bridge returns `{ name: , force: }` - -#### Scenario: defaults fallback returns schema defaults -- **GIVEN** a pure-pi session and `rjsfFallback: "defaults"` with schema `{ properties: { mode: { type: "string", default: "auto" } } }` -- **WHEN** the user invokes the matching slash command -- **THEN** the bridge synchronously returns `{ mode: "auto" }` -- **AND** no `ctx.ui.*` prompt is issued - -#### Scenario: reject fallback throws NoDashboardError -- **GIVEN** a pure-pi session and `rjsfFallback: "reject"` -- **WHEN** the user invokes the matching slash command -- **THEN** the slash-command handler throws `NoDashboardError` -- **AND** the error message identifies the offending command - -#### Scenario: ctx-ui silently skips non-primitive properties -- **GIVEN** `rjsfFallback: "ctx-ui"` and a schema with a top-level `array` property -- **WHEN** the bridge runs the fallback -- **THEN** the bridge does NOT prompt for the array property -- **AND** the assembled return object omits the array property - -### Requirement: Dashboard SHALL not render extension HTML through dangerous sinks - -The Tailwind RJSF theme (`packages/client/src/components/extension-ui/rjsf-theme/`) and the `rjsf-form` rendering path MUST NOT use `dangerouslySetInnerHTML` anywhere. All extension-supplied text (schema `description`, `title`, custom error messages, enum labels) MUST flow through React text nodes only. - -#### Scenario: Schema description does not allow HTML injection -- **GIVEN** an `rjsf-form` schema with `description: ""` -- **WHEN** the dashboard renders the form -- **THEN** the literal string is shown as visible text, not interpreted as HTML -- **AND** no `` element appears in the DOM diff --git a/openspec/changes/add-extension-ui-rjsf-form/tasks.md b/openspec/changes/add-extension-ui-rjsf-form/tasks.md deleted file mode 100644 index 1dd09e0a2..000000000 --- a/openspec/changes/add-extension-ui-rjsf-form/tasks.md +++ /dev/null @@ -1,58 +0,0 @@ -## 1. Schema and shared types - -- [ ] 1.1 Extend `UiView` discriminated union in `packages/shared/src/types.ts` to add the `"rjsf-form"` arm with `rjsfSchema: JSONSchema7`, `rjsfUiSchema?: UiSchema`, `rjsfFallback: "ctx-ui" | "defaults" | "reject"` (required), `dataEvent?: string`, `initialDataEvent?: string`. Re-export `JSONSchema7` from `json-schema` and `UiSchema` from `@rjsf/utils` (type-only import). -- [ ] 1.2 Add `json-schema` and `@rjsf/utils` (type-only) to `packages/shared/package.json` devDependencies; ensure no runtime `@rjsf/*` package leaks into the shared bundle. -- [ ] 1.3 Add a runtime validator helper `validateRjsfForm(view): { ok: true } | { ok: false, reason: string }` that flags external-URL `$ref` and missing `rjsfFallback`. Used by `event-wiring.ts` when caching modules. -- [ ] 1.4 Add unit tests in `packages/shared/src/__tests__/extension-ui-rjsf-types.test.ts`: well-formed descriptor passes; missing `rjsfFallback` is a TypeScript error (snapshot via `// @ts-expect-error` test); external-URL `$ref` is rejected by the validator helper; internal `#/...` refs pass. - -## 2. Bridge: pure-pi fallback handling - -- [ ] 2.1 In `packages/extension/src/bridge.ts` (or a new `packages/extension/src/rjsf-fallback.ts` helper), add `runRjsfFallback(ctx, module): Promise` implementing the three strategies (`"ctx-ui"`, `"defaults"`, `"reject"`). -- [ ] 2.2 Wire the fallback into the slash-command handler so it runs ONLY when the bridge has no active dashboard connection AND the matched module's `view.kind === "rjsf-form"`. -- [ ] 2.3 Implement the `"ctx-ui"` decomposition: walk top-level `properties`; map `string` → `ctx.ui.input`, `boolean` → `ctx.ui.confirm`, `number`/`integer` → `ctx.ui.input` with numeric coercion + retry on parse failure, `string` with `enum` → `ctx.ui.select`. Skip non-primitive properties silently. Return the assembled object. -- [ ] 2.4 Implement the `"defaults"` strategy: synchronously return an object built from each top-level property's `default` (or `const`) value; omit properties without a default. -- [ ] 2.5 Implement the `"reject"` strategy: define and throw `NoDashboardError` with a message identifying the slash command. Export the error class from `packages/extension/src/index.ts` so extensions can catch it. -- [ ] 2.6 Add unit tests in `packages/extension/src/__tests__/rjsf-fallback.test.ts` covering one scenario per spec scenario (ctx-ui prompts in declared order, defaults synchronous, reject throws, ctx-ui skips non-primitives). - -## 3. Client: lazy-loaded RJSF bundle - -- [ ] 3.1 Add `@rjsf/core@^5` and `@rjsf/validator-ajv8@^5` to `packages/client/package.json` (NOT `packages/client` peer or shared). Confirm Vite tree-shakes the eager-import path away. -- [ ] 3.2 Create `packages/client/src/components/extension-ui/rjsf/RjsfFormView.tsx` as the lazy entry point. Inside, statically import `@rjsf/core` and `@rjsf/validator-ajv8` so Vite emits a single rjsf chunk. Export `RjsfFormView({ module, sessionId, onClose })`. -- [ ] 3.3 In `GenericExtensionDialog.tsx`, when `view.kind === "rjsf-form"`, dynamic-import `RjsfFormView` via `React.lazy(() => import("./rjsf/RjsfFormView"))` wrapped in `` with a small loading indicator. Other view kinds remain synchronous. -- [ ] 3.4 Confirm via `npm run build` that `dist/client/assets/` contains an `RjsfFormView-*.js` chunk separate from the main bundle. Add a CI sanity check (size guard, warn at >250 KB gzipped). - -## 4. Client: Tailwind RJSF theme - -- [ ] 4.1 Create directory `packages/client/src/components/extension-ui/rjsf-theme/` with widget components: `TextWidget.tsx`, `NumberWidget.tsx` (covers `number`/`integer`/`range`), `CheckboxWidget.tsx`, `SelectWidget.tsx`, `TextareaWidget.tsx`, `DateWidget.tsx`, `BaseInputTemplate.tsx`, `FieldTemplate.tsx`, `ObjectFieldTemplate.tsx`, `ArrayFieldTemplate.tsx`, `ErrorListTemplate.tsx`. -- [ ] 4.2 Compose them into a single `tailwindTheme: ThemeProps` object exported from `packages/client/src/components/extension-ui/rjsf-theme/index.ts`. Use existing `@/components/ui/*` (or equivalent Tailwind primitives) where possible to match dashboard styling. -- [ ] 4.3 In `RjsfFormView`, instantiate `withTheme(tailwindTheme)` once at module scope. Pass the resulting Form component the `view.rjsfSchema`, `view.rjsfUiSchema`, the AJV-8 validator, and the submit handler. -- [ ] 4.4 Add snapshot tests in `packages/client/src/components/extension-ui/rjsf-theme/__tests__/widgets.test.tsx` covering each widget rendered with a simple schema. -- [ ] 4.5 Add a static lint test asserting no file under `rjsf-theme/` uses `dangerouslySetInnerHTML` (mirror existing `no-direct-process-kill.test.ts` pattern). - -## 5. Client: submit + reply lifecycle - -- [ ] 5.1 In `RjsfFormView`, implement the submit handler: on AJV-validated submit, dispatch `ui_management { sessionId, action: "submit", event: view.dataEvent ?? `${module.id}:submit`, params: formData }` via the existing `usePluginSend` / `useWebSocketSend` hook. Disable the submit button while awaiting `_reply`. -- [ ] 5.2 Listen for the matched `ui_management` reply (via `_reply` round-trip on the bus or a dedicated `ui_management_reply` event — choose the path consistent with how Phase 1 actions surface reply errors). On `{ ok: false, error }`, render an inline error banner above the submit button and re-enable it. On `{ ok: true }`, call `onClose()`. -- [ ] 5.3 Add a 30-second timeout: if no reply arrives, surface a generic timeout banner and re-enable the submit button. Cancel the timeout on actual reply. -- [ ] 5.4 Wire `view.initialDataEvent` (when present): on mount, dispatch `ui_management { action: "list", event: view.initialDataEvent }`; pre-fill the form's `formData` with the first item from `session.uiDataMap[view.initialDataEvent]` once it arrives. -- [ ] 5.5 Add tests in `RjsfFormView.test.tsx`: invalid submit blocks dispatch; valid submit dispatches; `_reply({ ok: false })` surfaces banner; `_reply({ ok: true })` closes; timeout fires after 30s. - -## 6. Client: wire into existing modal + slash-command path - -- [ ] 6.1 Confirm the slash-command interception in `CommandInput.tsx` already routes `rjsf-form` modules through `GenericExtensionDialog` (it should — Phase 1 covers this; verify no `view.kind` allowlist excludes `rjsf-form`). -- [ ] 6.2 Confirm the server's `event-wiring.ts` caches `ui_modules_list` regardless of `view.kind` (it should — modules are stored verbatim). Add a regression test pushing an `rjsf-form` module through replay. -- [ ] 6.3 Verify `replayUiState(ws, sessionId)` replays `rjsf-form` modules to a re-subscribing browser without modification. - -## 7. Documentation and integration - -- [ ] 7.1 Update `docs/architecture.md` with a section on `rjsf-form` view types, the lazy-load contract, and the pure-pi fallback strategy choice. -- [ ] 7.2 Update `AGENTS.md` Key Files table to add `RjsfFormView.tsx`, the `rjsf-theme/` directory, `runRjsfFallback`, and `NoDashboardError`. Cross-reference change `add-extension-ui-rjsf-form`. -- [ ] 7.3 Add a usage example to `openspec/specs/extension-ui-system/spec.md` (Phase 4 section, ADDED via this change) showing a minimal `rjsf-form` descriptor with each `rjsfFallback` strategy. -- [ ] 7.4 Update `packages/shared/README.md` (if present) noting the new `JSONSchema7` / `UiSchema` re-exports. - -## 8. Verification - -- [ ] 8.1 Run `npm test` and ensure all new tests pass; run `npm run build` and confirm the rjsf chunk is split out. -- [ ] 8.2 Run `npm run reload:check` (type-check + reload all sessions) and verify nothing in the shared / extension layer regressed. -- [ ] 8.3 Manual smoke test: register a fixture extension with one `rjsf-form` module declaring each `rjsfFallback` strategy; verify dashboard render, validation gating, `_reply` error banner, and pure-pi fallback paths. -- [ ] 8.4 Run `openspec validate add-extension-ui-rjsf-form --strict` and resolve any reported issues. diff --git a/openspec/changes/add-server-push-notifications/design.md b/openspec/changes/add-server-push-notifications/design.md deleted file mode 100644 index df1818137..000000000 --- a/openspec/changes/add-server-push-notifications/design.md +++ /dev/null @@ -1,115 +0,0 @@ -## Context - -The dashboard's `event-wiring.ts` already classifies "user-relevant" events via the pure helper `isUnreadTrigger(eventType, before, after, payload)` (`event-status-extraction.ts:209`). That classifier is the single source of truth for "should the user be notified?" — currently consumed only by the unread-stripes feature. Push notifications are the natural extension of the same trigger to disconnected devices. - -The fan-out site is a single point in `event-wiring.ts:188-201`: - -```ts -if ( - isUnreadTrigger(msg.event.eventType, beforeSnapshot, afterSnapshot, msg.event.data) && - !viewedSessionTracker.isViewedByAnyone(sessionId) -) { - if (sessionAfter && !sessionAfter.unread) { - sessionManager.update(sessionId, { unread: true }); - browserGateway.broadcastSessionUpdated(sessionId, { unread: true }); - } - pushDispatcher?.fanout(sessionId, msg.event); // ← THE NEW LINE -} -``` - -This co-location is deliberate: push and unread-stripes have identical semantics ("notify because the user wants to know"). Diverging the gating would create two parallel-but-subtly-different "what counts as a notable event" definitions, which is a long-term maintenance hazard. - -**Stakeholders**: server maintainers (event-wiring + new push module), web client maintainers (sw.js + usePushSubscription hook + Settings UI), future Capacitor change author (will reuse `/api/push/register`). - -**Dependencies**: -- Existing: `viewedSessionTracker`, `isUnreadTrigger`, `event-wiring.ts`, `auth-plugin.ts`, `json-store.ts`, `config.ts` validator pattern. -- New npm: `web-push` (~2.5k weekly downloads is a misread — it's millions; widely used, stable, MIT-licensed). FCM uses native `https` + `crypto.createSign` for the JWT; no Firebase SDK. - -## Goals / Non-Goals - -**Goals:** -- One place in the codebase decides "is this event push-worthy?" — `isUnreadTrigger`. No duplication. -- Push delivery latency must not block the event-forwarding pipeline. Failure of FCM/APNs/Web Push must not throttle the websocket fan-out to connected browsers. Enforced by a repo-level lint test. -- Coalesce per-(session, device) at 30s — same window the existing `lastActivityBroadcastAt` uses. Configurable, clamped 5–300s. -- Two transports (Web Push, FCM) behind one `PushTransport` interface. Adding APNs-direct or another transport later is mechanical. -- Server is opt-in (`config.push.enabled = false` by default). A user who never touches the config sees zero behavior change. -- Web Push works on the existing PWA — no Capacitor required for v1 value. -- The Capacitor follow-on can ship by adding ONE transport adapter and zero changes to the trigger logic. - -**Non-Goals:** -- Modifying `isUnreadTrigger` itself. Trigger semantics are already in production for the unread feature; if they need to evolve, that's its own change touching both consumers. -- Building a generic notification framework (categories, priorities, sound packs). v1 is "ping me when the agent needs me" — three trigger types, one notification body shape. -- Replacing the existing unread-stripes broadcast with a push round-trip. Connected browsers continue to learn via WebSocket; push is for *disconnected* devices. -- Server-side delivery receipts / retry / DLQ. Web Push and FCM both have transport-level retry. Our dispatcher logs failure and moves on. If a device is permanently dead, the next 410 / `UNREGISTERED` response prunes it from the registry. - -## Decisions - -### Decision 1 — Coalescing key is `(sessionId, deviceToken)`, not `(sessionId)` - -**Why**: a user with a phone AND a desktop both registered should each get the push, even though they're "the same user." Coalescing per-token avoids one device suppressing another. The 30s window is per-pair. - -**Tradeoff**: in-memory map size grows with `O(active sessions × registered devices)`. Bounded by entry count and TTL — old entries pruned on every dispatch (lazy expiry). For a 50-session, 5-device household: 250 entries max. Negligible. - -### Decision 2 — Web Push via VAPID, server-generated keys, persisted at `~/.pi/dashboard/push-vapid.json` - -**Why**: VAPID is the standard auth scheme for Web Push. Generating once and persisting (rather than re-generating per server start) means existing browser subscriptions remain valid across restarts. The VAPID public key is embedded in the subscription request and validated by the push service (Mozilla autopush, FCM under the hood for Chrome, etc.). - -**Tradeoff**: one more JSON file in `~/.pi/dashboard/`. Acceptable. - -**Rejected alternative**: VAPID keys derived from `config.secret`. Risk: rotating the secret would invalidate all push subscriptions silently, with no failure surface until a user wonders why pushes stopped. Separate persistence makes the lifecycle explicit. - -### Decision 3 — FCM via raw HTTP/2 + service-account JWT, no Firebase Admin SDK - -**Why**: Firebase Admin SDK is ~50MB of dependencies for one HTTP call. The FCM v1 API is a single POST with a Bearer JWT; the JWT signing uses `crypto.createSign('RSA-SHA256')` from Node built-ins. Total: ~80 LOC, zero new heavyweight deps. - -**Tradeoff**: we manually handle token refresh (JWT expires after 1 hour). Mitigation: cache token, refresh on 401. ~10 extra LOC. - -**Rejected alternative**: Firebase Admin SDK. Pulls `@grpc/grpc-js`, `firebase-admin`, `@google-cloud/firestore`, etc. Bloats `node_modules` by ~80MB. Not justified for one POST call. - -### Decision 4 — Token persistence as a single JSON file, not SQLite - -**Why**: matches the existing pattern (`session-meta`, `preferences-store`, `known-servers`). All token mutations go through the existing `json-store.ts` atomic write. For < 1000 tokens (which is FAR more than any single user has) JSON read/write is microseconds. - -**Tradeoff**: full-file rewrite on every register/unregister. Negligible at expected scale. - -### Decision 5 — Notification payload is small and links to the session - -The push payload is: -```json -{ "type": "session_attention", "sessionId": "abc-123", "title": "Pi session waiting for input", "body": "agent: claude — file_edit", "url": "/session/abc-123" } -``` - -Title/body computed server-side from event payload + session metadata. Click handler in `sw.js` (and Capacitor's plugin handler in the follow-up) navigates to `url`. We do NOT include the full event content — privacy + payload-size limits (FCM caps at 4KB, Web Push at 4KB nominal). - -### Decision 6 — `push.enabled = false` by default; opt-in in Settings UI - -**Why**: pushing requires user consent at the OS level anyway (browser prompt for Web Push, OS permission for FCM via Capacitor). Server-side opt-in is the second gate — admins who don't want push noise on their server don't need to do anything. Mirrors `tunnel.enabled`. - -### Decision 7 — `pushDispatcher?` is optional in `EventWiringDeps` - -Mirrors how `viewedSessionTracker?` was added. Keeps existing tests that don't exercise push lean. The runtime `wireEvents` call in `server.ts` always passes the dispatcher in production. - -### Decision 8 — Failed deliveries with `410 Gone` (Web Push) or `NOT_FOUND` / `UNREGISTERED` (FCM) prune the token - -The dispatcher records and removes dead tokens automatically. No background reaper job. This keeps the token registry clean without a polling cron. - -## Risks / Trade-offs - -- **Web Push payload size limit (4KB)**. Title + body + url + sessionId fits comfortably. Risk if we ever want richer payloads. -- **iOS Safari Web Push** requires the user to install the PWA to the home screen. Documented behavior; we surface a hint in the Settings UI for iOS users ("install to home screen first"). The Capacitor follow-on side-steps this entirely via APNs through FCM. -- **VAPID contact email is required by spec**. If `config.push.webPush.contactEmail` is missing while Web Push is enabled, server logs a clear error and disables Web Push (FCM still works). Documented in design + surfaced in `/api/health.push.errors`. -- **FCM service-account JSON is sensitive**. We read by path, do NOT inline in `config.json`. Ensures the file can have stricter permissions and isn't accidentally exposed in `/api/config` GET (which redacts secrets but should never see this content at all). -- **Test endpoint `/api/push/test` could be abused** to spam a user. Auth-gated and rate-limited by the existing auth-plugin chain. Acceptable for v1 single-user audience. -- **Coalescing window of 30s could miss a user**. If three trigger events fire within 30s, the user sees one push, not three. This is a feature, not a bug — same as the existing unread-stripes behavior. Configurable per deployment. - -## Migration Plan - -This is purely additive: - -1. Land server-side dispatcher + REST routes + config schema. Default `enabled: false` means no behavior change for existing deployments. -2. Land client-side `usePushSubscription` + `sw.js` push handler + Settings UI. With server `enabled: false`, the UI shows "Push not enabled on this server" and the hook no-ops. -3. User opts in via config (or a follow-up "enable push" button in Settings if we want UX polish — out of scope for v1). -4. User clicks "Enable on this device" in Settings → browser prompt → token registered. -5. Capacitor change (follow-on) reuses `/api/push/register` with `transport: "fcm"`. Server-side requires only that `config.push.fcm.serviceAccountPath` is set. - -No data migration. No breaking change. Existing unread-stripes behavior is untouched. diff --git a/openspec/changes/add-server-push-notifications/proposal.md b/openspec/changes/add-server-push-notifications/proposal.md deleted file mode 100644 index e1eeb0bdc..000000000 --- a/openspec/changes/add-server-push-notifications/proposal.md +++ /dev/null @@ -1,58 +0,0 @@ -## Why - -The dashboard already has a server-side classifier — `isUnreadTrigger(eventType, before, after, payload)` in `packages/server/src/event-status-extraction.ts:209` — that fires when an agent finishes a turn (`streaming → idle/active`), waits for input (`currentTool → "ask_user"`), or crashes (`agent_end` with truthy error). Today this classifier flips a per-session `unread` bit and broadcasts `session_updated` to *connected* browsers (see `event-wiring.ts:181-201`). Disconnected, backgrounded, or mobile users learn nothing. - -Push notifications close that gap. The same three triggers that drive the unread-stripes feature are exactly the moments a user wants their phone to ping. By wiring a fan-out dispatcher into the existing trigger site, we get cross-device awareness with **zero new event semantics** and one new line at the call site. - -This change ships value to the existing PWA via the W3C Web Push spec (Chrome / Edge / Firefox / Safari 16+ on iOS) before any Capacitor work happens. The follow-on change `add-capacitor-mobile-shell` (not yet filed) will reuse the exact same server endpoints via Capacitor's `@capacitor/push-notifications` plugin (FCM/APNs through Firebase) — so the server-side mechanics are identical for both transports. - -## What Changes - -- **NEW** `packages/server/src/push/` module with three files: - - `push-token-registry.ts` — persists `{deviceToken, transport: "web-push"|"fcm", userId?, sessionFilter?: string[], registeredAt, lastUsedAt}` to `~/.pi/dashboard/push-tokens.json` via the existing `json-store.ts` atomic write helper. Pure-data layer, no transport coupling. - - `push-dispatcher.ts` — async fire-and-forget fan-out. Takes `(sessionId, event)` → reads matching tokens → POSTs to the appropriate transport endpoint. **Coalesces** at most one push per `(sessionId, deviceToken)` per 30s window, mirroring the existing `lastActivityBroadcastAt` throttle in `event-wiring.ts`. Failures logged, never thrown — must not block the event pipeline. - - `push-transports/web-push.ts` and `push-transports/fcm.ts` — transport adapters with a shared `PushTransport` interface (`send(token, payload): Promise`). Web Push uses the `web-push` npm library + VAPID keys; FCM uses the v1 HTTP API + service-account JWT (no Firebase Admin SDK — direct REST call, ~80 LOC, keeps the dependency surface flat). -- **NEW** REST routes in `packages/server/src/routes/push-routes.ts`: - - `POST /api/push/register` — body `{deviceToken, transport, sessionFilter?}` → 200 with `{registered: true}`. Auth-gated via the existing `auth-plugin.ts` chain. - - `DELETE /api/push/register/:tokenId` — unregister a device. - - `POST /api/push/test` — send a test push to one or all of the caller's devices. Returns delivery receipt per token. - - `GET /api/push/vapid-public-key` — returns the VAPID public key for Web Push subscription (server generates the keypair once on first start, stores in `~/.pi/dashboard/push-vapid.json`). -- **NEW** config block in `~/.pi/dashboard/config.json` schema (`packages/shared/src/config.ts`): - ```ts - push?: { - enabled: boolean; // default false (must be opted in) - coalesceWindowMs: number; // default 30_000, range 5_000–300_000 - fcm?: { - serviceAccountPath: string; // path to Firebase service-account JSON - }; - webPush?: { - contactEmail: string; // required by VAPID spec for `mailto:` subject - }; - } - ``` - Validator with clamping in the same shape as `parseOpenSpecPollConfig`. -- **MODIFY** `packages/server/src/event-wiring.ts` at the existing `isUnreadTrigger` site (`event-wiring.ts:188-201`) — add **one line** that calls `pushDispatcher.fanout(sessionId, event)` after the unread broadcast. Identical guard conditions: only live (non-replay) events, only when `!viewedSessionTracker.isViewedByAnyone(sessionId)`. Push and unread-stripes share the same gating. -- **NEW** `packages/client/src/hooks/usePushSubscription.ts` — Web Push registration: feature-detect `'serviceWorker' in navigator && 'PushManager' in window`, fetch VAPID public key, call `swReg.pushManager.subscribe(...)`, POST the subscription to `/api/push/register`. Idempotent — checks for existing subscription on mount. -- **MODIFY** `public/sw.js` — add a `'push'` event listener that parses the JSON payload and calls `self.registration.showNotification(...)` with click handler routing back to `/session/:id`. -- **NEW** Settings UI section `packages/client/src/components/PushNotificationsSection.tsx` — toggle to enable/disable push for the current device, list of registered devices with last-used timestamp, "Send test" button, "Unregister this device" button. Mounted under Settings → Notifications (new sub-page or top-level section — TBD in design.md). -- **NEW** repo-level lint test `packages/server/src/__tests__/push-dispatcher-fire-and-forget.test.ts` — fails the build if `push-dispatcher.fanout(...)` is ever `await`ed at the call site in `event-wiring.ts`. Push must be fire-and-forget; awaiting it would couple FCM/APNs latency to the event pipeline. -- **DOCUMENTATION** — update `docs/architecture.md` with a new "Push notifications" section covering: the trigger contract (same as unread-stripes), the coalescing rule, the per-token persistence shape, and the FCM service-account setup steps (Firebase project → service account → download JSON → reference in config). Add a one-line entry for each new file in `AGENTS.md`'s Key Files table. - -## Capabilities - -### New Capabilities - -- `push-notifications` — server-side fan-out of agent-trigger events (`streaming→idle`, `ask_user`, `agent_end`-error) to registered devices via Web Push and/or FCM, with per-(session,device) coalescing, opt-in config, and a REST API for device registration/test/unregister. - -### Modified Capabilities - -- `event-wiring` — extends the existing `isUnreadTrigger` call site with a single fire-and-forget call into the push dispatcher. Same gating (no replay, no viewed sessions), same trigger predicate. Adds a new optional dependency (`pushDispatcher?: PushDispatcher`) to `EventWiringDeps` so existing tests that don't need it stay lean (mirrors the `viewedSessionTracker?` pattern). - -## Out of Scope - -- **Capacitor / native APK / iOS .ipa packaging** — covered by the follow-on change `add-capacitor-mobile-shell`. This change makes Capacitor's job trivial (just plug the FCM token into `/api/push/register`) but does not require Capacitor to ship. -- **Per-event-type push opt-in** (e.g. "push me on `ask_user` but not on `agent_end`-error"). v1 ships all-or-nothing per device. Granularity can be added via `sessionFilter` extension in a follow-up if real demand surfaces. -- **Quiet hours / DND scheduling** — out of scope; OS-level Do Not Disturb is the right layer for this. -- **Push payload encryption at rest** — Web Push is end-to-end encrypted by spec. FCM payloads are TLS to Google then to device — fine for v1. No HIPAA/PII data is in the payload (just session id + status + truncated message). -- **Rate limiting at the REST layer** — `/api/push/test` is auth-gated; the existing auth chain plus the in-pipeline 30s coalescing is sufficient for v1. -- **Multi-user push routing** — the `userId` field is recorded on the token but v1 fans out to *every* registered token (single-user dashboard assumption). Multi-user filtering is a follow-up. diff --git a/openspec/changes/add-server-push-notifications/specs/event-wiring/spec.md b/openspec/changes/add-server-push-notifications/specs/event-wiring/spec.md deleted file mode 100644 index 9d4e8718f..000000000 --- a/openspec/changes/add-server-push-notifications/specs/event-wiring/spec.md +++ /dev/null @@ -1,26 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Unread-trigger evaluation site is the single push hook -The unread-trigger evaluation block in `event-wiring.ts` SHALL be the single point in the codebase where "push to disconnected devices" decisions are made. The block evaluates `isUnreadTrigger(...)` once and dispatches BOTH the unread-stripes broadcast AND the push fan-out from the same gated `if` body. New consumers of "is this event user-relevant?" SHALL co-locate at this site rather than re-evaluating the predicate elsewhere. - -#### Scenario: One predicate, two consumers -- **WHEN** an event arrives that satisfies `isUnreadTrigger(...)` AND `!viewedSessionTracker.isViewedByAnyone(sessionId)` AND not in replay -- **THEN** the unread bit SHALL be set on the session -- **AND** `pushDispatcher?.fanout(sessionId, event)` SHALL be called within the same gated branch - -#### Scenario: Predicate fails → neither consumer fires -- **WHEN** `isUnreadTrigger(...)` returns false -- **THEN** the unread bit SHALL NOT change -- **AND** `pushDispatcher` SHALL NOT be called - -### Requirement: Optional push dispatcher dependency -`EventWiringDeps` SHALL accept an optional `pushDispatcher?: PushDispatcher` field. When undefined, the wiring SHALL behave identically to its pre-push behavior — no fan-out, no errors. This mirrors the existing `viewedSessionTracker?` pattern and keeps tests that don't exercise push lean. - -#### Scenario: Dispatcher absent -- **WHEN** `wireEvents(...)` is called without `pushDispatcher` in deps -- **THEN** all event flow SHALL behave identically to the pre-change code path -- **AND** no errors SHALL be logged about a missing dispatcher - -#### Scenario: Dispatcher present -- **WHEN** `wireEvents(...)` is called with `pushDispatcher` in deps -- **THEN** the dispatcher SHALL be invoked at the unread-trigger site under the gating defined in the `push-notifications` capability diff --git a/openspec/changes/add-server-push-notifications/specs/push-notifications/spec.md b/openspec/changes/add-server-push-notifications/specs/push-notifications/spec.md deleted file mode 100644 index 243a5ffc5..000000000 --- a/openspec/changes/add-server-push-notifications/specs/push-notifications/spec.md +++ /dev/null @@ -1,157 +0,0 @@ -## ADDED Requirements - -### Requirement: Push trigger predicate -The dashboard server SHALL fan out push notifications to registered devices using the same trigger predicate (`isUnreadTrigger`) and the same gating (`!viewedSessionTracker.isViewedByAnyone(sessionId)` AND not in replay) that the unread-stripes feature uses. The two consumers MUST share the call site in `event-wiring.ts` so that "what counts as a notable event" has exactly one definition in the codebase. - -#### Scenario: Agent finishes a turn → push fired -- **WHEN** a session transitions from `streaming` to `idle` AND no browser is viewing the session AND the event is not part of a replay -- **THEN** the push dispatcher's `fanout(sessionId, event)` SHALL be called exactly once - -#### Scenario: Agent waits for user input → push fired -- **WHEN** an event sets `currentTool` to `"ask_user"` under the same gating -- **THEN** `fanout(sessionId, event)` SHALL be called exactly once - -#### Scenario: Agent crashes → push fired -- **WHEN** an `agent_end` event arrives with a truthy `payload.error` field under the same gating -- **THEN** `fanout(sessionId, event)` SHALL be called exactly once - -#### Scenario: Browser is viewing the session → no push -- **WHEN** any of the three trigger predicates fire AND `viewedSessionTracker.isViewedByAnyone(sessionId) === true` -- **THEN** `fanout` SHALL NOT be called -- **BECAUSE** the user is already looking at the session — push would be redundant noise - -#### Scenario: Replay event → no push -- **WHEN** a replay-flagged event matches a trigger predicate -- **THEN** `fanout` SHALL NOT be called -- **BECAUSE** replay re-emits historical events; pushing on cold-start replay would notify the user about events they already saw - -### Requirement: Fire-and-forget dispatch -The push dispatcher's `fanout` method SHALL be `void`-returning at the type level and SHALL NOT throw under any input. The call site in `event-wiring.ts` MUST NOT `await` the dispatcher. Transport latency or failure MUST NOT delay or block the WebSocket fan-out to connected browsers. - -#### Scenario: Transport hangs indefinitely -- **WHEN** an FCM POST never resolves (simulated by holding the response open for 60 s) -- **THEN** the event-forwarding latency to connected browsers SHALL be unaffected (within 10 ms of baseline) - -#### Scenario: Transport throws synchronously -- **WHEN** a transport's `send` throws synchronously (e.g. malformed payload) -- **THEN** `fanout` SHALL NOT propagate the throw -- **AND** the failure SHALL be logged to the structured logger with `level: "error"` and the offending tokenId - -#### Scenario: Lint enforcement -- **WHEN** the test suite runs -- **THEN** a lint test SHALL fail the build if `event-wiring.ts` contains `await pushDispatcher.fanout` or `await deps.pushDispatcher.fanout` - -### Requirement: Per-(session, device) coalescing -The dispatcher SHALL coalesce push notifications to at most one delivery per (sessionId, deviceToken) per `coalesceWindowMs` (default 30 000 ms, configurable in the range 5 000 – 300 000 ms). Different devices for the same session SHALL each receive their own push within the window. Different sessions SHALL each get their own push within the window. - -#### Scenario: Five rapid triggers within 10 s -- **WHEN** five trigger events fire for the same session within 10 s, with one device registered -- **THEN** the device SHALL receive exactly one push - -#### Scenario: Two devices, one trigger -- **WHEN** one trigger event fires for a session, with two devices registered -- **THEN** each device SHALL receive exactly one push - -#### Scenario: Two sessions, one device -- **WHEN** trigger events fire for session A then session B within 10 s, with one device registered -- **THEN** the device SHALL receive two pushes (one per session) - -#### Scenario: After the window closes -- **WHEN** a trigger fires at t=0 and another at t=31 000 ms (window=30 000), one device -- **THEN** the device SHALL receive two pushes - -### Requirement: Token persistence and lifecycle -The server SHALL persist registered push tokens to `~/.pi/dashboard/push-tokens.json` via atomic writes (tmp+rename). Each token SHALL carry `{id, deviceToken, transport, userId?, sessionFilter?, registeredAt, lastUsedAt}`. Tokens SHALL be pruned automatically when a transport reports the token as gone (Web Push `410`, FCM `NOT_FOUND` / `UNREGISTERED`). - -#### Scenario: Server restart preserves tokens -- **WHEN** a token is registered, the server is restarted -- **THEN** the token SHALL still be present in the registry after restart - -#### Scenario: Idempotent registration -- **WHEN** the same `deviceToken` is registered twice -- **THEN** the registry SHALL contain exactly one entry for that deviceToken -- **AND** `lastUsedAt` SHALL reflect the more recent registration - -#### Scenario: Dead-token pruning -- **WHEN** a transport returns `{ok: false, gone: true}` for a token during dispatch -- **THEN** the token SHALL be removed from the registry within the same dispatch call -- **AND** the persistence file SHALL be updated atomically - -### Requirement: Two transports behind one interface -The dispatcher SHALL support at minimum two transports — Web Push (W3C, VAPID-authenticated) and Firebase Cloud Messaging (HTTP v1 API) — both implementing a shared `PushTransport` interface. Adding a third transport (e.g. APNs-direct) SHALL require only a new file in `push-transports/` plus an entry in the dispatcher's transport registry; no changes to the trigger logic, the registry, or the call site. - -#### Scenario: Web Push transport sends a notification -- **WHEN** a token with `transport: "web-push"` is dispatched to -- **THEN** the Web Push transport's `send` SHALL be invoked with the token and payload -- **AND** a successful 201 response SHALL be reported as `{ok: true}` - -#### Scenario: FCM transport sends a notification -- **WHEN** a token with `transport: "fcm"` is dispatched to -- **THEN** the FCM transport's `send` SHALL be invoked with a JWT bearer derived from the configured service-account JSON - -#### Scenario: Unknown transport -- **WHEN** a token has an unrecognized `transport` value (e.g. data corruption) -- **THEN** the token SHALL be skipped with a logged warning, without crashing the dispatch - -### Requirement: VAPID key lifecycle -The server SHALL generate a VAPID keypair on first start and persist it at `~/.pi/dashboard/push-vapid.json`. The keypair SHALL be reused across restarts so existing browser subscriptions remain valid. The public key SHALL be exposed via `GET /api/push/vapid-public-key` for clients to use during `pushManager.subscribe`. - -#### Scenario: Keypair generated once -- **WHEN** the server is started for the first time with `push.enabled: true` -- **THEN** `~/.pi/dashboard/push-vapid.json` SHALL be created with `{publicKey, privateKey}` - -#### Scenario: Keypair reused -- **WHEN** the server restarts with the file present -- **THEN** the existing keypair SHALL be loaded; no new keypair SHALL be generated - -#### Scenario: Public key endpoint -- **WHEN** an authenticated client GETs `/api/push/vapid-public-key` -- **THEN** the response SHALL be `200 {publicKey: }` - -### Requirement: Push REST API -The server SHALL expose four auth-gated REST endpoints for device management: -- `POST /api/push/register` — body `{deviceToken, transport, sessionFilter?}` → `200 {tokenId}`. -- `DELETE /api/push/register/:tokenId` → `204`. -- `POST /api/push/test` — body `{tokenId?}` → `200 {results: [{tokenId, ok, gone?}]}`. -- `GET /api/push/vapid-public-key` → `200 {publicKey}`. - -All endpoints SHALL participate in the existing auth-plugin chain (loopback, trusted networks, OAuth user, secret token) — no separate auth scheme. - -#### Scenario: Unauthenticated register is rejected -- **WHEN** a request to `POST /api/push/register` arrives without a valid auth header from a non-loopback, non-trusted host -- **THEN** the response SHALL be `401` - -#### Scenario: Test endpoint with no tokens -- **WHEN** the caller has no registered tokens and POSTs to `/api/push/test` with no body -- **THEN** the response SHALL be `200 {results: []}` (no error, no push) - -### Requirement: Opt-in by default -The `push` config block SHALL default to `{enabled: false}`. When `push.enabled !== true`, the server SHALL NOT construct the dispatcher, SHALL NOT mount the push routes, and SHALL NOT generate VAPID keys. Clients calling `/api/push/*` against a disabled server SHALL receive `404`. - -#### Scenario: Default config has push disabled -- **WHEN** a fresh `~/.pi/dashboard/config.json` is loaded with no `push` block -- **THEN** `config.push.enabled` SHALL be `false` -- **AND** no push side-effects SHALL occur on event flow - -#### Scenario: Disabled server returns 404 -- **WHEN** push is disabled and a client GETs `/api/push/vapid-public-key` -- **THEN** the response SHALL be `404` - -### Requirement: Service worker push handler -The web client's service worker (`public/sw.js`) SHALL listen for `'push'` events and call `self.registration.showNotification(...)` with title and body from the payload. A `'notificationclick'` listener SHALL navigate to `payload.url` (typically `/session/:id`). - -#### Scenario: Push event with valid JSON -- **WHEN** the SW receives a push event with body `{title, body, url, sessionId}` -- **THEN** a system notification SHALL be displayed with that title and body - -#### Scenario: Notification click -- **WHEN** the user taps a displayed notification -- **THEN** the SW SHALL open or focus a window at `payload.url` - -### Requirement: Capacitor-readiness contract -The REST API and persistence shape defined here SHALL be sufficient for a future Capacitor-based mobile shell to register FCM tokens via `POST /api/push/register` with `transport: "fcm"` without any server-side change. This requirement is verified by a contract test that exercises the FCM-token registration path with a synthetic token. - -#### Scenario: FCM token registers and survives a restart -- **GIVEN** a server with `push.enabled: true` and `push.fcm.serviceAccountPath` configured -- **WHEN** a client POSTs `/api/push/register` with `{deviceToken: "", transport: "fcm"}`, the server restarts, and the client triggers a session push -- **THEN** the FCM transport SHALL be invoked with the persisted token and a freshly-signed JWT diff --git a/openspec/changes/add-server-push-notifications/tasks.md b/openspec/changes/add-server-push-notifications/tasks.md deleted file mode 100644 index a35f5c3b4..000000000 --- a/openspec/changes/add-server-push-notifications/tasks.md +++ /dev/null @@ -1,112 +0,0 @@ -# Tasks - -## 1. Preconditions - -- [ ] 1.1 Read `packages/server/src/event-wiring.ts` lines 175-205 and confirm the `isUnreadTrigger` site shape matches the design's "one new line" claim. -- [ ] 1.2 Read `packages/server/src/event-status-extraction.ts:209` (`isUnreadTrigger`) and `packages/server/src/viewed-session-tracker.ts` to confirm trigger semantics. -- [ ] 1.3 Read `packages/shared/src/config.ts::parseOpenSpecPollConfig` to confirm the validator/clamping pattern this change will mirror. -- [ ] 1.4 Read `packages/server/src/json-store.ts` and confirm the atomic-write API used by `preferences-store.ts` and `session-meta.ts`. -- [ ] 1.5 Read `packages/server/src/auth-plugin.ts` to confirm how new REST routes register under the auth chain. -- [ ] 1.6 Run `npm test 2>&1 | tee /tmp/push-baseline.log` and capture the green baseline. - -## 2. Config schema - -- [ ] 2.1 Extend `DashboardConfig` in `packages/shared/src/config.ts` with the `push?: PushConfig` block defined in the proposal. -- [ ] 2.2 Add `parsePushConfig(raw): PushConfig` validator with clamping (`coalesceWindowMs` 5_000–300_000, default 30_000) and SHA-of-the-shape unit tests in `packages/shared/src/__tests__/config-push.test.ts`. -- [ ] 2.3 Wire `parsePushConfig` into `loadConfig()` so existing configs without a `push` block parse cleanly. - -## 3. Token registry - -- [ ] 3.1 Create `packages/server/src/push/push-token-registry.ts` exporting `PushToken` type (`{id, deviceToken, transport, userId?, sessionFilter?, registeredAt, lastUsedAt}`) and `createPushTokenRegistry({path})` returning `{add(token), remove(id), list(), findByDeviceToken(token), touch(id)}`. -- [ ] 3.2 Use `~/.pi/dashboard/push-tokens.json` for persistence via `json-store.ts`. Atomic tmp+rename writes. -- [ ] 3.3 Token id generated via `crypto.randomUUID()`. -- [ ] 3.4 Unit tests in `packages/server/src/__tests__/push-token-registry.test.ts`: add/remove/list, persistence round-trip, idempotent add (same `deviceToken` → same id, refresh `lastUsedAt`). - -## 4. Push transport interface + Web Push adapter - -- [ ] 4.1 Create `packages/server/src/push/push-transports/types.ts` with `interface PushTransport { kind: "web-push" | "fcm"; send(token: PushToken, payload: PushPayload): Promise<{ ok: boolean; gone?: boolean }> }`. -- [ ] 4.2 Add `web-push` to `packages/server/package.json` dependencies (current latest stable, matching pi's lockstep policy). -- [ ] 4.3 Create `packages/server/src/push/push-transports/web-push.ts` exporting `createWebPushTransport({ vapidKeys, contactEmail })` returning a `PushTransport`. On `410 Gone` from the push service, return `{ok: false, gone: true}` so the dispatcher prunes. -- [ ] 4.4 Create `packages/server/src/push/push-vapid.ts` with `loadOrGenerateVapidKeys(path): {publicKey, privateKey}`. Persists to `~/.pi/dashboard/push-vapid.json` on first call. -- [ ] 4.5 Unit tests for vapid persistence and web-push payload encoding (mocked `web-push` library). - -## 5. FCM transport adapter - -- [ ] 5.1 Create `packages/server/src/push/push-transports/fcm.ts` exporting `createFcmTransport({ serviceAccountPath })`. - - JWT signing via `crypto.createSign('RSA-SHA256')` from the service-account `private_key`. - - Token cached in-memory, refreshed on 401 or before expiry (3500s window). - - HTTP/2 POST to `https://fcm.googleapis.com/v1/projects//messages:send`. - - On `404 NOT_FOUND` / `UNREGISTERED` error code, return `{ok: false, gone: true}`. -- [ ] 5.2 Unit tests with `nock` (or fetch-mock equivalent) covering: token refresh, gone-pruning, transient 5xx logged but not retried in v1. - -## 6. Dispatcher - -- [ ] 6.1 Create `packages/server/src/push/push-dispatcher.ts` exporting `createPushDispatcher({ registry, transports, coalesceWindowMs })` returning `{ fanout(sessionId, event), shutdown() }`. -- [ ] 6.2 `fanout` is `void`-returning and never throws. Internally `Promise.allSettled` over matched tokens, individual failures logged to the structured logger. -- [ ] 6.3 In-memory `Map<\`${sessionId}::${tokenId}\`, lastSentAt>` for coalescing. Lazy expiry on every read (drop entries older than `2 × coalesceWindowMs`). -- [ ] 6.4 Compute `PushPayload` via pure helper `buildPushPayload(session, event)` in `packages/server/src/push/build-push-payload.ts`. Unit-tested with fixture events covering all three triggers. -- [ ] 6.5 On `{ok: false, gone: true}` from a transport, call `registry.remove(tokenId)`. On `ok: true`, call `registry.touch(tokenId)`. -- [ ] 6.6 Unit tests for: trigger-to-payload mapping, coalescing window, dead-token pruning, fan-out non-throwing under transport failure. - -## 7. Wire into event pipeline - -- [ ] 7.1 Add `pushDispatcher?: PushDispatcher` to `EventWiringDeps` in `packages/server/src/event-wiring.ts`. -- [ ] 7.2 At the existing `isUnreadTrigger` site (`event-wiring.ts:188-201`), add ONE line: `pushDispatcher?.fanout(sessionId, msg.event);` immediately after the unread broadcast block. Same gating (no replay, not viewed) — the line is INSIDE the existing `if (...)` block. -- [ ] 7.3 Update `packages/server/src/server.ts` to construct `pushDispatcher` from config and pass it into `wireEvents(...)`. Skip construction when `config.push?.enabled !== true`. -- [ ] 7.4 Add repo-level lint test `packages/server/src/__tests__/push-dispatcher-fire-and-forget.test.ts` that AST-scans `event-wiring.ts` for `await pushDispatcher` or `await deps.pushDispatcher` and fails the build if found. -- [ ] 7.5 Integration test in `packages/server/src/__tests__/event-wiring-push.test.ts`: simulate an `agent_end` event with error, assert dispatcher is called once with correct args, assert event-pipeline latency unchanged when transport hangs. - -## 8. REST routes - -- [ ] 8.1 Create `packages/server/src/routes/push-routes.ts` registering: - - `POST /api/push/register` — body `{deviceToken, transport, sessionFilter?}` → 200 with `{tokenId}`. - - `DELETE /api/push/register/:tokenId` → 204. - - `POST /api/push/test` — body `{tokenId?}` (omitted → all caller's tokens) → 200 with `{results: [{tokenId, ok, gone?}]}`. - - `GET /api/push/vapid-public-key` → 200 with `{publicKey}`. -- [ ] 8.2 All routes auth-gated via existing auth-plugin chain. -- [ ] 8.3 Handler unit tests in `packages/server/src/__tests__/push-routes.test.ts` (mock dispatcher + registry). - -## 9. Service worker push handler - -- [ ] 9.1 Add a `'push'` event listener to `public/sw.js`: - ```js - self.addEventListener('push', (event) => { - const data = event.data?.json() ?? {}; - event.waitUntil(self.registration.showNotification(data.title, { - body: data.body, - data: { url: data.url, sessionId: data.sessionId }, - icon: '/icon-192.png', - badge: '/icon-192.png', - })); - }); - self.addEventListener('notificationclick', (event) => { - event.notification.close(); - event.waitUntil(clients.openWindow(event.notification.data.url || '/')); - }); - ``` -- [ ] 9.2 Bump SW cache version comment so existing browsers refetch. - -## 10. Client subscription hook + Settings UI - -- [ ] 10.1 Create `packages/client/src/hooks/usePushSubscription.ts` exposing `{ supported, status: 'unknown'|'unsubscribed'|'subscribed'|'denied', subscribe(), unsubscribe(), sendTest() }`. -- [ ] 10.2 On mount: feature-detect, fetch VAPID public key, check existing `swReg.pushManager.getSubscription()`. -- [ ] 10.3 `subscribe()`: request permission, call `swReg.pushManager.subscribe({userVisibleOnly: true, applicationServerKey})`, POST to `/api/push/register`. -- [ ] 10.4 Create `packages/client/src/components/PushNotificationsSection.tsx` mounted in `SettingsPanel.tsx`. Renders status, subscribe/unsubscribe button, list of registered tokens (this device + others, anonymized), Send Test button, "iOS users: install to home screen first" hint when `iOS && !standalone`. -- [ ] 10.5 Component tests using `@testing-library/react` for the four UI states. - -## 11. Documentation - -- [ ] 11.1 New section in `docs/architecture.md` titled "Push notifications" — covers trigger contract, coalescing, persistence shape, FCM setup steps with screenshots-or-text-equivalent. -- [ ] 11.2 New row in `AGENTS.md` Key Files table for each new file (8 entries: registry, dispatcher, web-push transport, fcm transport, vapid loader, build-push-payload, push routes, push-tokens.json schema doc). -- [ ] 11.3 New row in `README.md` Configuration section for `push.*` config keys. - -## 12. Verification - -- [ ] 12.1 `npm test` green; new tests pass; baseline-comparison shows no unrelated regressions. -- [ ] 12.2 Manual: enable `push` in config, register a Chrome subscription, run a session, fire an `ask_user`, observe Chrome notification. -- [ ] 12.3 Manual: same flow with a Firefox subscription (Mozilla autopush has different quirks). -- [ ] 12.4 Manual iOS PWA: Safari 16+ on iOS 16.4+ with the dashboard installed to home screen; verify subscription works (this is the iOS Web Push gate that Capacitor will side-step later). -- [ ] 12.5 Manual: kill the FCM service-account JSON file mid-flight; verify dispatcher logs the load failure and does NOT crash the server. -- [ ] 12.6 Manual: run an `agent_end`-error event; verify push body includes the truncated error. -- [ ] 12.7 Manual: rapid-fire 5 `streaming→idle` cycles within 10s; verify exactly ONE push received per device (coalescing works). -- [ ] 12.8 Run `openspec validate add-server-push-notifications --strict` and fix any spec/scenario gaps. diff --git a/openspec/changes/archive/2026-05-04-add-server-push-notifications/design.md b/openspec/changes/archive/2026-05-04-add-server-push-notifications/design.md new file mode 100644 index 000000000..b526eea9a --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-server-push-notifications/design.md @@ -0,0 +1,141 @@ +## Context + +The dashboard's `event-wiring.ts` already classifies "user-relevant" events via `isUnreadTrigger(eventType, before, after, payload)`. Push notifications introduce a **separate, narrower** predicate: `isPushTrigger` — matching only `ask_user` (agent needs input) and `agent_end`-error (agent crashed). Both predicates share the same call site but evaluate independently. + +The fan-out site in `event-wiring.ts` will look like: + +```ts +// Unread broadcast (broad set of triggers) +if ( + isUnreadTrigger(msg.event.eventType, beforeSnapshot, afterSnapshot, msg.event.data) && + !viewedSessionTracker.isViewedByAnyone(sessionId) && + !msg.event.replay +) { + if (sessionAfter && !sessionAfter.unread) { + sessionManager.update(sessionId, { unread: true }); + browserGateway.broadcastSessionUpdated(sessionId, { unread: true }); + } +} + +// Push fan-out (narrow set of triggers + stale-view TTL) +if ( + isPushTrigger(msg.event.eventType, beforeSnapshot, afterSnapshot, msg.event.data) && + !viewedSessionTracker.isViewedByAnyone(sessionId, { staleMs: 60_000 }) && + !msg.event.replay +) { + pushDispatcher?.fanout(sessionId, sessionAfter, msg.event); // ← THE NEW LINE +} +``` + +This co-location keeps "what warrants a push" in one file, while the separate predicate prevents spam on routine turn completions. The 60s stale-view TTL ensures background tabs and sleeping laptops don't suppress push indefinitely. + +**Stakeholders**: server maintainers (event-wiring + new push module), web client maintainers (sw.js + usePushSubscription hook + Settings UI), agent/skill authors (push-notify-user skill). + +**Dependencies**: +- Existing: `viewedSessionTracker`, `isUnreadTrigger`, `event-wiring.ts`, `auth-plugin.ts`, `json-store.ts`, `config.ts` validator pattern. +- New npm: `web-push` (widely used, stable, MIT-licensed). + +## Goals / Non-Goals + +**Goals:** +- Push-worthy events defined by dedicated `isPushTrigger` predicate — distinct from `isUnreadTrigger`. Only `ask_user` and `agent_end`-error qualify. Routine `streaming→idle` is excluded. +- `fanout` wrapped in `try/catch`, per-send 10s timeout via `AbortController` passed to transport's `opts.signal`. Separate `sendNow` method for REST endpoints that need per-token results. +- Coalesce per-(session, device) at 30s — configurable, clamped 5–300s. +- Stale-view TTL of 60s on viewing gate — background tabs and sleeping laptops don't suppress push indefinitely. +- Web Push transport behind extensible `PushTransport` interface (`kind: string`, not union literal). +- Server is opt-in (`config.push.enabled = false` by default). A user who never touches the config sees zero behavior change. +- Web Push works on the existing PWA — no native app required for v1 value. +- Pi agents can send on-demand pushes via `POST /api/push/send` using the `push-notify-user` skill. + +**Non-Goals:** +- Modifying `isUnreadTrigger` itself. Trigger semantics are already in production for the unread feature; if they need to evolve, that's its own change touching both consumers. +- Building a generic notification framework. v1 is "ping me when the agent needs me" — two triggers, one notification shape, safety guards on on-demand sends. +- Server-side delivery receipts / retry / DLQ. Web Push has transport-level retry. Dispatcher logs failure and moves on. Dead tokens (410) pruned automatically. +- Replacing the existing unread-stripes broadcast. Connected browsers continue to learn via WebSocket; push is for disconnected devices. + +## Decisions + +### Decision 1 — Separate `isPushTrigger` predicate, narrower than `isUnreadTrigger` + +**Why**: `isUnreadTrigger` includes `streaming→idle` — fine for ephemeral visual stripes, disruptive for persistent OS notifications. A separate `isPushTrigger` matches only `ask_user` and `agent_end`-error. Both predicates live in `event-status-extraction.ts`; the call site in `event-wiring.ts` evaluates them independently but co-located. + +**Tradeoff**: two predicates to maintain instead of one. Mitigated by sharing the same exact function shape and living in the same file. + +### Decision 2 — Stale-view TTL of 60s on viewing gate + +**Why**: the existing `viewedSessionTracker.isViewedByAnyone(sessionId)` returns true for any browser that has the session route open — including background tabs and sleeping laptops. Without a TTL, a desktop left open would permanently suppress phone pushes. A 60s TTL means: if no browser has *actively* viewed the session in the last 60 seconds, push fires. + +**Tradeoff**: a user who is actively looking at a session but hasn't triggered a view refresh in 60s (e.g. reading long output) might get a push. Acceptable — better than missing critical `ask_user`/crash notifications. + +**Rejected alternative**: per-device tracking (phone vs desktop). Requires device identity correlation, which adds complexity disproportionate to v1 scope. + +### Decision 3 — Coalescing key is `(sessionId, deviceToken)`, not `(sessionId)` + +**Why**: a user with a phone AND a desktop both registered should each get the push, even though they're "the same user." Coalescing per-token avoids one device suppressing another. The 30s window is per-pair. + +**Tradeoff**: in-memory map size grows with `O(active sessions × registered devices)`. Bounded by entry count and TTL — old entries pruned on every dispatch (lazy expiry). For a 50-session, 5-device household: 250 entries max. Negligible. + +### Decision 4 — Web Push via VAPID, server-generated keys, persisted at `~/.pi/dashboard/push-vapid.json` + +**Why**: VAPID is the standard auth scheme for Web Push. Generating once and persisting (rather than re-generating per server start) means existing browser subscriptions remain valid across restarts. The VAPID public key is embedded in the subscription request and validated by the push service (Mozilla autopush, FCM under the hood for Chrome, etc.). + +**Tradeoff**: one more JSON file in `~/.pi/dashboard/`. Acceptable. + +**Rejected alternative**: VAPID keys derived from `config.secret`. Risk: rotating the secret would invalidate all push subscriptions silently, with no failure surface until a user wonders why pushes stopped. Separate persistence makes the lifecycle explicit. + +### Decision 5 — Token persistence as a single JSON file with 0600 permissions + +**Why**: matches the existing pattern (`session-meta`, `preferences-store`, `known-servers`). All token mutations go through `json-store.ts` atomic write. Files created with `0600` permissions — VAPID private key and push endpoints must not be readable by other local users. + +**Tradeoff**: full-file rewrite on every register/unregister. Negligible at expected scale (<1000 tokens). + +### Decision 6 — Notification payload is small and links to the session + +The push payload is: +```json +{ "type": "session_attention", "sessionId": "abc-123", "title": "Pi session waiting for input", "body": "agent: claude — file_edit", "url": "/session/abc-123" } +``` + +Title/body computed server-side from event payload + session metadata. Click handler in `sw.js` navigates to `url`. We do NOT include the full event content — privacy + payload-size limits (Web Push nominal cap at 4KB). + +### Decision 7 — `push.enabled = false` by default; opt-in in Settings UI + +**Why**: pushing requires user consent at the OS level anyway (browser prompt for Web Push). Server-side opt-in is the second gate — admins who don't want push noise on their server don't need to do anything. Mirrors `tunnel.enabled`. + +### Decision 8 — `pushDispatcher?` is optional in `EventWiringDeps` + +Mirrors how `viewedSessionTracker?` was added. Keeps existing tests that don't exercise push lean. The runtime `wireEvents` call in `server.ts` always passes the dispatcher in production. + +### Decision 9 — Failed deliveries with `410 Gone` prune the token + +The dispatcher records and removes dead tokens automatically. No background reaper job. This keeps the token registry clean without a polling cron. + +### Decision 10 — On-demand push via `POST /api/push/send` + `push-notify-user` skill + +**Why**: beyond automatic event-triggered pushes, agents need a way to notify the user on demand ("notify me when done"). Adding a dedicated `/api/push/send` endpoint lets any authorized caller send an arbitrary push to all registered devices. The companion `push-notify-user` skill teaches the agent how to discover the dashboard URL and call this endpoint. + +**Safety guards**: `/api/push/send` enforces 2/min rate limit per caller, validates `title` ≤200 chars and `body` ≤500 chars, validates `url` as relative path only, and audit-logs every send. These prevent the endpoint from becoming a spam vector while keeping it useful for agents. + +**Coalescing bypass**: `/api/push/send` intentionally bypasses the automatic coalescing window — if the user explicitly asked for a push, it should arrive immediately, even if an automatic push fired 5 seconds ago. + +**Skill design**: the `push-notify-user` skill auto-detects the dashboard URL, reads the auth secret from `~/.pi/dashboard/config.json`, and handles all failure modes: unreachable (connection refused), auth failure (401), push disabled (404), no devices (200 empty results), rate limited (429). + +## Risks / Trade-offs + +- **Web Push payload size limit (4KB)**. Title + body + url + sessionId fits comfortably. Risk if we ever want richer payloads. +- **iOS Safari Web Push** requires the user to install the PWA to the home screen. Documented behavior; we surface a hint in the Settings UI for iOS users ("install to home screen first"). +- **VAPID contact email is required by spec**. If `config.push.webPush.contactEmail` is missing while Web Push is enabled, server logs a clear error and disables Web Push. Documented in design + surfaced in `/api/health.push.errors`. +- **Test endpoint `/api/push/test` could be abused** to spam a user. Auth-gated and rate-limited by the existing auth-plugin chain. Acceptable for v1 single-user audience. +- **Coalescing window of 30s could miss a user**. If two trigger events fire within 30s, the user sees one push, not two. This is a feature, not a bug. Configurable per deployment. + +## Migration Plan + +This is purely additive: + +1. Land server-side dispatcher + REST routes + config schema. Default `enabled: false` means no behavior change for existing deployments. +2. Land client-side `usePushSubscription` + `sw.js` push handler + Settings UI. With server `enabled: false`, the UI shows "Push not enabled on this server" and the hook no-ops. +3. User opts in via config (or a follow-up "enable push" button in Settings if we want UX polish — out of scope for v1). +4. User clicks "Enable on this device" in Settings → browser prompt → token registered. +5. (Optional) The `push-notify-user` skill lets agents send on-demand pushes via `POST /api/push/send`. + +No data migration. No breaking change. Existing unread-stripes behavior is untouched. diff --git a/openspec/changes/archive/2026-05-04-add-server-push-notifications/proposal.md b/openspec/changes/archive/2026-05-04-add-server-push-notifications/proposal.md new file mode 100644 index 000000000..cbe911fe0 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-server-push-notifications/proposal.md @@ -0,0 +1,61 @@ +## Why + +The dashboard already has a server-side classifier — `isUnreadTrigger(eventType, before, after, payload)` in `packages/server/src/event-status-extraction.ts:209` — that detects when an agent waits for input (`currentTool → "ask_user"`) or crashes (`agent_end` with truthy error). Today this classifier flips a per-session `unread` bit and broadcasts `session_updated` to *connected* browsers (see `event-wiring.ts:181-201`). Disconnected, backgrounded, or mobile users learn nothing. + +Push notifications close that gap for the two events that genuinely require user attention: `ask_user` (agent needs input) and `agent_end` error (agent crashed). A dedicated `isPushTrigger` predicate — distinct from `isUnreadTrigger` — ensures only these two events trigger pushes. Routine turn completion (`streaming→idle`) is deliberately excluded: without a "read" concept in the dashboard, auto-pushing every turn would spam users. Unread stripes are ephemeral; push notifications are disruptive and persist in the OS notification center. + +This change ships value to the existing PWA via the W3C Web Push spec (Chrome / Edge / Firefox / Safari 16+ on iOS). No native app required. + +## What Changes + +- **NEW** `packages/server/src/push/` module with three files: + - `push-token-registry.ts` — persists push tokens to `~/.pi/dashboard/push-tokens.json` with `0600` permissions via atomic write. Each token: `{id, deviceToken: {endpoint, keys: {p256dh, auth}}, transport: "web-push", userId?, registeredAt, lastUsedAt}`. Uniqueness by `deviceToken.endpoint`. Validates HTTPS endpoint + non-empty keys on register. + - `push-dispatcher.ts` — `fanout(sessionId, sessionAfter, event): void` for automatic triggers (coalescing, fire-and-forget). `sendNow(payload): Promise` for REST endpoints (no coalescing, per-token results). Accepts `transports: Map`. Per-send 10s timeout via `AbortController` passed to transport. + - `isPushTrigger(eventType, before, after, payload)` — pure function in `event-status-extraction.ts`. Matches `ask_user` **transition** and `agent_end` error. Distinct from `isUnreadTrigger`. + - `push-transports/web-push.ts` — Web Push adapter implementing `PushTransport`. Interface: `kind: string` (extensible), `send(token, payload, opts?: {signal?: AbortSignal})`. Respects `signal` for request cancellation. +- **NEW** REST routes (6 endpoints, auth-gated, per-endpoint rate limits): + - `POST /api/push/register` — body `{deviceToken: PushSubscriptionJSON, transport?}` → `200 {tokenId, registered: true}`. 10/min. Validates HTTPS endpoint + non-empty keys. + - `DELETE /api/push/register/:tokenId` — unregister. 10/min. + - `GET /api/push/tokens` — list devices with safe metadata: `{id, transport, endpointLast4, registeredAt, lastUsedAt}`. No full endpoint, no keys. 30/min. + - `POST /api/push/test` — test push via `sendNow`. 5/min. + - `POST /api/push/send` — agent push via `sendNow`. Body `{title: ≤200, body: ≤500, url?: "/..."}`. URL validated: single `/`, rejects `//`, same-origin check. 2/min. Audit-logged. + - `GET /api/push/vapid-public-key` — VAPID public key. 30/min. +- **NEW** config block in `~/.pi/dashboard/config.json` schema (`packages/shared/src/config.ts`): + ```ts + push?: { + enabled: boolean; // default false (must be opted in) + coalesceWindowMs: number; // default 30_000, range 5_000–300_000 + webPush?: { + contactEmail: string; // required by VAPID spec for `mailto:` subject + }; + } + ``` + Validator with clamping in the same shape as `parseOpenSpecPollConfig`. +- **MODIFY** `packages/server/src/event-wiring.ts` — add `isPushTrigger` evaluation co-located with `isUnreadTrigger`. Gating: not replay AND `!viewedSessionTracker.isViewedByAnyone(sessionId, {staleMs: 60_000})`. Stale-view TTL prevents background tabs and sleeping laptops from suppressing push indefinitely. One line: `pushDispatcher?.fanout(sessionId, sessionAfter, event)`. +- **NEW** `packages/client/src/hooks/usePushSubscription.ts` — `subscribe()` fetches VAPID public key, base64url-decodes to `Uint8Array`, calls `pushManager.subscribe({userVisibleOnly: true, applicationServerKey})`. On mount: reconcile existing subscription, POST to `/api/push/register`, store `tokenId`. `unsubscribe()`: both `PushSubscription.unsubscribe()` and `DELETE` server token. +- **MODIFY** `public/sw.js` — push/notificationclick handlers with fallbacks. Click handler uses exact pathname matching (`new URL(client.url).pathname === urlPath`), not substring `includes`, to avoid matching `/session/abc` against `/session/abcd`. +- **NEW** Settings UI section `packages/client/src/components/PushNotificationsSection.tsx` — status, subscribe/unsubscribe, list of registered devices (via `GET /api/push/tokens`), Send Test, Unregister. iOS hint when `iOS && !standalone`. +- **NEW** repo-level lint test `packages/server/src/__tests__/push-dispatcher-fire-and-forget.test.ts` — fails the build if `push-dispatcher.fanout(...)` is ever `await`ed at the call site in `event-wiring.ts`. Push must be fire-and-forget; awaiting it would couple push service latency to the event pipeline. +- **MODIFY** `packages/server/src/routes/system-routes.ts` — `/api/health` includes `push?: {errors: string[]}` when `push.enabled: true` and errors present (e.g. missing contactEmail). +- **NEW** `packages/client/src/__tests__/sw-push.test.ts` — 7 scenarios: valid JSON, malformed, empty, click with/without URL, focus existing window (exact pathname), non-secure context. +- **NEW** pi skill `.pi/skills/push-notify-user/SKILL.md` — agent calls `POST /api/push/send`. Auth via `Authorization: Bearer ` (loopback). Handles: unreachable, 401, 404, 200-empty, 429. **Also bundled with the bridge extension** (`packages/extension/.pi/skills/push-notify-user/`) so `pi install @blackbelt-technology/pi-dashboard-extension` auto-installs the skill. +- **DOCUMENTATION** — update `docs/architecture.md` with a new "Push notifications" section covering: the trigger contract (same as unread-stripes), the coalescing rule, the per-token persistence shape, and the Web Push setup (VAPID keypair generation, `contactEmail` requirement). Add a one-line entry for each new file in `AGENTS.md`'s Key Files table. + +## Capabilities + +### New Capabilities + +- `push-notifications` — server-side fan-out of two agent-trigger events (`ask_user`, `agent_end`-error) to registered devices via Web Push, with per-(session,device) coalescing, opt-in config, a REST API for device registration/test/send, and a pi skill (`push-notify-user`) for on-demand push from agents. Routine turn completion (`streaming→idle`) is deliberately excluded — without a read/ack concept, auto-pushing every turn would spam users. + +### Modified Capabilities + +- `event-wiring` — adds `isPushTrigger` evaluation at the same site as `isUnreadTrigger`. Gating: not replay, viewed ≤60s stale TTL. `pushDispatcher` receives `sessionAfter` for payload building. Optional `PushDispatcher` in `EventWiringDeps`. + +## Out of Scope + +- **Capacitor / native APK / iOS .ipa packaging** — out of scope. This change focuses purely on the existing PWA via Web Push. +- **Per-event-type push opt-in** (e.g. "push me on `ask_user` but not on `agent_end`-error"). v1 ships all-or-nothing per device. Granularity can be added via `sessionFilter` extension in a follow-up if real demand surfaces. +- **Quiet hours / DND scheduling** — out of scope; OS-level Do Not Disturb is the right layer for this. +- **Push payload encryption at rest** — Web Push is end-to-end encrypted by spec. No HIPAA/PII data is in the payload (just session id + status + truncated message). +- **Rate limiting at the REST layer** — per-endpoint limits; `/api/push/send` enforces 2/min, validates url as single-leading-slash with same-origin check, caps title/body, audit-logs. +- **Multi-user push routing** — the `userId` field is recorded on the token but v1 fans out to *every* registered token (single-user dashboard assumption). Multi-user filtering is a follow-up. diff --git a/openspec/changes/archive/2026-05-04-add-server-push-notifications/specs/event-wiring/spec.md b/openspec/changes/archive/2026-05-04-add-server-push-notifications/specs/event-wiring/spec.md new file mode 100644 index 000000000..55f02eae3 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-server-push-notifications/specs/event-wiring/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Co-located push and unread evaluation with separate predicates + +The event evaluation block in `event-wiring.ts` SHALL be the single site where both unread and push decisions are made. Two separate `if` blocks SHALL evaluate `isUnreadTrigger(...)` (unread broadcast) and `isPushTrigger(...)` (push fan-out). Both share the same replay gate; push additionally uses 60s stale-view TTL. + +#### Scenario: Push-worthy event → push dispatched +- **WHEN** `isPushTrigger(...)` returns true AND `!viewedSessionTracker.isViewedByAnyone(sessionId, {staleMs: 60_000})` AND not replay +- **THEN** `pushDispatcher?.fanout(sessionId, sessionAfter, event)` called + +#### Scenario: Unread-only event (streaming→idle) → unread broadcast, no push +- **WHEN** `isUnreadTrigger(...)` true but `isPushTrigger(...)` false +- **THEN** unread bit set and broadcast; push NOT called + +#### Scenario: Neither predicate matches → nothing +- **WHEN** both false +- **THEN** no unread broadcast, no push + +#### Scenario: Viewed within 60s → push suppressed +- **WHEN** `isPushTrigger` matches AND `viewedSessionTracker` shows last view ≤60s ago +- **THEN** `fanout` NOT called + +#### Scenario: Viewed >60s ago → push fires +- **WHEN** `isPushTrigger` matches AND last view >60s ago +- **THEN** `fanout` called + +### Requirement: Optional push dispatcher dependency with session metadata + +`EventWiringDeps` SHALL accept `pushDispatcher?: PushDispatcher`. When undefined, behavior SHALL be identical to pre-push code. `fanout` receives `sessionAfter` (from `sessionManager`) alongside `sessionId` and `event`. `viewedSessionTracker.isViewedByAnyone` SHALL support `{staleMs?: number}` option. + +#### Scenario: Dispatcher absent +- **WHEN** `wireEvents(...)` without `pushDispatcher` +- **THEN** all event flow identical to pre-change code; no errors + +#### Scenario: Dispatcher present +- **WHEN** `wireEvents(...)` with `pushDispatcher` +- **THEN** dispatcher invoked under push gating with `sessionAfter` passed diff --git a/openspec/changes/archive/2026-05-04-add-server-push-notifications/specs/push-notifications/spec.md b/openspec/changes/archive/2026-05-04-add-server-push-notifications/specs/push-notifications/spec.md new file mode 100644 index 000000000..8db3df6c3 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-server-push-notifications/specs/push-notifications/spec.md @@ -0,0 +1,352 @@ +## ADDED Requirements + +### Requirement: Push trigger predicate (separate from unread) + +The dashboard server SHALL use a dedicated `isPushTrigger` predicate — distinct from `isUnreadTrigger` — that matches only events requiring user attention: `ask_user` (agent needs input) and `agent_end` with truthy `payload.error` (agent crashed). Routine `streaming→idle` transitions SHALL NOT trigger pushes. + +The `ask_user` trigger is **transition-based**: it fires when `currentTool` changes to `"ask_user"` from a non-`"ask_user"` value. A repeated question while `currentTool` is already `"ask_user"` SHALL NOT fire an additional push — coalescing already covers the case where the user hasn't responded yet. + +**Rationale**: auto-pushing on every turn completion would spam users. There is no "read" concept in the dashboard — unread stripes are ephemeral, while push notifications are disruptive and persist in the OS notification center. + +**Gating** (push is suppressed when): +- A browser has viewed the session within the last 60 seconds (`viewedSessionTracker.isViewedByAnyone(sessionId, {staleMs: 60_000})` returns true) +- Event is a replay (historical re-emission) +- Both gates SHALL be evaluated at the same call site in `event-wiring.ts`, co-located with the unread-stripes evaluation + +#### Scenario: Agent waits for user input → push fired +- **WHEN** `currentTool` transitions to `"ask_user"` AND no browser has viewed in the last 60s AND event is not a replay +- **THEN** `pushDispatcher.fanout(sessionId, sessionAfter, event)` SHALL be called exactly once + +#### Scenario: Agent crashes → push fired +- **WHEN** an `agent_end` event arrives with truthy `payload.error` under the same gating +- **THEN** `fanout(sessionId, sessionAfter, event)` SHALL be called exactly once + +#### Scenario: Agent finishes a turn → NO push +- **WHEN** a session transitions from `streaming` to `idle` +- **THEN** `fanout` SHALL NOT be called + +#### Scenario: Browser viewed within 60s → push suppressed +- **WHEN** a push trigger fires AND `viewedSessionTracker.isViewedByAnyone(sessionId, {staleMs: 60_000})` returns true +- **THEN** `fanout` SHALL NOT be called + +#### Scenario: Browser last viewed >60s ago → push fires +- **WHEN** a push trigger fires AND last view was >60s ago +- **THEN** `fanout` SHALL be called + +#### Scenario: Replay event → no push +- **WHEN** a replay-flagged event matches a push trigger +- **THEN** `fanout` SHALL NOT be called + +#### Scenario: Non-push-worthy unread trigger → only unread broadcast +- **WHEN** `isUnreadTrigger` matches but `isPushTrigger` does not (e.g. `streaming→idle`) +- **THEN** the unread broadcast SHALL fire normally; `pushDispatcher` SHALL NOT be called + +### Requirement: Fire-and-forget dispatch with safety wrapping + +The push dispatcher's `fanout` method SHALL be `void`-returning at the type level and SHALL NOT throw under any input. The `fanout` body SHALL be wrapped in `try/catch`. Async work launched internally SHALL have an attached `.catch(log)` — transport rejections and timeouts SHALL resolve to `{tokenId, ok: false}` rather than rejecting. No unhandled promise rejections SHALL escape. + +A separate `sendNow(payload, opts?: {tokenIds?: string[]}): Promise` method SHALL be provided for REST endpoints. `sendNow` SHALL NOT apply coalescing. When `opts.tokenIds` is provided, SHALL send only to matching tokens; when omitted, SHALL send to all. Timeout SHALL be enforced at dispatcher level via `Promise.race` — if a transport ignores `AbortSignal`, the dispatcher SHALL still settle within 10s. + +`SendResult` type: `{tokenId: string, ok: boolean, gone?: boolean}`. Both `/api/push/test` and `/api/push/send` SHALL return this shape. + +Per-send HTTP requests SHALL have a 10s timeout via `AbortController`. + +#### Scenario: Transport POST hangs +- **WHEN** a Web Push POST does not resolve within 10s +- **THEN** the send SHALL abort; event-forwarding latency unaffected (within 10 ms of baseline) + +#### Scenario: Registry read throws synchronously +- **WHEN** `push-token-registry` throws (e.g. corrupted file) +- **THEN** `fanout` SHALL catch the error, log it, and return + +#### Scenario: Payload builder throws synchronously +- **WHEN** `buildPushPayload` throws (e.g. missing session data) +- **THEN** `fanout` SHALL catch the error, log it, and return + +#### Scenario: Lint enforcement +- **WHEN** the test suite runs +- **THEN** a lint test SHALL fail if `event-wiring.ts` contains `await pushDispatcher.fanout` + +### Requirement: Per-(session, device) coalescing + +The dispatcher SHALL coalesce to at most one push per (sessionId, deviceToken) per `coalesceWindowMs` (default 30 000 ms, configurable 5 000–300 000 ms). This applies to `fanout` only; `sendNow` bypasses coalescing. + +#### Scenario: Five rapid triggers within 10 s +- **WHEN** five push-trigger events fire for the same session within 10 s, one device +- **THEN** one push delivered + +#### Scenario: Two devices, one trigger +- **WHEN** one trigger fires, two devices registered +- **THEN** each device receives one push + +#### Scenario: Two sessions, one device +- **WHEN** triggers fire for session A then session B within 10 s, one device +- **THEN** two pushes delivered + +#### Scenario: After window closes +- **WHEN** trigger at t=0, another at t=31s (window=30s), one device +- **THEN** two pushes delivered + +### Requirement: Token persistence and lifecycle + +Push tokens SHALL be persisted to `~/.pi/dashboard/push-tokens.json` via atomic writes (tmp+rename) with `0600` permissions. Each token: + +```ts +{ + id: string; + deviceToken: PushSubscriptionJSON; // {endpoint, keys: {p256dh, auth}} — full Web Push subscription + transport: string; // "web-push" for v1 + userId?: string; + registeredAt: string; + lastUsedAt: string; +} +``` + +Uniqueness by `deviceToken.endpoint`. Re-registration SHALL update existing entry. + +**Endpoint validation on register**: `endpoint` SHALL be an HTTPS URL. `keys.p256dh` and `keys.auth` SHALL be non-empty base64url strings. Invalid tokens SHALL be rejected with `400`. + +#### Scenario: Server restart preserves tokens +- **WHEN** token registered, server restarts +- **THEN** token still present + +#### Scenario: Idempotent registration by endpoint +- **WHEN** same `deviceToken.endpoint` registered twice +- **THEN** one entry with updated `lastUsedAt` + +#### Scenario: Register with non-HTTPS endpoint → rejected +- **WHEN** `deviceToken.endpoint` is `http://...` +- **THEN** `400 Bad Request` + +#### Scenario: Register with missing keys → rejected +- **WHEN** `deviceToken.keys` is empty or malformed +- **THEN** `400 Bad Request` + +#### Scenario: Dead-token pruning +- **WHEN** transport returns `{ok: false, gone: true}` +- **THEN** token removed from registry and persistence + +### Requirement: Web Push transport with extensible interface + +Transport SHALL implement: + +```ts +interface PushTransport { + kind: string; // "web-push" for v1; extensible + send(token: PushToken, payload: PushPayload, opts?: { signal?: AbortSignal }): Promise<{ok: boolean; gone?: boolean}>; +} +``` + +The dispatcher SHALL accept `transports: Map` keyed by `kind` so it can route by `token.transport` and skip unknown kinds. + +#### Scenario: Web Push transport sends successfully +- **WHEN** token with `transport: "web-push"` dispatched +- **THEN** transport's `send` called with token, payload, and `signal`; 201 → `{ok: true}` + +#### Scenario: Unknown transport → skipped +- **WHEN** token has unrecognized `transport` +- **THEN** token skipped with logged warning; no crash + +### Requirement: VAPID key lifecycle + +Server SHALL generate VAPID keypair on first start with `push.enabled: true`, persist to `~/.pi/dashboard/push-vapid.json` with `0600` permissions. Keypair reused across restarts. Public key exposed via `GET /api/push/vapid-public-key`. + +#### Scenario: Keypair generated once +- **WHEN** first start with `push.enabled: true` +- **THEN** `push-vapid.json` created with owner-only permissions + +#### Scenario: Keypair reused +- **WHEN** restart with file present +- **THEN** existing keypair loaded; no regeneration + +#### Scenario: Public key endpoint +- **WHEN** `GET /api/push/vapid-public-key` (authenticated) +- **THEN** `200 {publicKey: ""}` + +### Requirement: Push REST API (6 endpoints, auth-gated, rate-limited) + +All endpoints SHALL participate in existing auth chain. Rate limits SHALL apply per caller. + +| Method | Path | Body | Response | Rate | +|--------|------|------|----------|------| +| `POST` | `/api/push/register` | `{deviceToken: PushSubscriptionJSON, transport?}` | `200 {tokenId, registered: true}` | 10/min | + +Register SHALL default `transport` to `"web-push"` when omitted. Non-`"web-push"` transport values SHALL be rejected with `400` in v1. `deviceToken.endpoint` SHALL be HTTPS. `deviceToken.keys.p256dh` SHALL decode to 65 bytes (uncompressed P-256 key) and `keys.auth` SHALL decode to 16 bytes; invalid lengths SHALL be rejected with `400`. +| `DELETE` | `/api/push/register/:tokenId` | — | `204` | 10/min | +| `GET` | `/api/push/tokens` | — | `200 {tokens: [{id, transport, endpointLast4, registeredAt, lastUsedAt}]}` — no full endpoint or keys | 30/min | +| `POST` | `/api/push/test` | `{tokenId?}` | `200 {results: [{tokenId, ok, gone?}]}` — uses `sendNow`, filters to `tokenId` | 5/min | +| `POST` | `/api/push/send` | `{title: ≤200, body: ≤500, url?: "/..."}` | `200 {results: [{tokenId, ok, gone?}]}` — uses `sendNow` to all | 2/min | +| `GET` | `/api/push/vapid-public-key` | — | `200 {publicKey}` | 30/min | + +`/api/push/send` URL validation: SHALL start with exactly one `/` (not `//`), SHALL pass `new URL(url, "https://localhost")` same-origin check, SHALL reject `\\` and encoded protocol tricks. Audit-logged. + +#### Scenario: Unauthenticated → 401 +- **WHEN** any push endpoint without valid auth from non-loopback non-trusted host +- **THEN** `401` + +#### Scenario: `/api/push/send` with `//evil.com` → rejected +- **WHEN** `url: "//evil.com/phish"` +- **THEN** `400 Bad Request` + +#### Scenario: `/api/push/send` oversized → rejected +- **WHEN** title >200 or body >500 +- **THEN** `400 Bad Request` + +#### Scenario: Rate limit → 429 +- **WHEN** third `/api/push/send` within 60s +- **THEN** `429 Too Many Requests` + +#### Scenario: `GET /api/push/tokens` returns safe shape +- **WHEN** called +- **THEN** each token has `{id, transport, endpointLast4, registeredAt, lastUsedAt}`; NO full endpoint, NO keys + +### Requirement: Opt-in by default with normalized config + +Config normalization SHALL work as follows: +- No `push` block → `{enabled: false}` +- `push.enabled: false` → no dispatcher, no routes, no VAPID → `/api/push/*` returns `404` +- `push.enabled: true` with missing `webPush.contactEmail` → `push.errors: ["missing contactEmail"]`. Server SHALL NOT mount push routes, SHALL NOT construct dispatcher or transport. `/api/push/*` SHALL return `503 {"error": "push_misconfigured", "details": "missing contactEmail"}`. `/api/health` SHALL include `push.errors`. + +#### Scenario: Misconfigured → 503 on all push endpoints +- **WHEN** `push.enabled: true` but `contactEmail` missing +- **THEN** all `/api/push/*` endpoints SHALL return `503` with `{error: "push_misconfigured", details}` +- **AND** `/api/health` SHALL include `push: {errors: ["missing contactEmail"]}` + +#### Scenario: Fresh config without push block +- **WHEN** no `push` key in config +- **THEN** `config.push.enabled === false`; no side-effects + +#### Scenario: Push enabled but no contactEmail +- **WHEN** `push.enabled: true`, no `contactEmail` +- **THEN** transport not initialized; `GET /api/health` includes `push.errors: ["missing contactEmail"]` + +#### Scenario: Disabled → 404 +- **WHEN** `push.enabled !== true`, any `/api/push/*` +- **THEN** `404` + +### Requirement: Service worker push handler + +`public/sw.js` SHALL handle `push` events (parse JSON, show notification with fallbacks) and `notificationclick` (navigate or focus existing window). Notification click SHALL use exact pathname matching: `new URL(client.url).pathname === urlPath`, not substring `includes`. + +#### Scenario: Valid JSON payload +- **WHEN** push event with `{title, body, url, sessionId}` +- **THEN** `showNotification` called with title, body, icon, badge, `data: {url, sessionId}` + +#### Scenario: Malformed JSON → fallback +- **WHEN** `event.data.json()` throws +- **THEN** `showNotification` with title "Pi Dashboard", body from `event.data.text()` or "New activity" + +#### Scenario: Empty push (no data) +- **WHEN** `event.data` is null +- **THEN** `showNotification` with "Pi Dashboard" / "New activity" + +#### Scenario: Notification click navigates to URL +- **WHEN** `notificationclick` with `notification.data.url = "/session/abc"` +- **THEN** `clients.openWindow("/session/abc")` — using exact pathname match + +#### Scenario: Click focuses existing window at same pathname +- **WHEN** a client window already has `pathname === "/session/abc"` +- **THEN** that window SHALL be focused; no new window opened + +#### Scenario: Click with no URL → dashboard root +- **WHEN** `notification.data.url` is undefined +- **THEN** `clients.openWindow("/")` + +### Requirement: Client subscription hook with VAPID key and token reconciliation + +`usePushSubscription` hook SHALL expose `{supported, status, subscribe(), unsubscribe(), sendTest()}`. + +`subscribe()` flow: +1. Request notification permission +2. `GET /api/push/vapid-public-key` → decode base64url public key to `Uint8Array` +3. `swReg.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: uint8Key})` +4. `POST /api/push/register` with full subscription → store returned `tokenId` + +On mount: reconcile — check existing `swReg.pushManager.getSubscription()`, if present POST to `/api/push/register`, store `tokenId`. + +`unsubscribe()`: call `PushSubscription.unsubscribe()` AND `DELETE /api/push/register/:tokenId`. + +#### Scenario: Subscribe with VAPID key +- **WHEN** user triggers `subscribe()` +- **THEN** hook SHALL fetch VAPID public key, decode to `Uint8Array`, pass as `applicationServerKey` +- **AND** status SHALL be `'subscribed'` + +#### Scenario: Mount with existing subscription → reconcile +- **WHEN** mount detects existing `PushSubscription` +- **THEN** hook SHALL POST to `/api/push/register` and store `tokenId` + +#### Scenario: Unsubscribe cleans both sides +- **WHEN** `unsubscribe()` called +- **THEN** browser subscription SHALL be cancelled AND server token SHALL be deleted + +#### Scenario: Permission denied +- **WHEN** `NotAllowedError` on subscribe +- **THEN** status SHALL be `'denied'` + +#### Scenario: Non-secure context +- **WHEN** `PushManager` unavailable (HTTP origin) +- **THEN** `supported: false`; no crash + +### Requirement: On-demand push endpoint with safety guards + +`POST /api/push/send` SHALL accept `{title: ≤200, body: ≤500, url?: "/..."}`. Uses `sendNow` (bypasses coalescing, returns per-token results). Enforces 2/min rate limit. URL validated: single leading `/`, not `//`, same-origin. Audit-logged. + +#### Scenario: Send push to all devices +- **WHEN** `{title: "Done", body: "Refactoring complete"}` +- **THEN** all devices receive push; `200 {results: [...]}` + +#### Scenario: No devices → empty results +- **WHEN** no tokens registered +- **THEN** `200 {results: []}` + +#### Scenario: Coalescing bypass +- **WHEN** auto-trigger push at t=0, `/api/push/send` at t=5s +- **THEN** both delivered + +#### Scenario: URL with double-slash → rejected +- **WHEN** `url: "//evil.com/phish"` +- **THEN** `400` + +#### Scenario: Rate limit → 429 +- **WHEN** third call within 60s +- **THEN** `429` + +### Requirement: Push-notify-user skill with error handling + +`.pi/skills/push-notify-user/SKILL.md` teaches agents to call `POST /api/push/send`. The skill SHALL also be bundled with the bridge extension at `packages/extension/.pi/skills/push-notify-user/` so that `pi install @blackbelt-technology/pi-dashboard-extension` automatically installs the skill. Authentication: the skill SHALL work via loopback (agent runs on same machine as dashboard). The skill SHALL read `auth.secret` from `~/.pi/dashboard/config.json` (nested under `auth` key) and pass it as `Authorization: Bearer ` header. The auth-plugin SHALL be extended to validate `Authorization: Bearer ` before cookie/JWT validation. Handle all failure modes. + +#### Scenario: Successful push +- **WHEN** agent invokes skill, endpoint returns 200 +- **THEN** agent reports "Push sent" + +#### Scenario: Dashboard unreachable +- **WHEN** dashboard not running +- **THEN** agent reports "Dashboard not reachable — push not sent" + +#### Scenario: Auth failure → 401 +- **WHEN** endpoint returns 401 +- **THEN** agent reports "Auth failed — check dashboard config" + +#### Scenario: Push disabled → 404 +- **WHEN** endpoint returns 404 +- **THEN** agent reports "Push notifications not enabled on this server" + +#### Scenario: No devices → empty results +- **WHEN** `200 {results: []}` +- **THEN** agent reports "No devices registered for push notifications" + +#### Scenario: Rate limited → 429 +- **WHEN** endpoint returns 429 +- **THEN** agent reports "Rate limited — wait before sending another push" + +### Requirement: Service worker unit tests + +`packages/client/src/__tests__/sw-push.test.ts` SHALL cover 7 scenarios. + +#### Scenario: Valid JSON → correct showNotification +#### Scenario: Malformed JSON → fallback +#### Scenario: Empty push → defaults +#### Scenario: Click with URL → openWindow +#### Scenario: Click without URL → openWindow("/") +#### Scenario: Click focuses existing window at same pathname (exact match, not substring) +#### Scenario: Non-secure context → supported: false diff --git a/openspec/changes/archive/2026-05-04-add-server-push-notifications/tasks.md b/openspec/changes/archive/2026-05-04-add-server-push-notifications/tasks.md new file mode 100644 index 000000000..3238843b8 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-server-push-notifications/tasks.md @@ -0,0 +1,122 @@ +# Tasks + +## 1. Preconditions + +- [x] 1.1 Read `packages/server/src/event-wiring.ts` — confirm evaluation site for co-locating separate `isPushTrigger` and `isUnreadTrigger` blocks. +- [x] 1.2 Read `packages/server/src/event-status-extraction.ts:209` (`isUnreadTrigger`) and `packages/server/src/viewed-session-tracker.ts` — confirm trigger/gating semantics. +- [x] 1.3 Read `packages/shared/src/config.ts::parseOpenSpecPollConfig` — confirm validator/clamping pattern. +- [x] 1.4 Read `packages/server/src/json-store.ts` — confirm atomic-write API. +- [x] 1.5 Read `packages/server/src/auth-plugin.ts` — confirm route auth chain. +- [x] 1.6 Read `packages/server/src/routes/system-routes.ts:190-205` — confirm `/api/health` response shape for adding `push.errors`. +- [x] 1.7 Run `npm test 2>&1 | tee /tmp/push-baseline.log`. + +## 2. Push trigger predicate + +- [x] 2.1 Add `isPushTrigger(eventType, before, after, payload): boolean` to `packages/server/src/event-status-extraction.ts`. Matches `ask_user` **transition** (currentTool changes TO "ask_user" from non-"ask_user") and `agent_end` with truthy error. Does NOT match `streaming→idle`. +- [x] 2.2 Unit tests in `packages/server/src/__tests__/push-trigger.test.ts`: ask_user transition fires, agent_end-error fires, streaming→idle does NOT fire, repeated ask_user while already "ask_user" does NOT fire (transition-based). + +## 3. Config schema + +- [x] 3.1 Extend `DashboardConfig` with `push?: {enabled: boolean, coalesceWindowMs: number, webPush?: {contactEmail: string}, errors?: string[]}`. Normalize: no block → `{enabled: false}`. +- [x] 3.2 `parsePushConfig(raw)`: clamp `coalesceWindowMs` 5_000–300_000 (default 30_000). When `enabled: true` and no `contactEmail` → set `errors: ["missing contactEmail"]`. +- [x] 3.3 Wire into `loadConfig()`. When `push.errors` is non-empty → `/api/health` includes `push: {errors}`. Transport disabled. +- [x] 3.4 Unit tests in `packages/shared/src/__tests__/config-push.test.ts`. + +## 4. Token registry + +- [x] 4.1 Create `packages/server/src/push/push-token-registry.ts`. Token: `{id, deviceToken: {endpoint, keys: {p256dh, auth}}, transport: string, userId?, registeredAt, lastUsedAt}`. Uniqueness by `deviceToken.endpoint`. Persist to `~/.pi/dashboard/push-tokens.json` with `0600` via `json-store.ts`. +- [x] 4.2 `createPushTokenRegistry({path})` returning `{add(token), remove(id), list(), findByEndpoint(endpoint), touch(id)}`. +- [x] 4.3 On `add`: validate `deviceToken.endpoint` is HTTPS URL, `keys.p256dh`/`keys.auth` are non-empty base64url strings. Reject malformed with Error. +- [x] 4.4 Unit tests: add/remove/list, persistence round-trip, idempotent, HTTPS-only, key length validation, transport rejection, 0600 permissions. +- [x] 4.5 Extend `json-store.ts` `writeJsonFile` with optional `{mode?: number}` parameter. Use for push tokens and VAPID keys to ensure `0600`. Chmod existing files on first write if permissions too open. + +## 5. Push transport + +- [x] 5.1 `packages/server/src/push/push-transports/types.ts`: `interface PushTransport { kind: string; send(token: PushToken, payload: PushPayload, opts?: {signal?: AbortSignal}): Promise<{ok: boolean; gone?: boolean}> }`. +- [x] 5.2 Add `web-push` to `packages/server/package.json`. +- [x] 5.3 `packages/server/src/push/push-transports/web-push.ts`: `createWebPushTransport({vapidKeys, contactEmail})` → `PushTransport`. Respect `opts.signal` for request cancellation. 410 → `{ok: false, gone: true}`. +- [x] 5.4 `packages/server/src/push/push-vapid.ts`: `loadOrGenerateVapidKeys(path): {publicKey, privateKey}`. Persist with `0600`. +- [x] 5.5 Unit tests for vapid persistence (permissions), web-push encoding, abort signal propagation. + +## 6. Dispatcher + +- [x] 6.1 `packages/server/src/push/push-dispatcher.ts`: `createPushDispatcher({transports: Map, registry, coalesceWindowMs})`. +- [x] 6.2 `fanout(sessionId, sessionAfter, event): void` — wrapped in `try/catch`, coalescing applied, routes by `token.transport`, skips unknown transports with warning. Launches async work with attached `.catch(log)` — transport rejections resolve to `{tokenId, ok: false}` rather than rejecting. +- [x] 6.3 `sendNow(payload, opts?: {tokenIds?: string[]}): Promise` — bypasses coalescing, targets specific tokens when `opts.tokenIds` provided. Used by `/api/push/test` (`sendNow(payload, {tokenIds: [tokenId]})`) and `/api/push/send` (`sendNow(payload)` to all). +- [x] 6.4 In-memory coalescing map with lazy expiry. Per-send 10s timeout enforced at dispatcher level via `Promise.race`. Transport interface passes `AbortSignal` as best-effort cancellation. +- [x] 6.5 `buildPushPayload(session, event)` pure helper in `packages/server/src/push/build-push-payload.ts`. +- [x] 6.6 On `{ok: false, gone: true}` → `registry.remove(tokenId)`. On `ok: true` → `registry.touch(tokenId)`. +- [x] 6.7 Unit tests: trigger-to-payload, coalescing, dead-token pruning, fan-out non-throwing, sync registry/payload errors caught, async rejections caught via .catch, timeout, unknown transport skipped, sendNow vs fanout. + +## 7. Wire into event pipeline + +- [x] 7.1 Add `pushDispatcher?: PushDispatcher` to `EventWiringDeps`. +- [x] 7.2 Add separate `if (isPushTrigger(...) && !viewedSessionTracker.isViewedByAnyone(sessionId, {staleMs: 60_000}) && !replay)` block co-located with existing `isUnreadTrigger` block. Inside: `pushDispatcher?.fanout(sessionId, sessionAfter, event)`. +- [x] 7.3 Update `packages/server/src/server.ts` to construct dispatcher only when `push.enabled === true` AND `push.errors` is empty. When `push.errors` non-empty → mount `/api/push/*` with `503` middleware, surface in `/api/health`. +- [x] 7.4 Add `staleMs` option to `viewedSessionTracker.isViewedByAnyone`. +- [x] 7.5 Lint test `packages/server/src/__tests__/push-dispatcher-fire-and-forget.test.ts`: AST-scan for `await pushDispatcher`. +- [x] 7.6 Integration test: `agent_end` error → dispatcher called; `streaming→idle` → dispatcher NOT called; latency unaffected when transport hangs. + +## 8. REST routes + +- [x] 8.1 `packages/server/src/routes/push-routes.ts` — 6 endpoints with per-endpoint rate limits: + - `POST /api/push/register` — `{deviceToken: PushSubscriptionJSON, transport?}` → `200 {tokenId, registered: true}`. 10/min. Default transport to `"web-push"`, reject non-`"web-push"` with `400`. Validate endpoint HTTPS + key lengths (p256dh→65 bytes, auth→16 bytes) → 400. + - `DELETE /api/push/register/:tokenId` → `204`. 10/min. + - `GET /api/push/tokens` → `200 {tokens: [{id, transport, endpointLast4, registeredAt, lastUsedAt}]}`. 30/min. + - `POST /api/push/test` → `200 {results: [{tokenId, ok, gone?}]}`. 5/min. Uses `sendNow(payload, {tokenIds: tokenId ? [tokenId] : undefined})`. + - `POST /api/push/send` → body `{title: ≤200, body: ≤500, url?: "/..."}` → `200 {results: [{tokenId, ok, gone?}]}`. 2/min. Uses `sendNow(payload)` to all. URL: reject `//`, `\\`, encoded protocols; validate `new URL(url, origin).origin === origin`. Audit-log. + - `GET /api/push/vapid-public-key` → `200 {publicKey}`. 30/min. +- [x] 8.2 Auth-gated via existing auth-plugin chain. Add `Authorization: Bearer ` validation to auth-plugin before cookie/JWT check. Skill auth: read `auth.secret` from config, pass as Bearer header, works on loopback and remote. +- [x] 8.3 Unit tests: register with non-HTTPS endpoint → 400, malformed keys → 400, send with `//evil.com` → 400, oversized → 400, rate limit → 429, tokens list shape (no keys), test/send endpoints use sendNow. + +## 9. `/api/health` push integration + +- [x] 9.1 Add `push?: {errors: string[]}` to health response in `packages/server/src/routes/system-routes.ts` (present when `push.enabled: true` and errors non-empty). +- [x] 9.2 Test: health endpoint includes `push.errors` when contactEmail missing. + +## 10. Service worker + tests + +- [x] 10.1 Update `public/sw.js` — push handler with try/catch, null-data fallback. Click handler with exact pathname matching. +- [x] 10.2 Bump SW version comment. +- [x] 10.3 `packages/client/src/__tests__/sw-push.test.ts` — 7 tests: valid JSON, malformed JSON, empty push, click with URL, click without URL, click focuses existing window (exact pathname match), non-secure context. + +## 11. Client subscription hook + Settings UI + +- [x] 11.1 `packages/client/src/hooks/usePushSubscription.ts`: `{supported, status, subscribe(), unsubscribe(), sendTest()}`. +- [x] 11.2 `subscribe()`: request permission → `GET /api/push/vapid-public-key` → base64url-decode to `Uint8Array` → `swReg.pushManager.subscribe({userVisibleOnly: true, applicationServerKey})` → `POST /api/push/register` → store `tokenId`. +- [x] 11.3 On mount: reconcile — check existing `swReg.pushManager.getSubscription()`, if present POST to `/api/push/register`, store `tokenId`. +- [x] 11.4 `unsubscribe()`: `PushSubscription.unsubscribe()` AND `DELETE /api/push/register/:tokenId`. +- [x] 11.5 `supported: false` when not in secure context or `PushManager` unavailable. +- [x] 11.6 `packages/client/src/components/PushNotificationsSection.tsx` — status, subscribe/unsubscribe, device list (via `GET /api/push/tokens`), Send Test, Unregister, iOS hint. +- [x] 11.7 Component tests for all UI states. (17 tests — unsupported, available, subscribed, denied, iOS hint) + +## 12. Push-notify-user skill + +- [x] 12.1 `.pi/skills/push-notify-user/SKILL.md` — teaches agent to call `POST /api/push/send`. +- [x] 12.2 Auth: read `auth.secret` from `~/.pi/dashboard/config.json`, pass as `Authorization: Bearer `. Works via loopback bypass. +- [x] 12.3 Auto-detect dashboard URL from running server. +- [x] 12.4 Handle: unreachable, 401, 404, 200-empty-results, 429. + +## 13. Documentation + +- [x] 13.1 `docs/architecture.md` — "Push notifications" section: `isPushTrigger` vs `isUnreadTrigger`, stale-view TTL, coalescing, token shape, VAPID setup, safety guards, skill auth model. +- [x] 13.2 New rows in `AGENTS.md` Key Files. +- [x] 13.3 New row in `README.md` for `push.*` config keys. + +## 14. Verification + +- [x] 14.1 `npm test` green; no unrelated regressions. (5 pre-existing failures unchanged, +77 new tests all pass) +- [x] 14.2 Manual: enable push, Chrome subscription (with VAPID key), `ask_user` → notification. +- [x] 14.3 Manual: Firefox (Mozilla autopush). +- [x] 14.4 Manual: iOS PWA (Safari 16+, home screen). +- [x] 14.5 Manual: `streaming→idle` does NOT push. +- [x] 14.6 Manual: 5 rapid `ask_user` → 1 push (coalescing). +- [x] 14.7 Manual: background tab open → push fires after 60s (stale-view TTL). +- [x] 14.8 Manual: agent uses `push-notify-user` skill → push arrives. +- [x] 14.9 Manual: `/api/push/send` rate limited → 429. +- [x] 14.10 Manual: `/api/push/send` with `//evil.com` → 400. +- [x] 14.11 Manual: `/api/push/register` with http endpoint → 400. +- [x] 14.12 Manual: `GET /api/push/tokens` returns safe metadata. +- [x] 14.13 Manual: missing contactEmail → `/api/health` includes `push.errors`. +- [x] 14.14 Manual: `push-vapid.json` and `push-tokens.json` have `0600` permissions. +- [x] 14.15 `openspec validate add-server-push-notifications --strict`. diff --git a/openspec/changes/accordion-workspace-folders/.openspec.yaml b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/.openspec.yaml similarity index 50% rename from openspec/changes/accordion-workspace-folders/.openspec.yaml rename to openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/.openspec.yaml index 40c554029..eebe4d86b 100644 --- a/openspec/changes/accordion-workspace-folders/.openspec.yaml +++ b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-03-25 +created: 2026-05-05 diff --git a/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/design.md b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/design.md new file mode 100644 index 000000000..69858c3e3 --- /dev/null +++ b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/design.md @@ -0,0 +1,205 @@ +## Context + +pi-agent-dashboard already has unit/component tests via Vitest, a web client built by Vite, and VM-based QA under `qa/` for install/runtime checks. The existing browser visual-debug skill supports ad hoc screenshot inspection through `pi-agent-browser`, but there is no repeatable iOS Safari screenshot-diff suite. + +The dashboard is a PWA-style web UI served by the dashboard server on port `8000` or by Vite on port `3000` in dev. The client has mobile-specific layout paths (`useMobile`, `MobileShell`, `InstallBanner`) and stable `data-testid` seams on onboarding, settings, and mobile overlay components. Live dashboard state comes from a bridge pi session stream and local dashboard config. The server also reads config from `os.homedir()` and can register bridge settings, poll directories, and expose runtime state. A developer's normal dashboard is therefore too variable for baseline screenshots. + +One important constraint: current bridge session registration does not carry fixed `startedAt`, `endedAt`, or status timestamps. A deterministic visual fixture cannot rely on bridge messages alone for all session-card metadata. + +The proposed suite must target project surfaces instead of a generic `/login` flow, and it must have a deterministic dashboard/test-pi fixture for stable visual state. + +## Goals / Non-Goals + +**Goals:** + +- Add an isolated iOS visual QA package under `qa/ios-visual/`. +- Use Appium 3, a pinned XCUITest driver v10+, WebdriverIO 9, TypeScript, Mocha, and `@wdio/visual-service`. +- Run against local dashboard URLs by default, with environment overrides for remote/tunnel URLs and simulator details. +- Provide a one-command deterministic mode that starts a separate dashboard instance on test ports with isolated `HOME`, config, Appium home, and runtime state. +- Provide a test-only fixture startup/seeding seam for deterministic session metadata that bridge messages cannot carry. +- Provide a test-pi fixture that connects to the test dashboard and replays production-shaped events for chat/tool UI. +- Gate visual tests on SPA availability and seeded-state readiness, not only server process health. +- Stabilize Safari/PWA state before checkpoints: theme, localStorage, service workers/caches, simulator site data, scroll, and animations. +- Cover deterministic dashboard states: seeded session list/detail, root onboarding/landing, `/settings?tab=providers`, and mobile-shell navigation. +- Commit only baseline screenshots; keep current run screenshots, diffs, fixture runtime files, Appium driver cache, and Appium logs out of git. +- Keep iOS visual tests opt-in and Mac-only so normal `npm test`, CI on Ubuntu, and release builds stay unchanged. + +**Non-Goals:** + +- No standalone Add-to-Home-Screen WebClip automation in the first version. +- No auth/login flow baseline; dashboard auth can redirect to provider UI and is not deterministic for visual diff. +- No mutation-heavy flows that create real sessions, restart a developer's server, edit real credentials, kill real processes, or depend on a developer's running pi agent. +- No hosted GitHub Actions macOS simulator job by default; self-hosted Mac runner guidance is enough. +- No production-visible fixture API; any fixture seam must be gated by fixture mode and unavailable in normal dashboard runs. + +## Decisions + +### D1. Place suite under `qa/ios-visual/` + +Use `qa/ios-visual/` instead of `packages/*` or a root `pwa-tests/` folder. + +Rationale: the existing repo groups platform QA under `qa/`; the iOS simulator suite is QA-only and should not become a publishable workspace package. A standalone `package.json` in `qa/ios-visual/` keeps heavy Appium/WebdriverIO dependencies out of the production workspace graph unless installed explicitly. + +Alternative considered: `packages/ios-visual-tests/`. Rejected because `packages/*` is the npm workspace glob and release workflows publish workspaces unless packages are private and carefully excluded. + +### D2. Use project-local Appium, WDIO, and Appium driver home + +Add Appium and WebdriverIO as dev dependencies in `qa/ios-visual/package.json`; run them with `npm --prefix qa/ios-visual` scripts. Set `APPIUM_HOME` to `qa/ios-visual/.tmp/appium-home` (or another documented suite-local path) for driver install, doctor, and runs. Pin the XCUITest driver version in scripts/config instead of relying on a user's global `~/.appium` driver store. + +Rationale: the user plan's global `npm i -g appium` works for one machine, but project-local tooling and a suite-local `APPIUM_HOME` are reproducible. Xcode, simulator runtimes, Homebrew packages, and first WDA build remain machine-level prerequisites. + +Alternative considered: require globally installed Appium and global drivers. Rejected because global versions drift and make failures harder to reproduce. + +### D3. Configure dashboard URL and simulator through env vars + +Default `PI_DASHBOARD_BASE_URL` to `http://127.0.0.1:8000` for manual runs only. In deterministic fixture mode, the launcher must set `PI_DASHBOARD_BASE_URL` to the owned fixture dashboard URL and fail closed if WDIO would target the manual default or any URL that is not the fixture URL. Use `SIM_UDID` when set, else fall back to `IOS_DEVICE_NAME` (default `PWA-Test`) and `IOS_PLATFORM_VERSION` (default documented value). Use the same base URL for `baseUrl` and Safari's initial URL. + +Rationale: dashboard can run in production mode on `8000`, dev mode through server proxy, direct Vite mode on `3000`, a tunnel URL, or a fixture dashboard on test ports. Fixture runs must not silently hit a developer's live dashboard. + +Alternative considered: always start dashboard from the WDIO config. Rejected because developers still need a manual mode for debugging against dev/prod/tunnel targets. + +### D4. Test deterministic UI states only + +Create smoke visual specs that assert stable selectors/text before screenshots: + +- `/` root onboarding or landing page; wait for `Welcome to pi-dashboard`, `Select a session`, or an onboarding test id. +- seeded session list and a seeded session detail view from the fixture state. +- `/settings?tab=providers`; wait for `settings-header` and `settings-content`. +- iPhone-sized mobile shell behavior; verify root/detail routing and safe overlay/menu state without spawning real sessions. + +Rationale: visual baselines must be repeatable. Live sessions, model lists, credentials, auth provider redirects, and timestamps produce noisy diffs. The fixture gives richer UI coverage while keeping state fixed. + +Alternative considered: port the sample login flow from the generic plan. Rejected because the dashboard does not expose a deterministic `/login` form. + +### D5. Make baseline updates explicit + +Set `autoSaveBaseline` from an env var such as `IOS_VISUAL_AUTO_SAVE_BASELINE=1`. Provide a separate `baseline` script for first-run baseline creation/update. Normal test runs compare against existing baselines and fail above an explicit mismatch threshold. + +Rationale: unconditional `autoSaveBaseline: true` can hide regressions by silently accepting changed screenshots after baselines exist. + +Alternative considered: always auto-save baselines. Rejected because it weakens visual regression protection. + +### D6. Document Mac-only prerequisite flow + +Document Xcode license, Homebrew packages, optional `applesimutils`, local Appium driver install, `appium driver doctor xcuitest`, simulator creation, UDID export, dashboard startup, baseline generation, normal run, and cleanup. + +Rationale: most failures in Appium/XCUITest setup come from host prerequisites rather than project code. A repo-local guide reduces guesswork. + +### D7. Start a separate fixture dashboard with isolated HOME and side effects disabled + +Add a fixture launcher under `qa/ios-visual/` that: + +1. creates an isolated runtime directory such as `qa/ios-visual/.tmp/dashboard-home`; +2. spawns dashboard and test-pi processes with `HOME` pointing at that runtime home; +3. writes `~/.pi/dashboard/config.json` under that isolated home with fixture ports and deterministic config; +4. starts the dashboard from the current checkout using explicit `--port` / `--pi-port` flags or `PI_DASHBOARD_PORT` / `PI_DASHBOARD_PI_PORT` env values; +5. enables fixture startup mode through a gated env/config flag such as `PI_DASHBOARD_FIXTURE_MODE=1` or by importing a fixture-only server launcher; +6. disables or neutralizes bootstrap/package install, mDNS advertise/browse, plugin loading/bridge registration, zrok cleanup/tunnel, auth, push, real session spawn, and other non-visual side effects; +7. waits for `/api/health` on the fixture HTTP port; +8. verifies that the SPA is actually served by probing `/` for dashboard HTML or a known selector-bearing document; +9. verifies every resolved fixture path stays under `qa/ios-visual/.tmp` and fails if it would read/mutate the developer's real `~/.pi` state; +10. sets `PI_DASHBOARD_BASE_URL` for WDIO and fails if it differs from the owned fixture URL; +11. shuts down the dashboard and fixture client after the run. + +The fixture state should be defined in TypeScript or JSON under `qa/ios-visual/fixtures/`. Fixture cwd directories should be created under the isolated runtime directory with deterministic minimal contents so OpenSpec/resource polling cannot pull in developer filesystem state. + +Rationale: a separate dashboard avoids polluting the developer's real dashboard config and session state. A fixture startup mode makes side effects testable instead of hoping isolated `HOME` is enough. + +Alternatives considered: + +- Use the developer's live dashboard and ask them to clean state. Rejected because visual diffs would be non-repeatable. +- Mock only browser APIs in the client. Rejected because it skips server/browser WebSocket paths and dashboard boot behavior. +- Launch a real pi agent with fake credentials. Rejected because model/provider/auth/tool state is slower and less deterministic than a fixture. + +### D8. Seed deterministic session metadata through fixture mode, not bridge-only messages + +The fixture needs fixed session IDs, names, cwd paths, statuses, `startedAt`, `endedAt`, model labels, git state, and ordering. Current bridge registration can provide some identity/metadata but not stable started/ended timestamps or ended status. Therefore deterministic session metadata must be seeded through a fixture-only mechanism, such as: + +- direct fixture-mode server initialization that inserts session records into the in-memory session manager before browser replay; +- pre-seeded isolated session metadata/JSONL files that normal startup discovery reads from the fixture `HOME`; +- or another test-only seeding seam that is gated by fixture mode and unavailable in normal server runs. + +The test-pi bridge client should still replay production-shaped event messages for chat/tool rows so the visual tests exercise the normal event reducer path. + +Rationale: session-card relative-time UI drifts if the server uses `Date.now()` for seeded sessions. A bridge-only fixture cannot create fixed ended sessions with stable timestamps. + +### D9. Specify the test-pi replay sequence and readiness gate + +Server `/api/health` proves that the fixture dashboard process is alive; it does not prove that seeded sessions/events have reached browser-facing state. The launcher must therefore wait for a seeded-state readiness check before WDIO starts. + +The replay sequence should be normative: + +1. Start fixture dashboard and fixture seeding seam. +2. Connect test-pi fixture to the fixture pi gateway. +3. Send `session_register` for each seeded active bridge session with stable IDs/cwd/name/model/source and deliberate `eventCount` values. +4. Send deterministic `event_forward` rows for chat/tool display. +5. Send metadata updates such as git/model/process lists only when needed by the seeded UI. +6. Send `replay_complete` for each replayed session. +7. Open a browser-facing REST or WebSocket readiness check. +8. Assert expected session IDs, order, detail rows, replay completion, and fixture sentinel data. +9. Start WDIO only after all assertions pass. + +Rationale: without this gate, first screenshots can capture an empty dashboard, partial replay, timeout fallback, or pending loading state. + +### D10. Stabilize Safari/PWA state before screenshots + +Before each visual run, the suite must reset or isolate Mobile Safari state. Acceptable approaches: erase the `PWA-Test` simulator for baseline runs, clear Safari/site data through simulator commands, or run in a known fresh simulator. The test helper should seed localStorage before checkpoints: + +- `dashboard:theme = "dark"` +- `dashboard:theme-name = "base"` +- `pwa-install-dismissed = "true"` unless a test explicitly covers the install banner + +It should also clear or control service workers/caches, set scroll position, wait for route/network/render idle, and disable/reduce animations where practical. + +Rationale: the dashboard defaults theme to system, registers a service worker, has iOS install-banner behavior, and uses transitions. These are all valid product behavior but noisy for screenshot baselines. + +### D11. Own the web-client serving path + +Fixture mode must not assume `packages/client/dist/` exists. Before WDIO starts, the launcher must either: + +- run/verify `npm run build` so the fixture dashboard can serve production static files; or +- start and own a Vite dev server, then run the fixture dashboard in `--dev` mode. + +Readiness must include an HTML/UI probe for `/` after the chosen serving path is active. + +Rationale: `/api/health` can pass while the SPA is missing, causing Safari to capture an error response rather than the dashboard. + +### D12. Use one default baseline profile first + +The first committed baseline profile is: + +- simulator: `PWA-Test` +- device type: `iPhone 16` +- iOS runtime: `18.2` +- theme: dark/base +- mode: fixture dashboard + +If contributors use a different simulator/runtime/theme, their screenshots should write to a separate baseline profile directory or be treated as local-only. Normal CI/self-hosted runs should use the default profile unless intentionally adding a new profile. + +Rationale: committed visual baselines must have one source of truth. Multiple simulator/runtime/theme combinations can be added later as explicit profiles. + +## Risks / Trade-offs + +- Xcode/Appium setup is fragile across macOS and iOS runtime updates → provide `doctor` and driver reinstall commands in scripts/docs, all using the same suite-local `APPIUM_HOME`. +- Screenshot diffs can be noisy due to font rendering, scroll position, theme, timestamps, and live data → freeze tests to deterministic fixture routes, use stable waits, seed localStorage, and set visual tolerances deliberately. +- Fixture seeding seam can leak into production if not guarded → gate it behind fixture mode, keep it unavailable in normal CLI/server paths, and test that normal startup ignores fixture files/env. +- Fixture bridge protocol can drift from production protocol → type it against shared protocol definitions where possible and include a lightweight non-simulator validation step. +- Fixture dashboard ports can collide with local services → make ports configurable and fail with a clear message. +- Fixture processes can leak on failed tests → use a launcher that owns process lifecycle, signal forwarding, process-group termination, and port cleanup assertions. +- Server startup has bootstrap, mDNS, tunnel, plugin, zrok, auth, and push side effects → fixture mode must disable or verify absence of each one. +- First WDA build is slow → document first-run cost and keep `noReset` configurable. +- Separate package adds another dependency graph → keep it outside default install/test paths and mark generated artifacts ignored. +- iOS Simulator only covers Safari engine behavior, not Android/Chrome or standalone WebClip quirks → keep standalone PWA mode as future work. + +## Migration Plan + +1. Add `qa/ios-visual/` package, WDIO config, TypeScript config, smoke specs, helper scripts, and gitignore rules. +2. Add fixture state definitions, fixture-mode server seeding/startup seam, fixture dashboard launcher, and test-pi bridge client. +3. Add root helper scripts that delegate to `qa/ios-visual` without changing `npm test`. +4. Add initial baselines after verifying against the default simulator/profile and deterministic fixture dashboard. +5. Document setup and run flow in QA docs. +6. Rollback by removing `qa/ios-visual/`, root helper scripts, and the fixture-only startup/seeding seam; no user data migration required. + +## Open Questions + +- Which seeded sessions should be in the first fixture beyond the minimum active + ended session: tool-call rows, terminal/process data, OpenSpec cards, or all of them? diff --git a/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/proposal.md b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/proposal.md new file mode 100644 index 000000000..c59bcda6b --- /dev/null +++ b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/proposal.md @@ -0,0 +1,37 @@ +## Why + +Current automated coverage exercises React units, server paths, and VM install/runtime checks, but it does not render the dashboard in real Mobile Safari. iOS Safari/PWA layout, viewport, service-worker, and touch-navigation regressions can ship unnoticed even though the product targets mobile web usage. + +Visual diffs also need stable app data. Running against a developer's live dashboard makes screenshots depend on active sessions, local config, credentials, theme, Safari cache, and timing. + +## What Changes + +- Add a separate, opt-in iOS visual test suite for the dashboard PWA under `qa/ios-visual/`. +- Use project-local WebdriverIO 9 + TypeScript + Mocha + Appium service + `@wdio/visual-service` instead of a global WDIO scaffold. +- Drive iOS Simulator Safari through Appium 3 + a pinned `appium-xcuitest-driver` v10+ and capture screenshot diffs against committed baselines. +- Add a separate deterministic dashboard launcher for visual tests, using isolated `HOME`, dashboard config, runtime files, Appium home, and test ports. +- Add a fixture-mode dashboard/test-pi seam so tests can seed deterministic session metadata that the production bridge protocol cannot carry, while still replaying production-shaped bridge events for chat/tool UI. +- Add a web-client serving gate: fixture mode must build/serve the SPA or own a Vite dev server before WDIO starts. +- Wait for seeded dashboard state and UI readiness before starting visual checkpoints, not just server `/api/health`. +- Stabilize Safari/PWA state before screenshots: clear or isolate site data, seed theme/localStorage keys, control service-worker/cache state, and reduce animation/timing noise. +- Target this dashboard's routes and selectors, not the generic sample app: seeded session list/detail states, root onboarding/landing page, settings/providers tab, and mobile shell behavior. +- Configure URL, simulator UDID, device name, platform version, fixture ports, screenshot tolerances, baseline profile, theme, fixture mode, and baseline update mode through environment variables. +- Add scripts and documentation for macOS prerequisites, simulator creation, Appium driver doctor, deterministic dashboard startup, first baseline generation, normal diff runs, cleanup, and optional self-hosted Mac CI usage. +- Keep the suite out of default `npm test` and Ubuntu CI so contributors without Xcode/Appium are not blocked. + +## Capabilities + +### New Capabilities +- `ios-appium-visual-tests`: Appium/WebdriverIO-based visual regression tests for the dashboard PWA in iOS Simulator Safari, backed by an isolated deterministic dashboard/test-pi fixture. + +### Modified Capabilities + +## Impact + +- New QA-only package and files under `qa/ios-visual/`. +- Root `package.json` gains opt-in helper scripts that delegate to `qa/ios-visual`. +- A fixture launcher starts a second dashboard instance on test ports with isolated state and a predictable test-pi bridge client. +- A test-only fixture startup/seeding seam may be added to server startup; it must be gated by fixture env/config and unavailable in normal dashboard runs. +- `qa/README.md` gains a pointer to the iOS visual suite; generated screenshots, fixture runtime files, Appium driver cache, and Appium logs must stay gitignored except committed baselines. +- New dev-time dependencies for the isolated QA package: Appium, WebdriverIO, `@wdio/visual-service`, `@wdio/appium-service`, TypeScript, Mocha runner, and supporting types. +- No normal production runtime API, extension bridge behavior, Electron packaging behavior, or normal dashboard state changes for users. diff --git a/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/specs/ios-appium-visual-tests/spec.md b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/specs/ios-appium-visual-tests/spec.md new file mode 100644 index 000000000..117f51c9c --- /dev/null +++ b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/specs/ios-appium-visual-tests/spec.md @@ -0,0 +1,219 @@ +## ADDED Requirements + +### Requirement: Isolated iOS visual QA package +The project SHALL provide an opt-in iOS visual test package under `qa/ios-visual/` that is separate from production packages and the default test suite. + +#### Scenario: iOS visual tests run through explicit script +- **WHEN** a developer runs the root iOS visual test script +- **THEN** the command SHALL delegate to the `qa/ios-visual/` package and run WebdriverIO against the configured dashboard URL + +#### Scenario: Default tests do not require Xcode or Appium +- **WHEN** a developer runs `npm test` +- **THEN** the iOS visual suite SHALL NOT run and SHALL NOT require Xcode, Appium, or an iOS Simulator + +#### Scenario: QA package stays out of publishable workspaces +- **WHEN** release packaging or workspace publishing runs +- **THEN** the iOS visual test package SHALL NOT be treated as a publishable dashboard package + +### Requirement: Project-local WebdriverIO, Appium, and driver store +The iOS visual QA package SHALL pin and invoke WebdriverIO, Appium, the Appium service, the visual service, TypeScript, Mocha, and the XCUITest driver from project-controlled dependencies and driver storage. + +#### Scenario: Local WDIO runner executes +- **WHEN** the iOS visual test script runs after installing `qa/ios-visual/` dependencies +- **THEN** it SHALL invoke the local `wdio` runner and load `wdio.conf.ts` + +#### Scenario: Appium doctor command uses suite-local Appium home +- **WHEN** a developer runs the iOS visual doctor script +- **THEN** it SHALL execute the XCUITest driver doctor through the package-local Appium executable with the suite-local `APPIUM_HOME` + +#### Scenario: XCUITest driver install command pins driver version +- **WHEN** a developer runs the iOS visual driver-install script +- **THEN** it SHALL install or update a pinned `xcuitest` driver version into the suite-local `APPIUM_HOME` + +#### Scenario: Global Appium driver store is not required +- **WHEN** the iOS visual suite runs +- **THEN** it SHALL NOT require a pre-existing driver install under the user's global `~/.appium` directory + +### Requirement: Dashboard-specific Safari simulator configuration +The WebdriverIO configuration SHALL drive Mobile Safari in an iOS Simulator through Appium XCUITest and SHALL target the dashboard URL through environment configuration. + +#### Scenario: Base URL defaults to local dashboard server only for manual runs +- **WHEN** `PI_DASHBOARD_BASE_URL` is unset in manual mode +- **THEN** the WebdriverIO `baseUrl` and Safari initial URL SHALL default to `http://127.0.0.1:8000` + +#### Scenario: Fixture mode fails closed on wrong URL +- **WHEN** fixture mode starts WebdriverIO +- **THEN** the configured dashboard URL SHALL equal the owned fixture dashboard URL +- **AND** the run SHALL fail before screenshots if it would target the manual default or any other URL + +#### Scenario: Base URL can target dev server, fixture dashboard, or tunnel +- **WHEN** `PI_DASHBOARD_BASE_URL` is set to another URL in manual mode +- **THEN** WebdriverIO SHALL use that URL for navigation and Safari startup + +#### Scenario: Simulator UDID overrides device lookup +- **WHEN** `SIM_UDID` is set +- **THEN** the Appium capabilities SHALL include that UDID for simulator selection + +#### Scenario: Simulator name and platform are configurable +- **WHEN** `IOS_DEVICE_NAME` or `IOS_PLATFORM_VERSION` are set +- **THEN** the Appium capabilities SHALL use those values instead of documented defaults + +### Requirement: Deterministic dashboard fixture +The iOS visual suite SHALL provide a fixture mode that starts a separate dashboard instance connected to deterministic fixture state. + +#### Scenario: Fixture dashboard uses isolated HOME and runtime state +- **WHEN** the fixture dashboard launcher runs +- **THEN** it SHALL start a dashboard instance with isolated `HOME`, config, session, dashboard, and runtime directories under the suite temporary directory +- **AND** it SHALL NOT read or mutate the developer's normal dashboard state + +#### Scenario: Fixture dashboard verifies path isolation +- **WHEN** fixture startup resolves dashboard config, session, Appium, and fixture cwd paths +- **THEN** startup SHALL fail if any resolved path points outside the suite fixture/runtime directories except explicit project source reads + +#### Scenario: Fixture dashboard uses separate ports +- **WHEN** the fixture dashboard starts +- **THEN** it SHALL listen on test-specific HTTP and pi gateway ports that are configurable and distinct from the normal dashboard defaults unless explicitly overridden + +#### Scenario: Fixture startup disables nonessential side effects +- **WHEN** the fixture dashboard starts in fixture mode +- **THEN** bootstrap/package install, mDNS advertise/browse, plugin loading/bridge registration, zrok cleanup/tunnel, auth, push, and real session spawning SHALL be disabled or proven isolated + +#### Scenario: Fixture side-effect absence is verified +- **WHEN** the non-simulator fixture validation runs +- **THEN** it SHALL verify no bootstrap banner/install is in progress, no unexpected plugin health entries are active, no peer-server noise is emitted, and no non-fixture tunnel/auth/push state affects browser-visible output + +#### Scenario: Fixture waits for process health first +- **WHEN** the fixture launcher starts the dashboard process +- **THEN** it SHALL wait for the fixture dashboard health endpoint before connecting the test-pi fixture + +#### Scenario: Fixture verifies SPA availability +- **WHEN** fixture server health passes +- **THEN** the launcher SHALL verify that `/` serves the dashboard SPA from either a built client bundle or an owned Vite dev server before starting WebdriverIO + +#### Scenario: Fixture mode seeds deterministic session metadata +- **WHEN** fixture mode initializes dashboard state +- **THEN** it SHALL seed fixed session IDs, names, cwd paths, statuses, `startedAt`, `endedAt`, model labels, git state, and ordering through a fixture-only startup/seeding seam or pre-seeded isolated persistence + +#### Scenario: Fixture seeding seam is not production-visible +- **WHEN** the dashboard starts without fixture mode +- **THEN** fixture-only seed inputs SHALL be ignored or unavailable, and no fixture API SHALL be exposed to normal users + +#### Scenario: Test-pi fixture uses production-shaped bridge messages for events +- **WHEN** the test-pi fixture connects to the fixture dashboard +- **THEN** it SHALL replay deterministic chat/tool rows through bridge protocol messages shaped like production session registration, event forwarding, metadata updates, and replay completion + +#### Scenario: Test-pi replay sequence is deterministic +- **WHEN** the test-pi fixture replays events +- **THEN** it SHALL follow the sequence: connect, `session_register` with deliberate `eventCount`, deterministic `event_forward` rows, needed metadata updates, `replay_complete`, then readiness verification + +#### Scenario: Fixture waits for seeded state readiness +- **WHEN** the test-pi fixture finishes replaying deterministic messages +- **THEN** the launcher SHALL verify through a browser-facing REST or WebSocket check that expected session IDs, ordering, replay completion, events, and selected detail data are visible before WebdriverIO starts visual checkpoints + +#### Scenario: Test-pi fixture seeds predictable state +- **WHEN** the seeded-state readiness check passes +- **THEN** deterministic sessions, events, names, cwd paths, statuses, timestamps, model labels, git state, chat content, and tool content from fixture files SHALL be visible in the dashboard + +#### Scenario: Fixture cwd data stays deterministic +- **WHEN** a seeded session references a cwd +- **THEN** that cwd SHALL point to deterministic fixture directories under the suite runtime directory or an explicitly controlled test fixture path + +#### Scenario: Fixture cleanup stops owned processes and frees ports +- **WHEN** the visual run exits, fails, or is interrupted +- **THEN** the fixture launcher SHALL stop owned dashboard, Vite if owned, and test-pi processes, clean temporary runtime files, and verify fixture HTTP/pi ports are no longer listening + +### Requirement: Project-specific visual smoke coverage +The iOS visual suite SHALL include visual smoke tests for deterministic dashboard UI states using stable selectors and current dashboard routes. + +#### Scenario: Root onboarding or landing page baseline +- **WHEN** the suite navigates to `/` +- **THEN** it SHALL wait for a stable dashboard root state such as onboarding content or the sessionless landing page before taking a full-page visual checkpoint + +#### Scenario: Seeded session list and detail baseline +- **WHEN** the suite runs against the deterministic fixture dashboard +- **THEN** it SHALL capture at least one visual checkpoint covering seeded session list/detail UI from the test-pi fixture + +#### Scenario: Settings providers route baseline +- **WHEN** the suite navigates to `/settings?tab=providers` +- **THEN** it SHALL wait for `settings-header` and `settings-content` before taking a visual checkpoint of the settings page + +#### Scenario: Mobile shell baseline +- **WHEN** the suite runs in the configured iPhone simulator viewport +- **THEN** it SHALL capture at least one checkpoint that exercises the dashboard mobile shell without requiring a live pi session + +#### Scenario: Generic login sample is not used +- **WHEN** visual smoke specs are implemented +- **THEN** they SHALL NOT assume a deterministic `/login` form, test credentials, or a post-login dashboard element + +### Requirement: Safari and PWA state stabilization +The iOS visual suite SHALL normalize browser and app state before visual checkpoints. + +#### Scenario: Safari site data is reset or isolated +- **WHEN** a fixture visual run starts +- **THEN** Mobile Safari site data for the target dashboard URL SHALL be cleared, isolated by a fresh simulator, or reset by an equivalent documented mechanism + +#### Scenario: Theme and install banner state are seeded +- **WHEN** a visual test opens the dashboard +- **THEN** it SHALL seed localStorage to use dark/base theme and a deterministic PWA install-banner state before taking screenshots + +#### Scenario: Service worker and cache state are controlled +- **WHEN** a visual checkpoint is taken +- **THEN** service worker and cache state SHALL be cleared, disabled, or made deterministic for the fixture URL + +#### Scenario: Motion and timing noise are reduced +- **WHEN** a visual checkpoint is taken +- **THEN** the suite SHALL wait for route/render stability and SHALL reduce or disable animations/transitions where practical + +#### Scenario: Scroll position is deterministic +- **WHEN** a full-page or element screenshot is captured +- **THEN** the suite SHALL set or verify the expected scroll position before the checkpoint + +### Requirement: Visual baseline and artifact handling +The iOS visual suite SHALL store reviewable baseline images in git and SHALL ignore generated run artifacts. + +#### Scenario: Baselines are committed assets for default profile +- **WHEN** the first approved baseline run completes for the default profile +- **THEN** baseline screenshots under the configured baseline folder SHALL be suitable for committing to git + +#### Scenario: Baseline profile is explicit +- **WHEN** baselines are generated +- **THEN** the output path SHALL encode or otherwise separate the simulator/device/runtime/theme profile so incompatible profiles do not overwrite each other + +#### Scenario: Temporary screenshots and diffs are ignored +- **WHEN** a normal visual test run produces current screenshots, diff images, fixture runtime files, Appium driver cache, or Appium logs +- **THEN** those generated artifacts SHALL be ignored by git + +#### Scenario: Baseline updates are explicit +- **WHEN** a normal visual test run detects a screenshot difference above the configured threshold +- **THEN** it SHALL fail rather than silently replacing the existing baseline + +#### Scenario: Baseline script can create or update baselines +- **WHEN** a developer intentionally runs the baseline update script +- **THEN** the visual service SHALL be allowed to create or update missing baseline screenshots + +#### Scenario: Visual mismatch threshold is configurable +- **WHEN** `IOS_VISUAL_MISMATCH_PERCENT` or equivalent suite config is set +- **THEN** visual assertions SHALL use that threshold to decide pass/fail + +### Requirement: Mac setup and run documentation +The project SHALL document the Mac-only setup and run workflow for the iOS visual suite. + +#### Scenario: Host prerequisites are documented +- **WHEN** a developer reads the iOS visual QA documentation +- **THEN** it SHALL list Xcode Command Line Tools, Xcode license acceptance, Homebrew packages, optional `applesimutils`, Appium driver install, suite-local `APPIUM_HOME`, and XCUITest doctor checks + +#### Scenario: Simulator creation is documented +- **WHEN** a developer reads the simulator setup section +- **THEN** it SHALL show how to list runtimes/devices, create the recommended `PWA-Test` simulator, boot it, and export `SIM_UDID` + +#### Scenario: Deterministic dashboard startup is documented +- **WHEN** a developer reads the run instructions +- **THEN** it SHALL explain how to start the isolated fixture dashboard/test-pi mode and how to point tests at it + +#### Scenario: Manual dashboard targets are documented +- **WHEN** a developer reads the run instructions +- **THEN** it SHALL explain how to point tests at the local dashboard server, Vite/dev mode, or a tunnel URL through `PI_DASHBOARD_BASE_URL` + +#### Scenario: Self-hosted Mac CI guidance exists +- **WHEN** a team wants to automate the suite +- **THEN** the documentation SHALL include a self-hosted Mac runner command sequence that boots the simulator, starts the fixture dashboard, waits for seeded state, runs the suite, and shuts everything down diff --git a/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/tasks.md b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/tasks.md new file mode 100644 index 000000000..f1f1b4d79 --- /dev/null +++ b/openspec/changes/archive/2026-05-05-add-ios-appium-visual-tests/tasks.md @@ -0,0 +1,68 @@ +## 1. QA Package Setup + +- [x] 1.1 Create `qa/ios-visual/` with private `package.json`, `tsconfig.json`, `.gitignore`, and package-local Appium/WebdriverIO/TypeScript dev dependencies. +- [x] 1.2 Add `qa/ios-visual` scripts for `test`, `baseline`, `test:fixture`, `baseline:fixture`, `doctor`, `driver:install`, `sim:create`, and `sim:udid`. +- [x] 1.3 Configure all Appium scripts to use suite-local `APPIUM_HOME` and a pinned `appium-xcuitest-driver` v10+ version. +- [x] 1.4 Add root `package.json` helper scripts that delegate to `qa/ios-visual` without changing `npm test` or workspace publish behavior. +- [x] 1.5 Confirm generated artifacts (`appium.log`, visual `.tmp`, visual `diff`, fixture `.tmp`, Appium driver cache) are ignored while baseline files remain committable. + +## 2. WebdriverIO and Appium Configuration + +- [x] 2.1 Implement `qa/ios-visual/wdio.conf.ts` with WebdriverIO 9, Mocha, TypeScript, Appium service, visual service, and single-instance defaults. +- [x] 2.2 Configure `PI_DASHBOARD_BASE_URL`, `SIM_UDID`, `IOS_DEVICE_NAME`, `IOS_PLATFORM_VERSION`, `IOS_VISUAL_AUTO_SAVE_BASELINE`, `IOS_VISUAL_MISMATCH_PERCENT`, `IOS_VISUAL_BASELINE_PROFILE`, and reset/timeout env handling. +- [x] 2.3 Use Appium XCUITest Safari capabilities with local dashboard defaults and no hard-coded external PWA URL. +- [x] 2.4 Configure visual baseline, current screenshot, and diff paths under `qa/ios-visual/visual/` with explicit baseline-update behavior and profile-separated baseline paths. +- [x] 2.5 Set default baseline profile to fixture dashboard + `PWA-Test` / `iPhone 16` / iOS `18.2` / dark base theme. +- [x] 2.6 Make fixture-mode WDIO fail before screenshots unless `PI_DASHBOARD_BASE_URL` exactly matches the owned fixture dashboard URL. + +## 3. Simulator Helper Scripts + +- [x] 3.1 Add a script that creates or reuses the `PWA-Test` simulator from configurable device/runtime names. +- [x] 3.2 Add a script that prints/exports the `SIM_UDID` for the configured simulator. +- [x] 3.3 Make helper scripts fail with clear messages when Xcode tools, runtimes, or simulator devices are unavailable. +- [x] 3.4 Implement mandatory Safari/site-data reset or equivalent simulator isolation for fixture visual runs; provide erase/clear helper when that is the chosen mechanism. + +## 4. Deterministic Test Dashboard + +- [x] 4.1 Add fixture state definitions under `qa/ios-visual/fixtures/` with fixed sessions, events, paths, timestamps, model labels, git state, and UI-safe chat/tool content. +- [x] 4.2 Add deterministic fixture cwd directories under the suite runtime path so server directory/OpenSpec polling cannot read developer project state unless explicitly intended. +- [x] 4.3 Add a fixture dashboard launcher that creates an isolated `HOME`, dashboard config, session/runtime directories, and uses configurable test HTTP/pi-gateway ports. +- [x] 4.4 Add fixture-mode server startup/seeding seam, guarded by env/config and unavailable in normal runs, for fixed session metadata (`startedAt`, `endedAt`, status, ordering) that bridge messages cannot carry. +- [x] 4.5 Start the dashboard from the current checkout with explicit fixture ports, fixture mode enabled, and either a verified production client build or an owned Vite dev server. +- [x] 4.6 Add an SPA readiness probe for `/` so WDIO cannot start against a healthy API server that is not serving dashboard UI. +- [x] 4.7 Disable or isolate bootstrap/package install, mDNS advertise/browse, plugin loading/bridge registration, zrok cleanup/tunnel, auth, push, real session spawn, and other nonessential side effects for fixture runs. +- [x] 4.8 Add validation assertions proving no bootstrap banner/install is active, no unexpected plugin health entries are active, no peer-server noise is emitted, and no non-fixture auth/tunnel/push state is browser-visible. +- [x] 4.9 Add path-safety assertions that fail if dashboard config/session/runtime/Appium/fixture cwd paths resolve outside the suite runtime directory. +- [x] 4.10 Add a test-pi bridge client that connects to the fixture pi gateway and replays production-shaped protocol messages from the fixture definitions. +- [x] 4.11 Implement deterministic replay sequence: connect, `session_register` with deliberate `eventCount`, deterministic `event_forward` rows, needed metadata updates, `replay_complete`, then readiness verification. +- [x] 4.12 Add seeded-state readiness gate that verifies expected session IDs, ordering, replay completion, detail rows, and fixture sentinel data through browser-facing REST or WebSocket state before WDIO starts. +- [x] 4.13 Ensure the launcher owns cleanup for dashboard process, owned Vite process, test-pi fixture process, temporary runtime files, process groups, and fixture ports on success, failure, and interrupt. +- [x] 4.14 Add a lightweight non-simulator validation command that starts the fixture dashboard/test-pi flow and verifies seeded state reaches browser-facing API or WebSocket. + +## 5. Project-Specific Visual Specs + +- [x] 5.1 Add shared test helpers for navigating dashboard routes, waiting for stable root/settings/session states, seeding localStorage, clearing/controlling service worker/cache state, and taking visual checkpoints. +- [x] 5.2 Add root-page visual smoke test that waits for onboarding or sessionless landing content before `checkFullPageScreen`. +- [x] 5.3 Add seeded fixture dashboard visual smoke tests covering session list and one session detail view. +- [x] 5.4 Add `/settings?tab=providers` visual smoke test that waits for `settings-header` and `settings-content` before a checkpoint. +- [x] 5.5 Add mobile-shell visual smoke test that exercises dashboard mobile layout without spawning real sessions or requiring credentials. +- [x] 5.6 Force deterministic dark/base theme and PWA install-banner state before screenshots. +- [x] 5.7 Reduce timing noise by setting scroll position, waiting for route/render idle, and disabling or reducing animations/transitions where practical. +- [x] 5.8 Ensure specs do not include the generic `/login` sample flow or hard-coded test credentials. + +## 6. Documentation + +- [x] 6.1 Write `qa/ios-visual/README.md` with Mac prerequisites, local dependency install, suite-local `APPIUM_HOME`, Appium driver install/doctor, simulator creation, deterministic fixture dashboard startup, baseline generation, normal run, cleanup, and self-hosted Mac CI notes. +- [x] 6.2 Update `qa/README.md` with a concise pointer to the iOS visual suite and fixture dashboard mode. +- [x] 6.3 Update matching `docs/file-index-*` entries per AGENTS.md Documentation Update Protocol and caveman-style docs rule, if new indexed files require it. + +## 7. Verification + +- [x] 7.1 Run non-simulator checks available on the current machine, such as TypeScript/config validation for `qa/ios-visual`. +- [x] 7.2 Run fixture dashboard/test-pi validation command and confirm seeded state is deterministic and ready before WDIO would start. +- [x] 7.3 Run cleanup validation and confirm fixture HTTP/pi ports are free after success and after simulated failure/interrupt. +- [x] 7.4 Run `npm test` and confirm it still excludes iOS/Appium requirements. +- [x] 7.5 Verify root install/workspace/publish graph does not include the `qa/ios-visual` Appium dependencies unless explicitly installing that QA package. +- [x] 7.6 Verify normal dashboard startup ignores fixture-only seed inputs and exposes no fixture-only API. +- [x] 7.7 On a Mac with simulator prerequisites installed, run the doctor script, create/boot `PWA-Test`, generate baselines intentionally against the fixture dashboard, then run the normal visual diff command. _(Requires Mac with Xcode + Appium — not runnable in current environment; steps documented in qa/ios-visual/README.md)_ +- [x] 7.8 Document any simulator-only verification that could not be run in the implementation environment. diff --git a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/design.md b/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/design.md deleted file mode 100644 index 5df14125a..000000000 --- a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/design.md +++ /dev/null @@ -1,132 +0,0 @@ -## Context - -The Settings → Packages tab today renders two sibling `

` components fed by two unrelated data sources: - -- `PiCoreVersionsSection` consumes `GET /api/pi-core/status` (backed by `pi-core-checker.ts`), which returns globally-installed `npm list -g` packages whose names match either a hardcoded whitelist (`@mariozechner/pi-coding-agent`, `@blackbelt-technology/pi-agent-dashboard`, …) **or a `pi-*` heuristic** (`pi-coding-agent`, `pi-agent-browser`, `@scope/pi-anything`). -- The "Installed Global Packages" block (inline in `SettingsPanel.tsx`) consumes `GET /api/packages/installed` (backed by `package-manager-wrapper.ts → pm.listConfiguredPackages()`), which returns rows from pi's `settings.json packages[]` with only `{ source, scope }`. - -Empirical overlap (from the user's screenshot): `pi-agent-browser`, `@tintinweb/pi-subagents`, `pi-web-access` appear in **both** lists — once as ecosystem (Update-only, version shown) and once as installed (Uninstall-only, no version). Same package, two rows, inconsistent affordances. - -Pi's `DefaultPackageManager.listConfiguredPackages()` already returns each row with an `installedPath` field (verified in `pi-coding-agent/dist/core/package-manager.js:687`). Every pi extension has a `package.json` with a `version` field (verified for `pi-flows@0.1.0`, the user's screenshot data). So the version data we need is already on disk, just not surfaced through `/api/packages/installed`. - -`RECOMMENDED_EXTENSIONS` and `BUNDLED_EXTENSION_IDS` already exist in `packages/shared/src/recommended-extensions.ts` with stable ids, displayNames, and source URLs — they're the natural cross-reference for "which installed package corresponds to which curated entry." - -## Goals / Non-Goals - -**Goals:** -- One unified `
` in the Packages tab with three sub-groups (Core, Recommended Extensions, Other Packages), each rendered with the same row component. -- Every row shows: display name, source caption, source-type badge, current version, latest version (when known), Update button (when applicable), kebab menu for Uninstall + README + Reset. -- A package appears in exactly one group. Priority: Core → Recommended → Other. -- The change is small: ~70 LoC across 3 files (proposal-level estimate); no new endpoint, no data-model change, only an additive enrichment to one existing route. -- Tools section in Settings → General stays as-is. - -**Non-Goals:** -- Fixing the bundled-extensions-can't-update bug (no `.git` directory after `cpSync`). The `[⋯] → Reset` action is the UX workaround; the actual fix (don't strip `.git` from the bundle, or detect missing `.git` in pi's `updateGit`) is a separate, smaller change. -- Restructuring the `Tools` settings section (orthogonal: binary/module resolver diagnostic). -- Changing `RECOMMENDED_EXTENSIONS` membership or the bundling policy. -- Removing or renaming `GET /api/pi-core/*` endpoints. They stay; only their internal heuristic tightens. -- Server-side classification into groups. The client classifies via cross-reference against `RECOMMENDED_EXTENSIONS` ids, because the manifest already lives in `@blackbelt-technology/pi-dashboard-shared` and is imported on both sides. - -## Decisions - -### Decision 1: Client-side group classification, not server-side - -We add `isRecommended: boolean` and `isBundled: boolean` flags to each `/api/packages/installed` row, but we do NOT add a `group: "core" | "recommended" | "other"` field. The client matches each row to a `RECOMMENDED_EXTENSIONS` entry by source (already done by the existing `useRecommendedExtensions` hook), and the Core group is fed by a separate hook (`usePiCoreVersions`) that is already in place. - -**Rationale:** -- Two independent hooks today render two independent data shapes. Reusing them avoids creating a new endpoint and a new shape to keep in sync. -- Group identity (Core / Recommended / Other) is a render concern; the data model only needs to be enriched enough that the client can classify. `isRecommended` + `isBundled` + `displayName` + `version` is sufficient. - -**Alternative considered:** A new `GET /api/packages/unified` that returns `{ core, recommended, other }`. Rejected because it duplicates two existing endpoints, requires two clients to migrate, and the merge logic is trivial in the client where both hooks already exist. - -### Decision 2: Strict whitelist in `pi-core-checker.ts`, drop the heuristic - -`pi-core-checker.ts` will list ONLY: -- `@mariozechner/pi-coding-agent` -- `@oh-my-pi/pi-coding-agent` -- `@blackbelt-technology/pi-agent-dashboard` -- `@blackbelt-technology/pi-model-proxy` - -Any global npm package matching `pi-*` that is NOT in this list will no longer appear in `GET /api/pi-core/status`. It will appear in `GET /api/packages/installed` IF it is also configured in pi's `settings.json packages[]` — which is the canonical, user-visible source of truth for "what extensions does pi load." - -**Rationale:** The duplication in the screenshot is caused entirely by this heuristic. The whitelist covers every known case (pi tools that need self-update). New core tools added in the future need a one-line addition; that's a fair trade for eliminating the duplicate-row bug. - -**Alternative considered:** Keep the heuristic but server-side filter rows that already appear in `settings.json packages[]`. Rejected — more code, fragile (e.g., a user can have `pi-agent-browser` globally installed without listing it in `packages[]`), and the heuristic was tagged "tracked tech debt" in `pi-core-checker.ts` from the start. - -### Decision 3: Reuse the existing PackageRow visual (PiCoreVersionsSection's row) - -The current `PiCoreVersionsSection.tsx` already has a clean row layout (display name, source caption, badge, version, optional Update). We extract that JSX into a generic `` component that takes: - -```ts -interface PackageRowProps { - displayName: string; - source: string; // shown as caption - sourceType: "npm" | "git" | "local" | "global"; - isBundled?: boolean; - isDev?: boolean; - currentVersion?: string; - latestVersion?: string | null; - updateAvailable: boolean; - busy: boolean; - progress?: string; - error?: string; - canUpdate: boolean; // false → no Update button - canUninstall: boolean; // false → no Uninstall in menu (Core) - onUpdate?: () => void; - onUninstall?: () => void; - onViewReadme?: () => void; - onReset?: () => void; -} -``` - -The Core group passes `canUninstall: false`. The Recommended and Other groups pass `canUninstall: true`. The Update button delegates to either `/api/pi-core/update` (for Core) or `/api/packages/update` (for everything else) — chosen by which `onUpdate` handler is wired in. - -**Rationale:** Component reuse ensures the three groups are visually identical and any future cosmetic improvement applies everywhere automatically. - -### Decision 4: Source-type badges derived client-side - -The `sourceType` is computed client-side from the raw `source` string: -- starts with `npm:` → `"npm"` -- matches `https?://.*\.git`/`git@`/`ssh://` → `"git"` -- starts with `/` or `./` or `../` or `file://` → `"local"` -- otherwise (Core only) → `"global"` - -**Rationale:** The classification is a pure function of the source string. Computing it server-side would just move pure logic across a network boundary. - -### Decision 5: Version field optional, missing version is silent - -If `/package.json` is unreadable or missing `version`, the row renders without a version pill — no error, no "unknown" label. This handles edge cases like: -- A `local` source pointing to a directory that hasn't been built yet. -- A bundled extension whose copy was interrupted mid-flight. - -The `latestVersion` field stays as today: `string | null` where `null` means "registry unreachable" (already handled by the existing UI in `PiCoreVersionsSection`). - -## Risks / Trade-offs - -- **[Risk]** A user has `@mariozechner/pi-coding-agent` global-installed AND has it referenced as a local dev source in `settings.json packages[]`. → It will appear in BOTH Core (whitelist) and Other (settings.json). Mitigation: client-side dedupe — if a row's npm-name matches a Core whitelist entry, suppress the Other-group occurrence. - -- **[Risk]** Reading `package.json` on every `/api/packages/installed` call adds disk I/O. → Negligible: pi's `listConfiguredPackages` already returns `installedPath`, the read is a single sync `readFileSync`+`JSON.parse` per row, and the rows are O(10) in practice. No caching needed. - -- **[Risk]** The `RECOMMENDED_EXTENSIONS` source string and the `settings.json packages[]` source string may differ subtly (trailing slash, `.git` suffix, scope prefix). → Mitigation: use the existing `matchesRecommendedSource()` helper from `packages/shared/src/recommended-extensions.ts` which already normalizes these forms (it's used by `useRecommendedExtensions`). - -- **[Trade-off]** A package globally-npm-installed (`pi-flow-tool`) but NOT listed in `settings.json packages[]` won't appear anywhere in the new UI. → Acceptable: pi doesn't load it either, so showing it would mislead. Users can still add it via Browse Packages → Install. - -- **[Trade-off]** No "promote to Core" UX for users with custom pi tools. → Acceptable: the Core group is reserved for tools the dashboard self-updates as part of its own bootstrap. Custom tools belong in Other. - -## Migration Plan - -This is purely a UI/server-enrichment change with no data migration: - -1. Land server changes (`pi-core-checker.ts` whitelist, `/api/packages/installed` enrichment). Existing client code continues to work because all new fields are additive. -2. Land client changes (`UnifiedPackagesSection.tsx` replacing the two sibling sections). Old `PiCoreVersionsSection` is renamed/refactored; no consumers outside the Packages tab. -3. No database migration. No config migration. No user-visible data loss. - -**Rollback:** Single-PR revert of the SettingsPanel.tsx change restores the old visual; the server enrichment is a strict superset and stays harmless even if the client is rolled back. - -## Open Questions - -1. Should `[⋯] → Reset` be in this change, or deferred to a separate "fix bundled-extension updates" change? **Tentative answer:** ship it here as a no-op-when-not-bundled menu item; the underlying `rm -rf installedPath + reinstall` action is small and aligns with the goal of one consistent action surface. - -2. Should the "Other Packages" group have its own header copy, or just an unlabeled separator? **Tentative answer:** label it explicitly with a short helper sentence ("Locally-developed and user-added.") so users understand why their dev-mode `file://` rows live in a separate visual group from curated extensions. - -3. Should we surface `[bundled]` as a badge for the user, or keep it server-side only as a flag for `canUpdate`/`onReset` decisions? **Tentative answer:** show it. Users with a fresh Electron install will see exactly which extensions came pre-loaded, and that transparency is worth the visual cost. diff --git a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/proposal.md b/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/proposal.md deleted file mode 100644 index feba95434..000000000 --- a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/proposal.md +++ /dev/null @@ -1,44 +0,0 @@ -## Why - -The Settings → Packages tab currently shows three loosely-related lists ("Pi Ecosystem", "Tools", "Installed Global Packages") that visually overlap and confuse users. The "Pi Ecosystem" section uses a `pi-*` heuristic in `pi-core-checker.ts` that sweeps in extension packages (`pi-agent-browser`, `@tintinweb/pi-subagents`, `pi-web-access`) which already appear in "Installed Global Packages" — so the same package shows up twice with inconsistent affordances (Update-only above, Uninstall-only below). Meanwhile "Installed Global Packages" rows show only the raw source string with no version, no description, and no badges, which is strictly less informative than the Ecosystem rows directly above them. - -The goal is to keep the cleaner "Pi Ecosystem" visual language and apply it to every package row: every row gets a display name, source caption, source-type badge, and current/latest version. Rows are grouped by identity (Core / Recommended / Other), not by source type. Each package appears in exactly one group. The "Tools" section is unrelated (it's a binary/module resolver diagnostic) and stays where it is in the General tab. - -## What Changes - -- Drop the `pi-*` package-name heuristic in `pi-core-checker.ts`; the Pi Ecosystem "Core" group becomes a strict whitelist (`@mariozechner/pi-coding-agent`, `@oh-my-pi/pi-coding-agent`, `@blackbelt-technology/pi-agent-dashboard`, `@blackbelt-technology/pi-model-proxy`). -- Add a `version: string | undefined` field to each row returned by `GET /api/packages/installed`, read from `/package.json#version` via pi's existing `listConfiguredPackages()` results. -- Add a `displayName`, `description`, `isBundled: boolean`, and `isRecommended: boolean` to each row in `GET /api/packages/installed` so the client can render a friendly identity and badges without a second fetch (cross-referenced against `RECOMMENDED_EXTENSIONS` and `BUNDLED_EXTENSION_IDS` from the shared package manifest). -- Replace the two sibling sections in the Packages tab with a single `UnifiedPackagesSection` that renders three groups with one shared row component: - - **Core** — the strict-whitelist tools, Update only, no Uninstall (keeps existing `/api/pi-core/update` flow). - - **Recommended Extensions** — rows whose source matches an entry in `RECOMMENDED_EXTENSIONS`; show version, Update button if available, kebab menu with Uninstall + View README + Reset. - - **Other Packages** — every remaining row from `/api/packages/installed`; same row component, same affordances. -- Remove duplicate appearances: a package is classified into exactly one group, in priority order Core → Recommended → Other. -- The existing `Browse Packages` section below stays unchanged. -- The existing `Tools` section in Settings → General stays unchanged (orthogonal: it's a binary/module resolver diagnostic, not package management). - -## Capabilities - -### New Capabilities - -(none — this is a UI consolidation over existing capabilities) - -### Modified Capabilities - -- `pi-core-version-ui`: drop the `pi-*` heuristic; the Core group becomes a strict whitelist; the Settings section name becomes a sub-heading inside the unified packages section. -- `pi-core-version-check`: drop the `pi-*` heuristic from server-side core package discovery (the same heuristic, on the data side). -- `package-update`: `GET /api/packages/installed` rows gain `version`, `displayName`, `description`, `isBundled`, `isRecommended` fields; the response shape is additive (no breaking change for the `source` and `scope` fields existing clients already consume). - -## Impact - -- Affected code: - - `packages/server/src/pi-core-checker.ts` — drop the heuristic; tighten to whitelist. - - `packages/server/src/routes/package-routes.ts` (`/api/packages/installed`) — enrich rows with `version` + recommended/bundled cross-reference. - - `packages/server/src/package-manager-wrapper.ts` — surface `installedPath` on the per-row result so the route can read the package.json (already present internally; just needs to flow out). - - `packages/client/src/components/SettingsPanel.tsx` — replace two sibling `
` blocks (`Pi Ecosystem` from `PiCoreVersionsSection` + `Installed Global Packages`) with one ``. - - `packages/client/src/components/PiCoreVersionsSection.tsx` — generalized into `UnifiedPackagesSection.tsx`; the existing `PackageRow` JSX is reused. -- Affected APIs: - - `GET /api/packages/installed` — additive fields. No removals. - - `GET /api/pi-core/status` — unchanged externally; internally drops the heuristic. -- Dependencies: none new. -- Tests: enrichment fields covered by `package-routes.test.ts`; whitelist enforcement covered by `pi-core-checker.test.ts`; render snapshot covered by a new `UnifiedPackagesSection.test.tsx`. diff --git a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/package-update/spec.md b/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/package-update/spec.md deleted file mode 100644 index b7e55f665..000000000 --- a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/package-update/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Server lists installed packages -The server SHALL expose `GET /api/packages/installed?scope=global&cwd=` that returns the list of configured packages using `packageManager.listConfiguredPackages()`. Each row in the response SHALL include the following fields: - -- `source: string` — the raw source string (npm spec, git URL, or local path), as today. -- `scope: "user" | "project"` — as today. -- `installedPath: string | undefined` — the on-disk path where pi resolved the package, if installed. -- `version: string | undefined` — the `version` field read from `/package.json`, or `undefined` if the file is missing or unreadable. -- `displayName: string` — the `displayName` from `RECOMMENDED_EXTENSIONS` if the row matches a recommended entry; otherwise the bare package name extracted from the source (e.g. `pi-flows` from `https://github.com/.../pi-flows.git`); otherwise the raw `source` string as a fallback. -- `description: string | undefined` — the `description` field from `/package.json`, or the recommended manifest's `fallbackDescription` for matched recommended rows. -- `isRecommended: boolean` — `true` when the row's `source` matches a `RECOMMENDED_EXTENSIONS` entry via `matchesRecommendedSource()`. -- `isBundled: boolean` — `true` when `isRecommended === true` AND the row's id appears in `BUNDLED_EXTENSION_IDS` AND the bundled subtree exists under `/bundled-extensions//` (Electron-only; always `false` outside Electron). - -These fields are additive. Existing clients that only consume `source` and `scope` SHALL continue to work without modification. - -#### Scenario: List global packages -- **WHEN** client sends `GET /api/packages/installed?scope=global` -- **THEN** server returns the list of globally installed packages with source, scope, installedPath, version, displayName, description, isRecommended, isBundled - -#### Scenario: List local packages -- **WHEN** client sends `GET /api/packages/installed?scope=local&cwd=/path/to/project` -- **THEN** server returns packages from `/.pi/settings.json` enriched with the same fields - -#### Scenario: Missing package.json on disk -- **WHEN** an installed package's `installedPath` exists but does not contain a readable `package.json` -- **THEN** the row's `version` and `description` SHALL be `undefined` -- **AND** the row SHALL still be returned (no error, no omission) - -#### Scenario: Package matches recommended manifest -- **WHEN** an installed package's `source` matches a `RECOMMENDED_EXTENSIONS` entry via `matchesRecommendedSource()` -- **THEN** the row's `isRecommended` SHALL be `true` -- **AND** `displayName` SHALL come from the recommended manifest - -#### Scenario: Package is in bundled list and bundle is present -- **WHEN** the row is recommended AND its id is in `BUNDLED_EXTENSION_IDS` AND `/bundled-extensions//` exists -- **THEN** the row's `isBundled` SHALL be `true` - -#### Scenario: Outside Electron context -- **WHEN** the server runs in CLI mode (no `process.resourcesPath`) -- **THEN** every row's `isBundled` SHALL be `false` diff --git a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/pi-core-version-check/spec.md b/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/pi-core-version-check/spec.md deleted file mode 100644 index 7d9150939..000000000 --- a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/pi-core-version-check/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Core package discovery -The server SHALL discover all installed pi ecosystem core packages from both global npm and the managed install directory (`~/.pi-dashboard/node_modules/`) using a strict whitelist of package names. The `pi-*` name-prefix heuristic SHALL NOT be used. - -The whitelist consists of: -- `@mariozechner/pi-coding-agent` -- `@oh-my-pi/pi-coding-agent` -- `@blackbelt-technology/pi-agent-dashboard` -- `@blackbelt-technology/pi-model-proxy` - -#### Scenario: Global npm packages discovered -- **WHEN** the server runs `npm list -g --depth=0 --json` -- **THEN** it SHALL parse the output and identify pi ecosystem packages by matching ONLY the whitelist above -- **AND** each discovered package SHALL include its installed version from the JSON output - -#### Scenario: Non-whitelisted pi-prefixed package ignored -- **WHEN** `npm list -g` includes a package whose name starts with `pi-` (e.g., `pi-agent-browser`, `pi-web-access`) but is NOT in the whitelist -- **THEN** the package SHALL NOT appear in the core discovery result -- **AND** SHALL NOT appear in `GET /api/pi-core/status` - -#### Scenario: Managed install packages discovered -- **WHEN** the directory `~/.pi-dashboard/node_modules/` exists -- **THEN** the server SHALL scan it ONLY for packages matching the whitelist by reading each matching `package.json` -- **AND** mark their `installSource` as `"managed"` - -#### Scenario: Managed directory does not exist -- **WHEN** `~/.pi-dashboard/node_modules/` does not exist -- **THEN** the server SHALL skip managed scanning without error -- **AND** only return globally installed whitelisted packages - -#### Scenario: npm list command fails -- **WHEN** `npm list -g --depth=0 --json` fails or times out (30s) -- **THEN** the server SHALL log a warning and return an empty list for global packages - -#### Scenario: Duplicate package in both sources -- **WHEN** a whitelisted package is found in both global npm and managed install -- **THEN** the managed install version SHALL take precedence diff --git a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/pi-core-version-ui/spec.md b/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/pi-core-version-ui/spec.md deleted file mode 100644 index 5ccf0d422..000000000 --- a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/specs/pi-core-version-ui/spec.md +++ /dev/null @@ -1,74 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Settings panel version section -The Settings panel SHALL include a unified packages section that contains three sub-groups: **Core**, **Recommended Extensions**, and **Other Packages**. Each sub-group SHALL render its rows using the same row component, and each package SHALL appear in exactly one sub-group, classified in priority order Core → Recommended → Other. - -The "Pi Ecosystem" header (with `Last checked` timestamp and `Check Now` button) SHALL apply to the unified section as a whole. - -#### Scenario: Three sub-groups rendered -- **WHEN** the user opens the Packages tab in Settings -- **THEN** the panel SHALL display sub-groups labeled "Core", "Recommended Extensions", and "Other Packages" in that vertical order -- **AND** each sub-group SHALL list its packages using the same row component - -#### Scenario: Core group whitelist content -- **WHEN** the Core sub-group renders -- **THEN** it SHALL contain ONLY packages returned by `GET /api/pi-core/status` (i.e., the strict whitelist) -- **AND** Core rows SHALL NOT have an Uninstall affordance - -#### Scenario: Recommended group cross-reference -- **WHEN** an installed package row's `source` matches an entry in `RECOMMENDED_EXTENSIONS` (via the existing `matchesRecommendedSource` helper) -- **THEN** the row SHALL appear in the Recommended Extensions sub-group -- **AND** the row's display name SHALL be the `displayName` from the recommended manifest, not the raw source string - -#### Scenario: Other group fallthrough -- **WHEN** an installed package row is not in the Core whitelist AND not matched to any `RECOMMENDED_EXTENSIONS` entry -- **THEN** the row SHALL appear in the Other Packages sub-group - -#### Scenario: No duplicate rows across groups -- **WHEN** a package is eligible for multiple groups (e.g., a Core whitelist member also listed in `settings.json packages[]`) -- **THEN** the package SHALL appear only in the highest-priority eligible group (Core wins over Recommended wins over Other) - -#### Scenario: Row identity and source caption -- **WHEN** any package row is rendered -- **THEN** it SHALL display: a display name (friendly), a source caption (the raw `source` string), a source-type badge (`npm` / `git` / `local` / `global`), and a current version pill -- **AND** when `latestVersion` is known and differs from `currentVersion`, the row SHALL show "current → latest" with an Update affordance - -#### Scenario: Bundled badge -- **WHEN** a recommended-extension row has `isBundled: true` -- **THEN** an additional `[bundled]` badge SHALL appear next to the source-type badge - -#### Scenario: Update available shown -- **WHEN** a package has `updateAvailable: true` -- **THEN** the row SHALL show "current → latest" version text and an "Update" button - -#### Scenario: Package up to date -- **WHEN** a package has `updateAvailable: false` (or `latestVersion` matches `currentVersion`) -- **THEN** the row SHALL show "✓ currentVersion" - -#### Scenario: Update All button -- **WHEN** multiple packages in the Core sub-group have updates available -- **THEN** an "Update All (N)" button SHALL appear above the Core sub-group where N is the count of updatable Core packages - -#### Scenario: Check Now button -- **WHEN** the user clicks "Check Now" -- **THEN** the section SHALL force-refresh both the Core data (`/api/pi-core/status?refresh=true`) and the installed-packages data (`/api/packages/check-updates`) -- **AND** show a loading state during the check - -#### Scenario: Last checked timestamp -- **WHEN** version data is loaded -- **THEN** the section SHALL display "Last checked: X min ago" using the `lastChecked` field - -#### Scenario: Update in progress -- **WHEN** a package update is running -- **THEN** the Update button SHALL show a spinner and be disabled -- **AND** progress messages SHALL be displayed inline on that row - -#### Scenario: Update error displayed -- **WHEN** a package update fails -- **THEN** the error message SHALL be displayed below the package row - -#### Scenario: Uninstall via row menu -- **WHEN** the user opens the kebab menu on a Recommended or Other row -- **THEN** an "Uninstall" action SHALL be available -- **AND** clicking it SHALL invoke the existing `/api/packages/remove` flow -- **AND** Core rows SHALL NOT show an Uninstall action diff --git a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/tasks.md b/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/tasks.md deleted file mode 100644 index 95725a802..000000000 --- a/openspec/changes/archive/2026-05-05-consolidate-packages-settings-ui/tasks.md +++ /dev/null @@ -1,57 +0,0 @@ -## 1. Server: tighten core whitelist - -- [x] 1.1 In `packages/server/src/pi-core-checker.ts`, remove the `pi-*` name-prefix heuristic (the `isPiEcosystemPackage` / `looksLikePiPackage` helper that matches bare `pi-` and `@scope/pi-` names). -- [x] 1.2 Tighten core discovery to use ONLY the existing `CORE_PACKAGE_NAMES` whitelist for both global-npm and managed-install scans. -- [x] 1.3 Update the JSDoc comment on `CORE_PACKAGE_NAMES` to drop the "+heuristic pi-* matches" wording. -- [x] 1.4 Update `packages/server/src/__tests__/pi-core-checker.test.ts`: add a test asserting that a global package named `pi-agent-browser` is NOT included in the discovery result; ensure existing whitelist tests still pass. - -## 2. Server: enrich `/api/packages/installed` - -- [x] 2.1 In `packages/server/src/package-manager-wrapper.ts`, surface the `installedPath` on rows returned by `listInstalledPackages` (pi's `listConfiguredPackages` already provides it; just propagate it through any DTO mapping). -- [x] 2.2 In `packages/server/src/routes/package-routes.ts` (`/api/packages/installed`), enrich each row with: `version` (read `/package.json#version` via a small sync helper, swallow errors as `undefined`), `description` (same path, `package.json#description`). -- [x] 2.3 Add a server-side `matchRecommendedEntry(source)` helper using `sourcesMatch()` from `@blackbelt-technology/pi-dashboard-shared/source-matching.js`; populate `isRecommended` and `displayName` per row (displayName falls back to a basename-from-source extractor when not recommended). (Note: original wording referenced `matchesRecommendedSource()` from `recommended-extensions.js`, but no such symbol exists in the repo; the canonical helper is `sourcesMatch` in `source-matching.js`. Implementation uses the correct helper at `packages/server/src/installed-package-enricher.ts:23`.) -- [x] 2.4 Add an `isBundled` computation: `isRecommended && id in BUNDLED_EXTENSION_IDS && existsSync(/bundled-extensions/)`. Outside Electron (no `process.resourcesPath`), always `false`. -- [x] 2.5 Update `packages/server/src/__tests__/package-routes.test.ts` to cover the new fields: a recommended npm row, a non-recommended git row, a row with missing `installedPath`, and a row with a present-but-unreadable `package.json`. - -## 3. Client: extract `` component - -- [x] 3.1 Create `packages/client/src/components/PackageRow.tsx` exporting a generic row that takes the props described in `design.md` Decision 3 (`displayName`, `source`, `sourceType`, `isBundled`, `isDev`, `currentVersion`, `latestVersion`, `updateAvailable`, `busy`, `progress`, `error`, `canUpdate`, `canUninstall`, `onUpdate`, `onUninstall`, `onViewReadme`, `onReset`). -- [x] 3.2 Move the existing row JSX from `PiCoreVersionsSection.tsx` into `PackageRow.tsx`; verify visual parity in dev mode. -- [x] 3.3 Add a kebab `[⋯]` menu trigger that opens an action list using existing dropdown primitives; populate items conditionally on `canUninstall`, `onViewReadme`, `onReset`. -- [x] 3.4 Compute `sourceType` client-side from the `source` string (npm: prefix → `npm`; git URL or `.git` suffix → `git`; `/`/`./`/`../`/`file://` → `local`; otherwise `global`). -- [x] 3.5 Render badges based on `sourceType` (color-coded), `isBundled` (amber `[bundled]`), `isDev` (italic `[dev]`). - -## 4. Client: build `` - -- [x] 4.1 Create `packages/client/src/components/UnifiedPackagesSection.tsx`. -- [x] 4.2 Use the existing `usePiCoreVersions()` hook for Core data (no API change). -- [x] 4.3 Use the existing `useInstalledPackages()` hook for Recommended + Other data (now returning enriched rows from §2). -- [x] 4.4 Use `useRecommendedExtensions()` (or the shared `matchesRecommendedSource` helper directly) to classify each installed row; build three arrays in priority order Core → Recommended → Other; dedupe so a Core whitelist member never appears in Other. -- [x] 4.5 Render the section header with "Pi Ecosystem" title, "Last checked" timestamp, and "Check Now" button (logic copied from `PiCoreVersionsSection`). -- [x] 4.6 Render three sub-group blocks (Core / Recommended Extensions / Other Packages), each with its own optional sub-header and `Update All (N)` button (Core only). -- [x] 4.7 Wire row callbacks: Core `onUpdate` → `/api/pi-core/update`; Recommended/Other `onUpdate` → `/api/packages/update`; Recommended/Other `onUninstall` → `/api/packages/remove`; `onViewReadme` opens the existing `PackageReadmeDialog`; `onReset` is left unimplemented in this change (deferred to the bundled-extension-update fix; menu item not shown). - -## 5. Client: integrate into SettingsPanel - -- [x] 5.1 In `packages/client/src/components/SettingsPanel.tsx`, replace the `` JSX block AND the inline `
` JSX block with a single ``. -- [x] 5.2 Keep `
` and the existing dialog stack (`PackageInstallConfirmDialog`, `PackageReadmeDialog`) untouched and wired to the new section's onView/onInstall handlers. -- [x] 5.3 Delete the now-unused `PiCoreVersionsSection.tsx` file (its row logic moved to `PackageRow.tsx`, its section frame moved to `UnifiedPackagesSection.tsx`). -- [x] 5.4 Verify no other consumer imports `PiCoreVersionsSection` (`grep -r PiCoreVersionsSection packages/client/src/`). - -## 6. Client tests - -- [x] 6.1 Add `packages/client/src/components/__tests__/UnifiedPackagesSection.test.tsx`: snapshot test with mocked Core (3 rows), Recommended (3 rows), Other (1 row). Cover the dedupe scenario (Core whitelist row also configured in `settings.json`). -- [x] 6.2 Add `packages/client/src/components/__tests__/PackageRow.test.tsx`: render variants for each `sourceType`, badges combinations, `canUpdate=false`, `canUninstall=false`, kebab menu open/close. -- [x] 6.3 Add a classifier unit test (pure function): given a list of installed rows + Core whitelist + recommended manifest, asserts the three-group output, including dedupe. - -## 7. Documentation - -- [x] 7.1 Update `AGENTS.md` `Key Files` table: replace the `PiCoreVersionsSection.tsx` and `Installed Global Packages`-related entries with `UnifiedPackagesSection.tsx` and `PackageRow.tsx`. (Note: AGENTS.md never carried rows for either the old `PiCoreVersionsSection.tsx` or `Installed Global Packages` section. Per the current docs-update protocol that post-dates this proposal, new file rows belong in `docs/file-index-.md`. Added rows for `PackageRow.tsx` and `UnifiedPackagesSection.tsx` to `docs/file-index-client.md` instead.) -- [x] 7.2 Add a short paragraph to `docs/architecture.md` (Settings Panel section if any, or a new "Packages tab" subsection) describing the three-group classification rule. (Updated obsolete `PiCoreVersionsSection` reference at line 800 to `UnifiedPackagesSection`; added new `### Settings → Packages tab` subsection with the three-group classification rule.) -- [x] 7.3 Update `README.md` only if it mentions the previous "Pi Ecosystem" / "Installed Global Packages" naming. (Verified via grep — README.md mentions neither; no edit required.) - -## 8. Verify and ship - -- [x] 8.1 Run `npm test` and ensure all new and existing tests pass. (4496 passed / 10 skipped / 1 file skipped, 197s.) -- [x] 8.2 Run `npm run build` and load Settings → Packages in production mode; visually confirm the three-group rendering with no duplicate rows. (Build green: `vite build` completes in 8.35s, 49 files gzipped 8.81MB → 2.56MB. Manual visual confirmation deferred to user QA.) -- [x] 8.3 Spot-check in dev mode (`npm run dev`) that updates and uninstalls dispatch correctly to the right API per group. (Manual dev-mode spot-check deferred to user QA. Dispatch wiring verified by automated tests in tasks 6.1–6.3.) diff --git a/openspec/changes/archive/2026-05-05-electron-startup-splash/.openspec.yaml b/openspec/changes/archive/2026-05-05-electron-startup-splash/.openspec.yaml deleted file mode 100644 index 86ed27de4..000000000 --- a/openspec/changes/archive/2026-05-05-electron-startup-splash/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: v0.3 -status: active diff --git a/openspec/changes/archive/2026-05-05-electron-startup-splash/proposal.md b/openspec/changes/archive/2026-05-05-electron-startup-splash/proposal.md deleted file mode 100644 index 48f5401fe..000000000 --- a/openspec/changes/archive/2026-05-05-electron-startup-splash/proposal.md +++ /dev/null @@ -1,68 +0,0 @@ -## Why - -On Windows (especially portable / NSIS installer launches from cold disk cache), the Electron app takes 3-8 seconds between double-click and any visible window. During this window the user sees nothing — no splash, no cursor indicator, no taskbar entry — and frequently double-clicks again thinking the launch failed, producing duplicate processes and confusing state. - -The same cold-start happens on macOS and Linux to a lesser degree (typically 1-3s) but is still noticeable on first launch after a reboot. - -Root cause: `app.whenReady()` in `packages/electron/src/main.ts` runs synchronous + async dependency detection (`detectPi`, `detectOpenSpec`, `detectSystemNode`, `isDashboardRunning`) BEFORE creating any window. No visible UI exists until one of `createWizardWindow()` / `createMainWindow()` completes. - -## What Changes - -Add a **splash window** that appears within milliseconds of `app.whenReady()` firing, displays the pi logo + a live status indicator, and closes once the next intended window (wizard or main) is ready to show. - -### New surfaces - -- `packages/electron/src/splash-window.ts` — splash window lifecycle (`createSplashWindow`, `updateSplashStatus`, `closeSplashWindow`). -- `packages/electron/src/splash.html` — self-contained HTML (inline CSS + minimal JS, no bundler). Frameless, transparent, alwaysOnTop. Shows logo, spinner, status line. -- Status updates via `ipcMain → webContents.send("splash:status", text)`. - -### Modified surfaces - -- `packages/electron/src/main.ts` — `app.whenReady()` handler: - - Call `createSplashWindow()` FIRST (before any detection). - - Emit `updateSplashStatus(...)` before each detection / launch phase. - - Transfer to wizard or main window via `ready-to-show` event, then `closeSplashWindow()`. - -### Status messages (user-visible) - -Minimum viable set: - -``` -"Starting…" (initial, from splash.html default) -"Checking Node.js…" (detectSystemNode) -"Detecting pi agent…" (detectPi) -"Checking OpenSpec…" (detectOpenSpec) -"Checking dashboard server…" (isDashboardRunning) -"Opening setup wizard…" (if deps missing) -"Launching dashboard server…" (if server not running) -"Opening dashboard…" (final transition) -``` - -If any phase takes > 2s, the spinner remains animated and the status line reassures the user work is in progress. - -### Out of scope - -- Progress bars or percentages (status text is sufficient and honest). -- Persistent splash on subsequent launches after first-run (splash shows on every launch; cost is near-zero and UX is consistent). -- Custom splash animations beyond a simple CSS spinner. -- Configurability (splash is always on; if a user doesn't want it they can't opt out — this is an end-user UX feature, not a developer preference). - -## Impact - -### Specs affected - -- `electron-shell` — new requirements for splash-window lifecycle + status progression. - -### Code surface - -- **New files:** `splash-window.ts`, `splash.html` (both tiny — `<100` lines total). -- **Edited:** `main.ts` (~15 lines of insertions in `app.whenReady()`). -- **Bundled:** `splash.html` needs to ship in the packaged app. `forge.config.ts` `extraResources` already picks up `src/**/*.html`; verify explicitly. - -### Risk - -Very low. The splash window is additive — if `createSplashWindow()` throws, the rest of `main.ts` still proceeds (wrap in try/catch, log). Worst case: users see the current black-hole behavior if the splash itself fails to render. - -### Migration / rollback - -None required. Purely additive new window, no state persisted, no user-facing config. diff --git a/openspec/changes/archive/2026-05-05-electron-startup-splash/specs/electron-shell/spec.md b/openspec/changes/archive/2026-05-05-electron-startup-splash/specs/electron-shell/spec.md deleted file mode 100644 index 7a8372322..000000000 --- a/openspec/changes/archive/2026-05-05-electron-startup-splash/specs/electron-shell/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -## ADDED Requirements - -### Requirement: Splash window appears immediately on app launch - -The Electron main process SHALL create a splash window as the first action inside `app.whenReady()`, before any dependency detection, module resolution, or server launch work. The splash window SHALL be frameless, transparent, centered, alwaysOnTop, and non-resizable. It SHALL display a visual identity (pi logo + app name), a CSS spinner animation, and a status text line. - -#### Scenario: Cold launch on Windows shows splash within 1 second - -- **GIVEN** a Windows user double-clicks the packaged pi-dashboard executable on a cold-cached disk -- **WHEN** `app.whenReady()` resolves -- **THEN** a splash window SHALL appear within 1 second -- **AND** the splash SHALL be visible continuously until the next intended window (wizard or main) is ready to show -- **AND** no user action SHALL be required to dismiss it - -#### Scenario: Failed splash render does not block startup - -- **GIVEN** the splash window fails to create or render (e.g. GPU crash) -- **WHEN** the error is caught in `app.whenReady()` -- **THEN** the error SHALL be logged -- **AND** the main process SHALL continue to open the wizard or main window as normal - -### Requirement: Status messages progress through detection phases - -The splash window SHALL receive status updates via `webContents.send("splash:status", text)` from the main process. The main process SHALL emit a status update before each detection phase and before each window-transition phase. - -#### Scenario: Each detection phase emits a status update - -- **GIVEN** the main process runs dependency detection -- **WHEN** it invokes `detectSystemNode()`, `detectPi()`, `detectOpenSpec()`, `isDashboardRunning()`, or `launchServer()` -- **THEN** a corresponding status update SHALL be sent to the splash window before that call -- **AND** the status text SHALL be user-readable (e.g. "Checking Node.js…", not "detectSystemNode()") - -### Requirement: Splash closes when the next window is ready - -When the main process creates a wizard or main window, it SHALL close the splash only after the target window's `ready-to-show` event fires. This prevents a visible gap between splash and next window. - -#### Scenario: Splash closes after main window is ready - -- **GIVEN** splash is visible and main window is being created -- **WHEN** the main window emits `ready-to-show` -- **THEN** the splash window SHALL close -- **AND** the main window SHALL be shown in the same animation frame (no black flash) - -#### Scenario: Splash closes after wizard window is ready - -- **GIVEN** splash is visible and dependencies are missing, so the wizard is being created -- **WHEN** the wizard window emits `ready-to-show` -- **THEN** the splash window SHALL close -- **AND** the wizard window SHALL be shown diff --git a/openspec/changes/archive/2026-05-05-electron-startup-splash/tasks.md b/openspec/changes/archive/2026-05-05-electron-startup-splash/tasks.md deleted file mode 100644 index 83d1f0ea3..000000000 --- a/openspec/changes/archive/2026-05-05-electron-startup-splash/tasks.md +++ /dev/null @@ -1,17 +0,0 @@ -## 1. Implementation - -- [x] 1.1 ~~Create `packages/electron/src/splash.html`~~ — NOT NEEDED; splash already exists inline as a data: URL in `main.ts showSplash()`. Updated the inline HTML to add a CSS spinner + persistent status `
` replacing the static 3-blinking-dots. -- [x] 1.2 ~~Create `packages/electron/src/splash-window.ts`~~ — NOT NEEDED; `showSplash`/`closeSplash` already in main.ts. Added `updateSplashStatus(text)` next to them, uses `webContents.executeJavaScript()` to update the status `
` (simpler than IPC, no preload script, no forge.config.ts changes). -- [x] 1.3 Modified `packages/electron/src/main.ts` — wired `updateSplashStatus()` at 6 phases: checking server, detecting pi, checking bridge, opening wizard, launching server (with retry), opening dashboard. -- [x] 1.4 ~~Verify forge.config.ts packages splash.html~~ — NOT APPLICABLE; splash is an inline data: URL, nothing to package. - -## 2. Validation - -- [x] 2.1 `npm run lint` (tsc --noEmit) green -- [x] 2.2 `npm test` green — 2526/2526 -- [x] 2.3 Manual: `cd packages/electron && npm start` — splash appears immediately, status progresses, closes when main window ready _(operator gate)_ -- [x] 2.4 Manual Windows smoke: launch packaged .exe cold, confirm splash visible within 1s with progressing status text _(operator gate)_ - -## 3. Spec sync - -- [x] 3.1 Update `openspec/specs/electron-shell/spec.md` with splash lifecycle requirements (after validation passes) diff --git a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/proposal.md b/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/proposal.md deleted file mode 100644 index 4da9b507b..000000000 --- a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/proposal.md +++ /dev/null @@ -1,47 +0,0 @@ -## Why - -A series of test runs of the Windows Electron ZIP build (PI-Dashboard-win32-x64) revealed five distinct first-run blockers that prevented installation on a clean Windows machine, including one with a non-ASCII / spaced username (`Róbert Csákány`). Each blocker manifested as a different failure mode (silent hang, dead-end error, off-screen window, broken npm, broken git clone), but the user-visible result was the same: the wizard never completes and the dashboard never starts. - -This change captures the reactive bug fixes that landed during that test session so the build pipeline produces a working ZIP and the wizard recovers gracefully from each remaining failure mode. - -## What Changes - -### Build pipeline (`packages/electron/scripts/`) -- **New `build-windows-zip.sh`** — local Windows ZIP build path that mirrors what CI does without needing Docker. Steps: web client build → `bundle-server.mjs` → download Windows Node.js into `resources/node/` → `bundle-offline-packages.mjs` → `electron-forge package` → zip. -- **Lossless extractor** — `build-windows-zip.sh` uses `ditto` (macOS) or `7z` (Linux) to extract the Windows Node.js zip. Bash `unzip` was silently dropping nested files inside `node_modules/npm/` (specifically `minizlib/node_modules/minipass/dist/commonjs/`), causing `class extends value undefined` crashes on every `npm install` post-extraction. -- **Post-extraction sanity check** — explicitly verifies `minizlib/dist/commonjs/package.json` exists before bundling. Build fails loudly instead of producing a broken ZIP. -- **Bump bundled Node.js v22.12.0 → v22.18.0** in `build-installer.sh`, `build-windows-zip.sh`, `docker-make.sh`, `download-node.sh`, and `.github/workflows/publish.yml`. v22.12.0 has nodejs/node#58515 — Fastify crashes immediately at server startup. v22.18.0 is the smallest LTS that fixes it. -- **`bundle-offline-packages.mjs` uses bundled npm** (when target platform matches host) so the offline cache is built with the same npm version (10.9.3 from v22.18.0) that the runtime install will use, avoiding cache-key mismatches. -- **Offline install uses `--prefer-offline`** instead of `--offline`, so cache misses (npm version drift, missing transitive deps) fall back to the registry instead of hard-failing. -- **New `--windows-zip` flag** in `build-installer.sh` and corresponding `electron:zip-windows-docker` npm script: builds Windows ZIP only via Docker, threading `ZIP_ONLY=1` into `docker-make.sh` to skip the NSIS + portable-exe steps. - -### Wizard reliability (`packages/electron/src/lib/`) -- **Pre-clone git extensions with discrete argv** to bypass pi's `DefaultPackageManager.installAndPersist()` shelling `git clone ` without quoting ``. Spaces in destination paths (Windows usernames containing spaces) fail with `git: Too many arguments`. Pre-cloning ourselves with `spawn("git", ["clone", url, dest])` (no shell) makes pi's manager skip its broken clone since the directory already exists. -- **Augment `process.env.PATH` for recommended-extensions install** so pi's manager (which inherits parent env, no override hook) finds bundled npm. Restored after the loop to avoid leakage. -- **Fitness-based npm resolution** — `resolveNpm()` probes ` --version` before committing to managed; falls back to bundled if probe fails (e.g. partial cpSync, MAX_PATH issue, AV interference). -- **Offline install fallback to registry** — `installStandalone()` catches offline-install failures and retries via the registry path so users aren't dead-ended by cache-related issues. -- **Surface real npm errors** — extract `npm error` / `npm ERR!` lines from stderr instead of forwarding the truncated last-500-chars footer ("complete log of this run can be found in: ..."). Lets the user see what actually failed. - -### Wizard UX (`packages/electron/src/renderer/wizard.html`) -- **`node runtime` row prepended** to the standalone-install progress list. The first 10–30 s of `installManagedNode` (copying ~hundreds of node_modules files into `~/.pi-dashboard/node/`) emits progress under step id `node-runtime`, but no UI element existed for it — users saw three empty circles and assumed the wizard was frozen. -- **Per-package fanout** in `runOfflineInstall` — emits `running`/`done`/`error` events for each pinned package id (`pi-coding-agent`, `openspec`, `tsx`) so the matching UI rows update during the single npm install. Previously emitted only under `offline-cache` / `offline-install` step ids that didn't match any UI row. - -### Window state (`packages/electron/src/lib/window-state.ts`) -- **Bounds clamping** against `screen.getAllDisplays()`. If saved `x`/`y` coords land outside every connected display (e.g. user moved the install across machines / monitor layouts), drop them and fall back to centered default. Previously the dashboard window opened off-screen with no way to recover except deleting `window-state.json` manually. - -## Impact - -Affected code: -- `packages/electron/scripts/build-installer.sh` — `--windows-zip` flag, `ZIP_ONLY` env-var threading, Node version bump. -- `packages/electron/scripts/build-windows-zip.sh` (new) — local Windows ZIP path with lossless extractor and sanity check. -- `packages/electron/scripts/bundle-offline-packages.mjs` — prefers bundled npm when target=host. -- `packages/electron/scripts/docker-make.sh` — `ZIP_ONLY` skip-portable, Node version bump. -- `packages/electron/scripts/download-node.sh` — Node version bump. -- `packages/electron/src/lib/dependency-installer.ts` — fitness-based npm resolution, registry fallback, pre-clone helper, PATH augmentation, real npm error surface. -- `packages/electron/src/lib/offline-packages.ts` — `--offline` → `--prefer-offline`. -- `packages/electron/src/lib/window-state.ts` — display bounds clamping. -- `packages/electron/src/renderer/wizard.html` — node-runtime row + per-package fanout. -- `.github/workflows/publish.yml` — bundled Node version bump (v22.18.0). -- `package.json` — new npm scripts: `electron:zip-windows`, `electron:zip-windows-docker`, `electron:bundle-server`, `electron:bundle-server:source-only`. - -No protocol or API changes. No new dependencies. All fixes are additive (older / non-affected code paths still work). diff --git a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/specs/electron-wizard-install/spec.md b/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/specs/electron-wizard-install/spec.md deleted file mode 100644 index 502e95e93..000000000 --- a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/specs/electron-wizard-install/spec.md +++ /dev/null @@ -1,91 +0,0 @@ -## ADDED Requirements - -### Requirement: Fitness-based npm resolution -`dependency-installer.ts::resolveNpm()` SHALL probe ` --version` (5 s timeout) before returning the managed npm command. If the probe fails (non-zero exit, malformed stdout, or thrown error), the resolver SHALL fall through to the bundled npm in `resources/node/`. - -#### Scenario: managed npm is corrupt -- **WHEN** managed Node copy at `~/.pi-dashboard/node/` exists but ` --version` exits non-zero or hangs -- **THEN** `resolveNpm()` SHALL fall back to bundled npm -- **AND** the wizard install SHALL still proceed using the bundled binary - -#### Scenario: managed npm works -- **WHEN** the probe returns a `\d+.\d+.\d+`-shaped version string within 5 s -- **THEN** `resolveNpm()` SHALL return the managed npm command (preserving the canonical install for server + bridge to share) - -### Requirement: Offline install falls back to registry on failure -`installStandalone` SHALL wrap `runOfflineInstall` in a try/catch. On any failure (cache key mismatch, npm crash, missing transitive dep, non-ASCII path issue), the wizard SHALL emit `offline-install` error progress and continue with the registry-install code path so the user is never dead-ended. - -#### Scenario: offline cache crashes during install -- **WHEN** `runOfflineInstall` throws -- **THEN** `installStandalone` SHALL log the failure -- **AND** emit `running` progress for each per-package row labelled "Falling back to registry…" -- **AND** invoke `sharedBootstrapInstall` to install via the live registry - -### Requirement: Pre-clone git extensions to bypass pi's broken shell-quoting -For every recommended extension whose `source` is a git URL, `installRecommendedExtensions` SHALL pre-clone the repo to its destination path using `spawn("git", ["clone", url, dest])` (no shell, discrete argv) before invoking pi's `DefaultPackageManager.installAndPersist()`. The destination SHALL be `/git//` to match where pi's manager expects the cache. - -#### Scenario: Windows username with space pre-clones successfully -- **WHEN** the destination is `C:\Users\Róbert Csákány\.pi\agent\git\github.com\BlackBeltTechnology\pi-anthropic-messages` -- **THEN** `git clone` SHALL succeed because spaces in the destination are not re-split by any shell -- **AND** pi's subsequent `installAndPersist` SHALL skip its own (broken) clone because the directory exists - -#### Scenario: npm-source extension is not pre-cloned -- **WHEN** the source is `npm:` or any non-git URL -- **THEN** `preClonePiExtensionIfGit` SHALL be a no-op -- **AND** pi's manager SHALL handle the install normally - -### Requirement: PATH augmentation for recommended extensions install -`installRecommendedExtensions` SHALL temporarily set `process.env.PATH` to include the bundled / managed Node.js bin directory for the duration of the install loop. The original PATH SHALL be restored in a `finally` block. This ensures pi's `DefaultPackageManager` (which inherits parent process env, with no override hook) finds `npm` / `npm.cmd` when shelling `npm install -g `. - -#### Scenario: PATH is augmented during loop -- **WHEN** the install loop runs -- **THEN** `process.env.PATH` SHALL contain the bundled / managed Node.js bin directory -- **AND** any child process spawned by pi's manager SHALL inherit it - -#### Scenario: PATH is restored after loop -- **WHEN** the loop completes (success, failure, or thrown exception) -- **THEN** `process.env.PATH` SHALL be reset to its value before the loop ran - -### Requirement: Real npm error surfaced from stderr -`runNpmWithArgv` SHALL parse stderr for lines matching `^npm (error|ERR!)` (excluding the "A complete log of this run can be found in: ..." footer) and use those lines as the rejected error message. The fallback (when no error lines match) is the last 500 chars of stderr. - -#### Scenario: npm install fails with structured error lines -- **WHEN** stderr contains `npm error Class extends value undefined is not a constructor or null` followed by `npm error A complete log of this run can be found in: ...` -- **THEN** the rejected error SHALL contain "Class extends value undefined is not a constructor or null" -- **AND** SHALL NOT include only the footer - -### Requirement: Wizard renders node-runtime row -The standalone-install wizard step SHALL render a progress row keyed `prog-node-runtime` as the first item in `progress-list`. This row SHALL transition `pending → running → done` driven by `installManagedNode`'s progress events under step id `node-runtime`. - -#### Scenario: node-runtime row visible during managed Node copy -- **WHEN** the wizard reaches the standalone install step -- **THEN** the row labelled "node runtime" SHALL be visible -- **AND** SHALL show a spinning icon while `installManagedNode` is copying files -- **AND** SHALL show `✓` when the copy completes - -### Requirement: Per-package progress fanout in offline install -`runOfflineInstall` SHALL emit `running`, `done`, and `error` progress events under each pinned package's UI step id (the package basename, e.g. `pi-coding-agent`, `openspec`, `tsx`) in addition to the existing `offline-install` step id. This allows the wizard's per-package rows to update live during the single npm install. - -#### Scenario: all package rows transition to running together -- **WHEN** `runOfflineInstall` begins the npm install spawn -- **THEN** the wizard rows for every outstanding pinned package SHALL transition to `running` - -#### Scenario: all package rows transition to done together -- **WHEN** the npm install spawn exits successfully -- **THEN** all matching wizard rows SHALL transition to `done` - -#### Scenario: all package rows transition to error on failure -- **WHEN** the npm install spawn throws -- **THEN** all matching wizard rows SHALL transition to `error` with the surfaced message - -### Requirement: Window state coords clamped to visible displays -`window-state.ts::loadWindowState()` SHALL validate any persisted `x`/`y` against `screen.getAllDisplays()` work areas. If the window rect (using saved width/height) does not have at least 50×50 visible on any display, the saved coords SHALL be discarded and the window SHALL fall back to the centered default. - -#### Scenario: saved coords are off-screen -- **WHEN** the saved `x`/`y` lands the window entirely off all connected displays -- **THEN** `loadWindowState()` SHALL return state with `x` and `y` undefined -- **AND** the BrowserWindow SHALL open at the OS default centered position - -#### Scenario: saved coords are on-screen -- **WHEN** the saved `x`/`y` overlaps any display by ≥ 50×50 -- **THEN** `loadWindowState()` SHALL preserve the coords diff --git a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/specs/windows-zip-build/spec.md b/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/specs/windows-zip-build/spec.md deleted file mode 100644 index e1b99c259..000000000 --- a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/specs/windows-zip-build/spec.md +++ /dev/null @@ -1,40 +0,0 @@ -## ADDED Requirements - -### Requirement: Local Windows ZIP build script with lossless extractor -The script `packages/electron/scripts/build-windows-zip.sh` SHALL produce a Windows ZIP artifact under `packages/electron/out/make/zip//PI-Dashboard-win32-.zip` from a single command. It SHALL extract the Node.js Windows distribution losslessly (`ditto` on macOS, `7z` when present, fallback to `unzip` with post-extraction file-count validation) so that nested `node_modules` files are not silently dropped. - -The script SHALL run, in order: web client build (skippable via `--skip-client`), `bundle-server.mjs` (full install), Windows Node.js download, `bundle-offline-packages.mjs --platform=win32-`, `electron-forge package --platform win32 --arch `, and zip creation. - -#### Scenario: macOS host produces a Windows ZIP without dropped files -- **WHEN** `build-windows-zip.sh` runs on a macOS host with `ditto` available -- **THEN** the produced ZIP's `resources/node/node_modules/npm/node_modules/minizlib/dist/commonjs/package.json` SHALL be present -- **AND** the produced ZIP's nested `minipass` package SHALL be the v7+ shape with `Minipass` named export - -#### Scenario: extraction failure aborts the build -- **WHEN** the post-extraction sanity check finds `minizlib/dist/commonjs/package.json` missing -- **THEN** the script SHALL exit non-zero with an actionable error message naming the missing file and suggesting `ditto` / `7z` install - -#### Scenario: bundled Node version is at least v22.18.0 -- **WHEN** `build-windows-zip.sh`, `build-installer.sh`, `docker-make.sh`, `download-node.sh`, or the publish workflow downloads Node.js -- **THEN** the version SHALL be `v22.18.0` or higher (avoids nodejs/node#58515 Fastify-startup crash) - -### Requirement: Offline cache built with bundled npm when host matches target -`packages/electron/scripts/bundle-offline-packages.mjs` SHALL detect when the build host platform matches the target platform and a bundled Node distribution exists at `packages/electron/resources/node/`. In that case it SHALL invoke the bundled `node.exe` + `npm-cli.js` to build the cacache snapshot, ensuring the cache uses the same npm major.minor version as the runtime install. - -#### Scenario: Windows host with bundled npm uses bundled npm -- **WHEN** the script runs on a Windows host targeting `win32-x64` and `resources/node/node.exe` + `node_modules/npm/bin/npm-cli.js` exist -- **THEN** the script SHALL log `using bundled npm: ` -- **AND** the cacache SHALL be populated by spawning the bundled binary - -#### Scenario: cross-build host falls back to system npm -- **WHEN** the script runs on macOS targeting `win32-x64` -- **THEN** the script SHALL log a parity warning and fall back to system npm -- **AND** SHALL still produce a valid cacache (npm cache integrity hashes are universal across npm versions) - -### Requirement: Docker Windows ZIP-only build path -`build-installer.sh` SHALL accept a `--windows-zip` flag that triggers a Docker build producing only the Windows ZIP artifact (no NSIS installer, no portable exe). The flag SHALL set the `ZIP_ONLY=1` environment variable inside the Docker container, which `docker-make.sh` honors by skipping the `electron-builder --win portable` step. - -#### Scenario: Windows ZIP-only Docker build skips portable exe -- **WHEN** `build-installer.sh --windows-zip` runs and Docker succeeds -- **THEN** the artifacts directory SHALL contain `out/make/zip//*.zip` -- **AND** SHALL NOT contain `out/make/portable//*.exe` diff --git a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/tasks.md b/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/tasks.md deleted file mode 100644 index f905c61e1..000000000 --- a/openspec/changes/archive/2026-05-05-fix-windows-electron-zip-install/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -## 1. Build pipeline - -- [x] 1.1 Bump bundled Node.js v22.12.0 → v22.18.0 in `download-node.sh`, `build-installer.sh`, `build-windows-zip.sh`, `docker-make.sh`, `.github/workflows/publish.yml`. Avoids nodejs/node#58515 Fastify-startup crash. -- [x] 1.2 Create `packages/electron/scripts/build-windows-zip.sh` — local Windows ZIP path (no Docker, no NSIS, no portable). Steps: web client → bundle-server → download Node → bundle-offline-packages → forge package → zip. -- [x] 1.3 Use lossless extractor (`ditto` on macOS, `7z` on Linux, `unzip` + file-count-check fallback) for the Windows Node.js zip. Bash `unzip` was silently dropping nested files. -- [x] 1.4 Sanity check after extraction: assert `minizlib/dist/commonjs/package.json` exists. Fail build with actionable error if missing. -- [x] 1.5 `bundle-offline-packages.mjs` uses bundled npm when target OS matches host (cache built with same npm version that runtime uses). -- [x] 1.6 Add `--windows-zip` flag to `build-installer.sh` (Docker path) threading `ZIP_ONLY=1` into `docker-make.sh` to skip portable-exe step. -- [x] 1.7 Add npm scripts: `electron:zip-windows`, `electron:zip-windows-docker`, `electron:bundle-server`, `electron:bundle-server:source-only`. - -## 2. Wizard install reliability - -- [x] 2.1 In `dependency-installer.ts::resolveNpm()`, probe ` --version` (5s timeout) before committing to managed npm; fall back to bundled if probe fails. -- [x] 2.2 In `installStandalone`, catch offline-install failures and retry via registry install path. Reset per-package UI rows to "running" with `Falling back to registry…` message. -- [x] 2.3 Switch `buildOfflineInstallArgs` from `--offline` to `--prefer-offline` so cache misses fall back to network instead of hard-failing. -- [x] 2.4 Pre-clone git-source recommended extensions with `spawn("git", ["clone", url, dest])` (no shell) before pi's `DefaultPackageManager.installAndPersist()` runs. Bypasses pi's broken shell-quoting on paths with spaces. -- [x] 2.5 In `installRecommendedExtensions`, augment `process.env.PATH` with bundled/managed node bin dir for the duration of the install loop. Restore after loop. -- [x] 2.6 In `runNpmWithArgv`, extract `npm error` / `npm ERR!` lines from stderr and surface them as the rejected error message instead of the truncated last-500-chars footer. - -## 3. Wizard UX - -- [x] 3.1 Add `node runtime` row to the standalone-install progress list (`wizard.html`). `installManagedNode` emits progress under step id `node-runtime`; UI now has a row to display it during the 10–30 s file copy. -- [x] 3.2 In `runOfflineInstall`, fan out `running`/`done`/`error` events to each package step id (matching wizard UI rows) instead of only emitting under `offline-cache` / `offline-install` ids that no UI row consumes. - -## 4. Window state - -- [x] 4.1 In `window-state.ts::loadWindowState()`, clamp saved coords to displays' `workArea`. If no display has at least 50×50 of the window visible, drop x/y to fall back to centered default. - -## 5. Tested manually on Windows test machine - -- [x] 5.1 Wizard: dependency install completes (`pi-coding-agent`, `openspec`, `tsx`). -- [x] 5.2 Wizard: recommended extensions install (after PATH augmentation fix). -- [x] 5.3 Dashboard window opens after wizard completes (manually verified via `set PATH=...; pi-dashboard.exe` workaround equivalent to the committed PATH-augmentation fix). -- [x] 5.4 Server starts and serves the dashboard at http://localhost:8000. diff --git a/openspec/changes/archive/2026-05-05-platform-path-normalization/.openspec.yaml b/openspec/changes/archive/2026-05-05-platform-path-normalization/.openspec.yaml deleted file mode 100644 index c8af3f5f4..000000000 --- a/openspec/changes/archive/2026-05-05-platform-path-normalization/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-19 diff --git a/openspec/changes/archive/2026-05-05-platform-path-normalization/design.md b/openspec/changes/archive/2026-05-05-platform-path-normalization/design.md deleted file mode 100644 index ae6d402b9..000000000 --- a/openspec/changes/archive/2026-05-05-platform-path-normalization/design.md +++ /dev/null @@ -1,200 +0,0 @@ -## Context - -Path handling in the dashboard is currently spread across three layers: - -``` - client server shared - ───── ────── ────── - PathPicker.tsx directory-handler.ts (nothing) - parseInput() — Unix-only safeRealpathSync() + raw - descendInto() — "/" append string store - - PinDirectoryDialog preferences-store.ts - .replace(/\/+$/, "") || "/" raw-string equality - - session-grouping.ts browse.ts - Map, Set uses node:path correctly - exact match by raw cwd (good example) -``` - -Every site makes its own implicit assumptions. On Windows, the effects are visible in pin-directory: a session's `cwd` might arrive as `B:\Dev\BB\pi-agent-dashboard` while a pinned entry is stored as `B:\Dev\BB\pi-agent-dashboard\` or `B:\Dev\BB\pi-agent-dashboard/`, and the client's exact-string match drops them into separate groups. - -The `packages/shared/src/platform/` package already solves exactly this problem class for *binaries* (`binary-lookup.ts` — OS-aware PATH search with PATHEXT on Windows), *processes* (`process.ts` — netstat/lsof/taskkill abstractions), and *shells* (`shell.ts` — `detectShell()` across platforms). Adding a `paths.ts` module is a direct continuation of that pattern. - -### Reference implementation already nearby - -`packages/server/src/browse.ts` already uses `node:path` correctly: - -```ts -import path from "node:path"; -const parent = resolved === "/" ? null : path.dirname(resolved); -``` - -That's the level of correctness we want *every* caller to have, via a shared helper. - -## Goals / Non-Goals - -**Goals:** - -- One module owns every path primitive the dashboard uses for user-visible paths. -- Platform-correct equality (`samePath`) — Windows/macOS-HFS case-insensitive, Linux case-sensitive. -- Platform-correct input parsing (`parsePathInput`) — works for Windows drive letters (`C:\`), UNC paths (`\\server\share`), and Unix absolute paths (`/`). -- Pin/unpin, session grouping, path picker all use the shared primitive; no caller invents its own. -- One-time migration on preferences load — old stored paths get normalized in place. -- Fully unit-tested with platform injection (no `process.platform` mutation in tests). - -**Non-Goals:** - -- Not a general-purpose path manipulation library. We expose the minimum surface the dashboard actually needs today; additions are by explicit spec change. -- Not changing the server's `browse.ts` — it's already correct. Leaving it alone. -- Not implementing cross-machine path portability (translating Windows paths on macOS). Machine-local paths stay machine-local. The `pathsForSync` helper is a diagnostic flag, not a translator. -- Not replacing `safeRealpathSync` — realpath is orthogonal to normalization and stays where it is. -- Not a refactor of every path string in the codebase — scope is pin-directory + session-grouping + path picker, which is where users hit bugs today. Additional migrations can follow. - -## Decisions - -### 1. Module lives at `packages/shared/src/platform/paths.ts` - -Follows the existing platform-primitives convention. Exported from `platform/index.ts` as a namespace (`paths.normalizePath(...)`, `paths.samePath(...)`) alongside `git`, `openspec`, `npm`. This keeps the dashboard's "OS-aware stuff" visually unified. - -**Alternative considered:** put it at `packages/shared/src/paths.ts` (outside `platform/`). Rejected — path handling is exactly what the platform namespace is for; splitting it out fragments the mental model. - -### 2. Primitives take an injectable `platform: NodeJS.Platform` argument (default = `process.platform`) - -Mirrors the pattern already used by `packages/shared/src/platform/commands.ts` (`openBrowser`, `isVirtualMachine`) and `platform/process-scan.ts`. Tests exercise both Windows and Unix branches by passing `"win32"` or `"linux"` explicitly — no `vi.mock` of `process.platform`. - -```ts -export function normalizePath(p: string, platform: NodeJS.Platform = process.platform): string; -export function samePath(a: string, b: string, platform: NodeJS.Platform = process.platform): boolean; -export function parsePathInput(value: string, platform: NodeJS.Platform = process.platform): { parent: string; partial: string }; -``` - -This is the established test-friendly idiom in the platform package. Documented invariant in `AGENTS.md` already: "All exported helpers that depend on OS take an optional `platform: NodeJS.Platform` parameter." - -### 3. `samePath` semantics are the filesystem's, not the string's - -Equality rules: - -| Platform | Case | Separator | Trailing sep | UNC / drive | -|---|---|---|---|---| -| `win32` | case-insensitive | `\` and `/` interchangeable | ignored | `C:\foo` == `c:\foo`; `\\srv\share` preserved | -| `darwin` | case-insensitive (HFS+ default) | `/` only | ignored | n/a | -| `linux` | case-sensitive | `/` only | ignored | n/a | - -Implementation: run both inputs through `normalizePath` with the same platform, then string-compare (case-folded for Windows/macOS). - -**macOS caveat:** APFS in case-sensitive mode does exist but is rare and opt-in. Matching HFS+ default behavior (case-insensitive) is the right default for 99% of macOS users. Documented as a known limitation. - -**Alternative considered:** normalize-and-compare only on stored paths (not on session cwds). Rejected — the whole point is that drift between session `cwd` (reported by pi) and pinned storage (written by dashboard) is what breaks grouping today. Both sides must use `samePath`. - -### 4. `normalizePath` uses `node:path.normalize` + `.resolve` but preserves case as reported - -``` -normalizePath("C:\\Dev\\BB\\pi-agent-dashboard\\", "win32") - → path.win32.resolve(input) // "C:\\Dev\\BB\\pi-agent-dashboard" - → separator collapse (\\+ → \) // already handled by resolve - → drop trailing sep // resolve already does this - → return as-is (preserve case) // Windows FS preserves case -``` - -We do NOT lowercase. `samePath` folds at compare time, not at storage time. This keeps the stored path human-readable (`Dev\BB` not `dev\bb`). - -**Alternative considered:** always store lowercase on Windows for consistency. Rejected — UI would show ugly lowercase paths on Windows where the user expects title-case, and the original case *is* correct per the filesystem. - -### 5. `parsePathInput` is the client's ONLY path-parsing entry point - -Replaces `PathPicker.tsx`'s inline `parseInput`. Same signature (`{ parent, partial }`), but handles: - -- Windows drive letters: `"C:\\Users\\m"` → `{ parent: "C:\\Users", partial: "m" }`; `"C:\\"` → `{ parent: "C:\\", partial: "" }`. -- UNC paths: `"\\\\server\\share\\path"` — parent splits on path segments, not on the `\\server\share` root. -- Unix absolute: `"/Users/me/Dev"` → `{ parent: "/Users/me", partial: "Dev" }`. -- Unix root: `"/"` → `{ parent: "/", partial: "" }`. -- Mixed-separator Windows input from previous picker state: `"C:\\Users\\m/Dev"` treated as if all `\` — `parent: "C:\\Users\\m"`, `partial: "Dev"`. - -### 6. Client needs to know the OS to parse correctly - -The client runs in the browser; it doesn't have `process.platform`. Two options: - -a) **Server ships platform in the browse response.** `BrowseResult` already returns `{ entries, parent, current }`. Add `platform: "win32" | "darwin" | "linux"` — one field. Client caches the last-seen value. - -b) **Client sniffs the path.** If the input contains `\` or matches `/^[A-Za-z]:/`, treat as Windows. Heuristic. - -**Decision: (a).** Deterministic, matches the server's truth. Small protocol change in `BrowseResult`. Backward-compatible: old clients ignore the field. - -### 7. Windows multi-drive invariants (A:, B:, C:, …) and UNC paths - -Windows has a separate filesystem root per drive letter, plus UNC (`\\server\share`) roots. The primitive must treat these as completely independent namespaces — `A:\Foo` and `B:\Foo` have nothing to do with each other even when their path tails are identical. - -Node's `path.win32.resolve` handles multi-drive correctly out of the box: - -| Input | `path.win32.resolve` | Notes | -|---|---|---| -| `B:\Dev\BB` | `B:\Dev\BB` | each drive is its own root | -| `A:\Foo\Bar` | `A:\Foo\Bar` | different drive, independent | -| `D:\\` | `D:\` | trailing slash collapsed | -| `B:/Dev/BB` | `B:\Dev\BB` | separator conversion | -| `B:\Dev\..\BB` | `B:\BB` | `..` resolved within drive | -| `\\server\share\dir` | `\\server\share\dir` | UNC root preserved | - -So `normalizePath` gets multi-drive right by delegation. The spec-level invariants this change codifies on top: - -- **`samePath` never merges different drives.** `A:\x` and `B:\x` return `false`. Same for any UNC vs drive-letter cross-comparison. Case-folding only applies *within* a drive. -- **Drive-letter case IS case-insensitive.** `B:\Dev` and `b:\Dev` are the same path. Windows filesystem treats drive letters as case-insensitive, and so does `samePath`. -- **Bare drive letter `B:` (without backslash) is treated as drive root.** The Windows semantic for `B:` alone is "current directory on the B drive", which Node's `path.win32.resolve` implements by falling back to `process.cwd()`. That's cwd-dependent and useless for a pin dialog where the user clearly means "go to the root of B drive." `parsePathInput` shortcuts this: `B:` → `{ parent: "B:\\", partial: "" }` without touching `path.win32.resolve`. -- **Drive-relative typed form `B:Dev` is also treated as drive-root-plus-partial.** Windows would interpret this as `\Dev`, which is not what a user typing in a picker means. We interpret it defensively: `{ parent: "B:\\", partial: "Dev" }`. - -**One existing bug this work surfaces (outside the primitive, in `server/src/browse.ts`):** - -```ts -// current: -const parent = resolved === "/" ? null : path.dirname(resolved); -``` - -This only recognizes the Unix root. On Windows, `path.dirname("B:\\")` returns `"B:\\"` (a root is its own parent), so `parent` is never `null` for `B:\`, `C:\`, or `\\server\share\`. The picker then shows a `..` entry at the drive root that does nothing. Fix: detect "is this a filesystem root" via `path.parse(resolved).root === resolved`. This is a small follow-up in the same migration (see tasks.md §4). - -### 8. Preferences store migrates on load - -In `createPreferencesStore`: - -```ts -const rawPinned = data.pinnedDirectories ?? []; -let pinnedDirectories = rawPinned - .map(normalizePath) // NEW: normalize first - .map(safeRealpathSync); // then resolve symlinks (existing) -pinnedDirectories = [...new Set(pinnedDirectories)]; -``` - -If the normalized/realpathed result differs from the on-disk form, the store marks itself dirty and writes on the next debounce tick. Users on stable paths see nothing; users with drifty entries see one silent rewrite. - -## Risks / Trade-offs - -- **Risk:** Client's `parsePathInput` needs the server's platform, but the server's platform is only known after the first `browseDirectory` response. First render might use a wrong default. - **Mitigation:** The very first `useEffect` in `PathPicker` already fetches from the server before the user can type anything meaningful (there's a loading state). Cache the platform in a React context seeded from the first `/api/health` response (which already runs at app load and can include `platform` trivially). Fallback default: `process.platform` equivalent derived from `navigator.userAgent` — crude but acceptable for the 100ms before the real answer arrives. - -- **Risk:** `samePath`-keyed `Map` requires a custom key derivation because plain `Map` is string-keyed. Naive fix is to use the normalized string as the key — cheap, but loses the original case. - **Mitigation:** Key by the *normalized* string (used only for grouping), store the *original* on the value (used for display). `DirectoryGroup.cwd` already exists as a separate field, so display keeps the original. - -- **Risk:** macOS APFS case-sensitive mode users get unexpected merging (e.g., `Projects` and `projects` collapse into one group). - **Mitigation:** Matches macOS Finder behavior — this is what users expect. Document in the module JSDoc. - -- **Risk:** Stored preferences.json from before this change might have paths like `B:\Dev\BB\pi-agent-dashboard\` (trailing sep). After migration, entries collapse — if the same path appears twice (once with slash, once without), the dedup `Set` silently loses one. - **Mitigation:** That's the desired behavior — they were duplicates anyway. - -- **Trade-off:** Adding a module + migrating three call sites is more work than a one-line `path.resolve` in the server handler. Accepted because the bug class recurs (we'll see the same issue when someone adds a different path-taking REST endpoint or UI control), and a shared primitive is the definition of "don't solve this problem again." - -## Migration Plan - -1. **Phase 1 — Additive:** Ship `platform/paths.ts` + tests. Nothing uses it yet. Risk-free. -2. **Phase 2 — Server:** Migrate `directory-handler.ts` + `preferences-store.ts`. Existing stored paths get normalized on next server boot. Users see nothing unless they had drifty entries (in which case the drift heals). -3. **Phase 3 — Client:** Migrate `session-grouping.ts` (uses `samePath` via normalized key). Migrate `PinDirectoryDialog` trailing-slash strip. Migrate `PathPicker.parseInput` — this is the biggest client change; run it last so the server side is already correct when the UI ships. -4. **Phase 4 — Docs:** `docs/architecture.md` gets a "Path handling" subsection under "Platform primitives". `AGENTS.md` gets the `platform/paths.ts` entry. README Troubleshooting gets a "pinned folder doesn't group my sessions" entry. -5. **Rollback:** Each phase revertable independently. Phase 1 is pure addition. Phase 2 revert just removes the normalize calls. Phase 3 revert restores the Unix-only `parseInput`. - -## Open Questions - -- **Q1:** Should `BrowseResult.platform` land as part of this change, or is it worth its own tiny protocol change? *(Leaning: include it here — it's a one-line protocol extension and the reason is pure consequence of this work.)* -- **Q2:** Do we want `paths.pathsForSync(p)` (flag machine-local paths) in v1, or defer? It's a diagnostic helper for a future "your preferences.json has Windows paths, you probably shouldn't sync it" warning. *(Leaning: defer. Named as non-goal for now; trivial to add later.)* -- **Q3:** Should session grouping ALSO normalize the *display* path (currently the raw `cwd`)? *(Leaning: no — display the original case and separator as pi reports it; only normalize for comparison. UI expectation is that the path "looks right".)* -- **Q4:** Is there any existing test infrastructure for client-side React components that tests `PathPicker` behaviour? If not, do we need to add one now? *(Check during implementation — existing `known-servers-sections.test.ts` is logic-only, so we'd test `parsePathInput` as a pure function and skip DOM assertions.)* -- **Q5:** Should the primitive also handle Windows extended-length paths (`\\?\C:\very\long\...`, `\\?\UNC\server\share\...`)? *(Out of scope for v1 — dashboard paths don't hit the 260-char MAX_PATH limit in practice. Easy follow-up later if needed.)* -- **Q6:** NTFS alternate data stream suffixes (`file.txt:stream-name`) — does the primitive strip them? *(No, treat them as part of the path. The filesystem does. No dashboard feature manipulates them today.)* diff --git a/openspec/changes/archive/2026-05-05-platform-path-normalization/proposal.md b/openspec/changes/archive/2026-05-05-platform-path-normalization/proposal.md deleted file mode 100644 index c554a8bac..000000000 --- a/openspec/changes/archive/2026-05-05-platform-path-normalization/proposal.md +++ /dev/null @@ -1,46 +0,0 @@ -## Why - -Filesystem path handling is scattered across the client, server, and shared layers with ad-hoc Unix-style assumptions, causing cross-OS bugs — most visibly in **pin directory**, where paths captured on Windows drift between session `cwd` and pinned-directory storage (mixed separators, trailing separators, drive-letter case), so sessions don't group under their pinned folder. The dashboard already has a `packages/shared/src/platform/` package that owns OS-aware primitives (binary lookup, process spawning, shell detection, subprocess execution); path normalization is conceptually identical and belongs in the same place. Today there is no shared path primitive, and each call site invents its own (incorrect) logic. - -## What Changes - -- Add a new `packages/shared/src/platform/paths.ts` module that exposes OS-aware path primitives: - - `normalizePath(p)` — canonicalize separators to the OS-native form, strip trailing separators (except roots), collapse `..` and `.` segments, preserve case as reported by the filesystem when resolvable. - - `samePath(a, b)` — platform-aware equality (case-insensitive on Windows/macOS-HFS, case-sensitive on Linux), accepting any mix of separators. - - `parsePathInput(value)` — OS-aware equivalent of today's client-side `parseInput`: splits an in-progress user-typed path into `{ parent, partial }` using the OS's separator, with correct handling of Windows drive letters, UNC paths, and Unix roots. - - `joinForDisplay(parent, child)` / `withTrailingSep(p)` — small composition helpers used by the path picker. - - `pathsForSync(p)` — **flag whether a stored path is machine-local** (has drive letter / `~` expansion / realpathed symlink) so downstream code can warn if `preferences.json` is being synced across machines. -- Extend `packages/shared/src/platform/index.ts` to export the new module as a namespace (`paths.*`) alongside `git.*`, `openspec.*`, `npm.*`. -- Migrate call sites to use the new primitives: - - **Client**: `PathPicker.tsx` (`parseInput` + `descendInto`), `PinDirectoryDialog.tsx` (trailing-separator strip + root fallback), `session-grouping.ts` (map/set lookups become `samePath`-keyed). - - **Server**: `browser-handlers/directory-handler.ts` `handlePinDirectory` / `handleUnpinDirectory` / `handleReorderPinnedDirs` normalize before `safeRealpathSync`. - - **Server**: `preferences-store.ts` normalizes on load so pre-existing entries in `preferences.json` migrate forward. -- Document the new module in `docs/architecture.md` and add it to the "Platform primitives" section. - -**BREAKING (storage)**: Normalization on load will rewrite entries in `~/.pi/dashboard/preferences.json` the first time the server reads the file with this change present. The on-disk format is unchanged; only the individual string values may change (e.g., `B:\Dev\BB\pi-agent-dashboard\` → `B:\Dev\BB\pi-agent-dashboard`). - -## Capabilities - -### New Capabilities - -- `platform-paths`: OS-aware path normalization, comparison, and user-input parsing primitives. Lives in `packages/shared/src/platform/paths.ts` alongside other platform primitives. This is the canonical answer to "how do we compare / normalize / display filesystem paths" across the whole dashboard. - -### Modified Capabilities - -- `directory-path-display`: Session grouping and pinned-directory rendering use the new `samePath` primitive for equality, so sessions with drift in separator / case / trailing slash group under their pinned folder. Today's exact-string match in `groupSessionsByDirectory` becomes `samePath`-keyed. - -## Impact - -- **New module**: `packages/shared/src/platform/paths.ts` (+ tests) + `packages/shared/src/platform/__tests__/platform-paths.test.ts`. -- **Modified files**: - - `packages/shared/src/platform/index.ts` — add `paths.*` namespace export. - - `packages/client/src/components/PathPicker.tsx` — replace inline `parseInput` / separator handling with `paths.parsePathInput` + `paths.joinForDisplay`. - - `packages/client/src/components/PinDirectoryDialog.tsx` — replace Unix-only trailing-slash strip with `paths.normalizePath`. - - `packages/client/src/lib/session-grouping.ts` — replace `Map` / `Set` lookups with `samePath`-keyed helpers. - - `packages/server/src/browser-handlers/directory-handler.ts` — normalize on pin / unpin / reorder before storage. - - `packages/server/src/preferences-store.ts` — normalize on load (and deduplicate post-normalization). - - `docs/architecture.md` — add a "Path handling" subsection under "Platform primitives". -- **Dependencies**: Uses only `node:path` and `node:fs` — no new deps. -- **Platforms**: Fixes observable cross-OS pin-directory bugs on Windows; no behavior change on macOS/Linux beyond equality becoming tolerant of trailing-separator / case drift (tolerance that matches OS semantics). -- **Risk**: Medium. Touches the UI path-picker component and the session-grouping map keying — both are user-visible. Mitigated by (a) the primitive being pure and fully unit-tested, (b) a one-time normalize-on-load for existing preferences, (c) no wire-format change. -- **Supersedes**: This is the canonical home for path-handling primitives going forward. Any future OS-path-related work (symlink behavior, UNC paths, drive-letter normalization, macOS `/private` prefix handling) extends this module rather than re-inventing per-call-site. diff --git a/openspec/changes/archive/2026-05-05-platform-path-normalization/specs/directory-path-display/spec.md b/openspec/changes/archive/2026-05-05-platform-path-normalization/specs/directory-path-display/spec.md deleted file mode 100644 index 8053c63ca..000000000 --- a/openspec/changes/archive/2026-05-05-platform-path-normalization/specs/directory-path-display/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Full path display in group headers -Directory group headers SHALL display the full absolute path instead of only the basename. The displayed path SHALL be the original form as reported by pi (preserving case and separators as the filesystem sees them). For grouping and equality decisions (matching a session's `cwd` to a pinned directory entry), the dashboard SHALL use `platform/paths.samePath` — NOT exact string equality — so that sessions group correctly under their pinned folder even when the stored path and the session's reported `cwd` differ in trailing separator, separator style, or case (per the OS's filesystem semantics). - -#### Scenario: Short path fits available space -- **WHEN** the full path is `/Users/robson/judo-ng` -- **THEN** the group header SHALL display `/Users/robson/judo-ng` - -#### Scenario: Long path exceeds available space -- **WHEN** the full path is longer than the display threshold -- **THEN** the group header SHALL display the path with the middle replaced by `…`, preserving the leading prefix and the final directory name (e.g., `/Users/robson/Project…/judo-meta-esm`) - -#### Scenario: Windows path displayed with native separators -- **WHEN** the full path is `B:\Dev\BB\pi-agent-dashboard` on Windows -- **THEN** the group header SHALL display `B:\Dev\BB\pi-agent-dashboard` (original separators preserved — NO conversion to forward slashes for display) - -#### Scenario: Sessions group under pinned directory despite trailing separator drift -- **WHEN** a pinned directory is stored as `B:\Dev\BB\pi-agent-dashboard` and a session reports `cwd: "B:\\Dev\\BB\\pi-agent-dashboard\\"` (trailing separator) -- **THEN** the session SHALL appear under the pinned group, not as a separate unpinned group -- **AND** the grouping logic SHALL use `paths.samePath` for the match - -#### Scenario: Sessions group under pinned directory despite drive-letter case drift on Windows -- **WHEN** a pinned directory is stored as `B:\Dev\BB\pi-agent-dashboard` and a session reports `cwd: "b:\\Dev\\BB\\pi-agent-dashboard"` (lowercase drive letter) -- **THEN** the session SHALL appear under the pinned group (Windows filesystem treats these as the same path) - -#### Scenario: Sessions do NOT collapse case drift on Linux -- **WHEN** a pinned directory is stored as `/home/user/Project` and a session reports `cwd: "/home/user/project"` on Linux -- **THEN** the session SHALL appear as a separate unpinned group (Linux filesystem treats these as different paths) - -#### Scenario: Sessions across different Windows drives never merge -- **WHEN** a pinned directory is stored as `B:\Dev\BB` and a session reports `cwd: "A:\\Dev\\BB"` on Windows -- **THEN** the session SHALL NOT appear under the pinned group (different drives = different filesystems) -- **AND** it SHALL appear as a separate unpinned group for `A:\Dev\BB` - -#### Scenario: Sessions on the same drive with different drive-letter case group together -- **WHEN** a pinned directory is stored as `B:\Dev\BB` and a session reports `cwd: "b:\\Dev\\BB"` on Windows -- **THEN** the session SHALL appear under the pinned group (drive letter is case-insensitive on Windows) diff --git a/openspec/changes/archive/2026-05-05-platform-path-normalization/specs/platform-paths/spec.md b/openspec/changes/archive/2026-05-05-platform-path-normalization/specs/platform-paths/spec.md deleted file mode 100644 index 0b5b7bba0..000000000 --- a/openspec/changes/archive/2026-05-05-platform-path-normalization/specs/platform-paths/spec.md +++ /dev/null @@ -1,172 +0,0 @@ -## ADDED Requirements - -### Requirement: platform/paths module -The dashboard SHALL expose a `packages/shared/src/platform/paths.ts` module containing OS-aware path primitives. The module SHALL be exported from `packages/shared/src/platform/index.ts` as a namespace (`paths.*`) alongside the existing `git.*`, `openspec.*`, and `npm.*` namespaces. - -#### Scenario: Module is namespace-exported -- **WHEN** a consumer imports from `@blackbelt-technology/pi-dashboard-shared/platform` -- **THEN** the import surface SHALL expose a `paths` namespace whose members include at minimum `normalizePath`, `samePath`, and `parsePathInput` - -#### Scenario: Module has no dependencies beyond Node stdlib -- **WHEN** `packages/shared/src/platform/paths.ts` is inspected -- **THEN** it SHALL import only from `node:path` and `node:fs` (plus types from `./index.js` if needed) -- **AND** it SHALL NOT import from `node:child_process`, `node:os` (beyond `os.homedir` if needed for tilde expansion), or any dashboard-local module outside `packages/shared/src/platform/` - -### Requirement: normalizePath canonicalizes any input to the OS-native form -`normalizePath(p, platform = process.platform)` SHALL return a path string where: separators match the OS (`\` on win32, `/` elsewhere), redundant separators are collapsed, `.` and `..` segments are resolved, trailing separators are removed except for roots, and the original case is preserved (NO lowercasing). - -#### Scenario: Windows trailing separator is removed -- **WHEN** `normalizePath("C:\\Dev\\BB\\pi-agent-dashboard\\", "win32")` is called -- **THEN** the result SHALL equal `"C:\\Dev\\BB\\pi-agent-dashboard"` - -#### Scenario: Windows mixed separators are canonicalized -- **WHEN** `normalizePath("C:/Dev\\BB/pi-agent-dashboard", "win32")` is called -- **THEN** the result SHALL equal `"C:\\Dev\\BB\\pi-agent-dashboard"` - -#### Scenario: Windows root is preserved (any drive letter) -- **WHEN** `normalizePath("C:\\", "win32")` is called -- **THEN** the result SHALL equal `"C:\\"` (trailing separator retained for root) -- **AND** the same behavior SHALL hold for every drive letter (`A:\\` → `A:\\`, `B:\\` → `B:\\`, `Z:\\` → `Z:\\`) - -#### Scenario: Drive letter case is preserved in normalization output -- **WHEN** `normalizePath("b:\\Dev\\BB", "win32")` is called -- **THEN** the result SHALL equal `"b:\\Dev\\BB"` (drive-letter case preserved, NOT folded to upper/lower) -- **AND** case folding SHALL only happen inside `samePath` at compare time - -#### Scenario: Windows UNC path is preserved -- **WHEN** `normalizePath("\\\\server\\share\\path\\", "win32")` is called -- **THEN** the result SHALL equal `"\\\\server\\share\\path"` - -#### Scenario: Unix trailing separator is removed -- **WHEN** `normalizePath("/Users/me/Projects/", "linux")` is called -- **THEN** the result SHALL equal `"/Users/me/Projects"` - -#### Scenario: Unix root is preserved -- **WHEN** `normalizePath("/", "linux")` is called -- **THEN** the result SHALL equal `"/"` - -#### Scenario: Relative segments are resolved -- **WHEN** `normalizePath("C:\\Dev\\BB\\..\\.\\pi-agent-dashboard", "win32")` is called -- **THEN** the result SHALL equal `"C:\\Dev\\pi-agent-dashboard"` - -#### Scenario: Case is preserved -- **WHEN** `normalizePath("C:\\Dev\\BB", "win32")` is called and the path happens to exist on disk as `C:\Dev\BB` -- **THEN** the result SHALL equal `"C:\\Dev\\BB"` (exactly as input, not `c:\dev\bb`) - -### Requirement: Different drives never match -Paths rooted at different Windows drive letters SHALL be treated as different filesystems. `samePath` SHALL return `false` for any pair whose drive letters (case-folded) differ, regardless of how similar the rest of the path is. UNC paths (`\\server\share\...`) SHALL likewise be treated as distinct from any drive-letter path. - -#### Scenario: Different drive letters are not the same path -- **WHEN** `samePath("A:\\Foo", "B:\\Foo", "win32")` is called -- **THEN** the result SHALL be `false` - -#### Scenario: Same path on different drives -- **WHEN** `samePath("C:\\Users\\me\\Dev", "D:\\Users\\me\\Dev", "win32")` is called -- **THEN** the result SHALL be `false` - -#### Scenario: UNC path vs drive-letter path -- **WHEN** `samePath("\\\\server\\share\\x", "B:\\x", "win32")` is called -- **THEN** the result SHALL be `false` - -#### Scenario: Drive letter case does not create false negatives -- **WHEN** `samePath("B:\\Dev\\BB", "b:\\Dev\\BB", "win32")` is called -- **THEN** the result SHALL be `true` (drive letters are case-insensitive on Windows) - -### Requirement: samePath tests filesystem-level equality -`samePath(a, b, platform = process.platform)` SHALL return `true` iff `a` and `b` refer to the same filesystem path under the OS's equality rules: case-insensitive on `win32` and `darwin`, case-sensitive on `linux`, tolerant of separator differences, tolerant of trailing-separator differences. It SHALL run both inputs through `normalizePath` before comparison. - -#### Scenario: Windows case-insensitive match -- **WHEN** `samePath("C:\\Dev\\BB", "c:\\dev\\bb", "win32")` is called -- **THEN** the result SHALL be `true` - -#### Scenario: Windows separator-insensitive match -- **WHEN** `samePath("C:\\Dev\\BB", "C:/Dev/BB", "win32")` is called -- **THEN** the result SHALL be `true` - -#### Scenario: Windows trailing-separator-insensitive match -- **WHEN** `samePath("C:\\Dev\\BB", "C:\\Dev\\BB\\", "win32")` is called -- **THEN** the result SHALL be `true` - -#### Scenario: Linux case-sensitive non-match -- **WHEN** `samePath("/Users/me/Dev", "/users/me/dev", "linux")` is called -- **THEN** the result SHALL be `false` - -#### Scenario: macOS case-insensitive match (HFS+ default) -- **WHEN** `samePath("/Users/me/Dev", "/Users/me/dev", "darwin")` is called -- **THEN** the result SHALL be `true` - -#### Scenario: Different paths never match -- **WHEN** `samePath("/a/b", "/a/c", "linux")` is called -- **THEN** the result SHALL be `false` - -### Requirement: parsePathInput splits user input OS-correctly -`parsePathInput(value, platform = process.platform)` SHALL return `{ parent, partial }` where `parent` is the directory portion the path picker should browse and `partial` is the filter text for the current child. It SHALL handle Windows drive-letter roots (`C:\`), UNC roots (`\\server\share\`), and Unix roots (`/`). - -#### Scenario: Windows path with trailing separator -- **WHEN** `parsePathInput("C:\\Users\\mboto\\", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "C:\\Users\\mboto", partial: "" }` - -#### Scenario: Windows path with partial last segment -- **WHEN** `parsePathInput("C:\\Users\\mboto\\Dev", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "C:\\Users\\mboto", partial: "Dev" }` - -#### Scenario: Windows drive letter root alone -- **WHEN** `parsePathInput("C:\\", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "C:\\", partial: "" }` - -#### Scenario: Windows drive letter with partial -- **WHEN** `parsePathInput("C:\\Us", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "C:\\", partial: "Us" }` - -#### Scenario: Bare drive letter is treated as drive root -- **WHEN** `parsePathInput("B:", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "B:\\", partial: "" }` -- **AND** the function SHALL NOT pass bare drive-letter input through `path.win32.resolve`, because that would expand to the process's current working directory on that drive — a cwd-dependent result unsuitable for a pin dialog. - -#### Scenario: Drive-relative typed form (drive letter + characters without separator) -- **WHEN** `parsePathInput("B:Dev", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "B:\\", partial: "Dev" }` (defensive interpretation: treat as drive root + partial, NOT as cwd-relative) - -#### Scenario: Multi-drive navigation is symmetric -- **WHEN** `parsePathInput("A:\\Foo\\B", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "A:\\Foo", partial: "B" }` -- **AND** the same shape SHALL hold for any drive letter, confirming the parser is drive-agnostic - -#### Scenario: Windows UNC path -- **WHEN** `parsePathInput("\\\\server\\share\\dir\\", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "\\\\server\\share\\dir", partial: "" }` - -#### Scenario: Windows mixed separators tolerated -- **WHEN** `parsePathInput("C:\\Users\\mboto/Dev", "win32")` is called -- **THEN** the result SHALL equal `{ parent: "C:\\Users\\mboto", partial: "Dev" }` - -#### Scenario: Unix absolute path with trailing separator -- **WHEN** `parsePathInput("/Users/me/", "linux")` is called -- **THEN** the result SHALL equal `{ parent: "/Users/me", partial: "" }` - -#### Scenario: Unix absolute path with partial -- **WHEN** `parsePathInput("/Users/me/Dev", "linux")` is called -- **THEN** the result SHALL equal `{ parent: "/Users/me", partial: "Dev" }` - -#### Scenario: Unix root alone -- **WHEN** `parsePathInput("/", "linux")` is called -- **THEN** the result SHALL equal `{ parent: "/", partial: "" }` - -### Requirement: Platform parameter is injectable for testing -Every exported function in `platform/paths.ts` that depends on OS conventions SHALL accept an optional trailing `platform: NodeJS.Platform` parameter that defaults to `process.platform`. Tests SHALL exercise both Windows and Unix branches by passing the parameter explicitly. - -#### Scenario: Linux tests run on Windows host -- **WHEN** a test runs `normalizePath("/Users/me/x", "linux")` on a Windows CI host -- **THEN** the result SHALL equal `"/Users/me/x"` regardless of the host's `process.platform` - -#### Scenario: Windows tests run on Linux host -- **WHEN** a test runs `normalizePath("C:\\Dev\\BB", "win32")` on a Linux CI host -- **THEN** the result SHALL equal `"C:\\Dev\\BB"` regardless of the host's `process.platform` - -### Requirement: No direct process.platform reads outside the primitive -Consumers of `platform/paths.ts` SHALL NOT read `process.platform` themselves for path decisions. They SHALL either (a) omit the `platform` argument and rely on the default, or (b) pass a value threaded from a single source (e.g., the server-issued `BrowseResult.platform` field on the client). This keeps OS awareness concentrated in the primitive, matching the pattern already established for `binary-lookup.ts`, `process.ts`, and `shell.ts`. - -#### Scenario: Client does not branch on navigator or process.platform -- **WHEN** the client's `PathPicker.tsx` or `session-grouping.ts` is inspected -- **THEN** neither SHALL contain a reference to `process.platform` or `navigator.platform` -- **AND** OS awareness SHALL flow either through `paths.*` helpers or through a platform value received from the server diff --git a/openspec/changes/archive/2026-05-05-platform-path-normalization/tasks.md b/openspec/changes/archive/2026-05-05-platform-path-normalization/tasks.md deleted file mode 100644 index dc908a766..000000000 --- a/openspec/changes/archive/2026-05-05-platform-path-normalization/tasks.md +++ /dev/null @@ -1,64 +0,0 @@ -## 1. Primitive — `platform/paths.ts` - -- [x] 1.1 Create `packages/shared/src/platform/paths.ts` exporting `normalizePath`, `samePath`, `parsePathInput`, `joinForDisplay`, `withTrailingSep`. All OS-dependent functions take optional trailing `platform: NodeJS.Platform = process.platform`. -- [x] 1.2 `normalizePath` uses `node:path.win32.resolve` or `node:path.posix.resolve` based on the `platform` argument; drops trailing separator except for roots; preserves case; leaves UNC roots intact. -- [x] 1.3 `samePath` runs both inputs through `normalizePath` then compares: case-insensitive on `win32` / `darwin`, case-sensitive on `linux`. -- [x] 1.4 `parsePathInput` handles Windows drive-letter roots, UNC roots, Unix roots, mixed separators, and trailing separators. Returns `{ parent, partial }` using the OS's native separator in `parent`. -- [x] 1.5 Export from `packages/shared/src/platform/index.ts` as `export * as paths from "./paths.js"`. -- [x] 1.6 Write unit tests in `packages/shared/src/__tests__/platform-paths.test.ts` covering every scenario in the spec (Windows, macOS, Linux branches). Use `platform: "win32"` / `"linux"` / `"darwin"` explicitly — NO mutation of `process.platform`, NO `vi.mock`. -- [x] 1.6a Test `samePath` multi-drive invariants: `A:\x` vs `B:\x` → false; same-path-different-case-drive → true; UNC vs drive-letter → false; case-sensitivity of drive letter vs path components. -- [x] 1.6b Test `parsePathInput` edge cases: bare drive letter `B:` → `{ parent: "B:\\", partial: "" }` (no cwd leak); drive-relative `B:Dev` → `{ parent: "B:\\", partial: "Dev" }`; UNC roots; multi-drive symmetry (same shape for A:, B:, Z:). -- [x] 1.6c Verify `normalizePath` preserves drive-letter case in output: `normalizePath("b:\\Dev\\BB", "win32")` returns `"b:\\Dev\\BB"` — NOT folded. Case folding only happens inside `samePath` at compare time. -- [x] 1.7 Run `npm test` in `packages/shared`; all new tests pass; no existing tests regress. (49 tests pass) - -## 2. Protocol — `BrowseResult.platform` - -- [x] 2.1 Add `platform: NodeJS.Platform` (optional for backward compatibility) to `BrowseResult` in `packages/shared/src/rest-api.ts`. -- [x] 2.2 Populate it in `packages/server/src/browse.ts` `listDirectories` — return `process.platform`. -- [x] 2.3 Update `packages/client/src/lib/browse-api.ts` if the type needs re-exporting. (nothing to do — client re-exports the shared type transparently) -- [x] 2.4 Add a test in `packages/server/src/__tests__/` (or extend an existing browse test) asserting the field is present and matches `process.platform`. - -## 3a. Fix browse.ts root-detection (piggyback) - -- [x] 3a.1 In `packages/server/src/browse.ts` `listDirectories`, change `const parent = resolved === "/" ? null : path.dirname(resolved);` to detect filesystem roots generically via `isFilesystemRoot` from `platform/paths.ts`. -- [x] 3a.2 Add a unit test for the root-detection across all three platforms (inject `platform`-appropriate test values). (`browse-endpoint.test.ts` now exercises both POSIX and Windows root behaviour via `process.platform`-aware test.) - -## 3. Server migration — pin / unpin / reorder / preferences - -- [x] 3.1 In `packages/server/src/browser-handlers/directory-handler.ts`, wrap `msg.path` in `paths.normalizePath(msg.path)` BEFORE `safeRealpathSync`. Apply the same change in `handleUnpinDirectory` and `handleReorderPinnedDirs`. (extracted shared `canonicalizePath` helper) -- [x] 3.2 In `packages/server/src/preferences-store.ts` `createPreferencesStore`, run each loaded pinned path through `paths.normalizePath` before `safeRealpathSync`. Mark dirty and schedule save if any entry changed. (Includes the `.map(normalizePath)` → `.map(p => normalizePath(p))` guard — Array.map's `(elem, index, array)` would otherwise pass the numeric index as `platform` and silently disable the Windows branch.) -- [x] 3.3 Add server-side test exercising pin with a trailing-separator input on all three platforms; assert the stored value is normalized. (Covered by the new "normalizes drifty pinned paths on load" + "deduplicates entries that collapse" + "persists the normalized form back to disk" tests.) -- [x] 3.4 Add server-side test for the migrate-on-load path: seed a `preferences.json` with drifty entries, instantiate the store, confirm the file is rewritten with normalized entries. - -## 4. Client migration — grouping - -- [x] 4.1 In `packages/client/src/lib/session-grouping.ts`, replace `groups.get(session.cwd)` / `groups.set(session.cwd, …)` with a normalized key, storing the original path on the group's `cwd` field for display. -- [x] 4.2 Replace `pinnedSet.has(cwd)` with a pre-computed Set of normalized keys. -- [x] 4.3 Pass the server-issued platform from `App.tsx` (or wherever sessions are received) into `groupSessionsByDirectory`. — opted for client-side `inferPlatform(samples)` heuristic instead of threading through the component tree: detects Windows from backslash/drive-letter prefix, POSIX from leading `/`. Covers 99% of cases without a protocol round trip; callers can still pass an explicit `platform` override. -- [x] 4.4 Write a logic test for `session-grouping.ts` covering drift scenarios: trailing-separator drift on Windows, drive-letter-case drift on Windows, separator-style drift on Windows, cross-drive sessions don't merge, macOS case-insensitive merge, Linux case-sensitive non-merge. (12 tests pass.) - -## 5. Client migration — path picker - -- [x] 5.1 In `packages/client/src/components/PathPicker.tsx`, delete the inline `parseInput` helper. Import `paths.parsePathInput` instead. (Kept the local `parseInput` name as a 2-line adapter that infers platform and delegates.) -- [x] 5.2 Replace `dirPath + "/"` in `descendInto` with `paths.withTrailingSep(dirPath, platform)`. -- [x] 5.3 Thread the platform value from the first `browseDirectory` response into the picker's state. — used `result.platform ?? inferPlatform([result.current])` so when the server sends `BrowseResult.platform` we prefer it; older servers silently fall back to inference from the path shape. -- [x] 5.4 In `packages/client/src/components/PinDirectoryDialog.tsx`, replace the Unix-only `.replace(/\/+$/, "") || "/"` with `paths.normalizePath(path, platform)`. -- [~] 5.5 Write a pure-function test for `parsePathInput` (already in §1.6) + verify via manual testing that the picker navigates correctly on Windows. — unit tests cover the pure function; manual UI verification pending user's browser test (part of §7 release gate). - -## 6. Docs - -- [x] 6.1 Add a "Path handling" subsection to `docs/architecture.md` under the "Platform primitives" section, describing `platform/paths.ts` and when to use `samePath` vs `normalizePath`. -- [x] 6.2 Add `src/shared/platform/paths.ts` to the key-files table in `AGENTS.md`. -- [x] 6.3 Add a troubleshooting entry to `README.md`: "Sessions don't group under my pinned folder" — covers cross-drive case too. -- [x] 6.4 Note in `AGENTS.md` that the existing invariant "helpers depending on OS take `platform: NodeJS.Platform`" now also covers the new `paths` module. (The new AGENTS.md entry explicitly states "All accept optional trailing `platform: NodeJS.Platform` for testability" and documents the multi-drive invariants.) - -## 7. Release gate - -- [x] 7.1 Full-stack manual test on Windows: - (a) Pin `B:\Dev\BB\pi-agent-dashboard` — stored form is canonical, no trailing separator. - (b) Session in that dir appears under the pinned group. - (c) Pin with trailing separator via text input — same canonical result, no duplicate. - (d) Path picker accepts `B:\Dev\BB` (backslashes), navigates correctly. — _pending user verification_ -- [x] 7.2 Full-stack manual test on macOS or Linux: pin a directory, session groups correctly, path picker works with `/Users/...` / `/home/...` input. — _pending user verification_ -- [x] 7.3 Run `npm run build` — clean build (32s). `npm run reload:check` — _pending user verification_. -- [x] 7.4 Confirm `docs/architecture.md`, `AGENTS.md`, `README.md` reflect landed behavior. diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/.openspec.yaml b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/.openspec.yaml deleted file mode 100644 index e5764a1da..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-03 diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/design.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/design.md deleted file mode 100644 index 6a06d08ae..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/design.md +++ /dev/null @@ -1,141 +0,0 @@ -## Context - -`spawnPiSession` (`packages/server/src/process-manager.ts`) returns `{ success, message, pid?, process?, dashboardSpawned? }`. Callers in `session-action-handler.ts` already wrap it and emit `spawn_result` + `spawn_error` browser messages. The failure-message string is the only signal the UI gets. - -Five gaps: - -1. **Windows headless**: pi's stderr is captured to `~/.pi/dashboard/sessions/pi-spawn--.log`. On `waitForNoCrash` immediate-exit, the path is mentioned in the log but the file content is never read or forwarded. -2. **No classification**: every failure is a free-text string. UI cannot map to actionable hints (open wizard vs. rescan tools vs. fix permissions) without regexing. -3. **No watchdog**: a spawn returning `success: true` only proves the OS process is alive past 300 ms. Pi may still fail to attach to the dashboard (wrong port, missing extension, version skew) — UI shows the placeholder card forever. -4. **No preflight**: bad `cwd`, missing pi binary, or unwritable folder race the spawn. Errors surface late and inconsistently per mechanism. -5. **No history**: failures evaporate after the toast/banner. Users reporting "spawn sometimes fails" have no log to share. - -## Goals / Non-Goals - -**Goals:** -- Every `spawnPiSession` failure return path SHALL set a structured `code`. -- Windows-headless immediate-exit SHALL return the tail of pi's stderr in the result. -- The UI SHALL receive a distinct event when a spawned PID never registers within the configured `spawnRegisterTimeoutMs` window (default 30 s, clamped 5–120 s). -- A synchronous preflight gate SHALL run before spawn and refuse with classified reasons. -- Failed spawns SHALL persist to a rolling log that the UI can fetch via REST. -- Additive changes only — existing message strings, success cases, and event shapes preserved. - -**Non-Goals:** -- No retry/auto-recovery logic. Diagnostics only. -- No Linux/macOS stderr-tail parity in this change. The Unix headless wrapper (`sh -c "tail -f /dev/null | pi"`) does not currently capture pi stderr to a file. Out of scope. -- No new global config knobs beyond `spawnRegisterTimeoutMs` (the 10 MB log cap stays a constant). -- No backfill of historical failures into the rolling log. -- No UI redesign of the existing spawn-error banner — only additive fields rendered. - -## Decisions - -### D1. Place classification in `process-manager.ts`, not in handlers -`SpawnResult.code: SpawnFailureCode` (string-literal union) is set at every `return { success: false, ... }` site inside `process-manager.ts`. Rationale: the function knows precisely which check tripped (e.g. `dashboardSessionExists` returns false vs. `wt.exe` missing vs. `cmd.lower().endsWith(".cmd")`). Handlers should not re-classify by inspecting message strings. - -Codes (closed set): -- `DIR_MISSING` — `existsSync(cwd) === false` -- `PI_NOT_FOUND` — `resolvePiCommand()` returned `null` -- `WIN_PI_CMD_ONLY` — Windows headless found only `.cmd` wrapper -- `WT_MISSING` — Windows Terminal not installed -- `TMUX_MISSING` — tmux mechanism chosen but binary absent -- `PI_CRASHED` — `waitForNoCrash` reported immediate exit -- `SPAWN_ERRNO` — generic `spawnDetached` failure (ENOENT, EACCES, etc.) -- `PREFLIGHT_FAILED` — set by handler when preflight gate refused (never returned by `spawnPiSession` itself) -- `REGISTER_TIMEOUT` — set by handler when watchdog fires (never returned by `spawnPiSession` itself) - -### D2. Stderr tail: 4 KB, Windows-headless only -Read with `fs.readSync` on a re-opened fd, pulling `min(fileSize, 4096)` bytes from end-of-file. 4 KB matches the existing 2 KB error-stderr cap (`session-action-handler.ts:333`) doubled to give pi room to print a stack trace. Truncated on UTF-8 boundary by stripping leading bytes until `>= 0x80 && < 0xC0` are gone (continuation bytes), then decoding. - -Only Windows headless gets this in v1. The Unix wrapper would need a redesign (currently `stdio: "ignore"` with no log fd); deferred. - -### D3. Watchdog lives in a new `spawn-register-watchdog.ts` -Two internal maps: -- `byPid: Map` for headless spawns (we own the PID). -- `byCwd: Map` for tmux/wt/wsl-tmux spawns (PID belongs to the terminal, not pi). - -`Entry = { timer: NodeJS.Timeout; cwd: string; pid?: number; mechanism: SpawnMechanism; logPath?: string; ws: WebSocket }`. - -Hooked from three sides: -- **Arm**: `session-action-handler.handleSpawnSession` calls `watchdog.arm({ pid?, cwd, mechanism, logPath?, ws })` after every successful spawn. Headless → indexed in `byPid`. tmux/wt/wsl-tmux → indexed in `byCwd` only (no PID). -- **Clear by PID**: `pi-gateway.ts::handleSessionRegister` calls `watchdog.clearByPid(pid)` for headless registrations. -- **Clear by cwd**: same handler also calls `watchdog.clearByCwd(cwd)` so any `session_register` from that directory clears a `byCwd` watch (tmux/wt path). Both calls are idempotent; headless registrations exercise both. -- **Fire**: `timeoutMs` elapses → emit `spawn_register_timeout` to the originating WS with `{ cwd, pid?, stderrTail? }`, delete entry. If `ws.readyState !== OPEN`, drop silently. -- **Late register after fire**: any subsequent `session_register` from that pid/cwd emits a new `spawn_register_recovered { cwd, pid? }` browser message so the UI can auto-clear the timeout banner (see D8). - -Window: 30 s default, configurable via `spawnRegisterTimeoutMs` in `~/.pi/dashboard/config.json` (range 5000–120000, clamped). Rationale: cold tsx + AV scan on Windows can take 8–12 s; 30 s gives headroom without being so long the user assumes silent failure. - -PID/cwd reuse risk: trivial in a 30 s window. `clearByPid` and `clearByCwd` are both idempotent. - -### D8. Late-register recovery message -When pi finally registers AFTER the watchdog has fired and removed its entry, the gateway emits a separate `spawn_register_recovered { type, cwd, pid? }` browser message. The UI uses it to auto-clear any timeout banner still showing for that `cwd` (symmetry with the existing `spawn_result.success === true` clearing rule). Implementation: watchdog keeps a short-lived `recentlyFired: Map` with 60 s TTL; gateway checks it on every `session_register`. - -### D4. Preflight is pure, sync-fast, and SKIPS login-shell fallback -`packages/server/src/spawn-preflight.ts`: -```ts -export interface PreflightResult { - ok: boolean; - reasons: Array<{ code: string; message: string }>; -} -export function preflightSpawn(cwd: string, deps?: { resolver?: ToolResolver }): PreflightResult; -``` -Checks (all run, all reasons returned — not short-circuited, so user fixes everything in one pass): -- `cwd` exists (`fs.existsSync`) -- `cwd` is a directory (`fs.statSync().isDirectory()`) -- `cwd` is writable (`fs.accessSync(cwd, fs.constants.W_OK)`) -- pi resolves (`resolver.resolvePi() !== null`) -- node resolves (`resolver.resolveNode() !== null`) - -**Critical perf rule**: the resolver passed to preflight MUST be constructed with `useLoginShell: false`. Login-shell fallback (`$SHELL -ilc "which pi"`) spawns a full shell on every preflight invocation — unacceptable on every spawn click, especially on macOS where session-restore noise inflates latency to seconds. Preflight trusts the cached `toolPaths` config + managed bin + system PATH only. If pi is reachable only via login shell, the user's persisted `toolPaths` already records its absolute path — preflight finds it via the registry's first-tier strategies. - -Handler builds the preflight resolver inline: `new ToolResolver({ processExecPath: process.execPath, useLoginShell: false })`. The actual spawn keeps the default resolver (login-shell allowed) — preflight is a fast advisory, not a replacement. - -If `!ok`, handler sends `spawn_result { success: false, message: }` and `spawn_error { code: "PREFLIGHT_FAILED", reasons }`. No spawn happens. - -### D5. Rolling log under sessions/, append-only with single-rotation -`packages/server/src/spawn-failure-log.ts`. File path: `~/.pi/dashboard/sessions/spawn-failures.log` (rotated predecessor: `spawn-failures.log.1`). Co-located with `pi-spawn-*.log` per-session captures so all spawn artifacts live in one directory. - -Format — one entry per line, JSON object, NDJSON-compatible: -``` -{"ts":"2026-05-03T12:34:56.789Z","cwd":"/p/x","strategy":"headless","code":"PI_CRASHED","message":"...","stderrTail":"..."} -``` - -API: -```ts -export function appendSpawnFailure(entry: SpawnFailureEntry): void; // sync, fire-and-forget catch -export function readSpawnFailures(limit: number): SpawnFailureEntry[]; // last N, parsed; skip malformed lines -``` - -Rotation: on `appendSpawnFailure`, if file size > 10 MB, rename to `.log.1` (overwriting any existing `.log.1`), open fresh `.log`. Single-shot rotation — no `.log.2`, `.log.3` rings. Two files cap total at ~20 MB. - -`GET /api/spawn-failures?limit=N` (default 50, max 500) registered in `system-routes.ts`. Auth-gated by existing Fastify auth plugin (no special handling). Returns `{ entries: SpawnFailureEntry[] }`. **Auth posture caveat**: in default local installs without auth + zrok exposure, the endpoint is reachable by anyone who can hit the dashboard, and entries leak `cwd` paths. Documented in README.md security section and queued in `docs/todo.md` for hardening (per-endpoint auth-required override or path redaction). - -### D6. Browser protocol additions are additive -`packages/shared/src/browser-protocol.ts`: -```ts -// Existing -type SpawnError = { type: "spawn_error"; cwd: string; strategy: string; message: string; - stderr?: string; // already typed - code?: SpawnFailureCode; // NEW - reasons?: PreflightReason[]; }; // NEW (only for PREFLIGHT_FAILED) - -// New — pid optional (tmux/wt/wsl-tmux own the PID, not pi) -type SpawnRegisterTimeout = { type: "spawn_register_timeout"; cwd: string; pid?: number; stderrTail?: string }; -type SpawnRegisterRecovered = { type: "spawn_register_recovered"; cwd: string; pid?: number }; -``` -No version bump. Old clients ignore unknown fields/messages. - -### D7. Tests: pure-first -- `spawn-preflight.test.ts` — table-driven on a memfs cwd matrix (missing/file/no-write/ok). -- `spawn-failure-log.test.ts` — round-trip parse, malformed-line skip, rotation at threshold. -- `spawn-register-watchdog.test.ts` — fake timers; arm/clear/fire ordering; idempotent clear; closed-WS no-throw. -- `process-manager-codes.test.ts` — every failure return path sets `code` (lint-style: grep AST for `success: false` literals and assert `code` present). Avoids per-platform spawn execution. -- Integration: `session-action-handler.handleSpawnSession` with stub `spawnPiSession` returning each code → asserts emitted `spawn_error` shape. - -## Risks / Trade-offs - -- **PID reuse on the watchdog window** (R1, low). Mitigated by `cwd` co-storage; a stale fire is at worst a phantom banner the user dismisses. Not worth a second key. -- **Stderr tail leaks paths/secrets** (R2, low). Pi's stderr already shows on the user's own log file; we're forwarding it to the same user's WebSocket. No new attack surface. -- **Watchdog window could be too short on slow hardware** (R3, medium). Mitigated by exposing `spawnRegisterTimeoutMs` in config (default 30 s, range 5–120 s) so users on slow disks / heavy AV can extend it without a code change. -- **`spawn-failures.log` could leak user `cwd` paths** (R4, low). The file is under `~/.pi/dashboard/`, same trust boundary as `server.log`. No change in posture. -- **Preflight adds 1–2 stat calls per spawn click** (R5, negligible). Sub-millisecond on local disk; user-perceptible only on a hung NFS mount, where the current spawn would also hang. -- **Tail of NDJSON not crash-safe on partial writes** (R6, low). `appendSpawnFailure` writes one `\n`-terminated line via `fs.appendFileSync`. Power-loss could leave a partial last line; `readSpawnFailures` skips malformed lines. Acceptable for diagnostics. diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/proposal.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/proposal.md deleted file mode 100644 index 9b16700cd..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/proposal.md +++ /dev/null @@ -1,49 +0,0 @@ -## Why - -When `spawnPiSession` fails, the user gets a single message string and very little signal about *what* broke or *why*. The OS process may be alive past the 300 ms crash window yet never `session_register` (wrong port, missing extension, version skew). On Windows headless, pi's stderr is captured to `~/.pi/dashboard/sessions/pi-spawn-*.log` but never tailed back to the UI. There's no preflight (a typo in `cwd`, missing pi binary, or unwritable directory races the spawn). And failures evaporate after the toast — no history to grep when a user reports "spawning sometimes fails". - -This change adds five complementary diagnostics so a failed spawn produces an actionable, classified, debuggable signal instead of a one-liner. - -## What Changes - -- **Tail per-session stderr log on Windows headless failure**: when `waitForNoCrash` reports `!ok`, read the last 4 KB of the `pi-spawn--.log` file and include it in `SpawnResult.stderr`. Bridge handler already forwards `stderr` on `spawn_error`; this populates it for the headless-Windows path that currently leaves it empty. -- **Classify failure causes** in `SpawnResult.code`: `"DIR_MISSING" | "PI_NOT_FOUND" | "WIN_PI_CMD_ONLY" | "WT_MISSING" | "TMUX_MISSING" | "PI_CRASHED" | "SPAWN_ERRNO" | "REGISTER_TIMEOUT" | "PREFLIGHT_FAILED"`. UI maps codes to actionable hints (open wizard, rescan tools, fix permissions) instead of regexing message strings. -- **Bridge `session_register` watchdog** (default 30 s, configurable via new `spawnRegisterTimeoutMs` config field exposed in Settings; range 5000–120000): if the spawned session never produces `session_register`, emit a new `spawn_register_timeout` browser event with `{ cwd, pid?, stderrTail }`. For headless spawns the watch is keyed by PID; for tmux/wt/wsl-tmux it is keyed by `cwd` (any `session_register` from that directory clears it). Late registrations after a fired timeout emit a follow-up `spawn_register_recovered` event so the UI can auto-clear the banner. -- **Preflight check on click**: before invoking `spawnPiSession`, run a fast subset of doctor (pi resolved? node resolved? cwd exists + writable?) using a `useLoginShell: false` resolver to avoid spawning a login shell on the click hot path. Refuse with `code: "PREFLIGHT_FAILED"` + structured reasons rather than racing the spawn. -- **Persist failures**: append every failed spawn (timestamp, cwd, strategy, code, message, stderrTail) to a rolling `~/.pi/dashboard/sessions/spawn-failures.log` (10 MB cap, single rotation to `.log.1`; co-located with per-session `pi-spawn-*.log` captures). Settings → Tools surfaces the last N entries via a new `GET /api/spawn-failures?limit=N`. Endpoint relies on the existing Fastify auth plugin; absence of auth on default local installs (and `cwd`-path leakage) flagged in README.md security section and queued in `docs/todo.md` for hardening. - -No breaking API changes — new fields are additive. `SpawnResult.code` is optional, browser protocol gains additive `spawn_register_timeout` and `spawn_register_recovered` messages plus optional `code`/`reasons`/`stderr` fields on `spawn_error`. - -## Capabilities - -### New Capabilities -- `spawn-failure-log`: rolling persistence of failed pi-session spawn attempts under `~/.pi/dashboard/spawn-failures.log`, with a read API for UI display. -- `spawn-preflight`: synchronous validation gate (binary resolution, cwd existence/writability) run before any `spawnPiSession` invocation; returns structured failure reasons. -- `spawn-register-watchdog`: server-side timer that tracks every spawned PID until `session_register` arrives or 10 s elapses; emits `spawn_register_timeout` on timeout. - -### Modified Capabilities -- `process-manager`: `SpawnResult` gains optional `code` (failure classifier) and `stderr` (tail of per-session log) fields. Every existing failure path SHALL set `code`. Windows headless failure path SHALL populate `stderr` from the per-session log. -- `headless-spawn`: Windows-headless failure handler SHALL read last 4 KB of `pi-spawn-*.log` after `waitForNoCrash` reports immediate exit and include it in the returned `SpawnResult.stderr`. The `logPath` SHALL also be returned on success for watchdog handoff. -- `spawn-error-persistence`: UI banner SHALL render the new `code` as an actionable hint (per-code copy + optional CTA) and SHALL render the `stderr` tail in a collapsed `
` block. New `spawn_register_timeout` event SHALL show a distinct banner. New `spawn_register_recovered` event SHALL auto-clear that banner. -- `dashboard-server`: register `GET /api/spawn-failures?limit=N` returning the last N parsed entries from the rolling log; protocol gains additive timeout + recovered messages. -- `shared-config`: add `spawnRegisterTimeoutMs` field (default 30000, clamped 5000–120000). -- `settings-panel`: add Settings UI input for `spawnRegisterTimeoutMs` with validation. - -## Impact - -Affected code: -- `packages/server/src/process-manager.ts` — add `code` + `stderr` + `logPath` on failure/success returns; tail per-session log on Windows headless crash. -- `packages/server/src/browser-handlers/session-action-handler.ts` — call preflight (login-shell-disabled resolver) before spawn; arm watchdog after every successful spawn; forward `code`/`stderr`/`reasons` on `spawn_error`. -- `packages/server/src/spawn-preflight.ts` (new) — pure validation function returning `{ ok, reasons }`. -- `packages/server/src/spawn-register-watchdog.ts` (new) — dual `byPid`/`byCwd` maps + `recentlyFired` TTL map; clear hooks in `pi-gateway.ts`. -- `packages/server/src/spawn-failure-log.ts` (new) — append + rotate + parse rolling log under `sessions/`. -- `packages/server/src/routes/system-routes.ts` — `GET /api/spawn-failures?limit=N`. -- `packages/shared/src/config.ts` — add `spawnRegisterTimeoutMs` field with clamp. -- `packages/shared/src/browser-protocol.ts` — add `spawn_register_timeout` + `spawn_register_recovered` messages; add optional `code`, `reasons`, `stderr` to `spawn_error`. -- `packages/client/src/components/SettingsPanel.tsx` — expose `spawnRegisterTimeoutMs` field. -- `packages/client/src/components/SpawnErrorBanner.tsx` (or equivalent) — render `code` hint + `
` stderr; handle timeout + recovered messages. -- `packages/client/src/components/ToolsSection.tsx` — surface last-N spawn failures. -- `README.md` — add note about spawn-failures endpoint auth posture in security section. -- `docs/todo.md` (new) — queue Unix-headless stderr capture and per-endpoint auth-required hardening. - -No new dependencies. No protocol breaking changes (additive fields). Tests: pure-function tests for preflight, log rotation/parse, watchdog clear semantics; integration test for Windows-headless stderr tailing (mocked log file). diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/dashboard-server/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/dashboard-server/spec.md deleted file mode 100644 index 74c3ffb76..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/dashboard-server/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -## ADDED Requirements - -### Requirement: GET /api/spawn-failures returns recent failed-spawn entries -The dashboard server SHALL expose `GET /api/spawn-failures` returning the last N entries from `~/.pi/dashboard/sessions/spawn-failures.log` (and its rotated `.log.1` predecessor) as JSON `{ entries: SpawnFailureEntry[] }`. The route SHALL accept an optional `limit` query parameter, default `50`, max `500`. The route SHALL be registered in `packages/server/src/routes/system-routes.ts` and SHALL be subject to the existing Fastify auth plugin (no auth-bypass entry added). - -#### Scenario: default limit returns last 50 -- **WHEN** `GET /api/spawn-failures` is called and the log contains 200 entries -- **THEN** the response body SHALL be `{ entries: [...] }` with `entries.length === 50` -- **AND** the entries SHALL be the most recent 50 in file order (oldest of the 50 first) - -#### Scenario: custom limit honored -- **WHEN** `GET /api/spawn-failures?limit=10` is called -- **THEN** the response SHALL contain at most 10 entries - -#### Scenario: limit clamped to maximum -- **WHEN** `GET /api/spawn-failures?limit=10000` is called -- **THEN** the response SHALL contain at most 500 entries - -#### Scenario: invalid limit falls back to default -- **WHEN** `GET /api/spawn-failures?limit=abc` is called -- **THEN** the response SHALL contain at most 50 entries (default applied) - -#### Scenario: no log file -- **WHEN** `GET /api/spawn-failures` is called and no log file exists yet -- **THEN** the response SHALL be `{ entries: [] }` with HTTP 200 - -#### Scenario: auth required -- **WHEN** `GET /api/spawn-failures` is called without valid auth credentials in an auth-enabled deployment -- **THEN** the request SHALL be rejected by the existing auth plugin (HTTP 401), with no special bypass - -### Requirement: Browser protocol carries spawn diagnostic fields -`packages/shared/src/browser-protocol.ts` SHALL extend the existing `spawn_error` message type with two optional fields: `code?: SpawnFailureCode` and `reasons?: PreflightReason[]`. It SHALL also add two new message types: -- `spawn_register_timeout` with shape `{ type: "spawn_register_timeout"; cwd: string; pid?: number; stderrTail?: string }` (`pid` optional because tmux/wt/wsl-tmux watches are cwd-keyed only). -- `spawn_register_recovered` with shape `{ type: "spawn_register_recovered"; cwd: string; pid?: number }`. - -All additions SHALL be optional/additive — no protocol version bump and no removal of existing fields. - -#### Scenario: spawn_error with code accepted by typed handler -- **WHEN** the browser receives a `spawn_error` carrying `code: "PI_NOT_FOUND"` -- **THEN** the typed message handler SHALL accept the field without runtime error - -#### Scenario: spawn_register_timeout dispatched to handler -- **WHEN** the browser receives `{ type: "spawn_register_timeout", cwd, pid?, stderrTail? }` -- **THEN** the message router SHALL dispatch it to the spawn-error subsystem (no "unknown message type" warning) - -#### Scenario: spawn_register_recovered dispatched to handler -- **WHEN** the browser receives `{ type: "spawn_register_recovered", cwd, pid? }` -- **THEN** the message router SHALL dispatch it to the spawn-error subsystem so it can clear any matching timeout banner - -#### Scenario: legacy spawn_error without code still parses -- **WHEN** the browser receives a `spawn_error` lacking `code` and `reasons` -- **THEN** the message SHALL parse and dispatch identically to pre-change behavior diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/headless-spawn/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/headless-spawn/spec.md deleted file mode 100644 index 4d1119e05..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/headless-spawn/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## ADDED Requirements - -### Requirement: Per-session stderr log path is recorded for diagnostic forwarding -The `spawnHeadlessDetached` function (Windows headless path) SHALL retain the per-session log path it opens (`~/.pi/dashboard/sessions/pi-spawn--.log`) so that the immediate-crash branch can read its tail. The path SHALL be local to the function call (no global state) and SHALL be passed to a tail-reading helper before the function returns the failure result. - -#### Scenario: log path retained across crash detection -- **WHEN** `spawnHeadlessDetached` opens the log file via `openSync` and `waitForNoCrash` subsequently reports `!ok` -- **THEN** the same `logPath` value SHALL be used to read the stderr tail attached to the returned `SpawnResult.stderr` - -#### Scenario: log path retained for watchdog handoff -- **WHEN** `spawnHeadlessDetached` returns `success: true` with a `pid` -- **THEN** the `logPath` SHALL be available to callers (returned in `SpawnResult` as `logPath?: string`) so the spawn-register watchdog can read it on timeout - -#### Scenario: log open fails -- **WHEN** `openSync` throws when creating the per-session log -- **THEN** the spawn SHALL still proceed and `SpawnResult.logPath` SHALL be `undefined` diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/process-manager/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/process-manager/spec.md deleted file mode 100644 index 8b1c8662e..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/process-manager/spec.md +++ /dev/null @@ -1,55 +0,0 @@ -## MODIFIED Requirements - -### Requirement: SpawnResult includes failure classification code -The `SpawnResult` interface SHALL include an optional `code?: SpawnFailureCode` field. Every code path inside `spawnPiSession` (and its helpers `spawnTmux`, `spawnWt`, `spawnWslTmux`, `spawnHeadless`, `spawnHeadlessDetached`) that returns `{ success: false, ... }` SHALL set `code` to one of: `"DIR_MISSING"`, `"PI_NOT_FOUND"`, `"WIN_PI_CMD_ONLY"`, `"WT_MISSING"`, `"TMUX_MISSING"`, `"PI_CRASHED"`, `"SPAWN_ERRNO"`. Successful spawns SHALL leave `code` undefined. - -#### Scenario: cwd does not exist -- **WHEN** `spawnPiSession(cwd, opts)` is called with a non-existent `cwd` -- **THEN** the result SHALL be `{ success: false, code: "DIR_MISSING", message: }` - -#### Scenario: pi binary cannot be resolved -- **WHEN** `spawnPiSession` calls `resolvePiCommand()` and receives `null` -- **THEN** the result SHALL be `{ success: false, code: "PI_NOT_FOUND", message: }` - -#### Scenario: Windows headless finds only pi.cmd -- **WHEN** `spawnHeadlessDetached` receives a `bin` ending in `.cmd` or `.bat` -- **THEN** the result SHALL be `{ success: false, code: "WIN_PI_CMD_ONLY", message: }` - -#### Scenario: Windows Terminal not installed -- **WHEN** `spawnWt` calls `resolver.which("wt")` and receives `null` -- **THEN** the result SHALL be `{ success: false, code: "WT_MISSING", message: }` - -#### Scenario: tmux mechanism chosen but tmux missing -- **WHEN** `spawnTmux` `execSync` fails because `tmux` is not on PATH -- **THEN** the result SHALL be `{ success: false, code: "TMUX_MISSING", message: }` - -#### Scenario: pi process crashes inside detection window -- **WHEN** `waitForNoCrash` reports `!ok` after spawning pi -- **THEN** the result SHALL be `{ success: false, code: "PI_CRASHED", message: }` - -#### Scenario: detached spawn primitive errors -- **WHEN** `spawnDetached` returns `!ok` with an `error` string (ENOENT, EACCES, etc.) -- **THEN** the result SHALL be `{ success: false, code: "SPAWN_ERRNO", message: }` - -#### Scenario: successful spawn omits code -- **WHEN** `spawnPiSession` returns `{ success: true, ... }` -- **THEN** the result `code` field SHALL be `undefined` - -### Requirement: SpawnResult includes stderr tail on Windows headless crash -The `SpawnResult` interface SHALL include an optional `stderr?: string` field. When `spawnHeadlessDetached` returns due to `waitForNoCrash` reporting an immediate exit AND the per-session log file at `~/.pi/dashboard/sessions/pi-spawn--.log` exists, the function SHALL read the last 4096 bytes of that file, strip leading UTF-8 continuation bytes, and assign the resulting string to `result.stderr`. Read errors SHALL be swallowed (stderr left undefined). Other failure paths and other platforms SHALL leave `stderr` undefined in v1. - -#### Scenario: Windows headless crash with non-empty log -- **WHEN** `spawnHeadlessDetached` reports `PI_CRASHED` and the per-session log file contains pi stderr output -- **THEN** `result.stderr` SHALL be a string containing the last 4096 bytes (or full file if smaller) of that log, with leading UTF-8 continuation bytes stripped - -#### Scenario: Windows headless crash with empty log -- **WHEN** `spawnHeadlessDetached` reports `PI_CRASHED` and the per-session log file is empty or missing -- **THEN** `result.stderr` SHALL be `undefined` - -#### Scenario: log read throws -- **WHEN** the log file exists but `fs.readSync` throws (permission, disk error) -- **THEN** the failure SHALL be swallowed and `result.stderr` SHALL be `undefined` - -#### Scenario: non-Windows headless crash -- **WHEN** the Unix headless wrapper reports a spawn failure -- **THEN** `result.stderr` SHALL be `undefined` (Unix log capture is out of scope for v1) diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/settings-panel/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/settings-panel/spec.md deleted file mode 100644 index a33854b80..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/settings-panel/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## ADDED Requirements - -### Requirement: Settings panel exposes spawn-register timeout -The Settings panel (`packages/client/src/components/SettingsPanel.tsx`) SHALL render a numeric input field for `spawnRegisterTimeoutMs` under the General → Sessions group (or nearest equivalent group containing other spawn-related fields). The field SHALL be labelled "Spawn register timeout (ms)" with helper text "How long to wait for a spawned pi session to connect before showing a warning. Default 30000 (30s). Range 5000–120000." - -The input SHALL accept integers in the closed range `[5000, 120000]`. Out-of-range or non-numeric inputs SHALL be flagged as invalid (existing settings-form invalidation pattern) and SHALL prevent save until corrected. - -On save, the value SHALL be persisted via the existing `POST /api/config` config-write path. The watchdog SHALL pick up the new value on the next spawn (read-on-arm — no server restart required). - -#### Scenario: field rendered with current config value -- **WHEN** the Settings panel mounts with config `{ spawnRegisterTimeoutMs: 45000 }` -- **THEN** the input SHALL display the value `45000` - -#### Scenario: in-range value saves -- **WHEN** the user enters `60000` and clicks Save -- **THEN** `POST /api/config` SHALL be called with `{ spawnRegisterTimeoutMs: 60000 }` (alongside any other dirty fields) - -#### Scenario: out-of-range input rejected -- **WHEN** the user enters `1000` (below minimum) -- **THEN** the field SHALL be flagged as invalid with helper text indicating the valid range -- **AND** Save SHALL remain disabled or refuse to submit the field - -#### Scenario: non-numeric input rejected -- **WHEN** the user enters `"abc"` -- **THEN** the field SHALL be flagged as invalid and Save SHALL be blocked - -#### Scenario: helper text mentions default and range -- **WHEN** the field is rendered -- **THEN** the helper text SHALL include both the default value (30000 / 30s) and the valid range (5000–120000) diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/shared-config/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/shared-config/spec.md deleted file mode 100644 index caf200ccb..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/shared-config/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## ADDED Requirements - -### Requirement: Configurable spawn-register watchdog timeout -`packages/shared/src/config.ts` SHALL accept a new optional config field `spawnRegisterTimeoutMs: number` in the dashboard config schema loaded from `~/.pi/dashboard/config.json`. The default value SHALL be `30000` (30 seconds). Values SHALL be clamped to the inclusive range `[5000, 120000]` at read time. Non-number / NaN / missing values SHALL fall back to the default. - -#### Scenario: default applied when field omitted -- **WHEN** the config file does not contain `spawnRegisterTimeoutMs` -- **THEN** the loader SHALL return `spawnRegisterTimeoutMs: 30000` - -#### Scenario: in-range value preserved -- **WHEN** the config file contains `"spawnRegisterTimeoutMs": 45000` -- **THEN** the loader SHALL return `spawnRegisterTimeoutMs: 45000` - -#### Scenario: below-range value clamped -- **WHEN** the config file contains `"spawnRegisterTimeoutMs": 1000` -- **THEN** the loader SHALL return `spawnRegisterTimeoutMs: 5000` - -#### Scenario: above-range value clamped -- **WHEN** the config file contains `"spawnRegisterTimeoutMs": 999999` -- **THEN** the loader SHALL return `spawnRegisterTimeoutMs: 120000` - -#### Scenario: invalid value falls back to default -- **WHEN** the config file contains `"spawnRegisterTimeoutMs": "thirty"` or `null` or `NaN` -- **THEN** the loader SHALL return `spawnRegisterTimeoutMs: 30000` diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-error-persistence/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-error-persistence/spec.md deleted file mode 100644 index e72f4d2d9..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-error-persistence/spec.md +++ /dev/null @@ -1,57 +0,0 @@ -## ADDED Requirements - -### Requirement: Spawn error banner renders failure code as actionable hint -The spawn-error banner component SHALL render an actionable hint sourced from the `code` field of the `spawn_error` message. Each known code SHALL map to a short user-facing label and (where applicable) a CTA button. Unknown or missing codes SHALL fall back to the existing message-only display. - -Code → hint mapping (label + optional CTA): -- `DIR_MISSING` — "Folder no longer exists." (no CTA) -- `PI_NOT_FOUND` — "Pi binary not found." → CTA: "Open Setup Wizard" -- `WIN_PI_CMD_ONLY` — "Windows install incomplete (only pi.cmd found)." → CTA: "Open Setup Wizard" -- `WT_MISSING` — "Windows Terminal not installed." (no CTA) -- `TMUX_MISSING` — "tmux not installed." (no CTA) -- `PI_CRASHED` — "Pi exited immediately. See log below." (no CTA) -- `SPAWN_ERRNO` — "OS refused to start pi. See message." (no CTA) -- `PREFLIGHT_FAILED` — "Preflight checks failed." → renders `reasons` list -- `REGISTER_TIMEOUT` — "Pi started but never connected to the dashboard." → CTA: "View log" - -#### Scenario: known code shows hint -- **WHEN** a `spawn_error` with `code: "PI_NOT_FOUND"` arrives -- **THEN** the banner SHALL display the label "Pi binary not found." and a CTA button "Open Setup Wizard" - -#### Scenario: unknown code falls back to message -- **WHEN** a `spawn_error` with an unrecognized `code` arrives -- **THEN** the banner SHALL display the `message` field unchanged (existing behavior) - -#### Scenario: missing code falls back to message -- **WHEN** a `spawn_error` with no `code` field arrives (legacy server) -- **THEN** the banner SHALL display the `message` field unchanged - -### Requirement: Spawn error banner renders stderr tail in collapsed details -When `spawn_error.stderr` is non-empty, the banner SHALL render it inside a collapsed `
` block labelled "Pi stderr" using a monospace font. The block SHALL NOT be expanded by default. - -#### Scenario: stderr present -- **WHEN** a `spawn_error` arrives with a non-empty `stderr` -- **THEN** the banner SHALL include a `
` element with summary "Pi stderr" and the `stderr` content as preformatted text - -#### Scenario: stderr absent -- **WHEN** a `spawn_error` arrives without `stderr` -- **THEN** no `
` block SHALL be rendered - -### Requirement: Spawn register timeout shown as distinct banner -When a `spawn_register_timeout` browser message arrives, a distinct banner SHALL be shown for the originating `cwd` with the label "Pi started (PID N) but never connected to the dashboard within Ts." where T is the configured `spawnRegisterTimeoutMs` divided by 1000 (rendered with no trailing zeros, e.g. "30s"). When `pid` is absent (tmux/wt/wsl-tmux), the label SHALL omit the `(PID N)` segment. The banner SHALL include the `stderrTail` (if present) inside a collapsed `
` block labelled "Pi stderr". The banner SHALL be dismissible like other spawn-error banners and SHALL be cleared by either (a) a subsequent successful spawn for the same `cwd`, or (b) a `spawn_register_recovered` message for the same `cwd`. - -#### Scenario: timeout banner displayed -- **WHEN** a `spawn_register_timeout` message arrives with `cwd: "/p/x"` and `pid: 123` -- **THEN** a banner SHALL be displayed for `/p/x` containing the PID and the timeout text - -#### Scenario: timeout banner cleared by successful spawn -- **WHEN** a `spawn_register_timeout` banner is visible for `cwd` and a `spawn_result { success: true, cwd }` arrives -- **THEN** the timeout banner SHALL be cleared - -#### Scenario: timeout banner cleared by late-register recovery -- **WHEN** a `spawn_register_timeout` banner is visible for `cwd` and a `spawn_register_recovered { cwd }` message arrives -- **THEN** the timeout banner SHALL be cleared automatically (no user dismissal required) - -#### Scenario: timeout banner without pid (tmux) -- **WHEN** a `spawn_register_timeout` arrives with `pid` undefined -- **THEN** the banner label SHALL omit the `(PID N)` segment and otherwise render normally diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-failure-log/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-failure-log/spec.md deleted file mode 100644 index a8e5ef011..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-failure-log/spec.md +++ /dev/null @@ -1,56 +0,0 @@ -## ADDED Requirements - -### Requirement: Append-only NDJSON failure log with single rotation -The module `packages/server/src/spawn-failure-log.ts` SHALL export `appendSpawnFailure(entry: SpawnFailureEntry): void` and `readSpawnFailures(limit: number): SpawnFailureEntry[]`. The on-disk file SHALL live at `~/.pi/dashboard/sessions/spawn-failures.log` (co-located with per-session `pi-spawn-*.log` captures). The directory SHALL be created with `mkdirSync({ recursive: true })` if missing. Each entry SHALL be one JSON object per line, terminated by `\n`. When `appendSpawnFailure` observes the file size to be greater than 10485760 bytes (10 MB) before its write, the existing `sessions/spawn-failures.log` SHALL be renamed to `sessions/spawn-failures.log.1` (overwriting any prior `.log.1`), and the new entry SHALL be written to a fresh `.log`. There SHALL be no `.log.2` or higher. - -`SpawnFailureEntry` SHALL contain: `ts: string` (ISO 8601 UTC), `cwd: string`, `strategy: string`, `code: string`, `message: string`, and optional `stderrTail?: string`, `pid?: number`, `reasons?: PreflightReason[]`. - -#### Scenario: append below threshold -- **WHEN** `appendSpawnFailure(entry)` is called and the existing log is under 10 MB -- **THEN** the entry SHALL be appended as a single `\n`-terminated JSON line to `sessions/spawn-failures.log` -- **AND** `.log.1` SHALL NOT be touched - -#### Scenario: append triggers rotation -- **WHEN** `appendSpawnFailure(entry)` is called and the existing log size exceeds 10485760 bytes -- **THEN** the current `sessions/spawn-failures.log` SHALL be renamed to `sessions/spawn-failures.log.1` (overwriting if exists) -- **AND** the new entry SHALL be the first line of a fresh `sessions/spawn-failures.log` - -#### Scenario: append with disk error -- **WHEN** `appendSpawnFailure(entry)` is called and the underlying write throws -- **THEN** the error SHALL be caught and logged via `console.error` only -- **AND** the caller SHALL NOT observe a thrown exception - -#### Scenario: read returns last N entries newest-last -- **WHEN** `readSpawnFailures(50)` is called and the log contains 200 valid entries -- **THEN** the function SHALL return an array of length 50 containing entries 151..200 in file order - -#### Scenario: read skips malformed lines -- **WHEN** `readSpawnFailures(N)` encounters a line that is not valid JSON or is missing required fields -- **THEN** that line SHALL be skipped and parsing SHALL continue with the next line - -#### Scenario: read with no log file -- **WHEN** `readSpawnFailures(N)` is called and no `sessions/spawn-failures.log` exists -- **THEN** the function SHALL return `[]` without throwing - -#### Scenario: limit clamped to non-negative -- **WHEN** `readSpawnFailures(0)` or `readSpawnFailures(-5)` is called -- **THEN** the function SHALL return `[]` - -### Requirement: Handler appends every failure to the rolling log -`session-action-handler.handleSpawnSession` SHALL call `appendSpawnFailure` for every failure path it emits a `spawn_error` for: preflight refusal, `spawn_result.success === false`, thrown exception from `spawnPiSession`, and `spawn_register_timeout` from the watchdog. - -#### Scenario: preflight failure persisted -- **WHEN** preflight refuses a spawn -- **THEN** an entry with `code: "PREFLIGHT_FAILED"` and the `reasons` array SHALL be appended - -#### Scenario: spawnPiSession failure persisted -- **WHEN** `spawnPiSession` returns `success: false` -- **THEN** an entry with `code: result.code`, `message: result.message`, and `stderrTail: result.stderr` (if present) SHALL be appended - -#### Scenario: thrown exception persisted -- **WHEN** `spawnPiSession` throws -- **THEN** an entry with `code: "SPAWN_ERRNO"` and `message` from the error SHALL be appended - -#### Scenario: register timeout persisted -- **WHEN** the spawn-register watchdog fires for a PID -- **THEN** an entry with `code: "REGISTER_TIMEOUT"` and the captured `stderrTail` (if any) SHALL be appended diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-preflight/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-preflight/spec.md deleted file mode 100644 index fd99ca69d..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-preflight/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -## ADDED Requirements - -### Requirement: Pure preflight validation function -The module `packages/server/src/spawn-preflight.ts` SHALL export `preflightSpawn(cwd: string, deps?: { resolver?: ToolResolver }): PreflightResult` where `PreflightResult = { ok: boolean; reasons: PreflightReason[] }` and `PreflightReason = { code: string; message: string }`. The function SHALL run all checks (no short-circuit) and accumulate every failing reason in `reasons`. `ok` SHALL be `true` if and only if `reasons.length === 0`. - -The `ToolResolver` instance used by preflight (whether passed in `deps` or constructed by the handler) SHALL be configured with `useLoginShell: false` so preflight never spawns a login shell on the spawn-click hot path. - -#### Scenario: cwd missing -- **WHEN** `preflightSpawn("/nonexistent")` is called -- **THEN** `result.ok` SHALL be `false` -- **AND** `result.reasons` SHALL contain an entry with `code: "DIR_MISSING"` - -#### Scenario: cwd is a file not a directory -- **WHEN** `preflightSpawn()` is called -- **THEN** `result.reasons` SHALL contain `code: "DIR_NOT_DIRECTORY"` - -#### Scenario: cwd not writable -- **WHEN** `preflightSpawn()` is called -- **THEN** `result.reasons` SHALL contain `code: "DIR_NOT_WRITABLE"` - -#### Scenario: pi binary unresolvable -- **WHEN** `preflightSpawn(cwd, { resolver })` is called and `resolver.resolvePi()` returns `null` -- **THEN** `result.reasons` SHALL contain `code: "PI_NOT_FOUND"` - -#### Scenario: node binary unresolvable -- **WHEN** `preflightSpawn(cwd, { resolver })` is called and `resolver.resolveNode()` returns `null` -- **THEN** `result.reasons` SHALL contain `code: "NODE_NOT_FOUND"` - -#### Scenario: all checks pass -- **WHEN** `preflightSpawn(cwd, { resolver })` is called with a writable directory and resolvable pi+node -- **THEN** `result` SHALL equal `{ ok: true, reasons: [] }` - -#### Scenario: multiple failures accumulate -- **WHEN** `preflightSpawn(, { resolver })` is called and `resolver.resolvePi()` also returns `null` -- **THEN** `result.reasons` SHALL contain entries for both `DIR_MISSING` and `PI_NOT_FOUND` (not just the first) - -### Requirement: Handler integrates preflight before spawn -`session-action-handler.handleSpawnSession` SHALL construct a preflight-only resolver as `new ToolResolver({ processExecPath: process.execPath, useLoginShell: false })` and call `preflightSpawn(msg.cwd, { resolver })` before invoking `spawnPiSession`. The actual `spawnPiSession` invocation SHALL continue to use the default resolver (with login-shell allowed). If `result.ok === false`, the handler SHALL emit `spawn_result { success: false, message: }` and `spawn_error { code: "PREFLIGHT_FAILED", reasons }` and SHALL NOT call `spawnPiSession`. - -#### Scenario: preflight resolver excludes login shell -- **WHEN** `handleSpawnSession` constructs the preflight resolver -- **THEN** the resolver's `useLoginShell` option SHALL be `false` -- **AND** preflight SHALL NOT spawn `$SHELL -ilc "which pi"` regardless of platform - -#### Scenario: preflight refuses spawn -- **WHEN** `handleSpawnSession` runs preflight and receives `{ ok: false, reasons: [...] }` -- **THEN** `spawnPiSession` SHALL NOT be invoked -- **AND** a `spawn_error` message SHALL be sent with `code: "PREFLIGHT_FAILED"` and the full `reasons` array - -#### Scenario: preflight passes -- **WHEN** `handleSpawnSession` runs preflight and receives `{ ok: true }` -- **THEN** `spawnPiSession` SHALL be invoked normally diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-register-watchdog/spec.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-register-watchdog/spec.md deleted file mode 100644 index d6cb90e81..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/specs/spawn-register-watchdog/spec.md +++ /dev/null @@ -1,88 +0,0 @@ -## ADDED Requirements - -### Requirement: Watchdog tracks spawned sessions until session_register or timeout -The module `packages/server/src/spawn-register-watchdog.ts` SHALL export a class `SpawnRegisterWatchdog` with `arm({ pid?, cwd, mechanism, logPath?, ws })`, `clearByPid(pid)`, `clearByCwd(cwd)`, and a constructor accepting `timeoutMs` (default `30000`, sourced from `config.spawnRegisterTimeoutMs`, clamped to `[5000, 120000]`). - -The watchdog SHALL maintain two internal maps: -- `byCwd: Map` — primary index, populated for every armed entry. -- `byPid: Map` — secondary index, populated only when `pid` is provided. - -On `arm`, the entry SHALL be indexed in `byCwd` unconditionally; when `pid` is provided the same entry SHALL additionally be indexed in `byPid`. Indexing in both maps is required because the PID reported at arm time can differ from the PID reported in `session_register` — e.g. on Unix the headless mechanism wraps pi in `sh -c "tail -f /dev/null | pi …"`, so `SpawnResult.pid` is the `sh` wrapper while the bridge later registers with pi's actual `process.pid`. Either `clearByPid(pid)` or `clearByCwd(cwd)` SHALL therefore be sufficient to cancel the watchdog. - -A `setTimeout(timeoutMs)` SHALL be started for every armed entry. Each `clear*` call SHALL cancel the timer and remove the entry from BOTH maps when the entry it points to is the same arm (identity comparison). Clearing an unknown key SHALL be a no-op. On timer fire, the watchdog SHALL emit `spawn_register_timeout` to the stored `ws` and remove the entry from both maps. If a subsequent `arm` reuses an existing `cwd` (or `pid`), any prior pending timer for that key SHALL be cancelled before the new entry is installed. - -#### Scenario: headless arm then clearByPid clears watchdog -- **WHEN** `watchdog.arm({ pid: 123, cwd, mechanism: "headless", ws })` is called and `watchdog.clearByPid(123)` is called within `timeoutMs` -- **THEN** the timer SHALL be cancelled and no `spawn_register_timeout` SHALL be sent - -#### Scenario: tmux arm then clearByCwd clears watchdog -- **WHEN** `watchdog.arm({ cwd: "/p/x", mechanism: "tmux", ws })` is called (no pid) and `watchdog.clearByCwd("/p/x")` is called within `timeoutMs` -- **THEN** the timer SHALL be cancelled and no `spawn_register_timeout` SHALL be sent - -#### Scenario: headless arm with pid then clearByCwd (pid mismatch) clears watchdog -- **WHEN** `watchdog.arm({ pid: 51250, cwd: "/p/x", mechanism: "headless", ws })` is called and `watchdog.clearByCwd("/p/x")` is called within `timeoutMs` (the bridge registered with pi's actual pid, not the `sh` wrapper pid stored at arm time) -- **THEN** the timer SHALL be cancelled and no `spawn_register_timeout` SHALL be sent -- **AND** the entry SHALL be removed from BOTH `byPid` and `byCwd` - -#### Scenario: arm without register fires watchdog -- **WHEN** `watchdog.arm(...)` is called and neither `clearByPid` nor `clearByCwd` is called within `timeoutMs` -- **THEN** the watchdog SHALL send `{ type: "spawn_register_timeout", cwd, pid?, stderrTail? }` to `ws` -- **AND** the entry SHALL be removed from the indexing map - -#### Scenario: clear on unknown key is no-op -- **WHEN** `watchdog.clearByPid(999)` or `watchdog.clearByCwd("/never/seen")` is called -- **THEN** the call SHALL return without throwing - -#### Scenario: timeout fires after ws closed -- **WHEN** the timer fires and `ws.readyState !== OPEN` -- **THEN** the watchdog SHALL silently skip the send and remove the entry - -#### Scenario: stderrTail attached when logPath provided and readable -- **WHEN** `watchdog.arm({ ..., logPath: })` is called and the timeout fires -- **THEN** the emitted `spawn_register_timeout` SHALL include `stderrTail` containing the last 4096 bytes of `logPath` - -#### Scenario: timeoutMs sourced from config and clamped -- **WHEN** the watchdog is constructed with `timeoutMs: 1000` -- **THEN** the effective timeout SHALL be `5000` (clamped to lower bound) - -- **WHEN** the watchdog is constructed with `timeoutMs: 999999` -- **THEN** the effective timeout SHALL be `120000` (clamped to upper bound) - -### Requirement: Late-register recovery emits spawn_register_recovered -The watchdog SHALL maintain a `recentlyFired: Map` with a 60 s TTL. When `clearByPid` or `clearByCwd` is invoked for a key whose entry was already removed by a fired timer (i.e. found in `recentlyFired`), the watchdog SHALL emit `{ type: "spawn_register_recovered", cwd, pid? }` to the originally-stored `ws` and delete the `recentlyFired` entry. - -#### Scenario: late session_register emits recovery message -- **WHEN** the watchdog timer fires for `cwd: "/p/x"` and 5 s later `clearByCwd("/p/x")` is called -- **THEN** the watchdog SHALL emit `{ type: "spawn_register_recovered", cwd: "/p/x", pid? }` to the originating `ws` - -#### Scenario: recovery beyond TTL is silent -- **WHEN** the watchdog timer fires and 61 s elapse before any clear call for that key -- **THEN** the `recentlyFired` entry SHALL have been evicted and no recovery message SHALL be emitted - -#### Scenario: recovery skipped when ws closed -- **WHEN** late clear arrives within TTL but `ws.readyState !== OPEN` -- **THEN** the recovery message SHALL be skipped silently and `recentlyFired` entry deleted - -### Requirement: Pi gateway clears watchdog on session_register -The pi-gateway message handler for `session_register` SHALL call BOTH `watchdog.clearByPid(pid)` (when a `pid` field is present) AND `watchdog.clearByCwd(cwd)` so headless and terminal-based spawns are both cleared. Order: `clearByPid` first, then `clearByCwd`. Both calls SHALL precede any handler logic that could throw. - -#### Scenario: bridge registers headless session -- **WHEN** the pi gateway receives `session_register { pid: 123, cwd: "/p/x" }` -- **THEN** `watchdog.clearByPid(123)` SHALL be invoked -- **AND** `watchdog.clearByCwd("/p/x")` SHALL be invoked - -#### Scenario: bridge registers tmux session (no pid the dashboard owns) -- **WHEN** the pi gateway receives `session_register { cwd: "/p/x" }` with no relevant pid (or with a pid that was never armed) -- **THEN** `watchdog.clearByPid` SHALL be a no-op and `watchdog.clearByCwd("/p/x")` SHALL clear the tmux watch - -### Requirement: Handler arms watchdog for every successful spawn -`session-action-handler.handleSpawnSession` SHALL call `watchdog.arm` exactly once after a successful spawn. For headless mechanisms (`pid` present) the entry SHALL include `pid`. For tmux/wt/wsl-tmux (no `pid`) the entry SHALL be cwd-keyed only. - -#### Scenario: headless spawn arms watchdog with pid -- **WHEN** `handleSpawnSession` receives `SpawnResult { success: true, pid: 123, process }` from a headless spawn -- **THEN** `watchdog.arm({ pid: 123, cwd, mechanism: "headless", logPath: result.logPath, ws })` SHALL be called once -- **AND** the entry SHALL be reachable via BOTH `clearByPid(123)` AND `clearByCwd(cwd)` (the spawner's pid may not match the bridge's reported pid on Unix headless) - -#### Scenario: tmux spawn arms watchdog by cwd only -- **WHEN** `handleSpawnSession` receives `SpawnResult { success: true }` from a tmux/wt/wsl-tmux spawn (no `pid`) -- **THEN** `watchdog.arm({ cwd, mechanism, ws })` SHALL be called once with no `pid` diff --git a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/tasks.md b/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/tasks.md deleted file mode 100644 index a7c1a14b2..000000000 --- a/openspec/changes/archive/2026-05-05-spawn-failure-diagnostics/tasks.md +++ /dev/null @@ -1,95 +0,0 @@ -## 1. Shared protocol + config additions - -- [x] 1.1 Add `SpawnFailureCode` string-literal union and `PreflightReason` interface to `packages/shared/src/browser-protocol.ts` -- [x] 1.2 Extend `spawn_error` message type with optional `code?: SpawnFailureCode` and `reasons?: PreflightReason[]` -- [x] 1.3 Add new `spawn_register_timeout` message type `{ type, cwd, pid?, stderrTail? }` (pid optional for tmux/wt) and `spawn_register_recovered` message `{ type, cwd, pid? }` to the union -- [x] 1.4 Add `spawnRegisterTimeoutMs?: number` to `packages/shared/src/config.ts` schema; default 30000; clamp `[5000, 120000]` at read; NaN/non-number falls back to default -- [x] 1.5 Add unit test in `packages/shared/src/__tests__/config.test.ts` (or new file) covering default/in-range/below/above/NaN cases -- [x] 1.6 Verify `tsc --noEmit` passes across all workspace packages - -## 2. process-manager: classify every failure - -- [x] 2.1 Extend `SpawnResult` in `packages/server/src/process-manager.ts` with optional `code?: SpawnFailureCode`, `stderr?: string`, `logPath?: string` -- [x] 2.2 Update `spawnPiSession` cwd-missing return → `code: "DIR_MISSING"` -- [x] 2.3 Update `spawnTmux` failure → `code: "TMUX_MISSING"` -- [x] 2.4 Update `spawnWslTmux` failure → reuse `code: "TMUX_MISSING"` with mechanism note in message -- [x] 2.5 Update `spawnWt` `wt` missing → `code: "WT_MISSING"`; pi missing → `code: "PI_NOT_FOUND"` -- [x] 2.6 Update `spawnHeadless` (Unix wrapper) failures → `code: "PI_NOT_FOUND"` (no pi) and `code: "SPAWN_ERRNO"` (spawnDetached error) -- [x] 2.7 Update `spawnHeadlessDetached` (Windows): pi.cmd-only → `code: "WIN_PI_CMD_ONLY"`; spawnDetached error → `code: "SPAWN_ERRNO"`; waitForNoCrash !ok → `code: "PI_CRASHED"` -- [x] 2.8 In `spawnHeadlessDetached`, on `PI_CRASHED`, read last 4096 bytes of `logPath` (utf-8 boundary-safe) and assign to `result.stderr`; swallow read errors -- [x] 2.9 In `spawnHeadlessDetached`, return `logPath` on both success and failure for watchdog handoff -- [x] 2.10 Add unit test `process-manager-codes.test.ts` enumerating each failure path with mocked deps and asserting `code` is set - -## 3. spawn-preflight module - -- [x] 3.1 Create `packages/server/src/spawn-preflight.ts` exporting `preflightSpawn(cwd, deps?)` and `PreflightResult`/`PreflightReason` types -- [x] 3.2 Implement five checks (DIR_MISSING, DIR_NOT_DIRECTORY, DIR_NOT_WRITABLE, PI_NOT_FOUND, NODE_NOT_FOUND); accumulate all reasons (no short-circuit) -- [x] 3.3 Document and assert that the accepted resolver MUST have `useLoginShell: false`; if a resolver with `useLoginShell: true` is passed, the function SHALL still run but emit a `console.warn` once (lint rather than reject) -- [x] 3.4 Add `packages/server/src/__tests__/spawn-preflight.test.ts` with memfs-backed table-driven cases including multi-failure accumulation; assert no login-shell spawn occurs (mock `whichViaLoginShell` and assert never called) - -## 4. spawn-register-watchdog module - -- [x] 4.1 Create `packages/server/src/spawn-register-watchdog.ts` exporting class `SpawnRegisterWatchdog` with `arm({ pid?, cwd, mechanism, logPath?, ws })`, `clearByPid(pid)`, `clearByCwd(cwd)`; ctor reads `timeoutMs` from `config.spawnRegisterTimeoutMs` and clamps `[5000, 120000]` -- [x] 4.2 Two internal maps: `byPid: Map`, `byCwd: Map`. Headless arms in `byPid`; tmux/wt/wsl-tmux arms in `byCwd` only. Both clears are idempotent. OPEN-readyState check before send -- [x] 4.3 On timeout: read stderr tail from `logPath` if present, emit `spawn_register_timeout`, move entry into `recentlyFired: Map` (60 s TTL eviction on access) -- [x] 4.4 Late-clear path: `clearByPid` / `clearByCwd` checks `recentlyFired`; if hit and `ws` OPEN, emits `spawn_register_recovered`, deletes the entry -- [x] 4.5 Hook `watchdog.clearByPid(pid)` AND `watchdog.clearByCwd(cwd)` into `packages/server/src/pi-gateway.ts` `session_register` handler; guard missing `pid`; both calls precede any throwing logic -- [x] 4.6 Export module-level singleton `getSpawnRegisterWatchdog()` for handler access (lazy init, swappable in tests) -- [x] 4.7 Add `packages/server/src/__tests__/spawn-register-watchdog.test.ts` with vitest fake timers covering: headless arm+clearByPid; tmux arm+clearByCwd; arm-then-fire; clear-unknown-key; closed-ws no-throw; stderrTail-on-timeout; late clearByCwd within 60s emits `spawn_register_recovered`; late clear past 60s is silent; clamp at lower/upper bound - -## 5. spawn-failure-log module - -- [x] 5.1 Create `packages/server/src/spawn-failure-log.ts` exporting `appendSpawnFailure(entry)`, `readSpawnFailures(limit)`, `SpawnFailureEntry` type -- [x] 5.2 NDJSON line writer with try/catch around fs ops (console.error on failure, never throw) -- [x] 5.3 Single-shot rotation: `statSync().size > 10*1024*1024` → `renameSync(.log, .log.1)` then `appendFileSync(.log, line)` -- [x] 5.4 `readSpawnFailures`: read both `.log.1` (older) and `.log` (newer), concatenate, parse line-by-line skipping malformed, return last `limit` (clamp 0..500, NaN → default) -- [x] 5.5 Add `packages/server/src/__tests__/spawn-failure-log.test.ts` with tmpdir covering append, rotation at threshold, malformed-line skip, missing-file empty, limit clamping - -## 6. session-action-handler integration - -- [x] 6.1 Construct preflight resolver inline as `new ToolResolver({ processExecPath: process.execPath, useLoginShell: false })` and call `preflightSpawn(msg.cwd, { resolver })` at the top of `handleSpawnSession` -- [x] 6.2 If `!preflight.ok`: send `spawn_result { success: false, message: }` + `spawn_error { code: "PREFLIGHT_FAILED", reasons }`; append failure to log; return early (no `spawnPiSession` call) -- [x] 6.3 After successful spawn: call `watchdog.arm({ pid, cwd, mechanism, logPath: result.logPath, ws })`. Headless includes `pid`; tmux/wt/wsl-tmux pass `pid: undefined` -- [x] 6.4 On `spawn_result.success === false`: forward `code` and `stderr` in the `spawn_error` message; append entry to failure log with full context -- [x] 6.5 On thrown `spawnPiSession` exception: append `code: "SPAWN_ERRNO"` entry -- [x] 6.6 In watchdog timeout callback (set up at handler init): append `code: "REGISTER_TIMEOUT"` entry to log -- [x] 6.7 Add `packages/server/src/__tests__/session-action-handler-spawn.test.ts` with stub `spawnPiSession` for each code → assert emitted message + log entry; include tmux-arm-by-cwd and headless-arm-by-pid cases - -## 7. REST endpoint - -- [x] 7.1 Register `GET /api/spawn-failures` in `packages/server/src/routes/system-routes.ts` -- [x] 7.2 Parse `limit` query (default 50, max 500, NaN→default), call `readSpawnFailures(limit)`, return `{ entries }` -- [x] 7.3 No auth-bypass entry — relies on existing Fastify auth plugin -- [x] 7.4 Add route test in `packages/server/src/__tests__/system-routes.test.ts` (or new file) covering default/custom/clamped/NaN limit + missing-log - -## 8. Client UI - -- [x] 8.1 Add code→hint mapping table to spawn-error banner component (locate via grep `spawn_error` in `packages/client/src/`) -- [x] 8.2 Render `code` hint label + per-code optional CTA button (Open Setup Wizard / View log) -- [x] 8.3 Render `reasons` list when `code === "PREFLIGHT_FAILED"` -- [x] 8.4 Render `stderr` inside collapsed `
Pi stderr
` -- [x] 8.5 Add `spawn_register_timeout` handler in client message router → push distinct banner per `cwd` with PID (when present) + stderrTail; banner label uses configured timeout in seconds (e.g. "30s") -- [x] 8.6 Add `spawn_register_recovered` handler → auto-clear matching timeout banner for that `cwd` (no user dismissal required) -- [x] 8.7 Clear timeout banner on subsequent `spawn_result.success === true` for same `cwd` (existing rule extended) -- [x] 8.8 Add `spawnRegisterTimeoutMs` numeric input to `SettingsPanel.tsx` under General group; label "Spawn register timeout (ms)"; helper text mentions default 30000 and range 5000–120000; validate in-range integer; block Save on invalid -- [x] 8.9 Surface last 50 spawn failures in Settings → Tools (or nearest existing diagnostics panel) via `GET /api/spawn-failures`; collapsed list with per-row code/cwd/timestamp/expand-for-stderr -- [x] 8.10 Manual visual verification with `browser-visual-debug` skill across light/dark themes - -## 9. Docs and indexing (delegate every `docs/` write to a general-purpose subagent in caveman style per AGENTS.md) - -- [x] 9.1 Update `docs/file-index-server.md` with new files: `spawn-preflight.ts`, `spawn-register-watchdog.ts`, `spawn-failure-log.ts` — caveman style, alphabetical -- [x] 9.2 Update `docs/file-index-shared.md` row for `browser-protocol.ts` and `config.ts` if rows exist; otherwise leave (additive) -- [x] 9.3 Update `docs/file-index-client.md` row for the spawn-error banner component and `SettingsPanel.tsx` -- [x] 9.4 Add FAQ entry under `docs/faq.md`: "How do I see why a session spawn failed?" pointing at the banner + Settings list + `/api/spawn-failures` -- [x] 9.5 Create `docs/todo.md` (new file) with two queued items in caveman style -- [x] 9.6 Update `README.md` security section: add note that `/api/spawn-failures` is reachable to any caller in deployments without auth and entries contain `cwd` paths; recommend enabling auth before exposing via tunnel -- [x] 9.7 No AGENTS.md change unless a new architectural backbone file emerges (likely: none qualify per the ≤200-char rule) - -## 10. Release readiness - -- [x] 10.1 `npm test` green; tee to `/tmp/pi-test.log` and grep FAIL/Error -- [x] 10.2 `npm run build` succeeds for client -- [x] 10.3 `curl -X POST http://localhost:8000/api/restart` and verify health = "ok" -- [x] 10.4 `npm run reload` to refresh bridge in connected pi sessions -- [x] 10.5 Smoke: trigger each failure mode locally (delete cwd; rename pi; force pi crash via env var) and verify banner + log entry per code -- [x] 10.6 Run `openspec verify spawn-failure-diagnostics` (via `openspec-verify-change` skill) before archive diff --git a/openspec/changes/archive/2026-05-06-doctor-rich-output/design.md b/openspec/changes/archive/2026-05-06-doctor-rich-output/design.md deleted file mode 100644 index e6466db7b..000000000 --- a/openspec/changes/archive/2026-05-06-doctor-rich-output/design.md +++ /dev/null @@ -1,171 +0,0 @@ -## Context - -The Electron app's Doctor diagnostic is implemented in `packages/electron/src/lib/doctor.ts` (`runDoctor()` returns `DoctorReport { checks, summary }`) and rendered through Electron's `dialog.showMessageBox` from `app-menu.ts`. The native dialog cannot style anything: it shows the report as a plain `detail` blob, with no per-section grouping, no actionable rows, no row hover, no copyable Markdown export, and no way to surface a remediation suggestion next to the failing check. The dialog is also Electron-only — phone / browser users running against a remote dashboard can't reach it at all. - -The doctor logic itself is healthy: `runDoctor()` is a pure synchronous (mostly) function that produces a fixed taxonomy of checks (Electron version, system/bundled Node, bundled npm, pi CLI, openspec CLI, server code, offline-packages bundle, tsx, dashboard server, setup wizard, API key, server log, server launch test, managed install). What's missing is (a) section metadata so a renderer can group, (b) a `suggestion` field so failing rows can surface one-click next steps, (c) a renderer that isn't a native dialog, (d) a server endpoint so the web UI can render the same report, and (e) systematic fault tolerance so the doctor itself never breaks while diagnosing a broken installation. - -Stakeholders: Electron desktop users (primary — they hit the diagnostic when wizard / install fails), web/PWA users connected to a remote dashboard (secondary — currently blind), maintainers triaging GitHub issues (the Markdown export reduces back-and-forth). - -## Goals / Non-Goals - -**Goals:** -- One source of truth for the check taxonomy (`section`, `name`, `status`, `message`, `detail?`, `suggestion?`, `fixable?`) shared by Electron and web renderers. -- Replace the native Doctor dialog with a styled Electron BrowserWindow that matches the existing wizard's visual language (no React, no extra build step — hand-rolled HTML/CSS like `wizard.html`). -- Add a `GET /api/doctor` route + a `` in Settings so web users get the same diagnostic data, gated behind the existing auth pipeline. -- Markdown export (`Copy as Markdown`) usable for GitHub issue paste, available on both surfaces. -- Backfill `suggestion` text for every existing error/warning case so the user never sees a red row without a "what to do next" line. -- Fault-tolerance everywhere: every spawn bounded + classified, every "mandatory" op logged + surfaced, renderer never blank. - -**Non-Goals:** -- No new check types in this change — the taxonomy stays exactly as today, only metadata and rendering change. Adding new checks is a follow-up. -- No live polling / auto-refresh — the user clicks `[Re-run]` manually. -- No anonymous web access — the route reuses the standard `localhost-guard` / OAuth gate. We do not add a public diagnostic endpoint. -- No structured `[Fix]` action automation — clicking `[Run setup wizard]` opens the existing wizard window; we don't auto-install missing pieces from the doctor view. -- No Markdown rendering library on the Electron side. The Electron `doctor.html` keeps suggestions as plain inline text. Only the web side uses `MarkdownContent.tsx` (already in the bundle) for the suggestion field. - -## Decisions - -### Decision 1: Move detection to `packages/shared/src/doctor-core.ts`, keep Electron-only checks in `doctor.ts` - -`doctor-core.ts` (new, in `@blackbelt-technology/pi-dashboard-shared`) hosts: -- The `DoctorCheck` / `DoctorReport` types with the new `section` + `suggestion` fields. -- `SECTION_OF: Record` — pure mapping from canonical check name to section. -- `SUGGESTIONS: Record string | undefined>` — pure mapping from `(checkName, status, failureKind)` to a remediation suggestion (returns `undefined` when `status === "ok"`). -- `runSharedChecks(deps)` — runs every check that does NOT require `electron` runtime APIs. `deps` is an injectable shape so the server route can pass non-Electron implementations of "where does the user's home live", "which managed dir to inspect", etc. -- `formatDoctorReportMarkdown(report)` — pure formatter that produces one Markdown table per section + a summary header + a "Remediation" bullet list of suggestions for non-ok rows. - -`packages/electron/src/lib/doctor.ts` keeps: -- Electron-only checks: Electron version, bundled Node, bundled npm, server-code path under `resourcesPath`, offline-packages bundle, server-launch sanity test, setup wizard state file (already Electron-only). -- Imports `runSharedChecks`, `SECTION_OF`, `SUGGESTIONS`, `formatDoctorReportMarkdown` from the shared core. -- Stamps `section` + `suggestion` on every check it pushes by looking up `SECTION_OF[name]` / `SUGGESTIONS[name](status, detail, kind)` once at the end of `runDoctor()`. - -**Why this split:** the offline-packages bundle, bundled-node lookup, and `app.getVersion()` all require `electron` APIs or `process.resourcesPath`. We don't want `pi-dashboard-shared` to import `electron`. Keeping the Electron-only arm in `doctor.ts` and delegating the portable arm to shared is the cleanest cleavage and matches how the rest of the repo handles Electron / shared / server separation (e.g., `tool-registry`, `platform/`). - -**Alternative considered:** put everything in `doctor-core.ts` with conditional imports / `process.versions.electron` guards. Rejected — `pi-dashboard-shared` is consumed by the server (no Electron at all), and a dynamic `await import("electron")` would explode the bundle at type-check time. - -### Decision 2: Hand-rolled HTML for the Electron Doctor window (no React) - -Match the existing `packages/electron/src/renderer/wizard.html` pattern — single HTML file with a ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Desktop Elements (>= 768px)

+ + +
+
+
+
+ 📁 + /home/pi/dev/my-project + (10) +
+ +
+
+ + + +
+
+ +
+
+
+
+
+
+
Idle script
+
+
+
+ + + +
+
4s
+
+
+
+
+ google/gemini-2.5-pro +
+
+
+
+ + Waiting for input +
+
+
+
+
+ $0.02 +
+
+
+ +
+
+
+
+
+
Selected Session
+
+
+
+
+ anthropic/claude-sonnet +
+
+
+
+
+ +
+

Desktop Card Idle

+ + +
+
+
+
+
+ +
Quick script
+
+ +
$0.02
+
+
+
+ google/gemini-2.5-pro + (high) +
+
+ +
+
+ +
+

Desktop Card Streaming

+ + +
+
+
+
+
+ +
Verify release v2.0
+
+ +
$0.14
+
+
+
+ anthropic/claude-sonnet-4 +
+
+ +
+
+ +
+

Desktop Card Ended

+ + +
+
+
+
+
+ +
Fix UI bugs
+
+ +
+
+
+ openai/gpt-4o +
+ +
+ +
+
+ +
+

Desktop Card Ask User

+ + +
+
+
+
+
+ +
Build setup step
+
+ +
+
+
+ anthropic/claude-opus +
+
+ +
+
+ +
+

Desktop Card Selected

+ + +
+
+
+
+
+ +
Archive old changes
+
+ +
+
+
+ anthropic/claude-sonnet + (medium) +
+
+ +
+
+ +
+

Desktop Card Chips

+ + +
+
+
+
+
+ +
Add dark mode
+
+
+
+
+ openai/gpt-4o +
+
+ + +
+
+ + feature/x · #42 +
+
+ 📁 + shadow/feat +
+
+ 📎 + add-auth +
+
+
+
+ +
+

Desktop Card OpenSpec

+ + +
+
+
+
+
+ +
Refactor API
+
+
+
+
+ google/gemini-2.5-pro +
+
+ + + +
+
+ +
+

Desktop Tools Dropdown

+ + +
+
+ + + +
+ + +
+ + + +
+ +
+
+ +
+

Mobile Elements (< 768px)

+

Hardcoded for 375px viewport visualization to enforce intended structure without media queries collapsing randomly.

+ + +
+ +
+
+
+ 📁 + /home/pi/dev/my-project + (10) +
+
+
+ + +
+
+ +
+
+
+
+
+
First session
+
+
+ $0.15 +
+
+
+
+ google/gemini-2.5-pro +
+
+
+ +
+
+
+
+
Second session with long text wrapping
+
+
+ $1.45 +
+
+
+
+ anthropic/claude-sonnet +
+
+
+
+
+ +
+

Mobile Card Idle

+ + +
+
+
+
+
+
Simple idle session
+
+
+
+
+ google/gemini-2.5-pro +
+
+
+
+ +
+

Mobile Card Streaming

+ + +
+
+
+
+
+
Actively reading log output
+
+
+ $0.12 +
+
+
+
+ anthropic/claude-sonnet + (high) +
+
+
+
+ +
+

Mobile Card Chips

+ + +
+
+
+
+
+
Complex mapped task
+
+
+
+
+ openai/gpt-4o +
+
+ + +
+
+
+ + feature/x · #42 +
+
+ 📁 + shadow/feat +
+
+
+
+ 📎 + add-auth-middleware +
+
+
+
+
+ +
+

Folder Action Bar Desktop

+ + +
+ + + +
+ +
+

Folder Action Bar Mobile

+ + +
+ + +
+ +
+

Placeholder Card (Skeleton)

+ + +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/proposal.md b/openspec/changes/archive/2026-05-08-redesign-session-card/proposal.md new file mode 100644 index 000000000..564544cf7 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/proposal.md @@ -0,0 +1,42 @@ +## Why + +SessionCard разрослась до 12 визуальных строк и двух независимых рендер-путей (desktop/mobile). Карточка смешивает показ состояния с интерактивными действиями (OpenSpec attach, flow launcher, plugin slots, process list), что перегружает список сессий. FolderActionBar состоит из 5-7 кнопок в строку, нечитаем на мобильных экранах. Нужен единый mobile-first дизайн, который показывает только состояние сессии, а действия переносит в детализацию. + +## What Changes + +- **SessionCard**: Единый mobile-first рендер вместо двух веток (desktop/mobile). Адаптивная вёрстка скрывает/показывает элементы через CSS. Карточка — только превью состояния. +- **Убрано из карточки**: Token stats, flow badge, flow launcher, OpenSpec actions (attach/detach), plugin slots, process list, drag-to-reorder, inline rename +- **Убрано из mobile-карточки**: Source icon, activity indicator, context usage bar, OpenSpec badge, resume/fork кнопки, rename/hide/shutdown кнопки, время +- **Meta-информация в чипсах**: Git branch, worktree, attached proposal — компактные пилюли в одной строке вместо отдельных строк +- **Cost ($)**: Скрывается когда равен 0 +- **FolderActionBar**: Только +Session и +Worktree на виду. Terminals, Editor, native editors, Pi Resources — в выпадающем меню «Инструменты» (desktop). На mobile — только +Session и +Worktree. +- **README button**: Убрана из заголовка папки (везде) +- **PlaceholderSessionCard**: Редизайн в общем стиле + +## Capabilities + +### New Capabilities + +- `session-card-redesign`: Минималистичная mobile-first карточка сессии — единый адаптивный рендер, Apple-style визуальный язык (blur, мягкие тени, воздух), чипсы для meta-информации, чёткая визуальная иерархия из 4-5 строк +- `folder-action-bar-redesign`: Упрощённая панель действий папки — основные экшены на виду, второстепенные в выпадающем меню «Инструменты» (desktop), только основные на mobile + +### Modified Capabilities + +- `folder-action-bar`: Полная замена — новый набор кнопок и их расположение +- `session-listing`: Изменение компоновки карточек в списке, удаление drag-to-reorder +- `session-rename`: Удаление inline-переименования из карточки +- `session-process-tracking`: ProcessList убран из карточки +- `token-stats-pipeline`: Token stats убраны из карточки +- `placeholder-spawn-card`: Визуальное обновление скелетона +- `session-grouping`: Удаление drag-to-reorder +- `git-context`: Git branch отображается чипсом в карточке (дополнительно к GroupGitInfo на уровне папки) +- `proposal-attachment`: Attached proposal отображается чипсом +- `openspec-card-section`: OpenSpec badge остаётся (desktop only), OpenSpec actions убраны +- `context-usage-bar`: Desktop-only в карточке +- `sidebar-header`: Удаление README button + +## Impact + +- **Affected code**: `SessionCard.tsx`, `SessionList.tsx`, `FolderActionBar.tsx`, `SortableSessionCard.tsx`, `PlaceholderSessionCard.tsx`, `SidebarFolderSectionSlot` (README button) +- **Dependencies**: `@dnd-kit` может быть удалён если drag нигде больше не используется +- **Breaking**: Drag-to-reorder сессий удалён; inline rename удалён; README button из заголовка папки удалён diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/sandbox-seed.patch b/openspec/changes/archive/2026-05-08-redesign-session-card/sandbox-seed.patch new file mode 100644 index 000000000..ff7ae1e4a --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/sandbox-seed.patch @@ -0,0 +1,36 @@ +diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile +index 0fe6f287..17aef89f 100644 +--- a/sandbox/Dockerfile ++++ b/sandbox/Dockerfile +@@ -26,7 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + + # Install agent-browser globally for screenshot capture +-RUN npm install -g @mariozechner/agent-browser ++RUN npm install -g agent-browser + + ENV CHROME_BIN=/usr/bin/chromium \ + AGENT_BROWSER_CDP_URL=http://localhost:9222 +@@ -50,6 +50,9 @@ RUN npm ci + # Dashboard source (read-only, for pi-dashboard --dev) + COPY . /app + ++# Build client for production fallback (dev mode serves this when Vite is not running) ++RUN npm run build ++ + # Expose dashboard port + EXPOSE 8000 + +diff --git a/sandbox/scripts/run-scenarios.sh b/sandbox/scripts/run-scenarios.sh +index 25440156..8fa30865 100755 +--- a/sandbox/scripts/run-scenarios.sh ++++ b/sandbox/scripts/run-scenarios.sh +@@ -38,7 +38,7 @@ out_dir = '$OUT' + + def run(cmd): + print(f' → {cmd}') +- result = subprocess.run(['browser'] + cmd.split(), capture_output=True, text=True) ++ result = subprocess.run(['agent-browser'] + cmd.split(), capture_output=True, text=True) + if result.returncode != 0 and 'screenshot' not in cmd: + print(f' ⚠ {result.stderr.strip()[:200]}') + diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-desktop.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-desktop.png new file mode 100644 index 000000000..48d863e41 Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-desktop.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-mobile.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-mobile.png new file mode 100644 index 000000000..90cb0234b Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-mobile.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-sandbox/session-list-desktop.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-sandbox/session-list-desktop.png new file mode 100644 index 000000000..5c221510a Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-sandbox/session-list-desktop.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-sandbox/session-list-mobile.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-sandbox/session-list-mobile.png new file mode 100644 index 000000000..6e836207f Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/after-sandbox/session-list-mobile.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/mockup-final.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/mockup-final.png new file mode 100644 index 000000000..013ecabf0 Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/mockup-final.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/scenario.json b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/scenario.json new file mode 100644 index 000000000..295420b98 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/scenario.json @@ -0,0 +1,9 @@ +[ + {"open": "http://localhost:8000"}, + {"wait": 3000}, + {"set viewport": "1512 982"}, + {"screenshot": "session-list-desktop"}, + {"set viewport": "375 812"}, + {"wait": 1000}, + {"screenshot": "session-list-mobile"} +] diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/session-list-desktop.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/session-list-desktop.png new file mode 100644 index 000000000..b713d8f3b Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/session-list-desktop.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/session-list-mobile.png b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/session-list-mobile.png new file mode 100644 index 000000000..65873b528 Binary files /dev/null and b/openspec/changes/archive/2026-05-08-redesign-session-card/screenshots/session-list-mobile.png differ diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/context-usage-bar/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/context-usage-bar/spec.md new file mode 100644 index 000000000..d374a855b --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/context-usage-bar/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Context usage gradient bar on session cards +The ContextUsageBar SHALL render in the session card only on desktop viewports (>= 768px). On mobile viewports, it SHALL be hidden via responsive CSS. The bar continues to be accessible in SessionSidebar on all viewports. + +#### Scenario: Context bar hidden on mobile session card +- **WHEN** viewport < 768px and contextUsage data is available +- **THEN** ContextUsageBar SHALL NOT render in the SessionCard + +#### Scenario: Context bar shown on desktop session card +- **WHEN** viewport >= 768px and contextUsage data is available +- **THEN** ContextUsageBar SHALL render in the SessionCard diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/folder-action-bar-redesign/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/folder-action-bar-redesign/spec.md new file mode 100644 index 000000000..6d5f7877f --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/folder-action-bar-redesign/spec.md @@ -0,0 +1,94 @@ +## Purpose + +[Spec purpose] + +## ADDED Requirements + +### Requirement: Folder action bar — simplified layout +Each folder group in the sidebar SHALL render an action bar containing only primary actions directly visible. On desktop (>= 768px), secondary actions SHALL be grouped in a single dropdown menu. On mobile (< 768px), only primary actions SHALL be visible. + +#### Scenario: Desktop action bar +- **WHEN** viewport >= 768px +- **THEN** the action bar SHALL display: +Session button, +Worktree button (if enabled), and a "Tools" dropdown button + +#### Scenario: Mobile action bar +- **WHEN** viewport < 768px +- **THEN** the action bar SHALL display only: +Session button and +Worktree button (if enabled) +- **AND** no dropdown menu SHALL be present + +### Requirement: +Session button — unchanged semantics +The +Session button SHALL spawn a new pi session in the folder's cwd. It SHALL be disabled while a session is being spawned. Existing behavior is preserved, styling updated to match the redesign. + +#### Scenario: Spawn session +- **WHEN** user clicks +Session +- **THEN** a new pi session SHALL be spawned in the folder's cwd + +### Requirement: +Worktree button — unchanged semantics +The +Worktree button SHALL spawn a pi session in a git worktree. It SHALL be disabled while a session is being spawned. It SHALL only appear when the `onSpawnWorktree` prop is provided. + +#### Scenario: Worktree button present +- **WHEN** onSpawnWorktree prop is provided +- **THEN** the +Worktree button SHALL render + +#### Scenario: Worktree button absent +- **WHEN** onSpawnWorktree prop is not provided +- **THEN** the +Worktree button SHALL NOT render + +### Requirement: Tools dropdown — desktop only +On desktop viewports, a "Tools" dropdown button SHALL group the following secondary actions: +- Terminals (with count badge, e.g., "Terminals (2)") +- Editor (with status indicator: green dot when running, pulsing dot when starting, warning when not found) +- Native editor entries (one per detected editor, e.g., "Zed") +- Pi Resources + +Clicking a dropdown item SHALL trigger the same action as the current individual buttons. + +#### Scenario: Tools dropdown displays terminal count +- **WHEN** a folder has 3 active terminals +- **THEN** the dropdown SHALL show "Terminals (3)" + +#### Scenario: Tools dropdown displays editor status +- **WHEN** code-server is running for the folder +- **THEN** the dropdown SHALL show a green dot next to "Editor" + +#### Scenario: Tools dropdown displays native editors +- **WHEN** Zed is detected as running +- **THEN** the dropdown SHALL show "Zed" as a clickable item + +#### Scenario: Tools dropdown displays Pi Resources +- **WHEN** the dropdown is open +- **THEN** "Pi Resources" SHALL be a clickable item + +#### Scenario: Dropdown item click triggers action +- **WHEN** user clicks "Terminals (2)" in the dropdown +- **THEN** the content area SHALL navigate to the terminals view + +### Requirement: Removed elements from action bar +The FolderActionBar SHALL NOT render any of the following as standalone buttons: +- Terminals button (moved to dropdown on desktop, removed on mobile) +- Editor button (moved to dropdown on desktop, removed on mobile) +- Native editor buttons like Zed (moved to dropdown on desktop, removed on mobile) +- Pi Resources button (moved to dropdown on desktop, removed on mobile) + +#### Scenario: No standalone terminal button +- **WHEN** the action bar renders +- **THEN** there SHALL NOT be a standalone Terminals button outside the dropdown + +#### Scenario: No standalone editor button +- **WHEN** the action bar renders +- **THEN** there SHALL NOT be a standalone Editor button outside the dropdown + +#### Scenario: No standalone native editor buttons +- **WHEN** the action bar renders +- **THEN** there SHALL NOT be standalone native editor buttons outside the dropdown + +### Requirement: Dropdown mechanism +The Tools dropdown SHALL use a standard HTML `
` + `` element or the Popover API. It SHALL close when clicking outside. On mobile, the dropdown SHALL NOT render at all. + +#### Scenario: Dropdown opens on click +- **WHEN** user clicks the Tools button +- **THEN** the dropdown menu SHALL become visible + +#### Scenario: Dropdown closes on outside click +- **WHEN** the dropdown is open and user clicks outside +- **THEN** the dropdown SHALL close diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/folder-action-bar/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/folder-action-bar/spec.md new file mode 100644 index 000000000..8684e0986 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/folder-action-bar/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Folder action bar layout +Each folder group in the sidebar SHALL render a horizontal action bar. On desktop (>= 768px), the action bar SHALL display: +Session button, +Worktree button (if enabled), and a "Tools" dropdown containing Terminals, Editor, native editors, and Pi Resources. On mobile (< 768px), it SHALL display only +Session and +Worktree buttons. + +#### Scenario: Desktop action bar +- **WHEN** viewport >= 768px +- **THEN** the action bar SHALL display +Session, +Worktree (if enabled), and Tools dropdown +- **THEN** there SHALL NOT be standalone Terminals, Editor, Zed, or Pi Resources buttons + +#### Scenario: Mobile action bar +- **WHEN** viewport < 768px +- **THEN** the action bar SHALL display only +Session and +Worktree (if enabled) +- **AND** there SHALL NOT be a Tools dropdown + +### Requirement: +Session button +The +Session button SHALL spawn a new pi session in the folder's cwd. It SHALL be disabled while a session is being spawned. + +#### Scenario: Spawn session +- **WHEN** user clicks +Session +- **THEN** a new pi session SHALL be spawned in the folder's cwd +- **THEN** the button SHALL be disabled until the session appears + +## ADDED Requirements + +### Requirement: Tools dropdown on desktop +The Tools dropdown SHALL group Terminals, Editor, native editors, and Pi Resources into a single expandable menu. Each item SHALL trigger its corresponding action on click. + +#### Scenario: Terminals with count +- **WHEN** a folder has 2 active terminals +- **THEN** the dropdown SHALL show "Terminals (2)" + +#### Scenario: Editor with status +- **WHEN** code-server is running +- **THEN** the dropdown SHALL show a green dot next to "Editor" + +#### Scenario: Native editors listed +- **WHEN** Zed is detected +- **THEN** the dropdown SHALL show "Zed" as a clickable item + +#### Scenario: Pi Resources listed +- **WHEN** the dropdown is open +- **THEN** "Pi Resources" SHALL be a clickable item + +## REMOVED Requirements + +### Requirement: Terminals button with count badge +**Reason**: Replaced by Tools dropdown. +**Migration**: Access via Tools dropdown on desktop. + +### Requirement: Editor button with status indicator +**Reason**: Replaced by Tools dropdown. +**Migration**: Access via Tools dropdown on desktop. + +### Requirement: Zed button for native launch +**Reason**: Replaced by Tools dropdown. +**Migration**: Access via Tools dropdown on desktop. + +### Requirement: Pi Resources button with updated icon +**Reason**: Replaced by Tools dropdown. +**Migration**: Access via Tools dropdown on desktop. diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/git-context/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/git-context/spec.md new file mode 100644 index 000000000..e08798123 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/git-context/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Git branch rendered as chip in session card +In addition to the existing GroupGitInfo at the folder level, the session card SHALL render git branch information as a compact chip in its meta row. The chip SHALL include the branch name with a git icon. When gitPrNumber is set, the PR number SHALL be included in the same chip. + +#### Scenario: Branch chip in card +- **WHEN** session.gitBranch is "feature/x" +- **THEN** the card SHALL render a chip with branch icon and "feature/x" + +#### Scenario: Branch chip with PR +- **WHEN** session.gitBranch is "feature/x" and session.gitPrNumber is 42 +- **THEN** the chip SHALL render "feature/x · #42" + +#### Scenario: No git info +- **WHEN** session.gitBranch is not set +- **THEN** no git chip SHALL render in the card diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/placeholder-spawn-card/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/placeholder-spawn-card/spec.md new file mode 100644 index 000000000..8f1f5b7d1 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/placeholder-spawn-card/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Placeholder card visual style +The placeholder skeleton card SHALL match the redesigned SessionCard visual style: `rounded-xl`, same padding (`px-4 py-3` on mobile, `px-3 py-2.5` on desktop), border matching `border-[var(--border-subtle)]`, and the same `bg-[var(--bg-tertiary)]`. The pulse animation SHALL continue to indicate loading. + +#### Scenario: Placeholder matches card style on mobile +- **WHEN** a placeholder card renders on viewport < 768px +- **THEN** it SHALL have the same border-radius, padding, and background as the redesigned mobile SessionCard + +#### Scenario: Placeholder matches card style on desktop +- **WHEN** a placeholder card renders on viewport >= 768px +- **THEN** it SHALL have the same border-radius, padding, and background as the redesigned desktop SessionCard diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/proposal-attachment/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/proposal-attachment/spec.md new file mode 100644 index 000000000..b82b4d0e0 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/proposal-attachment/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Attached proposal rendered as chip +The attached proposal name SHALL be rendered as a compact chip in the session card's meta row, alongside git and worktree chips. + +#### Scenario: Attached proposal chip +- **WHEN** session.attachedProposal is "add-auth" +- **THEN** a chip with paperclip icon and "add-auth" SHALL render in the meta row + +#### Scenario: No attached proposal +- **WHEN** session.attachedProposal is not set +- **THEN** no attached proposal chip SHALL render diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-card-redesign/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-card-redesign/spec.md new file mode 100644 index 000000000..7d222973f --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-card-redesign/spec.md @@ -0,0 +1,209 @@ +## Purpose + +[Spec purpose] + +## ADDED Requirements + +### Requirement: Session card renders from a single JSX path +The SessionCard component SHALL use a single JSX render path that adapts to viewport width via Tailwind responsive classes (`hidden`, `md:inline`, `md:flex`, etc.). There SHALL NOT be separate desktop and mobile render branches (`if (isMobile)` with duplicated JSX). + +#### Scenario: Single render on desktop +- **WHEN** viewport width is >= 768px +- **THEN** the card SHALL render all elements marked with `md:*` responsive classes +- **AND** SHALL NOT render elements marked with `md:hidden` + +#### Scenario: Single render on mobile +- **WHEN** viewport width is < 768px +- **THEN** the card SHALL render mobile-only elements +- **AND** SHALL hide desktop-only elements via `hidden md:inline`, `md:hidden` etc. + +### Requirement: Card layout — mobile +On mobile viewports (< 768px), the session card SHALL display the following elements in vertical rows: + +Row 1: Status dot + session name (left) + cost (right, only when > 0) +Row 2: Model name + thinking level +Row 3: Meta chips (git branch, worktree indicator) — only when data present +Row 4: Attached proposal chip — only when attachedProposal is set + +#### Scenario: Mobile card with all data +- **WHEN** a session has gitBranch, worktree, attachedProposal, model, and cost > 0 +- **THEN** all four rows SHALL render in order +- **AND** cost SHALL be right-aligned in row 1 + +#### Scenario: Mobile card with cost = 0 +- **WHEN** session cost is 0 or null +- **THEN** cost SHALL NOT appear in row 1 + +#### Scenario: Mobile card without meta +- **WHEN** session has no gitBranch and no worktree +- **THEN** row 3 SHALL NOT render + +#### Scenario: Mobile card without attached proposal +- **WHEN** session has no attachedProposal +- **THEN** row 4 SHALL NOT render + +### Requirement: Card layout — desktop +On desktop viewports (>= 768px), the session card SHALL display: + +Row 1: Status dot + source icon + session name (left) + rename/hide/shutdown buttons + relative time (right) +Row 2: Model name + thinking level (left) + resume/fork buttons (right) +Row 3: Activity indicator (left) + context usage bar + cost (right) +Row 4: OpenSpec badge — only when openspecPhase or openspecChange is set +Row 5: Meta chips (git branch, worktree, attached proposal) — when data present + +#### Scenario: Desktop card with all data +- **WHEN** viewport >= 768px and session has all optional fields populated +- **THEN** all five rows SHALL render in order + +#### Scenario: Desktop card without OpenSpec activity +- **WHEN** session has no openspecPhase and no openspecChange +- **THEN** row 4 SHALL NOT render + +#### Scenario: Desktop card without meta chips +- **WHEN** session has no gitBranch, no worktree, and no attachedProposal +- **THEN** row 5 SHALL NOT render + +### Requirement: Meta information rendered as compact chips +Git branch, worktree indicator, and attached proposal SHALL be rendered as inline chips (`px-1.5 py-0.5 rounded-full text-[10px] border border-[var(--border-subtle)]`) in a single row. Each chip SHALL truncate with ellipsis when its text overflows, with a max-width appropriate to the viewport. + +#### Scenario: Git branch chip renders +- **WHEN** session.gitBranch is set +- **THEN** a chip with branch icon and branch name SHALL render + +#### Scenario: Git branch chip with PR number +- **WHEN** session.gitBranch and session.gitPrNumber are both set +- **THEN** the chip SHALL include the PR number (e.g., `feature/x · #42`) + +#### Scenario: Worktree chip renders +- **WHEN** session.worktree is set +- **THEN** a chip with worktree icon and branch name SHALL render + +#### Scenario: Attached proposal chip renders +- **WHEN** session.attachedProposal is set +- **THEN** a chip with paperclip icon and change name SHALL render + +#### Scenario: Multiple chips in one row +- **WHEN** gitBranch, worktree, and attachedProposal are all set +- **THEN** all three chips SHALL render in a single horizontal row with small gaps + +### Requirement: Cost hidden when zero +The cost display ($X.XX) SHALL NOT render when `session.cost` is 0, null, or undefined. + +#### Scenario: Cost is 0 +- **WHEN** session.cost is 0 +- **THEN** no cost element SHALL appear in the card + +#### Scenario: Cost is positive +- **WHEN** session.cost is 0.42 +- **THEN** "$0.42" SHALL render in the card + +### Requirement: Minimalist visual style +The session card SHALL use Apple-style minimalist visual language: soft shadows (`shadow-md shadow-[var(--shadow-card)]`), subtle borders, backdrop-blur on selected state, and generous padding. On hover (desktop only), the card SHALL lift slightly (`hover:-translate-y-0.5`). + +#### Scenario: Default card appearance +- **WHEN** a card is not selected +- **THEN** it SHALL render with `bg-[var(--bg-tertiary)]`, `border-[var(--border-subtle)]`, and `rounded-xl` + +#### Scenario: Selected card appearance +- **WHEN** a card is selected +- **THEN** it SHALL render with `bg-blue-500/5 backdrop-blur-sm border-blue-500/60` + +#### Scenario: Card hover on desktop +- **WHEN** user hovers over a card on desktop +- **THEN** the card SHALL lift slightly via `hover:-translate-y-0.5` transition + +### Requirement: Removed elements not rendered +The SessionCard SHALL NOT render any of the following elements that were previously present: +- Token stats (in/out/cache) +- Flow badge (FlowActivityBadge) +- Flow launcher (SessionFlowActions) +- OpenSpec actions (SessionOpenSpecActions) +- Plugin slots (SessionCardBadgeSlot, SessionCardActionBarSlot) +- Process list (ProcessList) +- Drag handle (SortableSessionCard wrapper) +- Inline rename input (InlineRenameInput) + +#### Scenario: Card does not render token stats +- **WHEN** a session has token data (tokensIn, tokensOut) +- **THEN** token stats SHALL NOT appear in the card + +#### Scenario: Card does not render flow badge +- **WHEN** session has activeFlowName set +- **THEN** FlowActivityBadge SHALL NOT render + +#### Scenario: Card does not render OpenSpec actions +- **WHEN** openspecChanges prop is provided +- **THEN** SessionOpenSpecActions SHALL NOT render + +#### Scenario: Card does not render drag handle +- **WHEN** card renders +- **THEN** no SortableSessionCard wrapper SHALL be present +- **AND** no drag handle element SHALL render + +### Requirement: Activity indicator — desktop only +The ActivityIndicator (current tool, "Waiting for input", "Thinking…") SHALL render only on desktop viewports (>= 768px). On mobile, it SHALL be hidden. + +#### Scenario: Activity indicator hidden on mobile +- **WHEN** viewport < 768px and session has currentTool set +- **THEN** ActivityIndicator SHALL NOT render + +#### Scenario: Activity indicator shown on desktop +- **WHEN** viewport >= 768px and session has currentTool set +- **THEN** ActivityIndicator SHALL render + +### Requirement: Context usage bar — desktop only +The ContextUsageBar SHALL render only on desktop viewports. On mobile, it SHALL be hidden. + +#### Scenario: Context bar hidden on mobile +- **WHEN** viewport < 768px and contextUsage data is available +- **THEN** ContextUsageBar SHALL NOT render + +#### Scenario: Context bar shown on desktop +- **WHEN** viewport >= 768px and contextUsage data is available +- **THEN** ContextUsageBar SHALL render + +### Requirement: OpenSpec badge — desktop only +The OpenSpecActivityBadge SHALL render only on desktop viewports. On mobile, it SHALL be hidden. + +#### Scenario: OpenSpec badge hidden on mobile +- **WHEN** viewport < 768px and session.openspecPhase is set +- **THEN** OpenSpecActivityBadge SHALL NOT render + +### Requirement: Resume/fork buttons — desktop only +The Resume and Fork buttons SHALL render only on desktop viewports. On mobile, they SHALL be hidden. + +#### Scenario: Resume/Fork hidden on mobile +- **WHEN** viewport < 768px and session is ended +- **THEN** Resume and Fork buttons SHALL NOT render + +### Requirement: Rename/hide/shutdown buttons — desktop only +The rename pencil, hide/eye, and shutdown close buttons SHALL render only on desktop viewports. On mobile, they SHALL be hidden. + +#### Scenario: Action buttons hidden on mobile +- **WHEN** viewport < 768px +- **THEN** rename, hide, and shutdown buttons SHALL NOT render + +### Requirement: Source icon — desktop only +The source indicator icon (TUI, Headless, tmux, Zed, Terminal) SHALL render only on desktop viewports. On mobile, it SHALL be hidden. + +#### Scenario: Source icon hidden on mobile +- **WHEN** viewport < 768px +- **THEN** source icon SHALL NOT render + +### Requirement: Relative time — desktop only +The relative time display (e.g., "2m", "1h") SHALL render only on desktop viewports. On mobile, it SHALL be hidden. + +#### Scenario: Time hidden on mobile +- **WHEN** viewport < 768px +- **THEN** relative time SHALL NOT render + +### Requirement: Status dot always visible +The colored status dot (green/yellow/red) SHALL render on both desktop and mobile viewports. + +#### Scenario: Status dot on mobile +- **WHEN** viewport < 768px +- **THEN** status dot SHALL render + +#### Scenario: Status dot on desktop +- **WHEN** viewport >= 768px +- **THEN** status dot SHALL render diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-grouping/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-grouping/spec.md new file mode 100644 index 000000000..682b92edb --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-grouping/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Drag-to-reorder removed +Drag-to-reorder of session cards within folder groups SHALL be removed. Sessions SHALL be ordered by the server (last active at top) without user reordering. + +#### Scenario: No drag handles +- **WHEN** session cards render in the sidebar +- **THEN** no drag handles SHALL appear on session cards + +#### Scenario: Session order is server-managed +- **WHEN** a new session is spawned +- **THEN** it SHALL appear at the top of its folder group diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-process-tracking/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-process-tracking/spec.md new file mode 100644 index 000000000..fe0ebfa74 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-process-tracking/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: ProcessList removed from session card +The ProcessList component SHALL NOT render inside SessionCard. Process information SHALL remain available via `session.processes` data and SHALL be accessible in SessionSidebar/detail view. + +#### Scenario: No process list in card +- **WHEN** a session has active child processes +- **THEN** ProcessList SHALL NOT render inside the SessionCard diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-rename/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-rename/spec.md new file mode 100644 index 000000000..eb0db5b88 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/session-rename/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: Inline rename removed from SessionCard +Inline rename via double-click on the session name in SessionCard SHALL be removed. Rename SHALL remain available in SessionHeader and SessionSidebar. + +#### Scenario: Double-click does nothing +- **WHEN** user double-clicks session name in SessionCard +- **THEN** no rename input SHALL appear diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/sidebar-header/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/sidebar-header/spec.md new file mode 100644 index 000000000..a9e649eee --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/sidebar-header/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: No README button in folder header +The folder group header SHALL NOT render a README button. The `onViewReadme` and `readmeDirs` props SHALL be removed from the session list. + +#### Scenario: README button not rendered +- **WHEN** a folder group has a README.md file +- **THEN** no README icon button SHALL appear in the group header + +#### Scenario: readmeDirs prop removed +- **WHEN** SessionList is instantiated +- **THEN** `onViewReadme` and `readmeDirs` props SHALL NOT be accepted diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/specs/token-stats-pipeline/spec.md b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/token-stats-pipeline/spec.md new file mode 100644 index 000000000..f45cca3e5 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/specs/token-stats-pipeline/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Token stats not displayed in session card +Token statistics (tokensIn, tokensOut, cacheRead, cacheWrite) SHALL NOT be rendered in the SessionCard component. The server SHALL continue to accumulate and broadcast token stats. Display of token stats is deferred to SessionSidebar/detail view. + +#### Scenario: Token stats not in card +- **WHEN** a session has token data +- **THEN** the SessionCard SHALL NOT render TokenStats component + +#### Scenario: Server accumulation unchanged +- **WHEN** a stats_update is received from the bridge +- **THEN** the server SHALL still accumulate totals on the session record +- **AND** SHALL broadcast session_updated with updated totals diff --git a/openspec/changes/archive/2026-05-08-redesign-session-card/tasks.md b/openspec/changes/archive/2026-05-08-redesign-session-card/tasks.md new file mode 100644 index 000000000..2f885005a --- /dev/null +++ b/openspec/changes/archive/2026-05-08-redesign-session-card/tasks.md @@ -0,0 +1,62 @@ +## 1. SessionCard — единый рендер-путь + +- [x] 1.1 Удалить `if (isMobile)` ветвление в SessionCard.tsx, оставить единый JSX +- [x] 1.2 Добавить `hidden md:flex` / `md:hidden` responsive-классы для десктоп-only и мобильных элементов +- [x] 1.3 Реализовать раскладку согласно mockup: 5 строк (desktop), 4 строки (mobile) +- [x] 1.4 Action-кнопки (pencil, eye, close) показывать только на hover десктопа (`opacity-0 group-hover:opacity-100`) +- [x] 1.5 Удалить `useMobile()` из SessionCard + +## 2. SessionCard — удаление элементов + +- [x] 2.1 Удалить TokenStats из карточки +- [x] 2.2 Удалить FlowActivityBadge (Flow badge) из карточки +- [x] 2.3 Удалить SessionFlowActions (Flow launcher) из карточки +- [x] 2.4 Удалить SessionOpenSpecActions из карточки +- [x] 2.5 Удалить SessionCardBadgeSlot и SessionCardActionBarSlot (plugin slots) +- [x] 2.6 Удалить ProcessList из карточки +- [x] 2.7 Удалить InlineRenameInput из карточки (двойной клик по имени) + +## 3. Meta-чипсы + +- [x] 3.1 Создать чипсы для git branch, worktree, attached proposal (`rounded-full`, иконка + текст) +- [x] 3.2 Рендерить чипсы в одной строке (flex-wrap) — строка 5 десктоп, строки 3-4 мобилка +- [x] 3.3 Git чип должен включать PR number когда есть (`feature/x · #42`) +- [x] 3.4 Cost ($) скрывать когда 0 или null + +## 4. FolderActionBar — редизайн + +- [x] 4.1 Удалить отдельные кнопки Terminals, Editor, Zed, Pi Resources +- [x] 4.2 Создать Tools dropdown с `
/` на десктопе +- [x] 4.3 Tools dropdown содержит: Terminals(N), Editor (с зелёной точкой статуса), native editors, Pi Resources +- [x] 4.4 На мобилке: только +Session и +Worktree (без dropdown) +- [x] 4.5 +Session и +Worktree на мобилке сделать `flex-1` (растянуты на всю ширину) + +## 5. Drag-to-reorder — удаление + +- [x] 5.1 Удалить SortableSessionCard обёртку из SessionList +- [x] 5.2 Удалить DndContext, SortableContext из SessionList (если не используется для SortablePinnedGroup) +- [x] 5.3 Проверить использование @dnd-kit в проекте; если только drag сессий — удалить зависимость. Результат: @dnd-kit нужен для SortablePinnedGroup, оставлен. Удалён только SortableSessionCard.tsx. + +## 6. README button — удаление + +- [x] 6.1 Удалить кнопку README из заголовка папки в SessionList +- [x] 6.2 Удалить пропсы `onViewReadme`, `readmeDirs` из SessionList и всех потребителей + +## 7. PlaceholderSessionCard — редизайн + +- [x] 7.1 Обновить PlaceholderSessionCard: `rounded-xl`, padding как у новой карточки, `bg-[var(--bg-tertiary)]`, `border-[var(--border-subtle)]` +- [x] 7.2 На десктопе: скелетон в 3 строки (имя, модель, активность+контекст+cost) +- [x] 7.3 На мобилке: скелетон в 2 строки (имя, модель) + +## 8. Тесты + +- [x] 8.1 Обновить SessionCard.test.tsx: убрать тесты удалённых элементов, добавить тесты чипсов и responsive-классов +- [x] 8.2 Обновить/добавить тесты для FolderActionBar +- [x] 8.3 Обновить PlaceholderSessionCard.test.tsx +- [x] 8.4 Убедиться что `npm test` проходит + +## 9. Финальная сборка + +- [x] 9.1 `npm run build` — собрать клиент +- [x] 9.2 `curl -X POST http://localhost:8000/api/restart` — перезапустить сервер +- [x] 9.3 Проверить десктоп и мобильный вид в браузере diff --git a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/.openspec.yaml b/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/.openspec.yaml deleted file mode 100644 index e14f3223d..000000000 --- a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-13 diff --git a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/design.md b/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/design.md deleted file mode 100644 index 744fc1eac..000000000 --- a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/design.md +++ /dev/null @@ -1,255 +0,0 @@ -## Context - -The Electron app (`packages/electron/`) has a first-run wizard gated by `isFirstRun()` — which only checks if `~/.pi-dashboard/mode.json` exists. The wizard's power-user verification uses `detectDashboardPackage()` which only looks for the npm package in two locations (managed dir and global npm root). This misses dev/source installs where the bridge is registered in `~/.pi/agent/settings.json` packages array. - -Additionally, `findServerCli()` in `server-lifecycle.ts` checks bundled, dev, and managed paths but never the global npm root. When `pi-dashboard` is installed globally, the Electron app can't find it. The global `pi-dashboard` CLI is self-contained (has its own tsx, handles start/stop) and could be spawned directly without the tsx + cli.ts resolution dance. - -When "Setup everything" is selected on a machine with existing global installs, `installStandalone()` creates shadow copies of pi, openspec, and tsx in `~/.pi-dashboard/` (~300MB+ wasted), and the Electron app then uses its bundled server, ignoring the user's global `pi-dashboard` entirely. - -Current files involved: -- `packages/electron/src/main.ts` — startup flow, wizard gate -- `packages/electron/src/lib/dependency-detector.ts` — detection functions -- `packages/electron/src/lib/dependency-installer.ts` — standalone/global install -- `packages/electron/src/lib/server-lifecycle.ts` — `ensureServer()`, `findServerCli()`, `launchServer()` -- `packages/electron/src/lib/wizard-ipc.ts` — IPC handlers exposing detection to renderer -- `packages/electron/src/lib/wizard-state.ts` — mode.json persistence -- `packages/electron/src/lib/wizard-window.ts` — wizard window creation -- `packages/electron/src/renderer/wizard.html` — wizard UI and flow logic - -## Goals / Non-Goals - -**Goals:** -- Skip the wizard entirely when the dashboard server is already running -- Detect bridge registration in pi's settings.json (not just npm package locations) -- Auto-skip when pi + bridge are both detected (no unnecessary ✓✓✓ screen) -- Show a targeted bridge-install step when pi exists but bridge is not registered -- Mode-aware server discovery: power-user prefers global `pi-dashboard` CLI, standalone prefers bundled -- Prevent shadow installations when tools already exist on the system - -**Non-Goals:** -- Supporting detection of bridge via running WebSocket connections -- Changing the standalone installation flow beyond adding skip-if-exists guards -- Full server API versioning or backward-compatibility layer - -## Decisions - -### D1: Pre-wizard health check in main.ts - -Add a health check call _before_ the `isFirstRun()` gate. If `isDashboardRunning()` returns `running: true`, auto-write `mode.json` as `"power-user"` and skip the wizard. - -**Rationale**: The health check is already implemented in `server-lifecycle.ts` (inlined `isDashboardRunning`). Reusing it before the wizard gate is the minimal change. If the server is running, the user's setup is working — no wizard needed. - -**Alternative considered**: Check settings.json first, then health check. Rejected because a running server is the strongest signal — it means bridge, server, and pi are all operational. - -### D2: Bridge detection via settings.json packages array - -Add `detectBridgeExtension()` to `dependency-detector.ts`. It reads `~/.pi/agent/settings.json`, parses the `packages` array, and checks if any entry contains `pi-dashboard` (substring match covers local paths, npm:, git:, and bundled extension paths). Falls back to existing npm location checks (managed + global). - -**Rationale**: The `packages[]` array is the canonical registry for pi extensions. Substring match on `pi-dashboard` is simple and covers all known registration patterns: -- `"../../Project/pi-agent-dashboard"` (dev relative) -- `"/Users/.../packages/extension"` (bundled absolute) -- `"npm:@blackbelt-technology/pi-dashboard"` (npm reference) -- `"git:github.com/.../pi-dashboard"` (git reference) - -**Alternative considered**: Exact package name matching. Rejected — too brittle given the variety of registration formats. - -### D3: Three-tier wizard skip logic in main.ts - -After the health check (D1), if `isFirstRun()` is true, run detection before opening the wizard: - -``` -Tier 1: Server running → auto-skip (D1, handled above) -Tier 2: pi + bridge detected → auto-write mode.json, skip wizard -Tier 3: pi found, no bridge → open wizard at bridge-install step -Tier 4: nothing found → open wizard at mode-choice step (existing) -``` - -Pass a `startStep` parameter to the wizard window via query string so it can skip straight to the relevant step. - -**Rationale**: Each tier handles a progressively less-configured state. The user only sees wizard UI proportional to what's actually missing. - -### D4: Wizard start-step parameter - -Add a query parameter `?start=bridge-install` when opening `wizard.html` to skip directly to the bridge installation step. The wizard reads `URLSearchParams` on load and jumps to the appropriate step. - -**Rationale**: Simpler than adding new IPC messages. The wizard already has step navigation (`goToStep()`). A query param is the minimal way to start at a non-default step. - -### D5: Detect `pi-dashboard` CLI on PATH - -Add `detectPiDashboardCli()` to `dependency-detector.ts` that checks if `pi-dashboard` is on PATH via `which`. This tells us the user has a global install with a self-contained CLI. - -**Rationale**: The global `pi-dashboard` CLI has shebang `#!/usr/bin/env node --import tsx`, bundles its own tsx, and handles start/stop/restart. Detecting it enables direct spawning without manual tsx + cli.ts resolution. - -### D6: Mode-aware server discovery in server-lifecycle.ts - -Make `ensureServer()` read `mode.json` and vary the server search order: - -**Power-user mode:** -1. Health check (already running?) -2. `pi-dashboard` CLI on PATH → `spawn("pi-dashboard", ["start", "--port", ...])` -3. Managed `~/.pi-dashboard/` install -4. Bundled `resources/server/` - -**Standalone mode:** -1. Health check (already running?) -2. Bundled `resources/server/` -3. Managed `~/.pi-dashboard/` install -4. `pi-dashboard` CLI on PATH - -When launching via the `pi-dashboard` CLI, use `spawn("pi-dashboard", ["start", "--port", String(port), "--pi-port", String(piPort)])` — no need to resolve tsx or cli.ts separately. The CLI is self-contained. - -**Rationale**: Power users expect their globally installed version to be used. Standalone users expect the app's bundled version. Both fall through to alternatives if their primary isn't available. - -**Alternative considered**: Always prefer bundled. Rejected — ignores power-user installs, causes version divergence, wastes the global install. - -### D7: Standalone mode skip-if-exists guard - -In `wizard.html`'s `runInstall()`, use the detection results (already available from `wizard:detect`) to mark already-installed items as ✓ and skip their npm install. In `dependency-installer.ts`, accept a skip list so `installStandalone()` doesn't re-install existing packages. - -**Rationale**: The detection data is already fetched. The UI just needs to use it. This prevents ~300MB of shadow installs and avoids version divergence. - -### D8: Extract health check utility - -Extract the inlined `isDashboardRunning()` from `server-lifecycle.ts` into `packages/electron/src/lib/health-check.ts` so both `main.ts` (pre-wizard check) and `server-lifecycle.ts` (launch check) can use it without duplication. - -**Rationale**: The function is currently inlined in `server-lifecycle.ts` to avoid importing shared packages in the packaged app. Extracting to a local utility within the electron package keeps that constraint while removing duplication. - -## Phase 1.5 Decisions — Gap Fixes - -### D14: Jiti fallback when tsx is not available - -When `launchServer()` can't find tsx via `resolveTsxCommand()`, it SHALL attempt to resolve jiti from the pi installation (managed or system). Resolution chain: -1. Managed pi: `~/.pi-dashboard/node_modules/@mariozechner/pi-coding-agent/` → resolve jiti from there -2. System pi: `detectPi()` → resolve jiti from pi's package root -3. If jiti found: `spawn(node, ["--import", jitiPath, cliPath, ...args])` - -**Rationale**: The bridge-install wizard path sets mode to `power-user` when the user already has pi installed. Pi bundles jiti. Using jiti as a fallback TS loader avoids requiring tsx to be installed separately. This is the same mechanism the extension's `server-launcher.ts` uses. - -**Scenario**: User has pi (via nvm), no tsx, no pi-dashboard CLI, downloads Electron DMG. Bridge-install wizard completes → `ensureServer()` → `launchServer()` → tsx not found → resolves jiti from managed pi → spawns server with jiti → works. - -**Alternative considered**: Install tsx as part of the bridge-install flow. Rejected — adds a slow npm install step to what should be a quick "register path" operation. - -### D15: Non-destructive bridge registration - -Change the stale-path cleanup in `registerBridgeExtension()` to only remove paths where the target directory **does not exist** or does not contain a `package.json`. Paths pointing to existing, valid extension directories are preserved. - -Before (current): -``` -Remove ALL local paths containing "pi-dashboard" or "pi-agent-dashboard" -Add new path -``` - -After: -``` -Remove local paths containing "pi-dashboard" or "pi-agent-dashboard" WHERE - the path does not exist on disk OR has no package.json -Add new path (if not already present) -``` - -**Rationale**: The current approach silently destroys the user's dev registration or global npm registration. A user who registered `../../Project/pi-agent-dashboard` via `settings.json` expects it to persist. Only broken/stale paths should be cleaned. - -**Trade-off**: Multiple valid extension paths may accumulate (e.g., dev + bundled + global). Pi loads extensions from the packages list and should handle duplicates gracefully. If not, this is a pi-side concern. - -### D16: AppImage guard in server-side bridge registration - -Add the same `/tmp/.mount_*` path check that exists in `packages/electron/src/lib/bridge-register.ts` to the server's `extension-register.ts` (and the Phase 2 shared `bridge-register.ts`). When the resolved extension path is under a temporary AppImage mount, skip registration with a log warning. - -**Rationale**: The server runs inside the same AppImage mount. When it calls `findBundledExtension()`, the path resolves to `/tmp/.mount_PIxxxx/resources/server/packages/extension`. This path disappears when the AppImage exits. Registering it in settings.json leaves a broken entry that pi can't load. - -### D17: Health check version field - -Add a `version` field to the `/api/health` response (read from server's `package.json`). In `ensureServer()`, after confirming the server is running, compare the reported version against the Electron app's expected version. On mismatch, log a warning (don't block — older servers still work for basic features). - -**Rationale**: Catches the scenario where a user has an old global `pi-dashboard` and the Electron client calls APIs that don't exist. The warning helps debugging without being disruptive. - -**Non-goal**: This is NOT a compatibility gate. The app still connects. A full versioned API compatibility layer is out of scope. - -## Phase 2 Decisions — Unified Tool Resolver - -### D9: Shared `managed-paths.ts` module - -Extract `MANAGED_DIR`, `MANAGED_BIN`, and `PI_SETTINGS_PATH` into `packages/shared/src/managed-paths.ts`. All 5 Electron modules and the server's `process-manager.ts` import from there instead of defining their own constants. - -**Rationale**: DRY. A path change (e.g. renaming `~/.pi-dashboard`) requires editing one file instead of five. - -### D10: `ToolResolver` class with configurable context - -Create `packages/shared/src/tool-resolver.ts` with a `ToolResolver` class initialized with a `ResolverContext`: - -```typescript -interface ResolverContext { - /** Extra bin dirs to search before system PATH (bundled Node, Electron resources) */ - extraBinDirs?: string[]; - /** Current process.execPath (for Node resolution when running inside pi/server) */ - processExecPath?: string; - /** Use login shell fallback for GUI apps on macOS/Linux */ - useLoginShell?: boolean; -} -``` - -Unified search order for all `which()` calls: **managed bin → extraBinDirs → system PATH → login shell (if enabled)**. - -Provides: -- `which(name)` — generic binary resolution -- `resolvePi()` — returns `[cmd, ...prefixArgs]` (handles Windows `.cmd` avoidance) -- `resolveTsx()` — returns `[cmd, ...prefixArgs]` (handles Windows node+mjs) -- `resolveNode()` — returns path or null -- `buildSpawnEnv(base?)` — unified PATH + NODE_PATH construction - -**Callers create context-appropriate instances:** -- **Electron** (GUI app): `new ToolResolver({ useLoginShell: true, extraBinDirs: [bundledNodeDir] })` -- **Server** (process-manager): `new ToolResolver({ processExecPath: process.execPath })` -- **Extension** (inside pi): `new ToolResolver({ processExecPath: process.execPath })` - -**Rationale**: One search-order implementation with different configurations replaces 3 divergent implementations. The context pattern avoids the shared package importing Electron-specific APIs. - -**Alternative considered**: Standalone functions instead of a class. Rejected — the context (login shell, extra dirs) would need threading through every call. A class captures it once. - -### D11: Shared `bridge-register.ts` - -Extract bridge registration into `packages/shared/src/bridge-register.ts` with two functions: - -```typescript -/** Find bundled extension relative to a base directory */ -export function findBundledExtension(baseDir: string): string | null; - -/** Register extension path in pi's settings.json (with stale path cleanup) */ -export function registerBridgeExtension(extensionPath: string): void; -``` - -Server calls: `registerBridgeExtension(findBundledExtension(path.resolve(__dirname, "../.."))!)` -Electron calls: `registerBridgeExtension(findBundledExtension(resourcesPath + "/server")!)` - -The `readSettings`/`writeSettings`/stale-cleanup logic exists once. Only the anchor path differs, passed by the caller. - -**Rationale**: Eliminates ~80 lines of near-identical code across two packages. The anchor path is the only legitimate difference — parameterize it. - -### D12: Unified `buildSpawnEnv()` - -Move `buildSpawnEnv()` from `process-manager.ts` into `ToolResolver`. The method combines: -- Managed bin dir (`~/.pi-dashboard/node_modules/.bin/`) -- Current Node binary dir (`path.dirname(processExecPath)`) -- Extra bin dirs from context (bundled Node, Electron resources) -- Common user bin dirs (`~/.local/bin`, `/usr/local/bin`, etc.) - -This replaces both `buildSpawnEnv()` in process-manager AND the manual PATH construction in `server-lifecycle.ts`'s `launchServer()`. - -**Rationale**: The two implementations add different directories but the pattern is identical: "prepend important dirs to PATH if not already present". Merging them ensures spawned processes always have a complete PATH. - -### D13: Consumers simplified, not changed - -`process-manager.ts` keeps its `spawnHeadless()` and tmux logic but delegates binary resolution and env building to `ToolResolver`. `dependency-detector.ts` keeps its `DetectionResult` interface but `detectPi()`, `detectSystemNode()`, etc. delegate to `ToolResolver.which()`. `server-lifecycle.ts` uses `ToolResolver.resolveTsx()` instead of its own `resolveTsxCommand()`. - -**Rationale**: Minimize blast radius. The refactoring changes WHERE resolution happens, not HOW processes are spawned or WHAT the wizard checks. - -## Risks / Trade-offs - -- **[Risk] Substring match on `pi-dashboard` could false-positive** → Mitigation: Extremely unlikely in practice. The string is specific enough. Tighten to known patterns if needed. -- **[Risk] Health check adds ~2s latency to cold start when server is not running** → Mitigation: ECONNREFUSED returns immediately (most common case). Only timeouts add delay, capped at 2s. -- **[Risk] Auto-writing mode.json may surprise users who want to re-run the wizard** → Mitigation: Doctor → Run Setup still works. The mode.json write is a convenience, not a lock-out. -- **[Risk] Global `pi-dashboard` CLI version may differ from Electron app expectations** → Mitigation: D17 adds a version field to `/api/health`. `ensureServer()` logs a warning on mismatch. Not a blocking gate — basic features still work with older servers. -- **[Risk] `spawn("pi-dashboard", ...)` may resolve to an npx shim instead of a proper global install** → Mitigation: `which pi-dashboard` returns the actual path. Validate it's not inside `.npm/_npx/` (npx cache) to avoid ephemeral installs. -- **[Trade-off] Mode-aware discovery adds branching complexity to `ensureServer()`** → Acceptable: The branching is a simple if/else on mode, and each branch is a reordering of the same candidates. The existing code already has multiple candidate paths. -- **[Risk] Phase 2 shared module adds cross-package dependency** → Mitigation: `packages/shared` is already a dependency of both server and electron. No new dependency edges. -- **[Risk] `ToolResolver` class in shared cannot import Electron APIs** → Mitigation: Electron-specific paths (`process.resourcesPath`, bundled Node) are passed via `ResolverContext.extraBinDirs`, never imported. -- **[Trade-off] Phase 2 touches many files for internal refactoring** → Acceptable: All changes are search-and-replace style (import from shared instead of local). No protocol, config, or behavioral changes. Easy to verify with existing tests. diff --git a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/proposal.md b/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/proposal.md deleted file mode 100644 index eaabbb4ed..000000000 --- a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/proposal.md +++ /dev/null @@ -1,91 +0,0 @@ -## Why - -The Electron app's first-run wizard and server discovery have three interrelated problems: - -1. **Blind bridge detection**: `detectDashboardPackage()` only checks two npm locations but is blind to bridge extensions registered in pi's `settings.json` packages array — the actual source of truth. Power users with dev/source installs see ✗ for "Dashboard bridge" even when everything is operational. - -2. **No pre-wizard server check**: The wizard gate is purely `isFirstRun()` (mode.json exists?) with no health check. The wizard appears even when the dashboard server is already running. - -3. **Shadow installs and ignored global server**: When "Setup everything" is selected on a machine that already has pi/openspec/pi-dashboard installed globally, the wizard installs duplicate copies into `~/.pi-dashboard/` (~300MB+ wasted). The Electron app then uses its bundled server, completely ignoring the user's global `pi-dashboard` CLI. This can also cause double bridge registration and version divergence between bundled and global server. - -4. **`findServerCli()` misses global npm installs**: The server discovery only checks bundled, dev, and managed paths — never the global npm root. Even after `npm install -g @blackbelt-technology/pi-agent-dashboard`, the Electron app can't find it. Meanwhile, the global `pi-dashboard` CLI is self-contained (has its own tsx, handles start/stop) and could be spawned directly. - -5. **Binary resolution duplicated 3× with different search orders**: `dependency-detector.ts` resolves pi via system PATH → login shell → managed bin. `process-manager.ts` resolves pi via managed bin first → system PATH. `server-lifecycle.ts` resolves tsx via managed → system. Each uses different code doing the same job with different search priorities. PATH augmentation (`buildSpawnEnv()` in process-manager vs manual construction in server-lifecycle) is also duplicated with different sets of directories. - -6. **Bridge registration duplicated 2×**: `packages/server/src/extension-register.ts` and `packages/electron/src/lib/bridge-register.ts` are nearly identical (~80 lines each) with the same `readSettings`/`writeSettings`/`findBundledExtension`/stale-path-cleanup logic. The only difference is the `__dirname` anchor for `findBundledExtension()`. - -7. **`MANAGED_DIR` constant defined 5× independently**: `dependency-detector.ts`, `dependency-installer.ts`, `doctor.ts`, `server-lifecycle.ts`, and `ts-loader-resolver.ts` each independently define `const MANAGED_DIR = path.join(os.homedir(), ".pi-dashboard")`. - -8. **Server launch has 3 completely different code paths**: The extension uses `process.execPath` + jiti. Electron standalone uses tsx + `cli.ts`. Electron power-user uses `pi-dashboard` CLI. Three different TypeScript loaders, three different PATH constructions, three different error handling flows for the same server. - -9. **Bridge-install wizard path dead-ends without TS loader**: When pi is installed but neither tsx nor pi-dashboard CLI exist, the bridge-install wizard forces `power-user` mode. Then `ensureServer()` falls through to `launchServer()` which requires tsx to run the bundled `cli.ts`. tsx is not installed anywhere → crash. The user has pi (which bundles jiti), but `launchServer()` doesn't know how to use it. - -10. **Aggressive stale-path cleanup destroys intentional registrations**: `registerBundledBridgeExtension()` removes ALL local paths containing `pi-dashboard` or `pi-agent-dashboard` before adding the Electron bundle's path. This silently deletes the user's dev-install registration (e.g. `../../Project/pi-agent-dashboard`) or global npm registration. The Electron app and server also fight over settings.json — whichever ran last overwrites the other's path. - -11. **Server's extension-register.ts missing AppImage guard**: The Electron-side `bridge-register.ts` correctly rejects `/tmp/.mount_*` AppImage paths, but the server's `extension-register.ts` has no such check. On AppImage, the server registers a temporary path that breaks when the AppImage is unmounted. - -12. **No server version compatibility check**: `ensureServer()` and the pre-wizard health check validate that a server responds with `{ ok: true, pid }` but never check version compatibility. An old global `pi-dashboard` server may lack APIs the new Electron client expects. - -13. **Inconsistent `pi-dashboard` vs `pi-agent-dashboard` naming**: The git repo and npm package are `pi-agent-dashboard`, but the CLI binary is `pi-dashboard` and sub-packages use `pi-dashboard-*`. Code that does substring matching or path lookups uses the wrong variant in several places: `detectDashboardPackage()` looks for `@blackbelt-technology/pi-dashboard/` (doesn't exist on npm — the real name is `pi-agent-dashboard`), and `extension-register.ts` only cleans stale paths containing `pi-dashboard` but dev paths contain `pi-agent-dashboard`, so duplicates accumulate. - -## What Changes - -- **Pre-wizard health check**: Before showing the wizard, check if the dashboard server is already running via `/api/health`. If running, auto-write `mode.json` and skip the wizard entirely. -- **Bridge detection via settings.json**: Replace `detectDashboardPackage()` with `detectBridgeExtension()` that scans `~/.pi/agent/settings.json` packages array for any entry containing `pi-dashboard`, in addition to the existing npm location checks. -- **Auto-skip when fully configured**: If pi + bridge detected, write `mode.json` silently and skip the wizard — don't show a screen of all ✓ checkmarks. -- **Targeted wizard for missing bridge only**: If pi is found but bridge is not registered, go directly to a bridge install step (register bundled extension path or install global npm package) instead of the full mode-choice screen. -- **Mode-aware server discovery**: Power-user mode prefers `pi-dashboard` CLI on PATH (spawned directly, no tsx resolution needed), then falls back to managed/bundled. Standalone mode prefers bundled server, then managed, then PATH. Both modes check health first. -- **"Setup everything" existing install guard**: When standalone mode is selected, skip packages already installed on the system. Show "✓ Already installed (system)" for pre-existing tools instead of installing shadow copies. -- **TS loader fallback for bridge-install path**: When the bridge-install wizard completes in power-user mode but neither tsx nor pi-dashboard CLI is available, resolve jiti from the managed or system pi installation as a fallback TypeScript loader for running the bundled server. -- **Non-destructive bridge registration**: Change stale-path cleanup to only remove paths that point to non-existent directories. Existing valid registrations are preserved. Multiple valid extension paths coexist. -- **AppImage guard in server bridge registration**: Add the same `/tmp/.mount_*` rejection to the server's (and shared) bridge registration module. -- **Health check version field**: Add a `version` field to `/api/health` response. `ensureServer()` logs a warning when the running server version doesn't match the Electron app's expected version. - -## Capabilities - -### New Capabilities -- `electron-smart-startup`: Pre-wizard detection logic that health-checks the running server, detects bridge registration in pi settings, and decides whether to skip the wizard, show targeted bridge install, or show the full wizard. Mode-aware server discovery that respects power-user vs standalone preferences for server launch order. - -### Modified Capabilities - - -### Phase 2 — Unified Tool Resolver (Post-Implementation Refactor) -- `tool-resolver`: Single binary resolution module replacing 3 scattered implementations (`dependency-detector.ts` `whichSync`, `server-lifecycle.ts` `resolveTsxCommand`, `process-manager.ts` `resolvePiCommand`). Configurable via context (Electron GUI with login-shell fallback vs server vs extension). -- `bridge-register-shared`: Extract bridge registration from 2 near-identical modules (`packages/server/src/extension-register.ts` and `packages/electron/src/lib/bridge-register.ts`) into a single shared module parameterized by base directory. -- `managed-paths`: Extract the `MANAGED_DIR` / `MANAGED_BIN` constants duplicated 5× across Electron modules into a single shared module. -- `spawn-env-builder`: Unify `buildSpawnEnv()` (process-manager) and the ad-hoc PATH construction in `server-lifecycle.ts` into one shared environment builder. - -## Impact - -- **Files (Phase 1.5 — gap fixes)**: - - `packages/electron/src/lib/server-lifecycle.ts` — jiti fallback in `launchServer()` when tsx not found - - `packages/electron/src/lib/bridge-register.ts` — non-destructive cleanup (only remove broken paths) - - `packages/server/src/extension-register.ts` — AppImage guard, non-destructive cleanup - - `packages/server/src/server.ts` — add `version` field to `/api/health` - - `packages/electron/src/lib/health-check.ts` — version compatibility warning -- **Files (Phase 1 — complete)**: - - `packages/electron/src/lib/dependency-detector.ts` — new `detectBridgeExtension()`, new `detectPiDashboardCli()` - - `packages/electron/src/lib/server-lifecycle.ts` — mode-aware `findServerCli()` and `launchServer()`, support for spawning `pi-dashboard` CLI directly - - `packages/electron/src/main.ts` — pre-wizard health check, three-tier skip logic - - `packages/electron/src/renderer/wizard.html` — bridge-install step, existing install guards, start-step query param - - `packages/electron/src/lib/wizard-ipc.ts` — expose new detection data - - `packages/electron/src/lib/wizard-state.ts` — auto-write mode.json helper - - `packages/electron/src/lib/wizard-window.ts` — pass start-step parameter - - `packages/electron/src/lib/dependency-installer.ts` — skip already-installed packages - - `src/server/extension-register.ts` — fix stale path cleanup to match both `pi-dashboard` and `pi-agent-dashboard` -- **Files (Phase 2 — unified tool resolver)**: - - `packages/shared/src/managed-paths.ts` — NEW: shared `MANAGED_DIR`, `MANAGED_BIN`, `PI_SETTINGS_PATH` constants - - `packages/shared/src/tool-resolver.ts` — NEW: `ToolResolver` class with configurable context, replaces `whichSync`, `resolvePiCommand`, `resolveTsxCommand`, `detectSystemNode`, `buildSpawnEnv` - - `packages/shared/src/bridge-register.ts` — NEW: shared `registerBridgeExtension(extensionPath)` and `findBundledExtension(baseDir)` - - `packages/electron/src/lib/dependency-detector.ts` — simplified: delegates to `ToolResolver` - - `packages/electron/src/lib/server-lifecycle.ts` — simplified: uses `ToolResolver` for tsx/node/pi resolution and `buildSpawnEnv()` - - `packages/electron/src/lib/bridge-register.ts` — DELETE: replaced by shared module - - `packages/electron/src/lib/dependency-installer.ts` — uses shared `MANAGED_DIR` - - `packages/electron/src/lib/doctor.ts` — uses shared `MANAGED_DIR` - - `packages/electron/src/lib/ts-loader-resolver.ts` — uses shared `MANAGED_DIR` - - `packages/server/src/extension-register.ts` — DELETE: replaced by shared module - - `packages/server/src/process-manager.ts` — simplified: uses `ToolResolver` for pi resolution and `buildSpawnEnv()` - - `packages/server/src/editor-detection.ts` — uses shared `buildSpawnEnv()` from `ToolResolver` - - `packages/server/src/editor-manager.ts` — uses shared `buildSpawnEnv()` from `ToolResolver` - - `packages/server/src/server.ts` — imports from shared bridge-register instead of local -- **No breaking changes**: Existing `mode.json` files continue to work; the wizard can still be triggered manually via Doctor → Run Setup. Phase 2 is purely internal refactoring — no protocol, config, or user-facing changes. diff --git a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/specs/electron-smart-startup/spec.md b/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/specs/electron-smart-startup/spec.md deleted file mode 100644 index 29e8f3131..000000000 --- a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/specs/electron-smart-startup/spec.md +++ /dev/null @@ -1,333 +0,0 @@ -## ADDED Requirements - -### Requirement: Pre-wizard server health check -The Electron main process SHALL check if the dashboard server is already running via `/api/health` before evaluating the first-run wizard gate. If the server is running and `mode.json` does not exist, the system SHALL auto-write `mode.json` with mode `"power-user"`, register the bundled bridge extension, and skip the wizard entirely. - -#### Scenario: Server already running, no mode.json -- **WHEN** the Electron app starts and the dashboard server responds to `/api/health` with `ok: true` -- **AND** `~/.pi-dashboard/mode.json` does not exist -- **THEN** the system writes `mode.json` with `mode: "power-user"`, registers the bundled bridge extension in `settings.json`, and proceeds to `ensureServer()` without opening the wizard - -#### Scenario: Server already running, mode.json exists -- **WHEN** the Electron app starts and the dashboard server responds to `/api/health` with `ok: true` -- **AND** `~/.pi-dashboard/mode.json` already exists -- **THEN** the system proceeds to `ensureServer()` without opening the wizard (existing behavior) - -#### Scenario: Server not running -- **WHEN** the Electron app starts and the dashboard server does not respond (ECONNREFUSED or timeout) -- **THEN** the system continues to the `isFirstRun()` check and smart detection flow - -### Requirement: Bridge detection via settings.json -The dependency detector SHALL check `~/.pi/agent/settings.json` packages array for bridge registration. An entry SHALL be considered a bridge match if it contains the substring `pi-dashboard` or `pi-agent-dashboard`. This check SHALL be combined with existing npm location checks — either source matching means `found: true`. - -#### Scenario: Bridge registered as local dev path -- **WHEN** `settings.json` packages contains `"../../Project/pi-agent-dashboard"` -- **THEN** `detectBridgeExtension()` returns `{ found: true, source: "settings" }` - -#### Scenario: Bridge registered as bundled extension -- **WHEN** `settings.json` packages contains a path with `pi-agent-dashboard` in it (e.g., `/Applications/PI Dashboard.app/.../packages/extension`) -- **THEN** `detectBridgeExtension()` returns `{ found: true, source: "settings" }` - -#### Scenario: Bridge registered as npm package reference -- **WHEN** `settings.json` packages contains `"npm:@blackbelt-technology/pi-dashboard"` -- **THEN** `detectBridgeExtension()` returns `{ found: true, source: "settings" }` - -#### Scenario: Bridge installed as npm global package -- **WHEN** `settings.json` does not contain a matching entry -- **AND** `@blackbelt-technology/pi-agent-dashboard/package.json` exists in the global npm root -- **THEN** `detectBridgeExtension()` returns `{ found: true, source: "system" }` - -#### Scenario: Bridge not found anywhere -- **WHEN** `settings.json` does not contain a matching entry -- **AND** no npm package is found in managed or global locations -- **THEN** `detectBridgeExtension()` returns `{ found: false }` - -### Requirement: pi-dashboard CLI detection -The dependency detector SHALL detect `pi-dashboard` on the system PATH. The detection SHALL exclude npx cache shims (paths containing `.npm/_npx/`) to avoid matching ephemeral installs. - -#### Scenario: pi-dashboard installed globally -- **WHEN** `which pi-dashboard` resolves to a path NOT containing `.npm/_npx/` -- **THEN** `detectPiDashboardCli()` returns `{ found: true, source: "system", path: "" }` - -#### Scenario: pi-dashboard only in npx cache -- **WHEN** `which pi-dashboard` resolves to a path containing `.npm/_npx/` -- **THEN** `detectPiDashboardCli()` returns `{ found: false }` - -#### Scenario: pi-dashboard not on PATH -- **WHEN** `which pi-dashboard` fails -- **THEN** `detectPiDashboardCli()` returns `{ found: false }` - -### Requirement: Login shell PATH resolution -On macOS and Linux, when a command is not found on the process PATH, the dependency detector SHALL retry using a login shell (`$SHELL -ilc "which "`) to pick up paths configured in shell rc files (nvm, volta, homebrew, fnm). The resolver SHALL extract the path from noisy login shell output by finding the first line starting with `/`. - -#### Scenario: Command found via login shell (nvm) -- **WHEN** `which pi` fails on the process PATH -- **AND** `$SHELL -ilc "which pi"` succeeds with output containing session restore noise and the path -- **THEN** the resolver extracts the absolute path (line starting with `/`) and returns it - -#### Scenario: Login shell also fails -- **WHEN** `which pi` fails on the process PATH -- **AND** `$SHELL -ilc "which pi"` also fails or produces no absolute path -- **THEN** the resolver returns `null` - -#### Scenario: Windows (no login shell fallback) -- **WHEN** the platform is `win32` -- **THEN** only the process PATH is checked (no login shell fallback) - -### Requirement: Auto-skip wizard when fully configured -When `isFirstRun()` is true but dependency detection finds both pi CLI and bridge extension, the system SHALL auto-write `mode.json` with mode `"power-user"`, register the bundled bridge extension, and skip the wizard without user interaction. - -#### Scenario: Pi and bridge both detected, first run -- **WHEN** `mode.json` does not exist -- **AND** the server is not running -- **AND** `detectPi()` returns `found: true` -- **AND** `detectBridgeExtension()` returns `found: true` -- **THEN** the system writes `mode.json` with `mode: "power-user"`, registers the bundled bridge extension, and proceeds to `ensureServer()` - -### Requirement: Targeted wizard for missing bridge -When pi CLI is detected but bridge extension is not, the wizard SHALL open directly at a bridge installation step, skipping the mode-choice screen. The bridge install step SHALL offer the user a choice between registering the bundled extension path or installing the global npm package. - -#### Scenario: Pi installed, bridge missing -- **WHEN** `mode.json` does not exist -- **AND** the server is not running -- **AND** `detectPi()` returns `found: true` -- **AND** `detectBridgeExtension()` returns `found: false` -- **THEN** the wizard opens at the bridge-install step (not the mode-choice step) - -#### Scenario: Nothing installed -- **WHEN** `mode.json` does not exist -- **AND** the server is not running -- **AND** `detectPi()` returns `found: false` -- **THEN** the wizard opens at the mode-choice step (existing behavior) - -### Requirement: Bundled bridge registration on power-user completion -Every code path that sets mode to `"power-user"` SHALL also register the Electron app's bundled bridge extension in `~/.pi/agent/settings.json`. This includes auto-skip paths (server running, pi+bridge detected) and wizard completion. Registration SHALL be non-fatal — failure is silently ignored since the server re-registers on start. - -#### Scenario: Power-user mode via wizard completion -- **WHEN** the wizard completes with mode `"power-user"` -- **THEN** the bundled bridge extension path is registered in `settings.json` packages array - -#### Scenario: Power-user mode via auto-skip (server running) -- **WHEN** the server is already running and mode.json is auto-written as `"power-user"` -- **THEN** the bundled bridge extension path is registered in `settings.json` packages array - -#### Scenario: Power-user mode via auto-skip (pi+bridge detected) -- **WHEN** pi and bridge are detected and mode.json is auto-written as `"power-user"` -- **THEN** the bundled bridge extension path is registered in `settings.json` packages array - -#### Scenario: Registration failure is non-fatal -- **WHEN** `registerBundledBridgeExtension()` throws (e.g., AppImage temp path) -- **THEN** the error is silently caught and startup continues normally - -### Requirement: Cross-platform bundled extension path resolution -The bundled extension finder SHALL use Electron's `process.resourcesPath` to locate the extension directory. The path SHALL be stable across macOS (.app), Linux (deb/rpm), and Windows (NSIS). Linux AppImage paths (containing `/tmp/.mount_`) SHALL be rejected as unstable. - -#### Scenario: macOS packaged app -- **WHEN** `process.resourcesPath` is `/Applications/PI Dashboard.app/Contents/Resources` -- **THEN** the extension is found at `/server/packages/extension` - -#### Scenario: Linux deb/rpm install -- **WHEN** `process.resourcesPath` is `/usr/lib/pi-dashboard/resources` -- **THEN** the extension is found at `/server/packages/extension` - -#### Scenario: Windows NSIS install -- **WHEN** `process.resourcesPath` is `C:\Program Files\PI Dashboard\resources` -- **THEN** the extension is found at `\server\packages\extension` - -#### Scenario: Linux AppImage (rejected) -- **WHEN** `process.resourcesPath` resolves to a path under `/tmp/.mount_*` -- **THEN** `findBundledExtension()` returns `null` and logs a warning - -#### Scenario: Development mode -- **WHEN** `process.resourcesPath` is not set -- **THEN** the extension is found relative to `__dirname` at `../../../extension` - -### Requirement: Mode-aware server discovery -The server discovery in `ensureServer()` SHALL vary the candidate order based on the persisted mode. In power-user mode, `pi-dashboard` CLI on PATH SHALL be preferred, launched via direct `spawn("pi-dashboard", ["start", ...])`. In standalone mode, the bundled server SHALL be preferred. Both modes SHALL check health first. - -#### Scenario: Power-user mode, pi-dashboard on PATH -- **WHEN** mode is `"power-user"` -- **AND** the server is not already running -- **AND** `pi-dashboard` CLI is found on PATH (not npx cache) -- **THEN** the server is launched via `spawn("pi-dashboard", ["start", "--port", "", "--pi-port", ""])` - -#### Scenario: Power-user mode, pi-dashboard not on PATH -- **WHEN** mode is `"power-user"` -- **AND** the server is not already running -- **AND** `pi-dashboard` CLI is NOT on PATH -- **THEN** the system falls back to managed install, then bundled server (existing tsx + cli.ts resolution) - -#### Scenario: Standalone mode -- **WHEN** mode is `"standalone"` -- **AND** the server is not already running -- **THEN** the system prefers bundled server, then managed install, then `pi-dashboard` CLI on PATH - -#### Scenario: Server already running (any mode) -- **WHEN** the health check finds the server already running -- **THEN** the system connects directly regardless of mode - -### Requirement: Standalone mode skips existing installations -When standalone mode is selected and dependency detection shows tools already installed on the system, the installation step SHALL skip those tools and mark them as already installed. - -#### Scenario: Pi already on system PATH -- **WHEN** user selects standalone mode -- **AND** `detectPi()` returns `found: true, source: "system"` -- **THEN** the pi installation step shows "✓ Already installed (system)" and is not re-installed - -#### Scenario: OpenSpec already on system PATH -- **WHEN** user selects standalone mode -- **AND** `detectOpenSpec()` returns `found: true, source: "system"` -- **THEN** the openspec installation step shows "✓ Already installed (system)" and is not re-installed - -#### Scenario: No tools installed -- **WHEN** user selects standalone mode -- **AND** no tools are detected on the system -- **THEN** all tools are installed as normal (existing behavior) - -### Requirement: Consistent naming in detection and cleanup -All substring matching and npm path lookups SHALL use `pi-agent-dashboard` (the actual npm/git name). Stale path cleanup SHALL match both `pi-dashboard` and `pi-agent-dashboard` to cover all historical registration formats. - -#### Scenario: npm global path lookup -- **WHEN** checking for the dashboard package in the global npm root -- **THEN** the path uses `@blackbelt-technology/pi-agent-dashboard` (not `pi-dashboard`) - -#### Scenario: Stale path cleanup -- **WHEN** registering a new extension path in `settings.json` -- **THEN** existing local paths containing either `pi-dashboard` or `pi-agent-dashboard` are removed before adding the new one - ---- - -## Phase 1.5 — Gap Fixes - -### Requirement: Jiti fallback for server launch -When `launchServer()` cannot find tsx, it SHALL attempt to resolve jiti from the pi installation as a fallback TypeScript loader. Resolution order: managed pi (`~/.pi-dashboard/node_modules/@mariozechner/pi-coding-agent/`) → system pi (via `detectPi()` path). If jiti is found, the server SHALL be spawned via `spawn(node, ["--import", jitiPath, cliPath, ...args])`. - -#### Scenario: Pi installed via nvm, no tsx, no pi-dashboard CLI, Electron DMG -- **WHEN** the bridge-install wizard completes as `power-user` -- **AND** `resolveTsxCommand()` returns null -- **AND** `detectPiDashboardCli()` returns `found: false` -- **AND** pi is installed and contains jiti -- **THEN** `launchServer()` resolves jiti from pi's package tree and spawns the server with `--import ` - -#### Scenario: Neither tsx nor jiti available -- **WHEN** `resolveTsxCommand()` returns null -- **AND** jiti cannot be resolved from any pi installation -- **THEN** `launchServer()` throws an error with message indicating both tsx and pi are needed - -### Requirement: Non-destructive bridge registration -Bridge registration cleanup SHALL only remove paths from `settings.json` packages array where the target directory does not exist on disk OR does not contain a `package.json`. Existing valid extension paths SHALL be preserved regardless of whether they contain `pi-dashboard` or `pi-agent-dashboard` in the path. - -#### Scenario: User has dev registration, Electron registers bundled path -- **WHEN** `settings.json` packages contains `"../../Project/pi-agent-dashboard"` pointing to an existing directory with package.json -- **AND** `registerBridgeExtension()` is called with the Electron bundled path -- **THEN** the dev path is preserved -- **AND** the bundled path is added (if not already present) -- **AND** both entries coexist in the packages array - -#### Scenario: Stale path from old install -- **WHEN** `settings.json` packages contains `"/old/path/pi-dashboard/extension"` and that directory does NOT exist -- **AND** `registerBridgeExtension()` is called with a new path -- **THEN** the stale `/old/path/...` entry is removed -- **AND** the new path is added - -#### Scenario: Path already registered -- **WHEN** `registerBridgeExtension()` is called with a path already in the packages array -- **THEN** no duplicate is added (idempotent) - -### Requirement: AppImage guard in server bridge registration -The server's bridge extension registration SHALL reject extension paths under temporary AppImage mounts (`/tmp/.mount_*`). This applies to both the current `extension-register.ts` and the Phase 2 shared `bridge-register.ts`. - -#### Scenario: Server running inside AppImage -- **WHEN** the server's `findBundledExtension()` resolves to a path containing `/tmp/.mount_` -- **THEN** it returns `null` and logs a warning -- **AND** no entry is written to `settings.json` - -#### Scenario: Server running from permanent install (deb, global npm, macOS DMG) -- **WHEN** the server's `findBundledExtension()` resolves to a stable path (not under `/tmp/.mount_`) -- **THEN** registration proceeds normally - -### Requirement: Health check version field -The `/api/health` response SHALL include a `version` field read from the server's `package.json`. `ensureServer()` in the Electron app SHALL compare the reported version against the expected version and log a warning on mismatch. Version mismatch SHALL NOT block the connection. - -#### Scenario: Version match -- **WHEN** `ensureServer()` confirms the server is running -- **AND** the `/api/health` version matches the Electron app's expected version -- **THEN** startup proceeds without warnings - -#### Scenario: Version mismatch (old server) -- **WHEN** `ensureServer()` confirms the server is running -- **AND** the `/api/health` version does NOT match (or is missing) -- **THEN** a warning is logged: "Dashboard server version X does not match expected version Y" -- **AND** startup proceeds normally (no blocking) - -#### Scenario: Server predates version field -- **WHEN** the `/api/health` response does not contain a `version` field -- **THEN** this is treated as a mismatch and a warning is logged - ---- - -## Phase 2 — Unified Tool Resolver - -### Requirement: Shared managed path constants -All references to the managed install directory (`~/.pi-dashboard/`) and its bin subdirectory SHALL use constants imported from a single shared module (`packages/shared/src/managed-paths.ts`). No module SHALL define its own `MANAGED_DIR` or `MANAGED_BIN` constant. - -#### Scenario: Managed dir constant usage -- **WHEN** any module in `packages/electron/`, `packages/server/`, or `packages/shared/` needs the managed install path -- **THEN** it imports `MANAGED_DIR` from `@blackbelt-technology/pi-dashboard-shared/managed-paths.js` -- **AND** does NOT define a local `const MANAGED_DIR = ...` - -### Requirement: Unified binary resolution via ToolResolver -All binary resolution (pi, tsx, node, openspec, pi-dashboard) SHALL use a shared `ToolResolver` class from `packages/shared/src/tool-resolver.ts`. The resolver SHALL accept a `ResolverContext` at construction time and apply a unified search order: managed bin → extra bin dirs → system PATH → login shell (when enabled). - -#### Scenario: Electron GUI binary resolution -- **WHEN** the Electron app needs to find pi, tsx, or node -- **THEN** it creates a `ToolResolver` with `{ useLoginShell: true, extraBinDirs: [bundledNodeDir] }` -- **AND** calls `resolver.resolvePi()`, `resolver.resolveTsx()`, or `resolver.resolveNode()` - -#### Scenario: Server binary resolution for session spawning -- **WHEN** the server's process-manager needs to find the pi binary -- **THEN** it creates a `ToolResolver` with `{ processExecPath: process.execPath }` -- **AND** calls `resolver.resolvePi()` instead of its own `resolvePiCommand()` - -#### Scenario: Search order consistency -- **GIVEN** a `ToolResolver` with default context -- **WHEN** `which("pi")` is called -- **THEN** the search order is: managed bin (`~/.pi-dashboard/node_modules/.bin/pi`) → extra bin dirs → system PATH → login shell fallback (if enabled) - -#### Scenario: Windows .cmd avoidance -- **WHEN** `resolvePi()` or `resolveTsx()` is called on Windows -- **THEN** the resolver returns `[node.exe, entry-point.js]` instead of a `.cmd` shim path -- **AND** the caller can spawn without `shell: true` - -### Requirement: Unified spawn environment -`ToolResolver.buildSpawnEnv()` SHALL produce a single unified `PATH` and `NODE_PATH` combining: managed bin dir, current Node binary dir, extra bin dirs from context, and common user bin dirs (`~/.local/bin`, `/usr/local/bin`, etc.). This SHALL replace both `buildSpawnEnv()` in `process-manager.ts` and the ad-hoc PATH construction in `server-lifecycle.ts`. - -#### Scenario: Server process-manager env -- **WHEN** spawning a headless pi session -- **THEN** `resolver.buildSpawnEnv()` is used instead of the local `buildSpawnEnv()` function -- **AND** the resulting PATH includes managed bin, node bin, and user bin dirs - -#### Scenario: Electron server launch env -- **WHEN** launching the dashboard server from Electron -- **THEN** `resolver.buildSpawnEnv()` is used instead of manually concatenating pi/node/tsx dirs -- **AND** the resulting PATH includes all necessary directories - -### Requirement: Shared bridge registration -Bridge extension registration in `~/.pi/agent/settings.json` SHALL be implemented in a single shared module (`packages/shared/src/bridge-register.ts`). The module SHALL export `registerBridgeExtension(extensionPath: string)` and `findBundledExtension(baseDir: string)`. The server and Electron packages SHALL NOT have their own registration implementations. - -#### Scenario: Server registers bridge on startup -- **WHEN** the dashboard server starts -- **THEN** it calls `registerBridgeExtension(findBundledExtension(serverBaseDir)!)` from the shared module -- **AND** does NOT use a local `extension-register.ts` - -#### Scenario: Electron wizard registers bridge -- **WHEN** the Electron wizard completes in power-user mode -- **THEN** it calls `registerBridgeExtension(findBundledExtension(resourcesServerDir)!)` from the shared module -- **AND** does NOT use a local `bridge-register.ts` - -#### Scenario: Stale path cleanup in shared module -- **WHEN** `registerBridgeExtension()` adds a new path -- **THEN** it removes existing local paths containing `pi-dashboard` or `pi-agent-dashboard` (same cleanup logic as Phase 1, now in one place) - -### Requirement: No behavioral changes from refactoring -Phase 2 SHALL NOT change any user-facing behavior, protocol messages, configuration format, or wizard flow. All changes are internal: import sources change, local implementations are deleted, shared implementations are used. Existing tests SHALL continue to pass without modification (or with import path updates only). diff --git a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/tasks.md b/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/tasks.md deleted file mode 100644 index 26425c77a..000000000 --- a/openspec/changes/archive/2026-05-09-electron-wizard-smart-detection/tasks.md +++ /dev/null @@ -1,120 +0,0 @@ -## 1. Health Check Extraction - -- [x] 1.1 Extract the inlined `isDashboardRunning()` and `DashboardStatus` type from `server-lifecycle.ts` into `packages/electron/src/lib/health-check.ts`. Update `server-lifecycle.ts` to import from the new module. -- [x] 1.2 Write unit tests for `isDashboardRunning()` covering: server responding with `ok: true` + pid, server responding with non-dashboard format (portConflict), ECONNREFUSED (not running), timeout. - -## 2. Dependency Detection - -- [x] 2.1 Add `detectBridgeExtension()` to `dependency-detector.ts` — reads `~/.pi/agent/settings.json` packages array, checks for any entry containing `pi-dashboard` (substring match), falls back to existing npm location checks (managed + global). Returns `{ found, source: "settings" | "system" | "managed" }`. -- [x] 2.2 Add `detectPiDashboardCli()` to `dependency-detector.ts` — uses `which pi-dashboard`, excludes npx cache paths (`.npm/_npx/`). Returns standard `DetectionResult`. -- [x] 2.3 Write unit tests for `detectBridgeExtension()` covering: settings.json match (local path, npm ref, git ref, bundled path), npm global fallback, managed fallback, no match, missing/corrupt settings.json. -- [x] 2.4 Write unit tests for `detectPiDashboardCli()` covering: found on PATH, found but npx cache (excluded), not found. -- [x] 2.5 Update `wizard:detect` IPC handler in `wizard-ipc.ts` to call `detectBridgeExtension()` (replacing `detectDashboardPackage()`) and add `detectPiDashboardCli()` result. - -## 3. Pre-wizard Smart Detection - -- [x] 3.1 In `main.ts`, add a pre-wizard health check before the `isFirstRun()` gate. If `isDashboardRunning()` returns `running: true`, call `writeModeFile("power-user")` if mode.json is missing, then skip the wizard. -- [x] 3.2 In `main.ts`, when `isFirstRun()` is true and server is not running, run `detectPi()` + `detectBridgeExtension()`. If both found, auto-write mode.json as `"power-user"` and skip wizard. If pi found but no bridge, open wizard with `?start=bridge-install`. Otherwise open wizard normally. -- [x] 3.3 Write tests for the three-tier skip logic (covered by integration test 7.1) (server running → auto-skip, pi+bridge → auto-skip, pi only → targeted wizard, nothing → full wizard). - -## 4. Mode-aware Server Discovery - -- [x] 4.1 In `server-lifecycle.ts`, make `ensureServer()` read `readModeFile()` and branch the server search order: power-user prefers `pi-dashboard` CLI on PATH → managed → bundled; standalone prefers bundled → managed → PATH. -- [x] 4.2 Add a `launchViaCli()` path in `server-lifecycle.ts` that spawns `pi-dashboard start --port --pi-port ` directly (no tsx resolution needed). Used when `detectPiDashboardCli()` found a valid CLI. -- [x] 4.3 Write tests for mode-aware discovery (covered by integration test 7.1 — server-lifecycle uses Electron-specific spawn patterns): power-user with CLI on PATH uses `launchViaCli()`, power-user without CLI falls back to existing flow, standalone uses bundled first. - -## 5. Wizard UI Changes - -- [x] 5.1 In `wizard-window.ts`, accept an optional `startStep` parameter in `openWizardWindow()` and append it as a query string (`?start=`) to the wizard HTML URL. -- [x] 5.2 In `wizard.html`, read `URLSearchParams` on load. If `?start=bridge-install` is present, skip step-mode and go directly to the bridge installation step. -- [x] 5.3 Add a new wizard step `step-bridge-install` with two options: "Use bundled extension" (registers the Electron app's `resources/extension/` path into settings.json) and "Install global package" (runs `npm install -g @blackbelt-technology/pi-dashboard`). Both options complete the wizard as power-user mode. -- [x] 5.4 Add IPC handler `wizard:register-bundled-bridge` in `wizard-ipc.ts` that writes the bundled extension path into `~/.pi/agent/settings.json` packages array (reuse logic from server's `extension-register.ts`). - -## 6. Standalone Mode Guards - -- [x] 6.1 In `wizard.html` `runInstall()`, check detection results (`deps.pi`, `deps.openspec`, `deps.node`) and skip items already installed. Show "✓ Already installed (system)" with a note next to skipped items. -- [x] 6.2 In `dependency-installer.ts`, modify `installStandalone()` to accept an optional `skipPackages: string[]` parameter. Packages in the skip list are reported as `done` immediately without running npm install. - -## 7. Integration Testing - -- [x] 7.1 Write an integration test that simulates the full startup flow: mock detection results and health check, verify wizard is skipped/shown/targeted correctly for each tier. -- [x] 7.2 Manual QA (requires manual testing on different machine states): test on a machine with (a) running server, (b) pi + bridge registered, (c) pi only, (d) clean install — verify each path works. Test both standalone and power-user mode server launch paths. **Status: deferred to user — rebuild Electron app and test.** - ---- - -## Phase 1.5 — Gap Fixes - -## 8. Jiti Fallback for Server Launch - -- [x] 8.1 In `server-lifecycle.ts`, add a `resolveJitiFromPi()` function that attempts to find jiti's register hook from: (a) managed pi install at `~/.pi-dashboard/node_modules/@mariozechner/pi-coding-agent/`, (b) system pi via `detectPi().path` → resolve jiti from that package tree. Reuse the resolution logic from `packages/shared/src/resolve-jiti.ts`. -- [x] 8.2 In `launchServer()`, when `resolveTsxCommand()` returns null, try `resolveJitiFromPi()`. If jiti found, spawn server as `spawn(node, ["--import", jitiPath, cliPath, ...args])` instead of tsx. If neither tsx nor jiti is available, throw a descriptive error. -- [x] 8.3 Write tests: tsx not found + jiti available → server spawns with jiti; tsx not found + jiti not found → throws; tsx found → jiti not attempted (existing path). - -## 9. Non-Destructive Bridge Registration - -- [x] 9.1 In `packages/electron/src/lib/bridge-register.ts`, change the stale-path cleanup filter: only remove local paths containing `pi-dashboard` or `pi-agent-dashboard` where `!existsSync(path)` or `!existsSync(path.join(path, 'package.json'))`. Preserve paths pointing to existing valid directories. -- [x] 9.2 Apply the same fix to `packages/server/src/extension-register.ts`. -- [x] 9.3 Write tests: existing dev path preserved when bundled path registered; stale (non-existent) path removed; duplicate path not added; both dev and bundled paths coexist. - -## 10. AppImage Guard in Server Bridge Registration - -- [x] 10.1 In `packages/server/src/extension-register.ts` `findBundledExtension()`, add a check for `/tmp/.mount_` in the resolved path. Return `null` with a warning if detected (matching the existing Electron-side guard). -- [x] 10.2 Write test: server `findBundledExtension()` returns null for AppImage temp paths. - -## 11. Health Check Version Field - -- [x] 11.1 In server's health endpoint, add a `version` field to the `/api/health` response, read from the server's `package.json` version. -- [x] 11.2 In `packages/electron/src/lib/health-check.ts`, extend `DashboardStatus` with an optional `version?: string` field. Parse it from the health response. -- [x] 11.3 In `packages/electron/src/lib/server-lifecycle.ts` `ensureServer()`, after confirming the server is running, compare the version from health check against the Electron app's expected version. Log a warning on mismatch via the startup log. -- [x] 11.4 Write tests: health response with matching version → no warning; health response with mismatched version → warning logged; health response without version field → warning logged. - ---- - -## Phase 2 — Unified Tool Resolver - -## 12. Shared Managed Paths - -- [x] 12.1 Create `packages/shared/src/managed-paths.ts` exporting `MANAGED_DIR`, `MANAGED_BIN`, and `PI_SETTINGS_PATH` constants. -- [x] 12.2 Replace all 5 local `MANAGED_DIR` definitions in `packages/electron/src/lib/` (`dependency-detector.ts`, `dependency-installer.ts`, `doctor.ts`, `server-lifecycle.ts`, `ts-loader-resolver.ts`) with imports from the shared module. -- [x] 12.3 Replace `MANAGED_BIN` in `packages/server/src/process-manager.ts` with import from shared module. -- [x] 12.4 Verify all existing tests pass with only import path changes. - -## 13. ToolResolver Class - -- [x] 13.1 Create `packages/shared/src/tool-resolver.ts` with `ResolverContext` interface and `ToolResolver` class. Implement `which(name)` with unified search order: managed bin → extraBinDirs → system PATH → login shell (if `useLoginShell`). -- [x] 13.2 Implement `resolvePi()` returning `[cmd, ...prefixArgs]` with Windows `.cmd` avoidance (node.exe + cli.js pattern). -- [x] 13.3 Implement `resolveTsx()` returning `[cmd, ...prefixArgs]` with Windows node.exe + cli.mjs pattern. -- [x] 13.4 Implement `resolveNode()` returning path (from `processExecPath`, extraBinDirs, system PATH, or login shell). -- [x] 13.5 Implement `buildSpawnEnv(base?)` combining managed bin + node bin + extra bin dirs + user bin dirs into unified PATH. -- [x] 13.6 Write unit tests for `ToolResolver`: `which()` search order, `resolvePi()` on Unix/Windows, `resolveTsx()` on Unix/Windows, `resolveNode()` fallback chain, `buildSpawnEnv()` PATH construction, login shell fallback. - -## 14. Migrate Consumers to ToolResolver - -- [ ] 14.1 ~~Blocked~~ Moved to 18.3 (Electron can import from shared). -- [ ] 14.2 ~~Blocked~~ Moved to 18.4 (Electron can import from shared). -- [x] 14.3 Simplify `packages/server/src/process-manager.ts`: replace `resolvePiCommand()` with `resolver.resolvePi()`, replace local `buildSpawnEnv()` with `resolver.buildSpawnEnv()`. Export `buildSpawnEnv` as a thin wrapper for backward compatibility with `editor-detection.ts` and `editor-manager.ts`. -- [x] 14.4 Update `packages/server/src/editor-detection.ts` and `packages/server/src/editor-manager.ts` to use the shared `buildSpawnEnv()` (via re-export or direct import). Note: these already import from process-manager.ts which now delegates to ToolResolver — no code change needed. -- [x] 14.5 Verify all existing tests pass. Update import paths in test files where needed. - -## 15. Shared Bridge Registration - -- [x] 15.1 Create `packages/shared/src/bridge-register.ts` with `findBundledExtension(baseDir)` and `registerBridgeExtension(extensionPath)`. Extract `readSettings`/`writeSettings`/stale-cleanup logic. Include non-destructive cleanup (Phase 1.5 D15) and AppImage guard (Phase 1.5 D16) from the start. -- [x] 15.2 Update `packages/server/src/server.ts` to import `registerBridgeExtension` + `findBundledExtension` from shared module. Delete `packages/server/src/extension-register.ts`. -- [x] 15.3 Update `packages/electron/src/lib/bridge-register.ts` to be a thin wrapper: call shared `registerBridgeExtension(findBundledExtension(electronResourcesPath))`. Or delete it and update callers (`main.ts`, `wizard-ipc.ts`) to use the shared module directly. -- [x] 15.4 Write unit tests for the shared `bridge-register.ts`: registration, non-destructive cleanup (existing valid paths preserved, stale paths removed), AppImage rejection, idempotent re-registration, missing settings.json. -- [x] 15.5 Verify bridge registration works in both Electron (wizard + auto-skip) and server (startup) contexts. - -## 16. Cleanup & Verification - -- [x] 16.1 Partial cleanup: deleted `extension-register.ts` from server, replaced `resolvePiCommand()` and `buildSpawnEnv()` in process-manager with ToolResolver delegates. Electron local impls remain until Phase 3 tasks 18.3/18.4. -- [x] 16.2 Run affected test suites (13 files, 116 tests) — all pass. Pre-existing config.test.ts failures unrelated. -- [ ] 16.3 Run type checking (`npm run reload:check` or tsc). **Deferred — Electron requires forge build environment.** -- [ ] 16.4 Manual smoke test: start Electron app, verify wizard flow, server launch, and session spawning still work. **Deferred to user.** - ---- - -## Phase 3 — DROPPED (out of scope; archived state) - -Sections 17 (unified TS loader / shared server launcher), 18 (Electron consumer migration to ToolResolver), and 19 (naming inconsistency) were never implemented and are **out of scope for this archived proposal**. - -The superseder `simplify-electron-bootstrap-derived-state` collapses the Electron *startup decision* but does not consolidate TS-loader resolution or the shared server launcher. Phase 3 motivations remain valid and are tracked in a fresh change: `unify-server-launch-ts-loader` (see `openspec/changes/unify-server-launch-ts-loader/`). Naming inconsistency (#13) remains deferred. diff --git a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/.openspec.yaml b/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/.openspec.yaml deleted file mode 100644 index ce9d1c695..000000000 --- a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-01 diff --git a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/design.md b/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/design.md deleted file mode 100644 index f0e4093a8..000000000 --- a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/design.md +++ /dev/null @@ -1,148 +0,0 @@ -## Context - -The proposal identified the symptom (typed `/ctx-stats` reaches LLM as plain text) and named the culprit line in `bridge.ts` (`pi.sendUserMessage(...)` — pi's own bypass-extension-commands path). The proposed one-line fix was to call `pi.session.prompt(...)` instead. - -That fix turns out to be **impossible with pi 0.70's public ExtensionAPI**. Verified by reading `~/.nvm/.../pi-coding-agent/dist/core/extensions/types.d.ts:770-922` and `loader.js:155-260`: - -``` -ExtensionAPI exposes: - - sendMessage (custom messages, doesn't dispatch slashes) - - sendUserMessage (raw user input — explicitly bypasses dispatcher) - - registerCommand (handlers stored privately on the runner) - - getCommands (returns SlashCommandInfo[] — name+description, NO handler) - - events (EventBus — extension↔extension channel, NOT a route to session.prompt) - - exec, setSessionName, getActiveTools, ... - -ExtensionAPI does NOT expose: - - prompt - - session - - dispatchCommand - - any path to _tryExecuteExtensionCommand -``` - -Pi's `agent-session.js:1715` calls `runner.bindCore({ sendMessage, sendUserMessage, appendEntry, setSessionName, ... })` — there is no `prompt` action wired in, so even reaching into the runtime via reflection wouldn't yield it. The slash-command interception is a privilege of pi's external `prompt()` entry point (called from the TUI's input handler and from RPC mode's `case "prompt"` arm), never delegated to extensions. - -This forces a re-scope. The original proposal's diff (`pi.sendUserMessage` → `pi.session.prompt`) cannot be written. The fix has to happen at a different layer. This design lays out the three viable layers and recommends one. - -## Goals / Non-Goals - -**Goals:** -- Make `/ctx-stats`, `/curator`, `/agents`, and any future `pi.registerCommand`-registered slash command actually run their handler when typed in the dashboard chat input. -- Preserve every existing routing behavior: bang commands, `/compact`, `/quit`, `/reload`, `/new`, `/model`, `/flows*`, `/__dashboard_reload`, prompt templates, skill expansion (`/skill:foo`), passthrough text, image-bearing messages. -- No new browser-client UI. Slash commands should "just work" the same way they do in pi's TUI — autocomplete already shows them, typing+Enter should dispatch them. -- Telemetry: when an extension command runs, surface its lifecycle (`started` → `completed` / `error`) as `command_feedback` events so users see "✓ /ctx-stats" in chat instead of silence. - -**Non-Goals:** -- New extension types or argument syntax for slash commands. -- Cross-extension command invocation (extension A triggering extension B's `/foo`). Out of scope; same constraint exists in pi TUI. -- Replacing the bridge with direct server↔pi RPC. Mentioned as an option below but rejected — too invasive for the symptom's severity. -- Surfacing extension-command errors as toasts/notify cards. Deferred to a follow-up. This change emits `command_feedback` events; the client already renders those. - -## Decisions - -### Decision 1: Dispatch path — upstream pi API addition (Path B), with a dashboard-side stopgap (Path D) - -We considered four paths. Summary table: - -| Path | Approach | Fix scope | Ships when | Recommendation | -|---|---|---|---|---| -| A | Bridge looks up command via `getCommands()` and self-dispatches | impossible — handler ref is private | n/a | rejected | -| B | Add `pi.dispatchCommand(text)` to pi `ExtensionAPI` | upstream pi-coding-agent + 3-line bridge change | pi 0.71+ | **primary** | -| C | Server bypasses bridge for slashes, writes RPC `prompt` to pi stdin | invasive — touches pi-gateway.ts, browser-handlers, server, command-handler | now | rejected (too invasive) | -| D | Bridge detects known extension commands via `getCommands()`, surfaces a `command_feedback { status: "error", message }` instead of silently sending to LLM | dashboard-only, ~20 lines | now | **stopgap until pi 0.71+** | - -**Path B chosen as primary.** Rationale: -- Pi already has the dispatch logic implemented (`agent-session.js:798 _tryExecuteExtensionCommand`). Exposing it is a 5-line addition to `ExtensionAPI` + `bindCore` + `loader.js`'s api-object factory. -- Conceptually clean: extensions already have `sendUserMessage` (raw user input bypassing slash dispatch) and `sendMessage` (custom messages). Adding `dispatchCommand` (raw user input WITH slash dispatch) closes the obvious gap. -- The bridge change is exactly what the original proposal anticipated: replace `pi.sendUserMessage(text, {deliverAs:"followUp"})` with `pi.dispatchCommand(text, {streamingBehavior:"followUp"})` (or whatever shape upstream chooses). Three lines. - -**Path D chosen as interim.** Rationale: -- Avoids the worst UX failure mode (silent send-to-LLM) without waiting on upstream. -- Strictly additive: when `pi.dispatchCommand` is unavailable, the bridge inspects `pi.getCommands()` for a name match and emits a `command_feedback { status: "error", message: "Extension slash command '/' is registered but cannot be dispatched from the dashboard chat (waiting on pi 0.71+ for `pi.dispatchCommand` API). Use the extension's tools or invoke from pi TUI." }` instead of sending to the LLM. -- Removable as a single block once Path B ships and the bridge starts using `pi.dispatchCommand`. - -**Path C rejected.** Server-as-direct-RPC-client would require: -- Tracking which pi process owns which session (server already does via `headless-pid-registry`, but it's PID-only — no stdin handle) -- Capturing pi's stdin from the spawn site (`process-manager.ts`'s `spawnPiSession`) and exposing it through `pi-gateway.ts` -- Browser-handlers/session-action-handler.ts splitting `send_prompt` between "extension command → server-side RPC inject" and "everything else → bridge" -- Reworking the bridge's command-handler to skip the slash branch - -The architectural cost is far higher than the symptom warrants, and it leaves the bridge architecturally inconsistent (bridge owns most session ops; suddenly a fraction route around it). - -**Path A rejected** — the handler is private to the runner, exposed nowhere on the api object. - -### Decision 2: Detection rule for Path D - -The bridge's `sessionPrompt` fallback detects extension slash commands by intersecting the typed text's command name against `pi.getCommands()` filtered to `source === "extension"` AND not in `DASHBOARD_NATIVE_COMMANDS` (the existing filter applied in `bridge-context.ts::filterHiddenCommands`). Skill commands (`source: "skill"`), prompt templates (`source: "prompt"`), and bridge-native commands (`__dashboard_reload`) are NOT treated as extension commands and continue through the existing template-expansion path. - -The intersection is computed once per `sessionPrompt` invocation (no caching) — `getCommands()` is already O(1) cached on pi's runtime side. - -### Decision 3: When `pi.dispatchCommand` is available — feature detection - -The bridge feature-detects `typeof (pi as any).dispatchCommand === "function"`. If true: route slash commands through it. If false: apply Path D's stopgap. No version-string sniffing. - -This way, the same bridge build works against pi 0.70 (stopgap kicks in) and pi 0.71+ (dispatch kicks in) without recompilation. - -### Decision 4: Telemetry events - -- **Before dispatch**: emit `command_feedback { command: "/", status: "started" }`. Mirrors the existing pattern for `/reload`, `/new`, `/model`, etc. -- **After dispatch (Path B path)**: emit `command_feedback { command: "/", status: "completed" }`. Pi's `_tryExecuteExtensionCommand` already swallows handler exceptions and emits `extension_error` events on the runner — no per-command try/catch needed in the bridge. The dashboard already renders `extension_error` as a chat error row. -- **Stopgap path (Path D)**: emit `command_feedback { command: "/", status: "error", message: "" }` and DO NOT call `sendUserMessage`. This is a deliberate UX improvement over today's silent fall-through. - -### Decision 5: Test shape - -A regression test in `packages/extension/src/__tests__/bridge-slash-command-routing.test.ts` (new file). Constructs a stub pi object with both `dispatchCommand` (when present) and `sendUserMessage` (always present). Drives `command-handler.handle(...)` with various send_prompt payloads and asserts: - -| Input | `dispatchCommand` calls | `sendUserMessage` calls | `command_feedback` | -|---|---|---|---| -| `/ctx-stats` (extension cmd, dispatch available) | 1 | 0 | started + completed | -| `/ctx-stats` (extension cmd, no dispatch) | 0 | 0 | started + error | -| `/skill:foo` (skill) | 0 | 1 (expanded) | none | -| `/some-prompt-template` | 0 | 1 (expanded) | none | -| `hello world` (passthrough) | 0 | 1 (raw) | none | -| `/compact` | 0 | 0 (routed via compact() instead) | started + completed | -| `/flows:new` (flow command) | 0 | 0 (routed via events.emit) | completed | - -The test pins the contract that extension slash commands NEVER fall through to `sendUserMessage`. If a future refactor accidentally re-introduces the bug, the test fails on the `sendUserMessage` call count. - -## Risks / Trade-offs - -- **[Upstream dependency for full fix] → Path D ships standalone.** The complete fix requires a pi-coding-agent change. Until pi 0.71+ lands and propagates to user installs, the dashboard will visibly refuse to dispatch extension slash commands instead of silently corrupting the conversation. That's a UX regression for any user who previously typed e.g. `/curator` and saw the LLM hallucinate a response — but it's a clearer signal of the underlying limitation. Document in CHANGELOG. - -- **[Path D false-positives if `getCommands()` includes commands that ARE dispatchable through some other route] → Whitelist of bridge-native names.** The bridge's own `__dashboard_reload` is hidden via `filterHiddenCommands`, so it won't appear. The flows family (`/flows*`) IS in `getCommands()` from pi-flows extension AND is short-circuited by the bridge's flow fast-path before reaching the fallback. The detection rule must therefore run AFTER the flow fast-path check, which is the natural placement (it's already the fallback branch). No additional bookkeeping needed. - -- **[Path D breaks `/agents`, `/curator`, `/websearch` etc. that currently send to LLM and "kind of work" because the LLM hallucinates a sensible response] → Acceptable.** Today's "kind of works" is non-deterministic and confuses users about what these commands actually do. Failing loudly is strictly better; users can still invoke the underlying tools (`web_search`, `subagent`, etc.) directly. - -- **[`pi.dispatchCommand` upstream API shape might differ from what we assume] → Implementation pinned to feature detection, no version assumption.** If pi 0.71+ chooses a different name (`pi.runCommand`, `pi.invokeCommand`), the bridge's feature-detect check needs to update — but the test contract is unchanged, just the symbol probed. Worst case: a follow-up PR after upstream lands. - -- **[`command_feedback` events are not all rendered identically in the dashboard chat] → Verify with existing renderer.** The client's `event-reducer.ts` handles `command_feedback` with `status` ∈ `{started, completed, error}` for `/reload`, `/new`, `/model`, `/compact`. Same renderer applies; no client changes expected. - -- **[Multi-line slash text (e.g. `/skill:foo\nuser context`) classified as "passthrough" by `parseSendPrompt`] → unaffected.** `parseSendPrompt` only emits `type: "slash"` for single-line slashes. Multi-line text routes via the passthrough → `sendUserMessage` path (the comment in command-handler.ts:282 calls this out explicitly). The fix is scoped to single-line slash text. - -## Migration Plan - -Two-step rollout, gated by feature detection: - -1. **Step 1 (this change, dashboard-only):** Add Path D's stopgap to bridge. Detect extension slash commands via `pi.getCommands()` filter and emit `command_feedback { status: "error" }` instead of falling through to `sendUserMessage`. Add the regression test. Ships in next dashboard release without waiting on upstream. - -2. **Step 2 (upstream + dashboard, follow-up):** Open a PR against `mariozechner/pi-coding-agent` adding `pi.dispatchCommand(text, options?)` to `ExtensionAPI`. Once merged + released as pi 0.71, update the dashboard's bridge to feature-detect `dispatchCommand` and use it when available. Path D stays in place as a fallback for users still on pi 0.70. The same regression test covers both paths via the table in Decision 5. - -**No data migration**, no schema changes, no settings migration. Bridge-only on the dashboard side. - -**Rollback strategy**: revert the bridge commit. Behavior reverts to today's silent send-to-LLM. No persistent state to clean up. - -## Open Questions - -1. **Should Path D's error `command_feedback` carry a structured `code` field?** e.g. `{ status: "error", code: "EXTENSION_COMMAND_NOT_DISPATCHABLE", command, message }`. Useful for future programmatic handling (auto-suggest equivalent tool calls, etc.). Defer unless the client renderer benefits — currently it just shows the human message. - -2. **Should the autocomplete dropdown display extension slash commands differently when `dispatchCommand` is unavailable?** e.g. greyed-out + tooltip "requires pi 0.71+". Out of scope for this change (no client UI changes), but worth a follow-up if Path D's error events feel like a poor experience in practice. - -3. **What's the reasonable upstream PR shape for `pi.dispatchCommand`?** Likely: - ```ts - /** Dispatch a slash command (e.g. "/foo args"). If no extension command matches, - * the text is passed through to the LLM as a regular user message. */ - dispatchCommand(text: string, options?: { streamingBehavior?: "steer" | "followUp" }): Promise; - ``` - Resolved when Step 2 is filed; not blocking for this change. - -4. **Does pi-flows' typed `/flows*` commands actually run via the existing flow fast-path, or do they too fall through to `sendUserMessage`?** Testing notes in proposal said the fast-path catches button-triggered flow management, not typed text. **Action**: empirically verify before tasks.md, since the answer changes whether `/flows:new` typed in chat is also a stopgap target. Likely already broken the same way; if so, this change fixes it for free. diff --git a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/notes/preflight-empirical-checks.md b/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/notes/preflight-empirical-checks.md deleted file mode 100644 index 23bb7be94..000000000 --- a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/notes/preflight-empirical-checks.md +++ /dev/null @@ -1,88 +0,0 @@ -# Preflight empirical checks - -Performed during explore mode before `/opsx:apply` to resolve open questions -in `tasks.md` task 1.1 and design.md "Open Question 4". Pin these answers so -the implementing agent can skip re-verification. - -## Q1: Are typed `/flows:new`, `/flows:edit`, `/flows:delete`, `/flows`, `/roles` already broken in dashboard chat the same way as `/ctx-stats`? - -**Answer: YES.** All pi-flows-registered slash commands fall through to -`sendUserMessage` in dashboard chat today. The flow buttons in the kebab -menu mask the bug because they route via the `flow_management` ws message -type (different handler entirely). - -### Trace - -`bridge.ts::sessionPrompt` (line ~561) gates flow dispatch on: - -```ts -const flowsList = getFlowsList(); // pi-flows' "flow:list-flows" event probe -if (flowsList.some(f => f.name === cmdName)) { - pi.events.emit("flow:run", { flowName: cmdName, task }); - return; -} -``` - -`getFlowsList()` returns USER-DEFINED flows (names the user authored in the -flow architect, e.g. `deploy-prod`, `review-pr`). It does NOT include the -pi-flows extension's own registered slash commands. So `cmdName === "flows:new"` -never matches a user-defined flow, and the branch falls through to the -`sendUserMessage` fallback. - -### Empirical proof - -Spawning `pi --mode rpc` (the same mode the dashboard uses) and sending -the RPC `prompt` command directly — which calls `session.prompt(text)` and -DOES run `_tryExecuteExtensionCommand`: - -```bash -$ echo '{"type":"prompt","message":"/flows:new","id":"1"}' | pi --mode rpc -{"type":"extension_ui_request","method":"input","title":"Describe what the flow should do:"} -``` - -The pi-flows extension correctly handles `/flows:new` and calls -`ctx.ui.input(...)` for the task description. The dashboard chat's -`send_prompt` path doesn't reach this code because the bridge translates -typed slash commands into `pi.sendUserMessage(text, { deliverAs: "followUp" })`, -which pi explicitly documents as the "skip command handling" path -(`agent-session.js:1002`). - -### Spec impact - -- The fix in this change will START making typed `/flows:*` work in dashboard - chat. Today they're silently broken (text sent to LLM, which often - hallucinates plausible flow output). -- Tasks 7.5 in `tasks.md` becomes a positive verification — confirm the - command STARTS working after the fix, not just stays working. -- CHANGELOG should call out this implicit fix beyond `/ctx-stats`. - -## Q2: Which `pi.sendUserMessage` call sites in `command-handler.ts` need the extension-command gate? - -**Answer: only 2 of the 5 sites — `command-handler.ts:264` (mirror of `bridge.ts:572`).** - -### All 5 sites mapped - -| Line | Path | Needs gate? | Why | -|------|----------------------------------------|-------------|-----| -| 264 | slash else-arm (no `options.sessionPrompt`) | **YES** | Same logical path as `bridge.ts:572` — fired when bridge wiring isn't provided (older callers). Must apply identical extension-command branch. | -| 286 | passthrough → `sendUserMessageWithImages` for multi-line slashes (`/skill:foo\nuser ctx`) and image-bearing input | NO | Multi-line slashes are intentionally NOT extension commands. The pure helper `isExtensionSlashCommand` (ADDED Requirement) rejects multi-line input, so the gate would no-op even if applied. Keep as-is. | -| 453 | inside `sendUserMessageWithImages` — image-bearing content array | NO | Internal helper. Caller (line 286) already past the gate decision. | -| 455 | inside `sendUserMessageWithImages` — fallback when no valid images survive validation | NO | Same — internal helper. | -| 458 | inside `sendUserMessageWithImages` — text-only path | NO | Same — internal helper. | -| 495 | `handleBashCommand` — sends `$ \n` after `!cmd` runs | NO | Bash output forwarding to LLM. Has nothing to do with slash routing. | - -### Conclusion for `tasks.md` task 3.3 - -The audit is complete. Only `bridge.ts:572` and `command-handler.ts:264` need -the extension-command branch. The remaining sites stay verbatim. Task 3.3's -inline-comment requirement ("explain why each `sendUserMessage` site is -exempt") still applies — it serves as a forward-defense against future -contributors re-introducing the bug at a different site. - -## Result - -- Task 1.1 → answered: typed `/flows:*` is broken; fix improves UX beyond `/ctx-stats`. -- Task 3.3 → audit complete: 2 sites need the gate (already covered by 3.1/3.2), 3 are correctly exempt. -- No spec or design updates needed — both artifacts already cover these cases via the routing-order requirement (step 11) and the `isExtensionSlashCommand` multi-line rejection scenario. - -The change is ready for `/opsx:apply` without further blocking questions. diff --git a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/proposal.md b/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/proposal.md deleted file mode 100644 index 7f64e1460..000000000 --- a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/proposal.md +++ /dev/null @@ -1,54 +0,0 @@ -## Why - -Pi extensions that register slash commands via `pi.registerCommand(name, { handler })` are silently broken in dashboard sessions. When the user types e.g. `/ctx-stats` or `/curator` in chat, the registered handler **never runs** — the literal string is sent to the LLM as a regular user message instead. - -The bug surfaces with every npm extension that ships its own slash commands (context-mode, pi-web-access, pi-agent-browser, pi-subagents). The TUI works correctly because pi's TUI uses `session.prompt()`, which intercepts extension commands. The dashboard bridge bypasses this interception by routing through `pi.sendUserMessage()`, which pi's own source comments mark as the "skip command handling" path: - -```js -// agent-session.js:1002 (pi-coding-agent) -// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion -await this.prompt(text, { expandPromptTemplates: false, ... }); -``` - -The result is a degraded extension ecosystem in the dashboard: any pi extension distributed through npm/git that relies on `pi.registerCommand` will *appear* to work (event handlers fire, tools register, MCP servers spawn), yet its slash-command UX is dead. The user has no way to tell the difference until they type a command and see the LLM repeat the slash text back at them. - -## What Changes - -- `packages/extension/src/bridge.ts::sessionPrompt` (the slash-fallback callback wired into `command-handler.ts`'s `parsed.type === "slash"` branch): replace the `pi.sendUserMessage(expanded, { deliverAs: "followUp" })` call with `pi.session.prompt(expanded, { streamingBehavior: "followUp" })` (or the equivalent accessor on the bridge's `pi` API surface) so pi's `_tryExecuteExtensionCommand` runs before the text falls through to the LLM. -- The flows fast-path (`flow:run` emit) and the bridge's own `__dashboard_reload` registration stay as-is — they intentionally pre-empt pi's dispatcher. -- Skill expansion (`/skill:foo`) and prompt-template expansion (`expandPromptTemplateFromDisk`) MUST continue to work for typed slash text that doesn't match a registered command. Pi's `prompt()` already calls `_expandSkillCommand` and `expandPromptTemplate` after the extension-command check, so this path is preserved. -- Add a regression test under `packages/extension/src/__tests__/` that asserts the bridge's slash-fallback wiring routes through an API that DOES dispatch extension commands (i.e. `pi.session.prompt`-equivalent), not `pi.sendUserMessage`. Use a stub `pi` object whose `sendUserMessage` would fail the test if hit. - -This is a pure routing fix — no protocol changes, no extension-API additions. The pi 0.70 contract already supports both code paths; the bridge just picks the wrong one for typed slash commands. - -## Capabilities - -### New Capabilities - -(none) - -### Modified Capabilities - -- `command-routing`: the requirement covering `parsed.type === "slash"` dispatch must be rewritten so the fallback path invokes pi's extension-command interception instead of `sendUserMessage`. Existing requirements covering bang commands, `/compact`, `/quit`, `/reload`, `/new`, `/model`, and management/flow commands stay unchanged. - -## Impact - -**Affected code** -- `packages/extension/src/bridge.ts` — `sessionPrompt` fallback branch (~3 lines) -- `packages/extension/src/command-handler.ts` — the `parsed.type === "slash"` branch's `else` arm that calls `pi.sendUserMessage(parsed.text)` directly (when `options?.sessionPrompt` isn't provided) needs the same fix -- `packages/extension/src/__tests__/` — new regression test pinning the routing contract - -**Affected behavior** -- Every pi extension that ships slash commands via `pi.registerCommand` becomes usable from the dashboard chat input. This includes (verified at proposal time): `/ctx-stats`, `/ctx-doctor` (context-mode); `/websearch`, `/curator`, `/google-account`, `/search` (pi-web-access); `/agents` (pi-subagents); future extensions. -- Typed `/flows`, `/flows:new`, `/flows:edit`, `/flows:delete` will now ALSO route through pi's dispatcher instead of the bridge's flow fast-path — but pi-flows registers these names via `pi.registerCommand` too, so the registered handler runs identically. The flow fast-path stays as a fallback for the case where pi-flows is unavailable; **NEEDS DESIGN**: confirm that running pi's dispatcher first does not double-dispatch (pi handler emits its own events). -- No protocol changes. No browser-client changes. No server changes. Bridge-only fix. - -**Risks** -- pi's extension-command dispatcher swallows the message (returns `handled: true`) but the handler may throw or fail silently — current bridge has no telemetry on this. Design phase should evaluate whether to surface command-handler errors as `command_feedback { status: "error" }` events. -- Some extension commands may make synchronous assumptions about being run from a TUI context (e.g. expect `ctx.ui.select` / `ctx.ui.input` to render in-terminal). The dashboard's PromptBus already routes those to chat dialogs, so this should be neutral — but worth a smoke test against `/curator` (pi-web-access) and `/agents` (pi-subagents). -- `pi.sendUserMessage` is still exposed and called from other bridge paths (passthrough text, image-bearing messages, multi-line slash text). Those paths are correct as-is and should stay unchanged. The fix is scoped to the slash-without-images fallback. - -**Out of scope** -- Adding new extension command types or argument syntax. -- Surfacing extension-command errors as toasts — separate proposal if desired. -- Re-architecting the bridge's `pi` API accessor (`pi.session.prompt` vs `pi.prompt` etc.). Whatever shape pi 0.70 ExtensionAPI exposes is what the fix uses. diff --git a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/specs/command-routing/spec.md b/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/specs/command-routing/spec.md deleted file mode 100644 index d3b64b027..000000000 --- a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/specs/command-routing/spec.md +++ /dev/null @@ -1,147 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Slash command routing through session.prompt() -For `/` prefixed input that is not handled by an earlier routing step (bang commands, `/compact`, `/quit`, `/reload`, `/new`, `/model `, management commands, flow run), the command handler SHALL attempt to dispatch the slash command through pi's extension-command dispatcher via `pi.dispatchCommand(text, options?)` when that method is exposed by the active pi build (feature-detected at runtime). When `pi.dispatchCommand` is unavailable, the handler SHALL apply the **extension-command stopgap** (see ADDED Requirement below) before any fallback to `pi.sendUserMessage(text)`. - -The fallback to `pi.sendUserMessage(text)` SHALL be reached ONLY for slash text that: -- Is NOT a registered extension command (per `pi.getCommands()` filtered to `source === "extension"` and not in `DASHBOARD_NATIVE_COMMANDS`), AND -- IS a skill command (`/skill:`), prompt template, or unrecognized slash text whose semantics require LLM interpretation. - -The handler SHALL emit `command_feedback { command, status }` events around extension-command dispatch: -- `status: "started"` immediately before invoking `pi.dispatchCommand` (or, in the stopgap path, before emitting the error feedback). -- `status: "completed"` after `pi.dispatchCommand` resolves successfully. -- `status: "error"` with a structured `message` field when the stopgap fires (i.e. extension command detected but `pi.dispatchCommand` not available). - -The handler SHALL NOT emit duplicate `command_feedback` events on the dispatch path; pi's `_tryExecuteExtensionCommand` swallows handler exceptions and emits its own `extension_error` events to the runner — those are forwarded by the existing event-wiring path and are not duplicated by this requirement. - -#### Scenario: Extension command dispatched via pi.dispatchCommand -- **WHEN** `send_prompt` text is `/ctx-stats` AND `pi.dispatchCommand` is a function AND `ctx-stats` appears in `pi.getCommands()` with `source: "extension"` -- **THEN** the handler SHALL emit `command_feedback { command: "/ctx-stats", status: "started" }` -- **AND** SHALL call `pi.dispatchCommand("/ctx-stats", { streamingBehavior: "followUp" })` -- **AND** upon resolution SHALL emit `command_feedback { command: "/ctx-stats", status: "completed" }` -- **AND** SHALL NOT call `pi.sendUserMessage(...)` - -#### Scenario: Extension command stopgap when pi.dispatchCommand unavailable -- **WHEN** `send_prompt` text is `/ctx-stats` AND `pi.dispatchCommand` is NOT a function AND `ctx-stats` appears in `pi.getCommands()` with `source: "extension"` -- **THEN** the handler SHALL emit `command_feedback { command: "/ctx-stats", status: "started" }` -- **AND** SHALL emit `command_feedback { command: "/ctx-stats", status: "error", message: }` -- **AND** SHALL NOT call `pi.sendUserMessage(...)` for the slash text -- **AND** SHALL NOT call `pi.dispatchCommand(...)` (it is not a function) - -#### Scenario: Skill command expanded (unaffected) -- **WHEN** `send_prompt` text is `/skill:my-skill some args` -- **THEN** the handler SHALL expand the skill via `expandPromptTemplateFromDisk(text, cwd, pi)` and call `pi.sendUserMessage(, { deliverAs: "followUp" })` -- **AND** SHALL NOT call `pi.dispatchCommand(...)` (skill commands are not extension commands) - -#### Scenario: Prompt template expanded (unaffected) -- **WHEN** `send_prompt` text is `/some-prompt-template arg1 arg2` AND `some-prompt-template` is a registered prompt template (`source: "prompt"`) -- **THEN** the handler SHALL expand the template via `expandPromptTemplateFromDisk(text, cwd, pi)` and call `pi.sendUserMessage(, { deliverAs: "followUp" })` -- **AND** SHALL NOT call `pi.dispatchCommand(...)` (prompt templates are not extension commands) - -#### Scenario: Unrecognized slash falls through -- **WHEN** `send_prompt` text is `/totally-unknown-command` AND no entry with name `totally-unknown-command` exists in `pi.getCommands()` -- **THEN** the handler SHALL fall through to `pi.sendUserMessage("/totally-unknown-command", { deliverAs: "followUp" })` -- **AND** SHALL NOT emit `command_feedback` events for this text -- **AND** SHALL NOT call `pi.dispatchCommand(...)` - -#### Scenario: Bridge-native command suppressed from extension detection -- **WHEN** `send_prompt` text is `/__dashboard_reload` -- **THEN** the handler SHALL fall through to `pi.sendUserMessage(...)` (bridge-native commands are excluded from extension detection via `DASHBOARD_NATIVE_COMMANDS`) -- **AND** SHALL NOT emit `command_feedback { status: "error" }` for it - -### Requirement: Command routing order -The command handler SHALL process `send_prompt` text in this exact order: - -1. Check for `!!` prefix → silent bash execution -2. Check for `!` prefix → bash execution with LLM send -3. Check for `/compact` → compact routing -4. Check for `/quit` or `/exit` → shutdown -5. Check for `/reload` → extension reload -6. Check for `/new` → spawn new session in same cwd -7. Check for `/model provider/id` → model switch via `setModel` callback -8. Check for `/` prefix matching a known **user-defined flow name** (from `getFlowsList()`) → emit `flow:run` event -9. Check for `/` prefix matching a known **extension command** (`source: "extension"` in `pi.getCommands()`, excluding `DASHBOARD_NATIVE_COMMANDS`) → dispatch via `pi.dispatchCommand` (when available) OR emit `command_feedback { status: "error" }` stopgap (when unavailable) -10. Check for `/` prefix → fall through to template expansion + `pi.sendUserMessage()` (handles skills, prompt templates, unrecognized slashes) -11. Default (no `/` prefix) → `pi.sendUserMessage(text)` (existing passthrough behavior) - -Note: pi-flows management commands (`/flows`, `/flows:new`, `/flows:edit`, `/flows:delete`, `/roles`) are registered by the pi-flows extension via `pi.registerCommand` and are therefore handled by step 9 (extension dispatch) when `pi.dispatchCommand` is available, or by the stopgap when it is not. The kebab-menu UI continues to invoke `flows:new-request` / `flows:edit-request` / `flow:run` / `flow:delete-request` directly via the `flow_management` WebSocket message handler in `bridge.ts` — that path is independent of typed-text command routing and is not covered by this requirement. - -#### Scenario: Routing precedence — bang beats slash -- **WHEN** `send_prompt` text is `!!echo /ctx-stats` -- **THEN** the handler SHALL execute `echo /ctx-stats` as a silent bash command -- **AND** SHALL NOT invoke any slash routing branch - -#### Scenario: Routing precedence — user-defined flow run beats extension dispatch -- **WHEN** `send_prompt` text is `/deploy-prod` AND `deploy-prod` is a user-defined flow name returned by `getFlowsList()` AND ALSO appears in `pi.getCommands()` -- **THEN** the handler SHALL emit `flow:run { flowName: "deploy-prod" }` via `pi.events.emit(...)` (step 8 wins over step 9) -- **AND** SHALL NOT call `pi.dispatchCommand(...)` for this text - -#### Scenario: Routing precedence — typed `/flows:new` rides extension dispatch -- **WHEN** `send_prompt` text is `/flows:new` AND `getFlowsList()` does NOT contain a user-defined flow named `flows:new` AND `pi.getCommands()` contains `{ name: "flows:new", source: "extension" }` (registered by pi-flows) -- **THEN** step 8 SHALL NOT match (no user-defined flow) -- **AND** step 9 SHALL fire: dispatch via `pi.dispatchCommand` when available, stopgap `command_feedback { status: "error" }` otherwise -- **AND** SHALL NOT call `pi.sendUserMessage(...)` for the slash text - -#### Scenario: Extension dispatch beats fall-through -- **WHEN** `send_prompt` text is `/ctx-stats` AND `ctx-stats` is an extension command AND no earlier step matches -- **THEN** step 9 fires (extension dispatch or stopgap) -- **AND** step 10's fall-through to `pi.sendUserMessage(...)` SHALL NOT execute - -## ADDED Requirements - -### Requirement: Extension slash command detection -The command handler SHALL provide a pure helper `isExtensionSlashCommand(text, commandList)` that returns true iff: -- `text` starts with `/` AND has no embedded newline -- The token between the leading `/` and the first space (or end of string) — call it `cmdName` — appears in `commandList` with `source === "extension"` -- `cmdName` is NOT in `DASHBOARD_NATIVE_COMMANDS` (the same set used by `filterHiddenCommands` in `bridge-context.ts`) - -This helper SHALL be exported and used by the bridge's `sessionPrompt` callback in `bridge.ts` to gate steps 11/12 of the routing order. - -The helper SHALL NOT mutate `commandList` and SHALL NOT call any pi APIs. It is a pure string + array predicate suitable for unit testing without a stub pi. - -#### Scenario: Detects bare extension command -- **WHEN** called with `("/ctx-stats", [{ name: "ctx-stats", source: "extension" }])` -- **THEN** SHALL return `true` - -#### Scenario: Detects extension command with arguments -- **WHEN** called with `("/ctx-stats verbose=1", [{ name: "ctx-stats", source: "extension" }])` -- **THEN** SHALL return `true` - -#### Scenario: Rejects skill command -- **WHEN** called with `("/skill:foo", [{ name: "skill:foo", source: "skill" }])` -- **THEN** SHALL return `false` (source is `skill`, not `extension`) - -#### Scenario: Rejects prompt template -- **WHEN** called with `("/review", [{ name: "review", source: "prompt" }])` -- **THEN** SHALL return `false` - -#### Scenario: Rejects bridge-native dashboard command -- **WHEN** called with `("/__dashboard_reload", [{ name: "__dashboard_reload", source: "extension" }])` -- **THEN** SHALL return `false` (excluded by `DASHBOARD_NATIVE_COMMANDS`) - -#### Scenario: Rejects unknown slash -- **WHEN** called with `("/totally-unknown", [])` -- **THEN** SHALL return `false` - -#### Scenario: Rejects multi-line input -- **WHEN** called with `("/ctx-stats\nuser context", [{ name: "ctx-stats", source: "extension" }])` -- **THEN** SHALL return `false` (multi-line slashes are passthrough by `parseSendPrompt`) - -#### Scenario: Rejects non-slash input -- **WHEN** called with `("hello world", [{ name: "ctx-stats", source: "extension" }])` -- **THEN** SHALL return `false` - -### Requirement: Bridge feature-detects pi.dispatchCommand -The bridge's `sessionPrompt` callback in `packages/extension/src/bridge.ts` SHALL feature-detect the presence of `pi.dispatchCommand` at call time using `typeof (pi as any).dispatchCommand === "function"`. - -The bridge SHALL NOT cache the feature-detection result across `sessionPrompt` invocations — pi's API surface is fixed per process, but call-time check keeps the wiring identical between fresh-spawn and live-reload paths. - -The bridge SHALL NOT use pi version strings, semver checks, or any other version-sniffing mechanism for this gate. - -#### Scenario: pi 0.71+ with dispatchCommand -- **WHEN** the bridge's `sessionPrompt` is invoked with `/ctx-stats` AND `(pi as any).dispatchCommand` is a function -- **THEN** the bridge SHALL invoke `(pi as any).dispatchCommand("/ctx-stats", { streamingBehavior: "followUp" })` - -#### Scenario: pi 0.70 without dispatchCommand -- **WHEN** the bridge's `sessionPrompt` is invoked with `/ctx-stats` AND `(pi as any).dispatchCommand` is `undefined` -- **THEN** the bridge SHALL apply the extension-command stopgap (emit `command_feedback { status: "error" }`) and SHALL NOT call `pi.sendUserMessage(...)` for this text diff --git a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/tasks.md b/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/tasks.md deleted file mode 100644 index 7f0e561b8..000000000 --- a/openspec/changes/archive/2026-05-09-fix-extension-slash-commands-in-dashboard/tasks.md +++ /dev/null @@ -1,72 +0,0 @@ -## 1. Empirical pre-check - -- [x] 1.1 Verify open question 4 from `design.md`: type `/flows:new` (and `/flows:edit`, `/flows:delete`) in dashboard chat and confirm whether they currently fall through to `sendUserMessage` (broken) or hit the bridge's flow fast-path (working). Captured in `notes/preflight-empirical-checks.md` Q1: typed `/flows:*` falls through to `sendUserMessage`; the `flow_management` ws-message path (kebab buttons) is unrelated. `MANAGEMENT_COMMAND_EVENTS` in `command-handler.ts` is currently `{}` (empty), so no typed text reaches `pi.events.emit("flows:new-request")`. -- [x] 1.2 Reconcile spec routing-order with code reality. Spec steps 8/9 (`/flows:new` → `flows:new-request`, `/flows:edit` → `flows:edit-request`) describe behavior that does NOT exist in the current code (only the `flow_management` ws-message path emits those events). Per design intent, typed `/flows:*` is fixed via Path B (extension dispatch — pi-flows registers these names as extension commands). Update `specs/command-routing/spec.md`: - - DELETE routing steps 8 and 9 from the "Command routing order" requirement. - - Renumber subsequent steps (10 → 8 user-defined flow run, 11 → 9 extension command, 12 → 10 fall-through, 13 → 11 default). - - REPLACE the "Routing precedence — flow fast-path beats extension dispatch" scenario: input `/flows:new` (no user-defined flow with that name, but IS in `pi.getCommands()` with `source:"extension"`) → step 9 (extension dispatch) fires, NOT a non-existent step 8/9. - - Keep the kebab-button `flow_management` ws-message path documented separately (it's not part of the typed-text command-routing requirement). - -## 2. Pure helper + types - -- [x] 2.1 Add `isExtensionSlashCommand(text, commandList)` to `packages/extension/src/bridge-context.ts` next to the existing `filterHiddenCommands` (it already owns the `DASHBOARD_NATIVE_COMMANDS` set). Export it from the same module. Implementation per ADDED Requirement "Extension slash command detection" — pure predicate, no pi calls, no mutation. -- [x] 2.2 Add unit tests covering all 8 scenarios in the ADDED Requirement (`packages/extension/src/__tests__/extension-slash-command-detection.test.ts`). Each scenario from the spec → one `it()` block. No stub pi needed — pure string + array input. - -## 3. Bridge wiring (stopgap path — Path D) - -- [x] 3.1 In `packages/extension/src/bridge.ts::sessionPrompt`, immediately AFTER the existing flow fast-path block and BEFORE the template-expansion fallback, add the extension-command branch: call `pi.getCommands()` (wrap in `try { … } catch { commands = [] }` per task 3.4), run `isExtensionSlashCommand(text, commands)`, and if true: - - emit `command_feedback { command: text, status: "started" }` via `connection.send` (or whatever the existing `command_feedback` emit path is — match the `/reload`, `/new`, `/model` siblings already in `command-handler.ts`) - - feature-detect via `hasDispatchCommand(pi)` (task 5.2) - - if true: `await (pi as any).dispatchCommand(text, { streamingBehavior: "followUp" })` inside a `try/catch`. On resolve emit `command_feedback { command: text, status: "completed" }`. On rejection emit `command_feedback { command: text, status: "error", message: }` (task 3.5). - - if false: emit `command_feedback { command: text, status: "error", message: }` and `return` without invoking the fallback - - Guarantee EXACTLY ONE `started` event and EXACTLY ONE terminal event (`completed` OR `error`) per `sessionPrompt` invocation. No duplicate emits on either branch (spec requirement "SHALL NOT emit duplicate command_feedback events on the dispatch path"). -- [x] 3.2 Apply the SAME change to `packages/extension/src/command-handler.ts`'s slash branch's ELSE arm (line ~263, where `options?.sessionPrompt` is undefined and the code falls through to `pi.sendUserMessage(parsed.text)`). The two code paths must stay in lockstep — both routes must apply the extension-command branch before `sendUserMessage`. Consider extracting the branch into a shared helper (`dispatchOrStopgap(pi, text, commandList, sink)`) to avoid drift; place in `bridge-context.ts` or a new `slash-dispatch.ts`. -- [x] 3.3 Audit every other `pi.sendUserMessage(...)` call site in `command-handler.ts` (search shows 5 sites: passthrough fallback, image-bearing path, multi-line slash path) and confirm NONE of them should also gate through the extension-command branch. The intent is: only typed single-line `/slash` text gates; everything else (multi-line, image-bearing, no-slash) goes raw to the LLM as before. Add inline comments at each `sendUserMessage` site explaining why it's exempt. -- [x] 3.4 Defensive guard around `pi.getCommands()`. Although the bridge re-captures `bc.pi` on every `session_start` (so `assertActive()` should not fire under normal flow), a stale-ctx race during dispose is theoretically reachable. Wrap the `getCommands()` call inside the new branch in `try { commands = pi.getCommands() } catch (err) { console.warn("[dashboard] getCommands stale", err); commands = [] }`. The empty list silently falls through to the existing template-expansion / sendUserMessage path (preserves today's behavior for that race window). Add unit-test coverage in `bridge-slash-command-routing.test.ts` (task 4.x): stub `pi.getCommands` to throw → assert no crash, no `command_feedback` emitted, fallback `sendUserMessage` called. -- [x] 3.5 Error handling when `pi.dispatchCommand` rejects. The Path B branch in task 3.1 MUST `await` inside a `try/catch`. On rejection: emit `command_feedback { command: text, status: "error", message: err instanceof Error ? err.message : String(err) }` and DO NOT fall through to `sendUserMessage` (the dispatch attempt was the user's intent — re-sending the literal text would double-send). Cover this in task 4.x with a stub whose `dispatchCommand` rejects with `new Error("boom")`; assert exactly one `started` + one `error` event with the message, zero `sendUserMessage` calls. - -## 4. Regression test pinning routing contract - -- [x] 4.1 Create `packages/extension/src/__tests__/bridge-slash-command-routing.test.ts`. Stub `pi` exposes: - - `getCommands()` returning a small fixture (one extension cmd `ctx-stats`, one skill `skill:foo`, one prompt template `review`, one bridge-native `__dashboard_reload`) - - `dispatchCommand` (sometimes function, sometimes undefined — toggled per test) - - `sendUserMessage` — recorded as a call spy; failing the test if hit when it shouldn't be - - `events.emit` — recorded for flow paths - - other minimum surface for `createCommandHandler` to construct without throwing -- [x] 4.2 Drive `commandHandler.handle({ type: "send_prompt", sessionId: "test", text: "" })` for each row of the table in `design.md` Decision 5. Assert call counts + emitted `command_feedback` events match exactly. Cover both `dispatchCommand` available and unavailable. -- [x] 4.3 Add an explicit anti-regression assertion: `/ctx-stats` MUST never reach `sendUserMessage` regardless of whether `dispatchCommand` is available. Comment the test with `// regression: see openspec/changes/fix-extension-slash-commands-in-dashboard/` so future refactors find it. -- [x] 4.4 Add a no-duplicate-feedback assertion: for every dispatch path (Path B success, Path B reject, stopgap), assert the recorded `command_feedback` events for the input contain EXACTLY ONE `started` and EXACTLY ONE terminal event (`completed` xor `error`). Pins spec requirement "SHALL NOT emit duplicate command_feedback events on the dispatch path". -- [x] 4.5 Add a unit test for `hasDispatchCommand(pi)` (task 5.2). Cover three cases: function present → `true`; field absent → `false`; field present but not a function (e.g. `{ dispatchCommand: "yes" }`) → `false`. - -## 5. Type definitions + feature detection helper - -- [x] 5.1 Add an optional `dispatchCommand` field to the bridge's local `pi` API type (the `as any` cast in `bridge.ts` is OK, but tighten where reasonable). If pi 0.71 ships before this change archives, replace the cast with the upstream type. -- [x] 5.2 Centralize the feature-detection in a one-liner helper `hasDispatchCommand(pi): boolean` in `bridge-context.ts`. Used by both call sites in tasks 3.1 and 3.2 to avoid duplicate `typeof === "function"` casts. Implementation: `return typeof (pi as any)?.dispatchCommand === "function"`. Test coverage in task 4.5. -- [x] 5.3 Audit `DASHBOARD_NATIVE_COMMANDS` (in `packages/extension/src/bridge-context.ts`) against the bridge-registered command set. Confirm `__dashboard_reload` is present. Confirm no other bridge-side `pi.registerCommand(...)` call sites exist that need entries (search: `pi.registerCommand(`). If any are missing, add them. Document the resulting set in a one-line comment above its declaration. - -## 6. Documentation + AGENTS.md - -- [x] 6.1 Update `AGENTS.md` "Key Files" entries for `command-handler.ts`, `bridge.ts`, and `bridge-context.ts` with one-line summaries of the new behavior (extension-command stopgap + feature-detected dispatch). Cite this change name (`fix-extension-slash-commands-in-dashboard`) so future readers find the design doc. -- [x] 6.2 Add a CHANGELOG entry under `## [Unreleased]` noting: - - Extension slash commands (e.g. `/ctx-stats`, `/curator`, `/agents`) now visibly fail with a `command_feedback` error in the dashboard chat instead of silently sending to the LLM - - Full dispatch will activate automatically once pi 0.71+ ships `pi.dispatchCommand` - - Reference the upstream PR (file in step 8) when its URL is known - -## 7. Manual verification - -- [x] 7.1 Run `npm run build && curl -X POST http://localhost:8000/api/restart && npm run reload`. In a fresh dashboard session, type `/ctx-stats` (context-mode is already installed in this dev env). Confirm: **VERIFIED via user screenshot 2026-05-09** — chat showed `/ctx-stats in progress` (blue) followed by `/ctx-stats failed — Extension slash commands cannot be dispatched from the dashboard yet — requires pi 0.71+ (`pi.dispatchCommand`). Invoke from the pi TUI, or use the extension's tools directly.` (red). After client reducer dedup fix, this renders as a single row that transitions in place. No LLM activity in the session timeline (stopgap path emits `command_feedback {error}` and does NOT call `sendUserMessage`). - - On pi 0.70: chat shows the started+error `command_feedback`, the LLM is NOT prompted - - On pi 0.71+ (when available): chat shows started+completed and `ctx.ui.notify` renders the stats card -- [x] 7.2 Repeat for `/curator` (pi-web-access), `/agents` (pi-subagents) — same expected outcomes. **VERIFIED by code-path equivalence with `/ctx-stats`** (`packages/extension/src/slash-dispatch.ts::tryDispatchExtensionCommand`). All extension slash commands route through the same predicate (`isExtensionSlashCommand`) and the same dispatch arm. If `/ctx-stats` works, `/curator` and `/agents` work identically. Direct hands-on confirmation deferred to user smoke-testing (no behavioral risk; pure path equivalence). -- [x] 7.3 Repeat for `/skill:openspec-explore` to verify skill-expansion path is unaffected (still routes through template-expansion → `sendUserMessage`). **VERIFIED by code-path inspection**: skill commands have `source: "skill"` in `pi.getCommands()`; `isExtensionSlashCommand` rejects them (proven by `extension-slash-command-detection.test.ts` scenario "Rejects skill command"); helper returns `false`; caller falls through to `expandPromptTemplateFromDisk` + `sendUserMessage` at `bridge.ts:729`. Path is unchanged from pre-fix behavior. -- [x] 7.4 Repeat for `/totally-unknown-command` to verify unknown slashes still passthrough as today. **VERIFIED by code-path inspection** + test (`bridge-slash-command-routing.test.ts` scenario "unrecognized slash → no dispatch, sendUserMessage called once, no command_feedback"). When token isn't in `pi.getCommands()`, predicate returns `false`, helper returns `false`, fallback path fires. -- [x] 7.4a Verify the `command_feedback { status: "error", message }` row renders the `message` string in the dashboard chat (not just the status). **VERIFIED via user screenshot 2026-05-09**: the screenshot shows the full message rendered (`Extension slash commands cannot be dispatched ... requires pi 0.71+ (`pi.dispatchCommand`). Invoke from the pi TUI, or use the extension's tools directly.`). Client component `packages/client/src/components/CommandFeedbackCard.tsx:42-44` explicitly renders `message` when `status === "error"`: `{message && status === "error" && (— {message})}`. Reducer dedup fix (`event-reducer.ts`) ensures a single chat row that transitions started→failed in place. No follow-up client task needed. -- [x] 7.5 Confirm `/flows`, `/flows:new`, `/flows:edit`, `/flows:delete` still work. **VERIFIED by code-path inspection**: `bridge.ts:707-714` checks user-defined flows via `getFlowsList()` and emits `flow:run` BEFORE invoking `tryDispatchExtensionCommand` (line 717). Note: typed `/flows:new` from pi-flows extension is NOT a user-defined flow (those come from user-saved flow definitions), so it falls through to extension-dispatch (step 9). On pi 0.74 (no `dispatchCommand`), it currently hits the stopgap; with this change shipped, the user sees the same clear error message as `/ctx-stats` rather than silent LLM hallucination. The kebab-button flow management UI continues to work via `flow_management` WS message handler (independent path). - -## 8. Upstream follow-up (separate change, NOT blocking this one) — deferred - -The upstream PR work is genuinely external to this repo and depends on the pi-coding-agent maintainer's review/release cycle. These tasks are **deferred to a follow-up change** rather than blocking this one's archive. Tracking in proposal `add-rpc-stdin-dispatch-with-keeper-sidecar` task §12 (which captures the same upstream PR work alongside the keeper alternative). - -- [~] 8.1 ~~File PR against pi-coding-agent~~ — **DEFERRED.** Pi 0.71 → 0.72 → 0.73 → 0.74 all shipped without `dispatchCommand`. Tracked as `add-rpc-stdin-dispatch-with-keeper-sidecar` task §12.1; the keeper sidecar change implements the workaround that doesn't depend on upstream landing. Filing the upstream PR remains valuable (would let us deprecate the keeper later) but isn't gating this change. -- [~] 8.1a ~~Pin argument-shape contract once upstream merges~~ — **DEFERRED** to whenever upstream lands. -- [~] 8.2 ~~Open follow-up dashboard change after pi 0.71 releases~~ — **DEFERRED.** Tracked as `add-rpc-stdin-dispatch-with-keeper-sidecar` task §12.2 (the eventual `retire-rpc-keeper-when-dispatchCommand-available` follow-up). diff --git a/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/proposal.md b/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/proposal.md deleted file mode 100644 index 29201686e..000000000 --- a/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/proposal.md +++ /dev/null @@ -1,217 +0,0 @@ -## Why - -Every release cut via `.github/workflows/publish.yml`'s `prepare` job -ships with a **stale `package-lock.json`** because the workflow bumps -every workspace's `package.json` version + cross-ref specifiers but -never regenerates the lockfile. This breaks consumers (and our own CI) -because of a strict-prerelease semver subtlety in npm. - -### The failure mechanism - -``` - 1. prepare job: - ├── npm version -ws --include-workspace-root - │ → bumps every package.json's "version" field - │ - ├── scripts/sync-versions.js - │ → rewrites every cross-ref dep specifier from - │ "^" to "^" - │ - ├── (CHANGELOG promotion) - ├── git add -A - ├── git commit -m "chore(release): vX.Y.Z" - ├── git tag vX.Y.Z - └── git push ← stale package-lock.json goes up - - 2. The tagged commit's package-lock.json still records: - packages//dependencies = "^" - - 3. Subsequent `npm ci` (in CI or by a consumer): - The strict prerelease semver rule says "^0.0.0-test.1" - does NOT match a workspace at "0.0.0-test.2". - → npm falls back to the registry → fetches the older - published tarball → installs it nested at - packages//node_modules/...// - - 4. The nested install masks the workspace symlink, so - TypeScript / Vite / runtime resolves through the stale - tarball — missing any types/exports added since the - previous release. -``` - -### Observed symptom - -CI run on `2f1d5ff` (after `v0.0.0-test-darwin-x64.2` cut) failed -the `lint` step with: - -``` -packages/extension/src/bridge.ts: error TS2339: - Property 'askUserPromptTimeoutSeconds' does not exist on type 'DashboardConfig'. - -packages/extension/src/vcs-info.ts: error TS2305: - Module '"@blackbelt-technology/pi-dashboard-shared/types.js"' - has no exported member 'JjState'. -``` - -Both symbols exist in the workspace source (`packages/shared/src/`). -TypeScript was reading from `packages/extension/node_modules/@blackbelt-technology/pi-dashboard-shared@0.4.5/src/types.ts` -— the previous published version — because the lockfile's stale -`^0.0.0-test-darwin-x64.1` specifier didn't match the workspace's -new `0.0.0-test-darwin-x64.2` version, so npm fell back to registry. - -### The latent risk - -This bug surfaces on every release after a feature lands in `shared/` -or any cross-package type. It will recur indefinitely until either: - -1. The workflow regenerates the lockfile in lockstep with version - bumps (this proposal), OR -2. Every contributor manually runs `npm install` after every release - tag and commits the lockfile (operationally fragile), OR -3. We migrate to `pnpm` / `yarn` `workspace:` protocol (much bigger - change; pnpm support discussed but not in scope here). - -The bug also exists in non-release scenarios — `scripts/sync-versions.js` -itself prints a hint: `"Remember to rm -rf node_modules package-lock.json -&& npm install to refresh the lockfile"` — but the hint isn't actionable -inside CI and was missed during the v0.0.0-test-darwin-x64.2 cut. - -## What Changes - -### 1. Add lockfile regeneration to the `prepare` job - -In `.github/workflows/publish.yml`'s `prepare` job, insert one step -between `node scripts/sync-versions.js` and the CHANGELOG promotion: - -```yaml -- name: Regenerate package-lock.json with bumped versions - run: | - # The workspace symlink graph changed (every package.json's - # version + cross-ref specifiers were bumped). The lockfile - # must be regenerated so its recorded specifiers match, - # otherwise strict prerelease semver causes npm ci to fall - # back to the registry on every consumer install. - # See change: fix-release-lockfile-drift. - npm install --package-lock-only --no-audit --no-fund -``` - -`--package-lock-only` is intentional: it updates the lockfile -without touching `node_modules/`. The actual `npm install` for the -build comes later in the publish job. This keeps the prepare step -fast (~5 seconds) while guaranteeing the committed tag has a -lockfile in lockstep with the version bumps. - -### 2. Sanity assertion right after regeneration - -```yaml -- name: Verify lockfile matches workspace versions - run: | - # Pure node script (no jq dependency) — fail the job if any - # workspace's recorded dep specifier still references the - # OLD version. Prevents silent drift if step #1 misbehaves. - node scripts/verify-lockfile-versions.mjs -``` - -A new `scripts/verify-lockfile-versions.mjs` reads `package-lock.json`, -walks `packages..dependencies` for every cross-ref entry -matching `@blackbelt-technology/pi-dashboard-*`, and asserts each -recorded specifier is `^`. Exits non-zero with a -file:specifier:expected report if any mismatch. - -### 3. Update `scripts/sync-versions.js` documentation - -Replace the trailing console hint: - -```js -// Before: -console.log(" Remember to `rm -rf node_modules package-lock.json && npm install` to refresh the lockfile."); - -// After: -console.log(" Note: package-lock.json regeneration runs automatically"); -console.log(" in CI (publish.yml > prepare > 'Regenerate package-lock.json')."); -console.log(" For LOCAL bumps, run: npm install --package-lock-only"); -``` - -### 4. Repo-level lint asserting the workflow contract - -Add a small test in `packages/shared/src/__tests__/publish-workflow-contract.test.ts` -(extending the existing file) that parses `.github/workflows/publish.yml` -and asserts the `prepare` job contains: - -- A step running `npm install --package-lock-only` (or equivalent). -- The step is positioned AFTER the `sync-versions.js` invocation and - BEFORE the `git commit` step. - -Failure message cites this change name so a future contributor who -removes the step learns where the rule comes from. - -### 5. Out of scope - -- **Migrating to pnpm or yarn workspaces** — orthogonal, much larger - change, considered separately. -- **Changing the cross-ref pin style** (e.g., to exact versions or - `workspace:*`) — would require either tooling change or release- - process redesign. -- **Backfilling historical releases** — published tarballs at - `0.4.5`, `0.0.0-test-darwin-x64.1`, etc. stay as-is; this only - affects future releases. -- **Fixing the same drift in `ci.yml` / non-release branches** — the - `prepare` job is the single source of truth; CI on develop runs - against whatever was last committed. If the lockfile is in sync - on every release commit, develop also stays in sync because the - release commit is the only place version refs change. - -## Impact - -- **Affected files:** - - `.github/workflows/publish.yml` — two new steps in `prepare` job - - `scripts/sync-versions.js` — updated console hint - - `scripts/verify-lockfile-versions.mjs` — new file, ~40 LOC - - `packages/shared/src/__tests__/publish-workflow-contract.test.ts` — extended -- **Affected users:** none directly. Internal release-pipeline only. -- **CI cost:** +5 s per release (one `npm install --package-lock-only`). -- **Risk:** low — the fix is additive, gated to the prepare job, and - has a sanity-assert step right after to catch any misbehavior. - -## Risks - -### Risk: `npm install --package-lock-only` writes unexpected diffs - -If a transitive dep's version range allows a newer subdep to be -selected, regenerating the lockfile picks the newest. This could -include unrelated transitive bumps in the release commit. Mitigation: -the existing `prepare` job already runs `npm version` + sync-versions -inside a single commit; folding lockfile regen into the same commit -keeps the diff coherent. The change isn't introducing new -unpredictability — it's just making explicit what's already implicit -in any local `npm install`. - -### Risk: lockfile regen surfaces a transitive conflict - -Possible if a registry-published version of a workspace dep has been -yanked / renamed. Mitigation: the `verify-lockfile-versions.mjs` step -runs immediately after and fails the job if any cross-ref isn't at -the expected spec. Investigation can happen pre-tag rather than -post-publish. - -### Risk: developers forget the local equivalent - -A maintainer running `npm version` locally without regenerating the -lockfile will hit the same problem. The updated `sync-versions.js` -hint surfaces the right command (`npm install --package-lock-only`), -and the `release-cut` skill should be updated to call it (out of -scope for this proposal but tracked as a follow-up note in the -skill's tasks). - -## Open questions - -1. **Should the workflow also re-run `npm ci` after the regen** to - verify the lockfile is internally consistent? Probably no — the - `publish` and `electron` jobs already run `npm ci` on the tagged - commit, and a broken lockfile would fail there. Adding it to - `prepare` is duplicative. -2. **Should we backport the fix to a hotfix release** (e.g., v0.4.6) - rather than waiting for the next planned release? The current - release pipeline keeps producing broken tarballs every time a - maintainer cuts a test release, which is the trigger for this - bug. Recommend: ship in next release. diff --git a/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/specs/ci-cd-pipeline/spec.md b/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/specs/ci-cd-pipeline/spec.md deleted file mode 100644 index 8c63ce10f..000000000 --- a/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/specs/ci-cd-pipeline/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## ADDED Requirements - -### Requirement: Release lockfile MUST mirror workspace versions -The release-pipeline `prepare` job in `.github/workflows/publish.yml` SHALL regenerate `package-lock.json` immediately after bumping workspace versions and rewriting cross-ref specifiers, so that the tagged commit contains a lockfile in which every cross-ref specifier matches `^` exactly. Without this, strict prerelease semver causes `npm ci` on consumers (and the publish job's own CI) to fall back to registry-published tarballs of workspace dependencies, masking the in-tree workspace via nested installs. - -#### Scenario: prepare job runs lockfile regen between sync-versions and commit -- **WHEN** the `prepare` job in `publish.yml` runs the `Bump versions and update CHANGELOG` step (or successor) -- **THEN** the job SHALL execute `npm install --package-lock-only --no-audit --no-fund` AFTER `node scripts/sync-versions.js` and BEFORE the `git commit -m "chore(release): ..."` step -- **AND** the regenerated `package-lock.json` SHALL be staged by the existing `git add -A` step and included in the release commit - -#### Scenario: prepare job verifies lockfile after regen -- **WHEN** the prepare job has regenerated the lockfile -- **THEN** the job SHALL execute `node scripts/verify-lockfile-versions.mjs` BEFORE the commit step -- **AND** the script SHALL exit non-zero with a file:specifier:expected report if any cross-ref dep specifier in `package-lock.json` does not equal `^` - -#### Scenario: Repo-lint enforces the step ordering -- **WHEN** the test `publish-workflow-contract.test.ts` runs as part of `npm test` -- **THEN** it SHALL parse `.github/workflows/publish.yml` and assert the `prepare` job's step list contains the lockfile-regen step in the position `sync-versions < regen < git commit` -- **AND** failure SHALL cite change `fix-release-lockfile-drift` in the assertion message - -#### Scenario: Local release-cut path documents the lockfile step -- **WHEN** a maintainer cuts a release manually (not via `workflow_dispatch`) -- **THEN** the `release-cut` skill in `.pi/skills/release-cut/SKILL.md` SHALL document running `npm install --package-lock-only` between `sync-versions.js` and the commit step -- **AND** `scripts/sync-versions.js` SHALL print a console hint pointing the maintainer at the right command diff --git a/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/tasks.md b/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/tasks.md deleted file mode 100644 index 530598fc9..000000000 --- a/openspec/changes/archive/2026-05-09-fix-release-lockfile-drift/tasks.md +++ /dev/null @@ -1,141 +0,0 @@ -# Tasks - -## 1. Add lockfile regen step to publish.yml - -- [x] In `.github/workflows/publish.yml`, locate the `prepare` job's - version-bump block (after `node scripts/sync-versions.js`, - before the CHANGELOG promotion). -- [x] Insert a new step: - - ```yaml - - name: Regenerate package-lock.json with bumped versions - run: | - # Lockfile must mirror the workspace version + cross-ref - # specifier bumps that just happened above. Without this, - # strict prerelease semver causes npm ci on consumers to - # fall back to the registry. See change: fix-release- - # lockfile-drift. - npm install --package-lock-only --no-audit --no-fund - ``` - -- [x] Confirm the existing `git add -A && git commit` step picks up - the regenerated `package-lock.json` (it runs `git add -A`, so - yes — but verify visually). - -## 2. Add lockfile sanity assertion - -- [x] Create `scripts/verify-lockfile-versions.mjs`: - - ```js - #!/usr/bin/env node - // Walks package-lock.json and asserts every recorded - // cross-ref dep specifier on a @blackbelt-technology/* - // workspace is "^". Exits non-zero - // with a file:specifier:expected report on mismatch. - // See change: fix-release-lockfile-drift. - - import { readFileSync } from "node:fs"; - const root = JSON.parse(readFileSync("package.json", "utf8")); - const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); - const expected = `^${root.version}`; - const failures = []; - for (const [k, v] of Object.entries(lock.packages)) { - if (!k.startsWith("packages/")) continue; - const deps = { ...(v.dependencies || {}), ...(v.devDependencies || {}) }; - for (const [name, spec] of Object.entries(deps)) { - if (!name.startsWith("@blackbelt-technology/")) continue; - if (spec !== expected) { - failures.push(` ${k} → ${name}: ${spec} (expected ${expected})`); - } - } - } - if (failures.length) { - console.error("::error::Lockfile cross-ref drift detected. See change: fix-release-lockfile-drift."); - for (const line of failures) console.error(line); - process.exit(1); - } - console.log(`✓ All cross-ref specifiers match ${expected}`); - ``` - -- [x] In `.github/workflows/publish.yml`, add a step right after - step 1's regen: - - ```yaml - - name: Verify lockfile matches workspace versions - run: node scripts/verify-lockfile-versions.mjs - ``` - -- [x] Test locally: cd into a clean clone, run `npm version 0.5.0 - --workspaces --include-workspace-root --allow-same-version` - then `node scripts/sync-versions.js` then `npm install - --package-lock-only` then the verify script. Confirm it - passes. - -## 3. Update `scripts/sync-versions.js` console hint - -- [x] Replace the trailing console hint: - - ```js - // Before - console.log(" Remember to `rm -rf node_modules package-lock.json && npm install` to refresh the lockfile."); - - // After - console.log(" Note: package-lock.json regeneration runs automatically"); - console.log(" in CI (publish.yml > prepare > 'Regenerate package-lock.json')."); - console.log(" For LOCAL bumps, run: npm install --package-lock-only"); - ``` - -## 4. Extend repo-level workflow lint - -- [x] In `packages/shared/src/__tests__/publish-workflow-contract.test.ts`, - add a new assertion: - - ```ts - test("prepare job regenerates lockfile after version bump (fix-release-lockfile-drift)", () => { - const wf = parseWorkflow(".github/workflows/publish.yml"); - const prepareSteps = wf.jobs.prepare.steps; - const syncIdx = prepareSteps.findIndex(s => /sync-versions\.js/.test(s.run || "")); - const regenIdx = prepareSteps.findIndex(s => - /npm install --package-lock-only/.test(s.run || "")); - const commitIdx = prepareSteps.findIndex(s => - /git commit -m "chore\(release\)/.test(s.run || "")); - expect(syncIdx, "sync-versions.js step missing").toBeGreaterThanOrEqual(0); - expect(regenIdx, "lockfile regen step missing — see change fix-release-lockfile-drift") - .toBeGreaterThan(syncIdx); - expect(commitIdx, "git commit step missing").toBeGreaterThan(regenIdx); - }); - ``` - -- [x] Run `npm test` and confirm the new assertion passes. - -## 5. Update release-cut skill - -- [x] In `.pi/skills/release-cut/SKILL.md`, add a sentence to the - pre-flight notes section: *"If you're cutting a release - LOCALLY (not via workflow_dispatch), run `npm install - --package-lock-only` after `node scripts/sync-versions.js` - and before the commit. The CI prepare job does this - automatically."* - -## 6. Documentation - -- [x] Update `AGENTS.md` `.github/workflows/publish.yml` row to - mention the new lockfile-regen step inline (alongside the - existing notes about sync-versions.js). -- [x] Add a `scripts/verify-lockfile-versions.mjs` row to AGENTS.md - after the existing `scripts/sync-versions.js` row. - -## 7. Verification - -- [x] After landing, the next test release tag (e.g. - `v0.0.0-test-lockfile.1`) SHALL produce a tagged commit whose - `package-lock.json` records every cross-ref specifier as - `^0.0.0-test-lockfile.1` — verifiable post-tag with: - - ```bash - git show :package-lock.json | jq -r '.packages | to_entries[] | select(.key | startswith("packages/")) | .value.dependencies // {} | to_entries[] | select(.key | startswith("@blackbelt-technology/")) | "\(.key)=\(.value)"' | sort -u - ``` - -- [x] CI on the tag SHALL not fail with TS2305/TS2339 errors caused - by stale-tarball resolution. (Other unrelated tsc errors - remain out of scope.) diff --git a/openspec/changes/archive/2026-05-09-register-build-time-tools/.openspec.yaml b/openspec/changes/archive/2026-05-09-register-build-time-tools/.openspec.yaml deleted file mode 100644 index 1b4051e95..000000000 --- a/openspec/changes/archive/2026-05-09-register-build-time-tools/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-27 diff --git a/openspec/changes/archive/2026-05-09-register-build-time-tools/design.md b/openspec/changes/archive/2026-05-09-register-build-time-tools/design.md deleted file mode 100644 index 6f419fe4f..000000000 --- a/openspec/changes/archive/2026-05-09-register-build-time-tools/design.md +++ /dev/null @@ -1,198 +0,0 @@ -## Context - -The dashboard already centralizes runtime tool resolution behind `ToolRegistry` (introduced by `archive/2026-04-19-consolidate-tool-resolution`). The registry resolves binaries and modules through an ordered strategy chain (`override` → `bare-import` → `managed` → `npm-global` → `where`), caches resolutions, exposes diagnostics, supports user overrides via `~/.pi/dashboard/tool-overrides.json`, and is unit-tested through the bootstrap-resolution-harness (`packages/shared/src/__tests__/bootstrap/`). - -Despite this, three build-time call sites still hardcode `node_modules/` paths: - -``` -.github/workflows/publish.yml:90-93 - → cd packages/electron/node_modules/electron && node install.js - → patched by 61b3c6e to use inline `node -e require.resolve(...)` - → patched IN PLACE; not registered in the registry - -packages/electron/scripts/Dockerfile.build:33 - → cd packages/electron/node_modules/electron && node install.js - → STILL BROKEN (will fail next Docker cross-build) - -scripts/fix-pty-permissions.cjs:12 - → path.join(__dirname, "..", "node_modules", "node-pty", "prebuilds") - → STILL BROKEN (silently fails on every fresh root install) - → Sister script at packages/server/scripts/fix-pty-permissions.cjs - already does this correctly with require.resolve -``` - -The hoisting layout changed in `f51e352` (workspace publishing fix) when `workspace:*` cross-refs were replaced with plain semver, allowing npm to use its default workspace hoisting. Electron and node-pty now hoist to the root `node_modules/` rather than nesting under their workspace. - -The proposal is a direct follow-up that: -1. Registers `electron` and `node-pty` in the existing registry. -2. Adds a shell-callable resolver wrapper so build-time scripts (YAML/Dockerfile) can use the registry without bundling code. -3. Migrates the three call sites. -4. Locks the fix in with a lint test that bans `node_modules/electron` and `node_modules/node-pty` substrings outside an explicit allowlist. - -## Goals / Non-Goals - -**Goals:** - -- All three known hardcoded path sites resolve through `ToolRegistry`. -- Build-time scripts (workflows, Dockerfiles) can resolve registry tools without depending on the shared package's `dist/` build (i.e., the wrapper must be CommonJS and require no transpilation). -- `node-pty` resolution must work during root `npm install`'s postinstall phase, before any workspace package is built or installed. -- Reintroduction of hardcoded `node_modules/` paths is caught at test time, not at release time. -- The bootstrap-resolution-harness covers `electron` and `node-pty` under hoisted, nested, and missing layouts. - -**Non-Goals:** - -- Refactoring `61b3c6e`'s inline form is in-scope (publish.yml gets migrated to the wrapper) but reverting it as a "wrong fix" is not — it solved the immediate v0.4.0 release crisis correctly. -- Syncing the archived `tool-registry` capability into `openspec/specs/tool-registry/spec.md` is out of scope; it is a separate housekeeping concern. The spec delta in this change targets the capability by name (`tool-registry`) regardless of whether the main spec file exists yet. -- Generalizing the lint to ALL `node_modules/` substrings is out of scope; the rule is scoped to `electron` and `node-pty` for now and can be widened in a follow-up if needed. -- Replacing the v0.2.7 `--ignore-scripts` workaround for `phantomjs-prebuilt` is out of scope; we keep that strategy and only fix the path resolution that follows it. - -## Decisions - -### Decision 1: Two new tool definitions, both `kind: "module"` - -Both `electron` and `node-pty` are npm modules whose useful artifacts live at deterministic paths inside the package directory (`electron/install.js`, `node-pty/prebuilds/`). The natural registry primitive is `resolveModule(name)`, which returns a `Resolution` whose `path` points at the package directory; consumers append the relative artifact path themselves. - -**Alternative considered:** Add a third `kind: "directory"` for "give me the package's containing dir" and special-case it. Rejected — `resolveModule` already returns a directory path (it's `path.dirname(require.resolve(name + "/package.json"))` semantics). No new primitive is needed. - -**Strategy chains:** - -- `electron`: - - `override` (per-tool override file) - - `bare-import` (`require.resolve("electron/package.json", { paths: ["packages/electron"] })`) — handles both hoisted root and nested workspace layouts via Node's standard module resolution. - - `managed` (`/node_modules/electron/package.json`) — fallback for managed-install scenarios. -- `node-pty`: - - `override` - - `bare-import` (`require.resolve("node-pty/package.json")`) — postinstall-friendly. No `paths` option needed; node-pty is a direct dependency of `packages/server`, so it always hoists. - -The `npm-global` strategy is intentionally NOT included for either. Build-time consumers are operating inside the repo checkout; a globally-installed electron or node-pty would be the wrong artifact (different version, different prebuilds). - -### Decision 2: Shell-callable wrapper at `packages/shared/bin/pi-dashboard-resolve-tool.cjs` - -Build-time consumers (`publish.yml`, `Dockerfile.build`) cannot import the shared package's TypeScript directly. We need a CommonJS entry point that can be invoked as `node packages/shared/bin/pi-dashboard-resolve-tool.cjs ` and prints the resolved path to stdout. - -**Why CJS, not ESM:** When the wrapper is invoked from a build step running before any TypeScript build, `dist/` does not exist; the wrapper must rely on the source-of-truth `registry.ts` either by `tsx`/`jiti` compilation OR by re-implementing the resolution inline. We choose **inline reimplementation of the strategy chain semantics** (~30 lines) — the wrapper hand-rolls the `bare-import` strategy with `createRequire(__filename).resolve(...)` and the `override` strategy by reading `~/.pi/dashboard/tool-overrides.json`. This is the same pattern already used by `61b3c6e`'s inline `node -e`, but lifted into a versioned, testable script. - -**Alternative considered:** Make the CLI delegate to the actual `getDefaultRegistry()` via `tsx --import` or build the shared package before invoking. Rejected — adds a build dependency to a fix that should be self-contained, and `tsx` itself is one of the registered tools (chicken-and-egg during bootstrap). - -**Alternative considered:** Inline `node -e "require.resolve(...)"` in each call site (Bence's pattern, applied uniformly). Rejected for build-time YAML/Dockerfile consumers because (a) the same logic ends up in 3 places, (b) the override file is not consulted, (c) the lint test cannot distinguish "inline correctness" from "inline regression". - -**Schema of the wrapper's behavior** (matches the registry's contract): - -``` -$ node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron -/abs/path/to/node_modules/electron -$ echo $? -0 - -$ node packages/shared/bin/pi-dashboard-resolve-tool.cjs nonexistent -Error: tool 'nonexistent' is not registered -$ echo $? -1 -``` - -The wrapper supports `--json` to print a `Resolution` object including the `tried` trail for diagnostics: - -``` -$ node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron --json -{"name":"electron","ok":true,"path":"/abs/...","source":"bare-import","tried":[...]} -``` - -### Decision 3: `fix-pty-permissions.cjs` stays inline (does not use the wrapper) - -The root `postinstall` script runs DURING `npm install`, before any workspace package is published in the local `node_modules/.bin/`. Calling `node packages/shared/bin/pi-dashboard-resolve-tool.cjs node-pty` may work in practice (workspace symlinks are typically created before lifecycle scripts), but is fragile. Instead, `scripts/fix-pty-permissions.cjs` reimplements the `bare-import` strategy inline: - -```js -let prebuildsDir; -try { - const ptyPkg = require.resolve("node-pty/package.json"); - prebuildsDir = path.join(path.dirname(ptyPkg), "prebuilds"); -} catch { - process.exit(0); // soft no-op -} -``` - -This is **the same logic the registry's `bare-import` strategy executes** for `node-pty`. The lint test treats this single inline copy as allowlisted because it is intentionally the bootstrap-friendly twin of the registry strategy — both implementations must stay in sync. - -**Alternative considered:** Delete `scripts/fix-pty-permissions.cjs` and have the root postinstall delegate to `packages/server/scripts/fix-pty-permissions.cjs`. Rejected — workspaces' postinstall hooks fire on the workspace directory, not the root; the root's postinstall does not implicitly run nested workspace postinstalls in all npm versions, so the root copy is needed. - -### Decision 4: Lint enforcement via vitest, not eslint or shellcheck - -The repo has three existing repo-level lint vitest tests: -- `packages/shared/src/__tests__/no-direct-process-kill.test.ts` -- `packages/shared/src/__tests__/no-raw-node-import.test.ts` -- `packages/extension/src/__tests__/no-session-replacement-calls.test.ts` -- `packages/shared/src/__tests__/no-direct-child-process.test.ts` - -Each scans a scoped subset of the codebase via `fs.readFileSync` + regex and fails with `file:line` citations. The new test follows the same pattern: - -```ts -// packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts -const PATTERN = /node_modules\/(electron|node-pty)/; -const SCOPE = [ - "packages/electron/scripts/Dockerfile.build", - "packages/electron/scripts/*.sh", - ".github/workflows/*.yml", - "scripts/*.cjs", "scripts/*.sh", -]; -const ALLOWLIST = [ - "scripts/fix-pty-permissions.cjs", // intentionally inline (Decision 3) - // Intentional matches in comments are stripped before the regex - // by line-prefixing logic. -]; -``` - -The test reads each scoped file, strips comments, and fails if the pattern matches outside the allowlist. The output cites `file:line:col` matching the existing tests' format. - -**Alternative considered:** ESLint custom rule. Rejected — ESLint does not parse YAML or Dockerfiles; would need a separate plugin chain. - -**Alternative considered:** A bash-based grep step in CI. Rejected — does not run locally as part of `npm test`, so contributors discover it only after pushing. - -### Decision 5: Bootstrap-harness families, not bespoke vitest specs - -The repo already has the `bootstrap-resolution-harness` (`packages/shared/src/__tests__/bootstrap/`) with memfs-backed fixtures and a 1080-cell scenario cube. New tools register as additional families: - -- `families/electron-resolution.test.ts` — cells: hoisted-root, nested-workspace, missing, overridden. -- `families/node-pty-resolution.test.ts` — cells: present-as-server-dep, missing-from-current-workspace, overridden. - -This avoids duplicating fixture machinery and gives both new tools the same cross-platform × source × layout coverage that pi/openspec/etc. already enjoy. - -## Risks / Trade-offs - -- **[Risk] Wrapper's inline reimplementation drifts from registry's TypeScript implementation.** - → Mitigation: the wrapper is ~30 lines; the bootstrap-harness families exercise it through the same scenarios they exercise the TS registry through (vitest can shell out to `node packages/shared/bin/pi-dashboard-resolve-tool.cjs` and assert the output matches the equivalent `getDefaultRegistry().resolveModule(name)` result). The lint test additionally requires the wrapper file to declare the strategy chain order in a comment that is grep-checked against `definitions.ts`. - -- **[Risk] `paths: ["packages/electron"]` hint to `require.resolve` is path-relative — breaks if invoked from a different cwd.** - → Mitigation: the wrapper resolves `paths` against the repo root using `findUp("package.json")` semantics. Build-time consumers always invoke from repo root in practice (publish.yml `working-directory` defaults to `${{ github.workspace }}`; Dockerfile `WORKDIR /build`), but the wrapper does not assume this. - -- **[Risk] Adding bootstrap-harness families re-runs the full 1080-cell cube and inflates test time.** - → Mitigation: families register themselves via the existing `scenarios.ts` registration pattern; cells are added incrementally, not multiplicatively. Verified by reading the harness before committing. - -- **[Trade-off] We keep two parallel implementations of the `bare-import` strategy: the canonical TS one in `strategies.ts`, and the inline CJS one in `scripts/fix-pty-permissions.cjs`.** - → Accepted because the postinstall context cannot consume the TS one. The bootstrap harness covers both with the same cell expectations, so drift surfaces in tests. - -- **[Trade-off] The lint test creates one more place to update when adding new tools to the registry.** - → Accepted; the cost is a single-line addition to either `PATTERN` or `ALLOWLIST` per tool, and the alternative (no lint) has already cost the project two undetected hardcoded-path bugs. - -## Migration Plan - -1. Land the registry definitions and wrapper script first (no consumer changes). Verify via existing harness that `electron` and `node-pty` resolve correctly in all layouts. -2. Migrate `publish.yml` line 92 to use the wrapper. Trigger a no-op tag push to a scratch tag (e.g., `v0.4.0-rc-build-time-tools`) to verify the linux/arm64 cell rebuilds successfully without the inline `node -e`. -3. Migrate `Dockerfile.build:33`. Run `bash packages/electron/scripts/build-installer.sh --linux` locally to verify the Docker cross-build succeeds. -4. Migrate `scripts/fix-pty-permissions.cjs`. Run `rm -rf node_modules && npm ci` and verify `find node_modules/node-pty/prebuilds -name spawn-helper -executable` returns hits. -5. Land the lint test last so it cannot block the migration steps. After landing, any reintroduction of a hardcoded path will fail `npm test` immediately. - -**Rollback:** Each step is independently revertible. The wrapper script + registry definitions can ship without consumer migration; the lint test only activates after at least one consumer is migrated. If the wrapper fails on a specific runner, individual consumers can fall back to inline `node -e require.resolve(...)` (matching `61b3c6e`) without affecting the others. - -## Open Questions - -- **Q1: Should `tsx` be added to `electron`'s strategy chain so the wrapper itself can be invoked via `node --import tsx packages/shared/bin/pi-dashboard-resolve-tool.cjs` and consume the canonical TS registry?** - Answer: No. `tsx` is itself a registered tool, and the wrapper must function before tsx is resolvable in some bootstrap scenarios. Inline reimplementation is the right call. - -- **Q2: Should the wrapper expose other registered tools (`pi`, `openspec`, etc.) too, or just the build-time ones?** - Answer: Yes — once the wrapper exists, it is trivial to extend to all registered tools. Build-time scripts that need any registered tool can use the same entry point. Out of scope for this proposal but a clean follow-up. - -- **Q3: Should we also fix `packages/electron/scripts/test-electron-install.sh:90-92` (hardcoded `linux-x64` in node-pty prebuild copy step) as part of this change?** - Answer: No — different class of issue (parameterization, not hoisting). Will be a separate proposal if anyone hits it. The lint pattern is scoped to `node_modules/` paths, not platform/arch hardcodes. - -- **Q4: Does the archived `tool-registry` capability spec need to be synced into `openspec/specs/tool-registry/spec.md` first?** - Answer: Not required for this change. OpenSpec deltas reference capabilities by name; the spec delta in this proposal will be applied to whatever main spec exists at archive time. If the main spec is still missing then, archival will surface that as a separate gap. Recommended follow-up: a dedicated `sync-tool-registry-spec` housekeeping change. diff --git a/openspec/changes/archive/2026-05-09-register-build-time-tools/proposal.md b/openspec/changes/archive/2026-05-09-register-build-time-tools/proposal.md deleted file mode 100644 index 59add55f9..000000000 --- a/openspec/changes/archive/2026-05-09-register-build-time-tools/proposal.md +++ /dev/null @@ -1,47 +0,0 @@ -## Why - -Bence's `61b3c6e fix(ci): OIDC trusted publishing + dynamic electron path resolve` patched `.github/workflows/publish.yml` line 92 inline using a hand-rolled `node -e require.resolve(...)` after the workspace publishing refactor (`f51e352`) caused npm to hoist `electron` to the root `node_modules/`, breaking the v0.4.0 linux/arm64 release. The fix only patched **one** of three identical hardcoded `node_modules/` paths in the repo: `Dockerfile.build:33` (Docker cross-platform builds) and `scripts/fix-pty-permissions.cjs:12` (root postinstall) still assume the pre-hoist nested layout. The Docker bug will reproduce the v0.4.0 failure on the next cross-platform installer build; the postinstall bug already fails silently on every fresh root install, leaving `node-pty`'s `spawn-helper` without execute permission and producing `posix_spawnp failed` at terminal-spawn time. The repo already has `ToolRegistry` (introduced by `2026-04-19-consolidate-tool-resolution`) precisely to centralize this kind of resolution with hoist-aware strategies, override files, and a diagnostic trail — but `electron` and `node-pty` were never registered, so build-time consumers continue to hand-roll inline lookups. - -## What Changes - -- Register `electron` and `node-pty` as `kind: "module"` tools in `packages/shared/src/tool-registry/definitions.ts`, each with an ordered strategy chain (`override` → `bare-import` → `managed`) that resolves regardless of npm hoisting layout. -- Add a thin shell-callable CLI wrapper at `packages/shared/bin/pi-dashboard-resolve-tool.cjs` (CommonJS, no build step required) so build-time consumers can resolve tools via `node packages/shared/bin/pi-dashboard-resolve-tool.cjs ` without depending on the shared package's `dist/` being built first. -- Migrate three hardcoded-path consumers to the registry: - - `.github/workflows/publish.yml` line 92 (linux/arm64 electron rebuild step) — replace inline `node -e require.resolve(...)` with the new CLI wrapper. - - `packages/electron/scripts/Dockerfile.build` line 33 (Docker cross-platform electron rebuild step) — replace `cd packages/electron/node_modules/electron` with the CLI wrapper. - - `scripts/fix-pty-permissions.cjs` line 12 (root postinstall) — replace hardcoded `node_modules/node-pty/prebuilds` with `require.resolve("node-pty/package.json")` mirroring the `bare-import` strategy semantics. Stays CJS-inline (not via the CLI wrapper) because it must run during `npm install` before any workspace package is built. -- Extend the bootstrap-resolution-harness (`packages/shared/src/__tests__/bootstrap/`) with families covering: `electron` resolution under hoisted vs. nested vs. missing layouts; `node-pty` resolution under present vs. missing-from-workspace layouts. -- Add a repo-level lint vitest test `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts` (mirroring the existing `no-direct-process-kill.test.ts` / `no-raw-node-import.test.ts` / `no-direct-child-process.test.ts` pattern) that scans `.github/workflows/`, `packages/electron/scripts/`, and root `scripts/` for `node_modules/electron` and `node_modules/node-pty` substrings outside an explicit allowlist. - -## Capabilities - -### New Capabilities - -(none — this change extends an existing capability) - -### Modified Capabilities - -- `tool-registry`: Adds two new registered tool definitions (`electron`, `node-pty`) and a shell-callable resolver CLI surface. Specifies that build-time scripts (workflows, Dockerfiles, postinstall hooks) MUST use the registry rather than hardcoded `node_modules/` paths. Adds a lint enforcement requirement. - -## Impact - -- **Code (new files)**: - - `packages/shared/bin/pi-dashboard-resolve-tool.cjs` (~30 lines, CommonJS) — shell-callable CLI that exposes `resolveModule(name).path` over a single argv arg. - - `packages/shared/src/__tests__/bootstrap/families/electron-resolution.test.ts` (~80 lines). - - `packages/shared/src/__tests__/bootstrap/families/node-pty-resolution.test.ts` (~50 lines). - - `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts` (~50 lines lint). - -- **Code (modified files)**: - - `packages/shared/src/tool-registry/definitions.ts` — add `electron` and `node-pty` to the registration block. - - `.github/workflows/publish.yml` — replace inline `node -e require.resolve(...)` block (lines 90-93) with `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron`. - - `packages/electron/scripts/Dockerfile.build` — replace line 33 `cd packages/electron/node_modules/electron && node install.js` with a registry-resolved path. - - `scripts/fix-pty-permissions.cjs` — replace hardcoded `path.join(__dirname, "..", "node_modules", "node-pty", "prebuilds")` with `require.resolve("node-pty/package.json")` (mirroring the existing correct version at `packages/server/scripts/fix-pty-permissions.cjs`). - - `package.json` — declare the new `bin` entry under `@blackbelt-technology/pi-dashboard-shared` package's `bin` field if needed (alternative: invoke directly by path, no bin entry). - -- **Dependencies**: None added. Uses only `node:fs` / `node:path` / `node:module` (already used by the existing tool-registry). - -- **Platforms**: Fixes the linux/arm64 release path that broke v0.4.0; fixes the Docker cross-platform build path that would break the next cross-build attempt; fixes the silent postinstall on macOS/Linux fresh installs. No platform regression risk — Windows arm64 and other matrix cells already use lifecycle scripts and don't hit either hardcoded path. - -- **Risk**: Low. The registry is already production code; this change adds two definitions and refactors three callers. The lint test prevents reintroduction. The CLI wrapper is CJS so it works pre-build. The postinstall path keeps an inline `require.resolve` (not the CLI) because the shared package may not be installed yet during root `npm install` — mirrors the strategy semantics rather than the implementation. - -- **Supersedes / follow-ups**: Direct follow-up to `archive/2026-04-19-consolidate-tool-resolution` — completes the consolidation by registering the two build-time tools that were missed in the original migration. Companion to `61b3c6e` (Bence's inline patch) by replacing the inline form with the registered form and applying the fix to the two remaining hardcoded sites. Open question (deferred): whether the archived `tool-registry` capability should be synced into `openspec/specs/tool-registry/spec.md` as a separate housekeeping change — does not block this change since spec deltas can target capabilities by name. diff --git a/openspec/changes/archive/2026-05-09-register-build-time-tools/specs/tool-registry/spec.md b/openspec/changes/archive/2026-05-09-register-build-time-tools/specs/tool-registry/spec.md deleted file mode 100644 index 9c2b26cf6..000000000 --- a/openspec/changes/archive/2026-05-09-register-build-time-tools/specs/tool-registry/spec.md +++ /dev/null @@ -1,139 +0,0 @@ -## ADDED Requirements - -### Requirement: Build-time tool definitions - -The registry SHALL ship with definitions for `electron` (kind: `module`) and `node-pty` (kind: `module`) in addition to the existing tool set defined by `2026-04-19-consolidate-tool-resolution`. Each definition SHALL declare an ordered strategy chain that resolves the package directory regardless of npm hoisting layout (nested under a workspace's `node_modules` OR hoisted to the workspace root). - -#### Scenario: electron strategy chain - -- **WHEN** `registry.resolveModule("electron")` runs -- **THEN** strategies SHALL be tried in order: `override`, `bare-import`, `managed` -- **AND** the `bare-import` strategy SHALL invoke `require.resolve("electron/package.json", { paths: ["packages/electron"] })` -- **AND** on success, `Resolution.path` SHALL be the directory containing the resolved `package.json` (i.e., the directory containing `electron/install.js`) - -#### Scenario: electron resolves under hoisted layout - -- **WHEN** `electron/package.json` exists at `/node_modules/electron/package.json` -- **AND** `electron/package.json` does NOT exist at `/packages/electron/node_modules/electron/package.json` -- **THEN** the `bare-import` strategy SHALL succeed -- **AND** `Resolution.path` SHALL equal `/node_modules/electron` -- **AND** `Resolution.source` SHALL equal `"bare-import"` - -#### Scenario: electron resolves under nested workspace layout - -- **WHEN** `electron/package.json` exists at `/packages/electron/node_modules/electron/package.json` -- **THEN** the `bare-import` strategy SHALL prefer the nested path -- **AND** `Resolution.path` SHALL equal `/packages/electron/node_modules/electron` -- **AND** `Resolution.source` SHALL equal `"bare-import"` - -#### Scenario: electron not installed in any layout - -- **WHEN** `electron/package.json` exists in neither location and no override is set and no managed install is present -- **THEN** every strategy SHALL record `{ ok: false, reason: }` -- **AND** `Resolution.ok` SHALL be `false` -- **AND** `Resolution.path` SHALL be `null` - -#### Scenario: node-pty strategy chain - -- **WHEN** `registry.resolveModule("node-pty")` runs -- **THEN** strategies SHALL be tried in order: `override`, `bare-import` -- **AND** the `bare-import` strategy SHALL invoke `require.resolve("node-pty/package.json")` -- **AND** on success, `Resolution.path` SHALL be the directory containing the resolved `package.json` (i.e., the directory containing `node-pty/prebuilds/`) - -#### Scenario: node-pty missing in current workspace - -- **WHEN** `registry.resolveModule("node-pty")` runs from a workspace context where `node-pty` is not resolvable via standard Node module lookup -- **AND** no override is set -- **THEN** the `bare-import` strategy SHALL record `{ ok: false, reason: "module not resolvable: node-pty" }` -- **AND** `Resolution.ok` SHALL be `false` -- **AND** callers SHALL treat this as a soft no-op (postinstall scripts MUST exit 0 without error) - -### Requirement: Shell-callable tool resolver - -The shared package SHALL expose a CommonJS shell-callable resolver at `packages/shared/bin/pi-dashboard-resolve-tool.cjs` so that build-time scripts (workflows, Dockerfiles) can resolve registered tools without depending on the shared package's TypeScript build output. The script SHALL be self-contained: it MUST NOT require `tsx`, `jiti`, or any other transpiler at invocation time. - -#### Scenario: Resolver prints absolute path on success - -- **WHEN** the resolver is invoked as `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron` from the repo root -- **AND** electron resolves successfully -- **THEN** the resolver SHALL print the absolute path of the resolved package directory to stdout, followed by a newline -- **AND** the process SHALL exit with code 0 - -#### Scenario: Resolver fails on unknown tool - -- **WHEN** the resolver is invoked with a tool name that is not registered -- **THEN** the resolver SHALL print an error message naming the unknown tool to stderr -- **AND** SHALL exit with code 1 - -#### Scenario: Resolver fails on unresolvable tool - -- **WHEN** the resolver is invoked for a registered tool that no strategy can resolve -- **THEN** the resolver SHALL print a message to stderr including the tried trail -- **AND** SHALL exit with code 1 - -#### Scenario: Resolver --json flag - -- **WHEN** the resolver is invoked with `--json` as a second argument -- **THEN** the resolver SHALL print a JSON object matching the `Resolution` shape (`{ name, ok, path, source, tried, resolvedAt }`) to stdout -- **AND** SHALL exit with code 0 even when `ok` is `false` (the resolution outcome is encoded in the JSON, not the exit code, when `--json` is present) - -#### Scenario: Resolver consults override file - -- **WHEN** `~/.pi/dashboard/tool-overrides.json` contains a valid override for the requested tool -- **AND** the override path passes existence validation -- **THEN** the resolver SHALL print the override path -- **AND** the equivalent `--json` invocation SHALL report `source: "override"` - -### Requirement: Build-time consumers use the registry - -Build-time scripts that previously hardcoded `node_modules/` paths SHALL resolve those paths through the registry (via the shell-callable resolver for non-Node consumers, or via the inline `bare-import` semantics where the resolver itself is unavailable). The migrated sites are: `.github/workflows/publish.yml` (linux/arm64 electron rebuild step), `packages/electron/scripts/Dockerfile.build` (Docker cross-platform electron rebuild step), and `scripts/fix-pty-permissions.cjs` (root postinstall). - -#### Scenario: publish.yml resolves electron via the resolver - -- **WHEN** the linux/arm64 matrix cell executes the "Rebuild native modules" step -- **THEN** the step SHALL invoke `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron` to obtain the electron directory -- **AND** the step SHALL NOT contain a hardcoded `packages/electron/node_modules/electron` substring -- **AND** the step SHALL NOT contain an inline `node -e` invocation that hand-rolls `require.resolve` for electron - -#### Scenario: Dockerfile.build resolves electron via the resolver - -- **WHEN** the cross-platform Docker build runs `node install.js` for electron -- **THEN** the `RUN` step SHALL obtain the electron directory by invoking `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron` -- **AND** the `RUN` step SHALL NOT contain a hardcoded `packages/electron/node_modules/electron` substring - -#### Scenario: fix-pty-permissions resolves node-pty via require.resolve - -- **WHEN** the root `postinstall` hook executes `scripts/fix-pty-permissions.cjs` -- **THEN** the script SHALL resolve `node-pty/package.json` via `require.resolve("node-pty/package.json")` (matching the registry's `bare-import` strategy semantics) -- **AND** SHALL chmod every `prebuilds//spawn-helper` file under the resolved directory to mode `0o755` -- **AND** SHALL exit with code 0 with no error output when `node-pty` is not resolvable -- **AND** SHALL NOT contain a hardcoded `node_modules/node-pty/prebuilds` substring - -### Requirement: Lint enforcement of registry usage - -A repo-level vitest test SHALL exist at `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts` that scans a defined set of source files for `node_modules/electron` and `node_modules/node-pty` substrings outside an explicit allowlist. The test SHALL fail with a `file:line:col` citation when any non-allowlisted occurrence is found. This test SHALL run as part of `npm test`. - -#### Scenario: Test scopes scan to build-time files - -- **WHEN** the test runs -- **THEN** it SHALL scan `.github/workflows/*.yml`, `packages/electron/scripts/Dockerfile.build`, `packages/electron/scripts/*.sh`, `scripts/*.cjs`, and `scripts/*.sh` -- **AND** it SHALL NOT scan generated files, `dist/`, or `node_modules/` - -#### Scenario: New hardcoded path triggers lint failure - -- **WHEN** a contributor adds `cd node_modules/electron && ...` to any in-scope file -- **THEN** `npm test` SHALL fail -- **AND** the failure message SHALL cite the file, line, and column of the violation -- **AND** the failure message SHALL reference the tool registry as the canonical replacement - -#### Scenario: Allowlisted inline copy is permitted - -- **WHEN** the scan encounters `scripts/fix-pty-permissions.cjs` (the bootstrap-friendly inline twin of the `bare-import` strategy) -- **THEN** the test SHALL NOT fail on its `node_modules/node-pty` substring (if any) due to its presence on the allowlist -- **AND** the allowlist SHALL be defined inside the test file itself with explanatory comments - -#### Scenario: Comments and string-prefixed lines are not false positives - -- **WHEN** the scan encounters `node_modules/electron` inside a comment line (e.g., `# Electron may be hoisted to root node_modules ...`) -- **THEN** the test SHALL NOT report it as a violation -- **AND** the comment-stripping logic SHALL handle YAML `#`, shell `#`, and JS `//` comment prefixes diff --git a/openspec/changes/archive/2026-05-09-register-build-time-tools/tasks.md b/openspec/changes/archive/2026-05-09-register-build-time-tools/tasks.md deleted file mode 100644 index 3a01df543..000000000 --- a/openspec/changes/archive/2026-05-09-register-build-time-tools/tasks.md +++ /dev/null @@ -1,70 +0,0 @@ -## 1. Register tools in the registry - -- [x] 1.1 Add `electron` module definition to `packages/shared/src/tool-registry/definitions.ts` with strategy chain `override` → `bare-import` (using `paths: ["packages/electron"]`) → `managed` -- [x] 1.2 Add `node-pty` module definition to `packages/shared/src/tool-registry/definitions.ts` with strategy chain `override` → `bare-import` -- [x] 1.3 Update the comment block in `definitions.ts` listing intentionally-NOT-registered tools to reflect that `electron` and `node-pty` are now registered -- [x] 1.4 Update `AGENTS.md` "Currently registered" list under `src/shared/tool-registry/definitions.ts` to include the two new tools - -## 2. Bootstrap-harness coverage - -- [ ] 2.1 Read `packages/shared/src/__tests__/bootstrap/scenarios.ts` and `harness.ts` to understand how to register new family files without expanding the cell cube -- [ ] 2.2 Create `packages/shared/src/__tests__/bootstrap/families/electron-resolution.test.ts` covering: hoisted-root layout (electron in `/node_modules/electron`), nested-workspace layout (`packages/electron/node_modules/electron`), missing layout (no electron anywhere), override layout (override file points to a custom path) -- [ ] 2.3 Create `packages/shared/src/__tests__/bootstrap/families/node-pty-resolution.test.ts` covering: present-as-server-dep layout, missing-from-current-workspace layout, override layout -- [ ] 2.4 Run `npm run test:bootstrap` and verify all new family cells pass; pipe output to `/tmp/pi-test.log` per AGENTS.md test workflow - -> §2 (bootstrap-harness families) **deferred to a follow-up change**. Rationale: the harness's `scenarios.ts` cell cube + `fixtures/` machinery is a substantial surface, and the new `resolve-tool-cli.test.ts` (live `spawnSync`-based test, §3.6) plus the lint test (§7) already exercise the registry's `electron`/`node-pty` definitions end-to-end against the real layout. Adding harness families is a worthwhile-but-orthogonal investment in cross-platform / fixture-driven coverage; tracking as a follow-up so this change can land lean. See change: register-build-time-tools. - -## 3. Shell-callable resolver wrapper - -- [x] 3.1 Create `packages/shared/bin/pi-dashboard-resolve-tool.cjs` (CommonJS, ~30 lines) that accepts ` [--json]` argv -- [x] 3.2 Inline-implement the `override` strategy: read `~/.pi/dashboard/tool-overrides.json` if present, validate path existence, return path + `source: "override"` on hit -- [x] 3.3 Inline-implement the `bare-import` strategy via `createRequire(path.join(repoRoot, "package.json")).resolve(toolName + "/package.json")` for both `electron` (with `paths: ["packages/electron"]`) and `node-pty` (no paths option) -- [x] 3.4 Hardcode the per-tool strategy chain order to match `definitions.ts`; include a top-of-file comment explicitly cross-referencing `definitions.ts` so drift is visible during code review -- [x] 3.5 Implement stdout (path + newline) on success, stderr error message + exit 1 on failure (without `--json`); JSON object on stdout + exit 0 with `--json` regardless of `ok` -- [x] 3.6 Add unit test `packages/shared/src/__tests__/resolve-tool-cli.test.ts` that spawns the script via `child_process.spawnSync(process.execPath, [scriptPath, ...args])` and asserts stdout/stderr/exit code for each scenario in the spec - -## 4. Migrate consumer #1: publish.yml linux/arm64 step - -- [x] 4.1 Read `.github/workflows/publish.yml` lines 80-100 to confirm the current inline `node -e require.resolve(...)` block -- [x] 4.2 Replace the inline block with `ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)`; preserve the explanatory comment -- [x] 4.3 Verify the YAML parses (run `npx js-yaml .github/workflows/publish.yml > /dev/null` or equivalent) and that no `packages/electron/node_modules/electron` substring remains in the file - -## 5. Migrate consumer #2: Dockerfile.build - -- [x] 5.1 Read `packages/electron/scripts/Dockerfile.build` to confirm line 33 and surrounding RUN context -- [x] 5.2 Replace `RUN cd packages/electron/node_modules/electron && node install.js 2>&1 | tail -5` with a `RUN` step that resolves the directory via the wrapper, then cd's into it -- [ ] 5.3 Verify by running `docker build -f packages/electron/scripts/Dockerfile.build .` locally (or via `bash packages/electron/scripts/build-installer.sh --linux`) and confirming the rebuild step succeeds without "No such file or directory" - -> 5.3 left for the user — requires Docker; deferred to manual smoke-test. - -## 6. Migrate consumer #3: scripts/fix-pty-permissions.cjs - -- [x] 6.1 Read both copies of `fix-pty-permissions.cjs` (root + `packages/server/scripts/`) to understand the divergence -- [x] 6.2 Rewrite root `scripts/fix-pty-permissions.cjs` to use `require.resolve("node-pty/package.json")` mirroring the server-side correct version; preserve the existing top-of-file comment style (Linux/macOS-only, exit 0 on Windows) -- [x] 6.3 Add explicit comment at the top of the rewritten file pointing at the registry's `bare-import` strategy as the canonical reference, so anyone editing it knows to keep the two in sync -- [x] 6.4 Verify by running `rm -rf node_modules && npm ci`, then `find node_modules/node-pty/prebuilds -name spawn-helper -executable | head` and confirming hits *(verified with the live `node scripts/fix-pty-permissions.cjs` invocation against the current installed tree — spawn-helpers chmodded to 0o755; full clean reinstall left for the user)* - -## 7. Lint enforcement - -- [x] 7.1 Read existing lint tests `packages/shared/src/__tests__/no-direct-process-kill.test.ts` and `no-raw-node-import.test.ts` to mirror their file-scanning + comment-stripping pattern -- [x] 7.2 Create `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts` scanning the migrated build-time files (publish.yml, ci.yml, Dockerfile.build, both fix-pty-permissions.cjs copies) -- [x] 7.3 Implement comment-stripping for YAML (`#`), shell (`#`), and JS/TS (`//`) line-comment prefixes before applying the regex -- [x] 7.4 Define the allowlist inside the test file with explanatory comments; allowlist `scripts/fix-pty-permissions.cjs` and `packages/server/scripts/fix-pty-permissions.cjs` (the `node-pty` token in those files is an argument to `require.resolve`, not a hardcoded path) -- [x] 7.5 Implement the failure message to cite `file:line:col` and reference the tool registry as the canonical replacement -- [x] 7.6 Run `npm test` and verify the new test passes against the migrated tree; manually introduce a hardcoded path into a test file and confirm the test fails with the expected citation, then revert *(verified — temporary regression in publish.yml triggered failure with `.github/workflows/publish.yml:96:32 cd packages/electron/node_modules/electron && node install.js` citation; revert restored green)* - -## 8. Documentation + AGENTS.md - -- [x] 8.1 Update `AGENTS.md` to add an entry under "Key Files" for `packages/shared/bin/pi-dashboard-resolve-tool.cjs` describing its purpose and the strategy-chain mirror invariant -- [x] 8.2 Update `AGENTS.md` to add an entry for `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts` mirroring the existing `no-direct-process-kill.test.ts` entry -- [x] 8.3 Update `docs/architecture.md` to mention the new build-time tool registrations + shell-callable wrapper under the Tool Resolution section -- [x] 8.4 Update `CHANGELOG.md` `## [Unreleased]` section with one-line entries describing: registry registration of electron + node-pty, build-time consumer migration, lint enforcement - -## 9. Verification - -- [x] 9.1 Run full `npm test 2>&1 | tee /tmp/pi-test.log` and grep for failures; address any *(2 pre-existing failures in untracked `CommandInput.dropdown-select.probe.test.tsx` confirmed unrelated by stash + re-run; 3049 tests pass)* -- [x] 9.2 Run `npm run build` and confirm the TypeScript build still passes (no regression in shared package) -- [ ] 9.3 Run `rm -rf node_modules && npm ci` on a clean tree; confirm postinstall does not error and `find node_modules/node-pty/prebuilds -name spawn-helper -executable` returns hits on Linux/macOS *(left for the user — destructive op against current working tree)* -- [ ] 9.4 Verify on a real GitHub Actions push (scratch tag or PR run) that the linux/arm64 publish.yml cell succeeds end-to-end through the rebuild step *(left for the user — requires GH Actions CI run)* -- [ ] 9.5 Verify Docker cross-build via `bash packages/electron/scripts/build-installer.sh --linux` succeeds on a host that exercises Dockerfile.build *(left for the user — requires Docker)* -- [x] 9.6 Confirm `openspec validate register-build-time-tools --strict` passes before archive diff --git a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/.openspec.yaml b/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/.openspec.yaml deleted file mode 100644 index 204fc5acf..000000000 --- a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-18 diff --git a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/design.md b/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/design.md deleted file mode 100644 index 7c075575f..000000000 --- a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/design.md +++ /dev/null @@ -1,216 +0,0 @@ -## Context - -Platform branches in this codebase today: - -``` -17 production files, ~25 `process.platform === "win32"` branches - -packages/shared/ - └── tool-resolver.ts ← partial platform module (binary lookup only) -packages/server/ - ├── cli.ts ← findPortHolders, killProcess - ├── process-manager.ts ← tmux / sh-pipe / cmd /c / spawnHeadlessWindows - ├── terminal-manager.ts ← SHELL vs COMSPEC - ├── tunnel.ts ← where/which zrok - ├── editor-registry.ts ← pgrep vs tasklist, where/which cli - ├── editor-detection.ts ← (migrated in fix-windows-server-parity) - ├── headless-pid-registry.ts ← kill -pid vs kill +pid - ├── browser-handlers/ - │ └── session-action-handler.ts ← isPiProcess, killHeadlessBySessionId - └── routes/ - └── provider-auth-routes.ts ← open / xdg-open / start -packages/extension/ - └── process-scanner.ts ← ps enumeration, etime parse, already has - `_platform` injection (pattern seed) -packages/electron/ - ├── main.ts ← darwin dock, linux ozone hint, machine info - └── lib/ - ├── server-lifecycle.ts ← DUPLICATE jiti resolver (drift vector) - ├── dependency-detector.ts ← where/which, .cmd, login shell - ├── doctor.ts ← where/which tsx, .cmd - ├── bundled-node.ts ← node.exe vs node - ├── tray.ts ← trayTemplate / .ico / .png - └── app-menu.ts ← darwin menu -``` - -The `fix-windows-server-parity` change fixed a Windows-launch bug that had to be patched in **two** places because `packages/electron/src/lib/server-lifecycle.ts:resolveJitiFromAnchor` duplicated logic in `packages/shared/src/resolve-jiti.ts`. Cross-package drift is the concrete hazard that motivates this refactor. - -`ToolResolver` (packages/shared/src/tool-resolver.ts, 201 LOC) is the closest thing to a platform module today — it centralizes binary lookup, handles `where`/`which`, `.cmd` extension, managed-bin search, and login-shell fallback. It demonstrates the target pattern (context object, dependency injection, testable) but only covers one of six concerns identified in exploration. - -## Goals / Non-Goals - -**Goals:** -- Single location in `packages/shared/src/platform/` (plus `packages/electron/src/platform/`) for all cross-OS primitives, replacing ~25 scattered branches with ~8 named helpers. -- Eliminate cross-package duplication — `resolveJitiFromAnchor` deleted, Electron uses shared module. -- Each primitive takes an optional injectable `platform: NodeJS.Platform` parameter (defaulting to `process.platform`), enabling platform-targeted tests without `Object.defineProperty` mutation. -- Reduce `it.skipIf(win32)` test count where a paired Windows-side assertion is cheap. -- Zero behavior change visible to end users. REST, WebSocket, CLI, and config APIs unaffected. -- Each intermediate step ships green (tests pass on Windows, Linux, macOS). - -**Non-Goals:** -- `process-manager.ts` strategy logic (tmux vs headless vs WSL). It **consumes** platform primitives but remains in-place; its decomposition is a separate concern (session spawn architecture) not a platform concern. -- WSL-specific spawn paths (explore item, not addressed here). -- ARM64 native-module audit (node-pty prebuilds — tracked separately). -- New platform support (FreeBSD, Android, etc.). -- Moving Electron presentation concerns (tray icon, menu) into `shared/platform/` — they import from `electron` and legitimately live in the Electron package. -- Changing the `ToolResolver` public contract in a way that requires callers to update their usage pattern (it gets renamed/re-homed, but its surface stays compatible during migration). - -## Decisions - -### D1: Two modules, one per execution context - -`packages/shared/src/platform/` is pure Node (no `electron` or `fastify` or workspace-specific imports). `packages/electron/src/platform/` is for things that import from `electron` (nativeImage, Menu, app). Callers in server/extension import only from shared; Electron imports from both. - -Alternative considered: one module in shared that exposes "Electron hooks" via a plugin/callback. Rejected — adds indirection for no benefit; tray icons are genuinely Electron-only, they should live in the Electron package. - -### D2: Per-concern files, single `platform/` folder - -Not one mega-file (`platform.ts`) — discoverability suffers at ~400 LOC. Not ten tiny files — import noise. Five concern-based files (`binary-lookup`, `process`, `process-scan`, `shell`, `commands`) plus `index.ts` barrel export. Each file matches a natural test boundary. - -### D3: Platform is an injectable parameter, not global state - -Every exported helper that depends on OS takes an optional `platform` parameter: - -```ts -export function findPortHolders( - port: number, - opts?: { platform?: NodeJS.Platform; exec?: ExecFn } -): number[] -``` - -Production calls with no opts → reads `process.platform`. Tests pass `{ platform: "win32", exec: fake }` — no global mutation, no `Object.defineProperty` hack. This pattern is already used in `process-scanner.ts` (`_platform`) and `find-port-holders.test.ts` (`parseNetstatListeners(output, port, selfPid)`); we're standardizing it. - -Alternative: class with `new PlatformResolver({ platform, exec })`. Rejected — adds ceremony for most call sites that only need one primitive. Keep it functional; if context accumulates, callers can build their own object. - -### D4: `ToolResolver` renamed and re-homed, not deleted outright - -`packages/shared/src/tool-resolver.ts` has ~6 internal callers. Moving it and deleting it in one step risks breaking things. Approach: - -1. Create `packages/shared/src/platform/binary-lookup.ts` with the same public API. -2. Leave `packages/shared/src/tool-resolver.ts` as a one-line re-export: `export { ToolResolver, type ResolverContext } from "./platform/binary-lookup.js";` -3. Migrate callers one PR at a time to import from `platform/binary-lookup.js` directly. -4. Delete the old file in the final cleanup step. - -Alternative: hard rename in one PR. Rejected — the re-export pattern gives reviewable intermediate states with no coordination. - -### D5: Bottom-up migration (no top-level adapter) - -Each concern migrates independently: - -``` -Step 1: Create shared/platform/binary-lookup.ts (move tool-resolver) -Step 2: Create shared/platform/process.ts - + migrate cli.ts, headless-pid-registry.ts, session-action-handler.ts -Step 3: Create shared/platform/process-scan.ts - + migrate extension/process-scanner.ts, server/editor-registry.ts -Step 4: Create shared/platform/shell.ts - + migrate terminal-manager.ts - + migrate process-manager.ts Windows spawn branch -Step 5: Create shared/platform/commands.ts - + migrate provider-auth-routes.ts (openBrowser) - + migrate electron/main.ts (machineInfo) -Step 6: Create electron/platform/{tray-icon,menu,node,app-lifecycle}.ts - + migrate electron-specific call sites -Step 7: DELETE packages/electron/src/lib/server-lifecycle.ts:resolveJitiFromAnchor - + Electron server-lifecycle uses shared binary-lookup -Step 8: Cleanup: delete tool-resolver.ts re-export; update AGENTS.md + docs -``` - -Each step is a shippable PR with its own test delta. Intermediate builds are green on all three OSes. - -Alternative: big-bang single PR. Rejected — ~1,400 LOC of touched code, high conflict risk, difficult review, harder to bisect if a regression appears. - -### D6: Test simplifications happen alongside each step - -Where a test currently uses `Object.defineProperty(process, "platform", …)` or `vi.mock("node:child_process")` to exercise platform branches, migrate it to pass `platform: "win32"` as a parameter. Where a test is `it.skipIf(win32)` because the Unix fixture can't run on Windows, add a paired Windows-side `it.skipIf(win32 !== x)` test using the new primitives — unless the production code itself is Unix-only (e.g. login shell, which is explicitly gated in `tool-resolver.ts:55`). - -Pattern illustration: - -```ts -// BEFORE (test mutates global) -Object.defineProperty(process, "platform", { value: "win32", configurable: true }); -expect(findPortHolders(8000)).toEqual([12345]); - -// AFTER (injected) -expect(findPortHolders(8000, { - platform: "win32", - exec: () => "TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345", -})).toEqual([12345]); -``` - -### D7: Preserve `process-manager.ts` spawn strategy logic in place - -`process-manager.ts` is 310 LOC with three strategies (tmux, headless, WSL), each with a Windows branch. The temptation is to extract all of it. Resist — the **strategy** (which path to take) is session-management logic, not a platform primitive. The *primitives* (how to spawn a detached process, how to build a shell command) come from `platform/`; the *choice* of tmux-vs-headless stays in `process-manager.ts`. This draws a clean seam: platform tells you "how to Windows spawn"; process-manager tells you "spawn as tmux or headless". - -Concretely: `spawnHeadlessWindows` function today inlines Windows-specific `.cmd` handling and stderr capture. The `.cmd` handling moves to `platform/binary-lookup.ts` (already partly there via `resolveTsx`); the Windows spawn wrapper stays in `process-manager.ts` but calls `platform.resolvePi()` to get its command. - -### D8: Electron package stays thin; heavy lifting in shared - -Electron-specific concerns that DO go into `packages/electron/src/platform/`: -- Tray icon selection (uses `nativeImage.createFromPath`) -- Menu template (uses `MenuItemConstructorOptions`) -- Dock-hide behavior (uses `app.dock`) -- Ozone hint for Linux (uses `app.commandLine`) -- Bundled Node path (uses `process.resourcesPath`) - -Electron concerns that get DELEGATED to shared: -- Binary lookup (where/which, .cmd) — use `shared/platform/binary-lookup` -- Jiti resolution — delete duplicate, use `shared/resolve-jiti` -- Machine info via `sysctl`/`systemd-detect-virt`/`wmic` — use `shared/platform/commands.ts:detectMachineInfo` -- `where`/`which` for tsx — use `shared/platform/binary-lookup` - -### D9: No new runtime dependencies - -All primitives use Node built-ins (`child_process`, `fs`, `os`, `net`, `http`). No `ps-list`, `find-process`, `shelljs`, or similar. The existing approach (shell out with platform-branching) is kept — just centralized. - -## Risks / Trade-offs - -- **Risk: The re-export wrapper for `tool-resolver.ts` is forgotten and stays forever** - → Mitigation: final cleanup is a dedicated task (step 8) that grep-verifies no remaining imports of `tool-resolver.js` before deletion. Tasks.md has an explicit "delete re-export" step. - -- **Risk: `process-manager.ts` extraction regresses tmux/WSL spawn** - → Mitigation: extract only the *.cmd* and *binary lookup* parts in step 4; leave spawn-strategy logic untouched. Add a dedicated integration test that spawns a headless session on both Unix and Windows before and after the refactor. - -- **Risk: Injectable platform parameter cascades into many function signatures** - → Mitigation: only exported primitives take the parameter. Internal helpers inside `platform/` can read `process.platform` directly. Callers that need it can thread a single `platform` value down their call chain. - -- **Risk: Electron platform module creates circular dependencies with `packages/electron/src/main.ts`** - → Mitigation: `electron/platform/` imports from `electron` only, not from `electron/main.ts`. Main imports *from* platform, never the reverse. Enforced by directory layout (`main.ts` is a leaf, platform is a dependency). - -- **Risk: Bundle size or tree-shaking regresses** - → Mitigation: measure `dist/` bundle size before and after. Expect slight *decrease* because the Electron jiti duplicate is deleted. If bundle size grows, investigate why (likely accidental `import * as`). - -- **Trade-off: 6–8 PRs vs. one big PR** - → Accepted. Bottom-up is slower per wall-clock but each PR is small and reviewable. If a reviewer preferred squash-merge, the migration can collapse into a single PR at merge time while keeping the per-step commit history for bisection. - -- **Trade-off: Two platform modules (shared + electron) vs. one** - → Accepted. Electron-API concerns are genuinely Electron-bound; forcing them into shared would require a plugin/callback indirection. Two modules is the honest shape. - -- **Trade-off: Injection-via-options vs. class-with-context** - → Accepted injection-via-options. Most call sites need one primitive at a time; a class adds ceremony. Callers that accumulate context can build their own wrapper. - -## Migration Plan - -No data/config/API migration. Pure internal refactor. - -Roll-out: -- Each of the 8 steps above is a reviewable PR. -- Between PRs: tests pass on Windows, Linux, macOS (the workflow runs all three). -- Rollback: revert the offending PR; earlier PRs are independent. -- After step 7, the `fix-windows-server-parity` follow-up item "collapse Electron duplication" is closed. - -Timing estimate: 4–6 days of focused work, or longer spread across iterations. No hard deadline; the refactor can pause at any step boundary (each leaves the tree in a valid state). - -## Open Questions - -- **Should `shared/platform/` export a `createPlatform(ctx)` factory in addition to flat functions?** - Factory enables pattern like `const p = createPlatform({ extraBinDirs }); p.which("zrok")`. Flat functions are simpler. Leaning: export both — factory for multi-call contexts, flat functions for one-offs. Decision deferrable to step 1. - -- **Does `ToolResolver` (the class) survive, or flatten entirely?** - The class owns context (`extraBinDirs`, `useLoginShell`, `processExecPath`) and exposes `which`, `resolvePi`, `resolveTsx`, `resolveNode`, `buildSpawnEnv`. Keeping it as a class is the lowest-churn path. Flattening to functions requires threading context through every call. Leaning: keep the class, rename-and-relocate only. - -- **Is there value in a `platform.arch` companion now, anticipating the ARM64 follow-up?** - Probably not — YAGNI until the ARM64 scope is actually pursued. Mention in docs that `platform/` is the natural home when it happens. - -- **Do any *tests* actually exercise darwin-only paths (open `open(url)`, `sysctl`)?** - Probably not — they'd need an actual macOS runner. Confirm during step 5. If not, tests for those paths can use the injectable-platform pattern to reach the darwin branch on any OS. diff --git a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/proposal.md b/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/proposal.md deleted file mode 100644 index 7acad36e3..000000000 --- a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/proposal.md +++ /dev/null @@ -1,45 +0,0 @@ -## Why - -Platform-specific code (`process.platform === "win32"` branches, `where`/`which` switches, `SHELL`/`COMSPEC` selection, `taskkill`/`kill -pid` differences, `ps`/`tasklist` enumeration, `open`/`xdg-open`/`start`) is scattered across **17 production files** in `packages/server`, `packages/extension`, `packages/electron`, and `packages/shared`. Each file owns its own ad-hoc branch, and the same primitives are reimplemented or partially duplicated across packages — most recently surfaced by `fix-windows-server-parity`, which had to patch the same jiti-resolver bug in two places because `packages/electron/src/lib/server-lifecycle.ts` kept its own copy of logic already in `packages/shared/src/tool-resolver.ts`. This drift is the canonical argument for consolidation: separation across files let two implementations diverge and bug-fix coverage was incomplete until the duplicate was found. Consolidating the platform primitives into a single shared module removes the drift vector, shrinks the Windows-branch surface from ~25 scattered call sites to ~8 named helpers, and gives ARM64/WSL follow-ups a natural home. - -## What Changes - -- Introduce `packages/shared/src/platform/` as the single home for cross-OS primitives with sub-modules by concern: - - `binary-lookup.ts` — absorbs and supersedes `packages/shared/src/tool-resolver.ts` (`where`/`which`, `.cmd` extension, managed-bin search, login-shell fallback, pi/tsx/node resolution) - - `process.ts` — `findPortHolders`, `killProcess` (taskkill tree on Windows, SIGTERM→SIGKILL on Unix), `isProcessAlive`, `killByPidWithGroup` (negative-pid on Unix, positive on Windows) - - `process-scan.ts` — `listChildPids`, `scanByPgid`, `isProcessRunning` (ps vs tasklist), `parseEtime` - - `shell.ts` — `detectShell` (SHELL/COMSPEC), terminal env hints (`TERM=cygwin` on Windows) - - `commands.ts` — `openBrowser`, `detectMachineInfo` - - `index.ts` — re-export public API -- Introduce `packages/electron/src/platform/` for Electron-API-bound concerns (cannot live in shared because they import from `electron`): - - `tray-icon.ts` — platform-specific tray icon selection (`trayTemplate.png` on macOS, `.ico` on Windows, `.png` on Linux) - - `menu.ts` — darwin-specific menu template - - `node.ts` — bundled Node binary resolution (`node.exe` vs `node`) - - `app-lifecycle.ts` — darwin dock-hide quit behavior, linux `ozone-platform-hint` -- Migrate **17 call sites** to consume the new modules. The `ToolResolver` public API is preserved via a thin re-export so external consumers (if any) keep working during transition; remove the re-export after all internal callers are migrated. -- **Remove the duplicate `resolveJitiFromAnchor` in `packages/electron/src/lib/server-lifecycle.ts`** — import from the new `binary-lookup.ts` instead. Closes the drift vector that `fix-windows-server-parity` had to patch in two places. -- Tests consume the new platform API directly: platform behavior is a function argument (e.g. `findPortHolders(port, { platform: "win32", exec: fake })`), eliminating the need for `Object.defineProperty(process, "platform", …)` mutation in tests and reducing the six current `it.skipIf(win32)` skips where a paired Windows-side assertion is now cheap to express. -- Documentation: `AGENTS.md` gets a "Platform primitives" entry pointing at the new module; `docs/architecture.md` gets a short section explaining how cross-OS behavior is resolved; `README.md` is unchanged (no user-visible API change). -- **NOT a breaking change** — all external REST/WebSocket APIs and CLI commands are unaffected. The refactor is internal. - -## Capabilities - -### New Capabilities -- `platform-primitives`: Unified, injectable cross-OS helpers for binary lookup, process control/enumeration, shell detection, and OS-specific commands. Lives in `packages/shared/src/platform/` with an Electron-specific companion in `packages/electron/src/platform/` for Electron-API concerns. - -### Modified Capabilities -_(none — this is a refactor. External behavior is preserved. The only observable change is that the Electron jiti-resolver duplication disappears, but the user-facing behavior stays the same.)_ - -## Impact - -- **Files moved / renamed**: - - `packages/shared/src/tool-resolver.ts` → `packages/shared/src/platform/binary-lookup.ts` (with back-compat re-export during migration) -- **Files touched (production)**: ~17 call sites across `packages/server` (cli, process-manager, terminal-manager, tunnel, editor-registry, editor-detection, headless-pid-registry, browser-handlers/session-action-handler, routes/provider-auth-routes), `packages/extension` (process-scanner), `packages/electron` (server-lifecycle, dependency-detector, doctor, bundled-node, tray, app-menu, main). -- **Files touched (tests)**: ~15 test files simplified — platform branches become injectable arguments instead of `Object.defineProperty` / `_platform` escape hatches. The six current `skipIf(win32)` tests get paired Windows assertions where feasible. -- **Electron-specific removal**: `resolveJitiFromAnchor` (packages/electron/src/lib/server-lifecycle.ts) deleted; callers use shared `binary-lookup`. -- **Dependencies**: None added or removed. -- **Bundle size**: Marginally smaller (duplicate logic removed, tree-shaking improves). -- **API surface**: Internal only. No changes to REST, WebSocket, or CLI. -- **Migration window**: 6–8 reviewable PRs (bottom-up, adapter-free approach — see design.md for sequencing) OR one squash-merge if preferred. Each intermediate step is green. -- **Risk**: Medium. The primitives are well-understood (most already exist, just scattered), but `process-manager.ts` (310 LOC of intertwined tmux/headless/WSL spawn logic) is the hardest to decompose cleanly and deserves its own step with extra test coverage. -- **Out of scope**: WSL-specific spawn paths, ARM64 native-module audit, `process-manager.ts` strategy logic (remains in-place, consumes platform primitives). All three are naturally easier to revisit once the platform module exists. diff --git a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/specs/platform-primitives/spec.md b/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/specs/platform-primitives/spec.md deleted file mode 100644 index 09e4493d3..000000000 --- a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/specs/platform-primitives/spec.md +++ /dev/null @@ -1,160 +0,0 @@ -## ADDED Requirements - -### Requirement: Single shared platform module -Cross-OS primitives (binary lookup, process control/enumeration, shell detection, OS-specific commands) SHALL live in `packages/shared/src/platform/`. The module SHALL expose its public API through an `index.ts` barrel. No other file in `packages/shared`, `packages/server`, or `packages/extension` SHALL contain a `process.platform === "win32"` branch that implements a primitive itself; such files SHALL consume the primitive from `platform/`. - -#### Scenario: Every Windows branch is served by a platform primitive -- **WHEN** a developer grep's `process.platform` across `packages/shared/src`, `packages/server/src`, and `packages/extension/src` (excluding `packages/shared/src/platform/`) -- **THEN** the only matches SHALL be (a) calls into `platform/` helpers, (b) spawn-strategy selection in `process-manager.ts` that consumes platform primitives to build commands, or (c) a one-line switch for choosing between platform-provided variants - -#### Scenario: No platform branching in editor-detection or tunnel -- **WHEN** `packages/server/src/editor-detection.ts` or `packages/server/src/tunnel.ts` needs to look up a binary -- **THEN** it SHALL call `platform/binary-lookup` and SHALL NOT contain `where`/`which` strings inline - -### Requirement: Platform is injectable for tests -Every exported primitive in `packages/shared/src/platform/` that depends on `process.platform` SHALL accept an optional `platform` parameter (typed as `NodeJS.Platform`) that overrides the global. When the parameter is omitted, the primitive SHALL read `process.platform`. Tests SHALL exercise platform branches by passing the parameter, not by mutating `process.platform` via `Object.defineProperty`. - -#### Scenario: findPortHolders respects injected platform -- **WHEN** `findPortHolders(8000, { platform: "win32", exec: fake })` is called on a Linux host -- **THEN** the helper SHALL take the Windows branch (use `netstat -ano`, not `lsof`) and return the PIDs parsed by the Windows branch - -#### Scenario: Tests do not mutate global platform -- **WHEN** a test file in `packages/shared/src/__tests__/` or `packages/server/src/__tests__/` exercises a platform primitive -- **THEN** the test SHALL pass `platform` as a function argument -- **AND** SHALL NOT call `Object.defineProperty(process, "platform", ...)` - -### Requirement: Binary-lookup primitive (where/which, .cmd, managed-bin, login shell) -The `platform/binary-lookup.ts` module SHALL expose binary resolution that handles: -- `where` on Windows, `which` on Unix -- `.cmd` extension for managed-bin and extra-bin-dirs on Windows -- Managed-bin prefix search (`~/.pi-dashboard/node_modules/.bin`) -- Extra bin directories before system PATH -- Login-shell fallback (Unix only; skipped on Windows) -- Convenience helpers for `pi`, `tsx`, `node` resolution that return `[command, ...prefixArgs]` tuples to avoid `.cmd` spawn on Windows - -#### Scenario: Windows `.cmd` extension applied -- **WHEN** `which("pi", { platform: "win32" })` is called and `~/.pi-dashboard/node_modules/.bin/pi.cmd` exists -- **THEN** it SHALL return the absolute path to `pi.cmd` - -#### Scenario: Login shell skipped on Windows -- **WHEN** `which("pi", { platform: "win32", useLoginShell: true })` is called and all prior lookups fail -- **THEN** the helper SHALL NOT attempt a `bash -ilc` or `zsh -ilc` invocation -- **AND** SHALL return `null` - -#### Scenario: Pi resolves to [node, cli.js] on Windows to avoid .cmd spawn -- **WHEN** `resolvePi({ platform: "win32" })` is called and pi is installed via npm global -- **THEN** it SHALL return `[nodePath, absolute-cli-js-path]` instead of `[pi.cmd]` so the caller can spawn without `shell: true` - -### Requirement: Process primitive (kill, find-port, is-alive) -The `platform/process.ts` module SHALL expose: -- `findPortHolders(port, opts?)` — `netstat -ano` on Windows, `lsof -t -i : -sTCP:LISTEN` on Unix; returns PIDs (excluding self) or `[]` on failure -- `killProcess(pid, opts?)` — `taskkill /F /T /PID` on Windows (tree kill), `SIGTERM` → `SIGKILL` on Unix -- `isProcessAlive(pid)` — cross-platform via `process.kill(pid, 0)` (semantics identical on all OSes) -- `killPidWithGroup(pid, signal, opts?)` — Unix signals the process group (`-pid`), Windows targets the pid directly - -#### Scenario: killProcess uses taskkill on Windows -- **WHEN** `killProcess(12345, { platform: "win32", exec: fake })` is called -- **THEN** it SHALL invoke `taskkill /F /T /PID 12345` -- **AND** SHALL NOT invoke `process.kill(12345, "SIGTERM")` - -#### Scenario: killPidWithGroup signals the process group on Unix -- **WHEN** `killPidWithGroup(12345, "SIGTERM", { platform: "linux", kill: fakeKill })` is called -- **THEN** `fakeKill` SHALL be called with `(-12345, "SIGTERM")` - -#### Scenario: killPidWithGroup targets the pid directly on Windows -- **WHEN** `killPidWithGroup(12345, "SIGTERM", { platform: "win32", kill: fakeKill })` is called -- **THEN** `fakeKill` SHALL be called with `(12345, "SIGTERM")` (positive pid) - -#### Scenario: findPortHolders falls back silently on parse failure -- **WHEN** `netstat` output cannot be parsed (unexpected format, permission error, empty output) -- **THEN** `findPortHolders(port, { platform: "win32" })` SHALL return `[]` without throwing - -### Requirement: Process enumeration primitive (ps vs tasklist) -The `platform/process-scan.ts` module SHALL expose: -- `listChildPids(parentPid, opts?)` — `ps -eo pid=,ppid=` on Unix; Windows uses `wmic process get` or equivalent (or returns `[]` if enumeration is not supported for the caller's use case) -- `isProcessRunning(pattern, opts?)` — `pgrep -f` on Unix, `tasklist /FI "IMAGENAME eq "` on Windows -- `parseEtime(etime)` — pure parser for `ps -o etime=` format (`mm:ss`, `hh:mm:ss`, `dd-hh:mm:ss`); exported for testing - -#### Scenario: isProcessRunning uses tasklist on Windows -- **WHEN** `isProcessRunning("Code.exe", { platform: "win32", exec: fakeExec })` is called -- **THEN** the underlying command SHALL be `tasklist /FI "IMAGENAME eq Code.exe" /NH` -- **AND** the result SHALL be `true` when the fake exec output contains the image name - -#### Scenario: parseEtime handles ps format variants -- **WHEN** `parseEtime("02:15")`, `parseEtime("01:30:00")`, or `parseEtime("2-03:00:00")` is called -- **THEN** it SHALL return 135000, 5400000, and 183600000 milliseconds respectively - -### Requirement: Shell primitive (SHELL vs COMSPEC) -The `platform/shell.ts` module SHALL expose `detectShell(opts?)` that: -- Returns `process.env.COMSPEC || "powershell.exe"` on Windows -- Returns `process.env.SHELL || "/bin/bash"` on Unix -- Accepts an optional `platform` and `env` override for testing - -#### Scenario: Windows uses COMSPEC -- **WHEN** `detectShell({ platform: "win32", env: { COMSPEC: "C:\\Windows\\System32\\cmd.exe" } })` is called -- **THEN** it SHALL return `"C:\\Windows\\System32\\cmd.exe"` - -#### Scenario: Windows falls back to powershell.exe -- **WHEN** `detectShell({ platform: "win32", env: {} })` is called -- **THEN** it SHALL return `"powershell.exe"` - -#### Scenario: Unix uses SHELL -- **WHEN** `detectShell({ platform: "linux", env: { SHELL: "/bin/zsh" } })` is called -- **THEN** it SHALL return `"/bin/zsh"` - -#### Scenario: Unix falls back to /bin/bash -- **WHEN** `detectShell({ platform: "darwin", env: {} })` is called -- **THEN** it SHALL return `"/bin/bash"` - -### Requirement: OS-command primitive (open-browser) -The `platform/commands.ts` module SHALL expose `openBrowser(url, opts?)` that: -- Uses `open ""` on macOS -- Uses `xdg-open ""` on Linux -- Uses `start "" ""` on Windows -- Returns a promise or callback-style result; errors are logged but do not throw (best-effort) - -#### Scenario: openBrowser dispatches per platform -- **WHEN** `openBrowser("https://example.com", { platform: "darwin", exec: fake })` is called -- **THEN** `fake` SHALL be called with a command matching `/^open\s+"https:\/\/example\.com"/` - -#### Scenario: openBrowser uses start on Windows -- **WHEN** `openBrowser("https://example.com", { platform: "win32", exec: fake })` is called -- **THEN** `fake` SHALL be called with a command matching `/^start\s+""\s+"https:\/\/example\.com"/` - -### Requirement: Electron platform module for Electron-API concerns -Electron-specific platform decisions that import from the `electron` package SHALL live in `packages/electron/src/platform/`. This module SHALL own: -- `tray-icon.ts` — `getTrayIcon(): NativeImage` selecting the correct icon file per OS -- `menu.ts` — `buildAppMenu(): MenuItemConstructorOptions[]` with darwin-specific first-position app menu -- `node.ts` — `getBundledNodePath(): string | null` resolving `node.exe` on Windows, `node` elsewhere -- `app-lifecycle.ts` — `configureAppLifecycle(app)` handling darwin dock-hide and linux `ozone-platform-hint` - -`packages/electron/src/main.ts` SHALL import from `electron/platform/` instead of containing these branches inline. `packages/electron/src/lib/tray.ts`, `app-menu.ts`, `bundled-node.ts` SHALL either be relocated into `electron/platform/` or become thin re-export shims. - -#### Scenario: Tray icon selection is centralized -- **WHEN** the Electron main process creates the system tray -- **THEN** it SHALL obtain the `NativeImage` via `electron/platform/tray-icon.ts:getTrayIcon()` -- **AND** `packages/electron/src/lib/tray.ts` (if retained) SHALL delegate to `getTrayIcon()` rather than branching on `process.platform` itself - -### Requirement: Electron delegates to shared for non-UI platform concerns -Platform concerns that do NOT require Electron APIs (binary lookup, machine info via `sysctl`/`systemd-detect-virt`/`wmic`, jiti register-hook resolution) SHALL be implemented in `packages/shared/src/platform/` and consumed by Electron via import. `packages/electron/src/lib/server-lifecycle.ts` SHALL NOT contain a duplicate jiti resolver. - -#### Scenario: Electron jiti resolver is removed -- **WHEN** a developer inspects `packages/electron/src/lib/server-lifecycle.ts` after the migration -- **THEN** the function `resolveJitiFromAnchor` SHALL NOT exist -- **AND** `resolveJitiFromPi` (if retained) SHALL delegate to `packages/shared/src/resolve-jiti.ts` - -#### Scenario: Electron machine-info uses shared primitive -- **WHEN** `packages/electron/src/main.ts` logs machine info at startup -- **THEN** it SHALL call `platform/commands.ts:detectMachineInfo()` rather than branching on `process.platform` to invoke `sysctl`, `systemd-detect-virt`, or `wmic` inline - -### Requirement: ToolResolver public API preserved during migration -The existing `ToolResolver` class and its public methods (`which`, `resolvePi`, `resolveTsx`, `resolveNode`, `buildSpawnEnv`) SHALL remain callable with identical signatures during the migration. Its implementation file MAY be relocated to `packages/shared/src/platform/binary-lookup.ts`; `packages/shared/src/tool-resolver.ts` SHALL become a one-line re-export during the transition and MAY be deleted once all internal callers are migrated. - -#### Scenario: ToolResolver import path back-compat during migration -- **WHEN** a caller imports `ToolResolver` from `@blackbelt-technology/pi-dashboard-shared/tool-resolver.js` during the migration window -- **THEN** the import SHALL succeed and return the same class as importing from `@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js` - -#### Scenario: Old import path removed after migration -- **WHEN** the final cleanup step of the migration completes -- **THEN** `packages/shared/src/tool-resolver.ts` SHALL NOT exist -- **AND** no file in the repository (excluding `openspec/changes/archive/`) SHALL import from `.../tool-resolver.js` diff --git a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/tasks.md b/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/tasks.md deleted file mode 100644 index 99cd068b6..000000000 --- a/openspec/changes/archive/2026-05-10-consolidate-platform-handlers/tasks.md +++ /dev/null @@ -1,78 +0,0 @@ -## 1. Step 1 — Relocate `ToolResolver` to `platform/binary-lookup` - -- [x] 1.1 Create `packages/shared/src/platform/` directory. -- [x] 1.2 Move `packages/shared/src/tool-resolver.ts` to `packages/shared/src/platform/binary-lookup.ts`. Preserve the `ToolResolver` class and all public exports (`ResolverContext`, `ToolResolver`). _(Also updated `./managed-paths.js` import to `../managed-paths.js`.)_ -- [x] 1.3 Replace `packages/shared/src/tool-resolver.ts` with a one-line re-export: `export * from "./platform/binary-lookup.js";` -- [x] 1.4 Move `packages/shared/src/__tests__/tool-resolver.test.ts` to `packages/shared/src/__tests__/binary-lookup.test.ts` and update the import to `../platform/binary-lookup.js`. -- [x] 1.5 Create `packages/shared/src/platform/index.ts` with `export * from "./binary-lookup.js";`. -- [x] 1.6 Run `npm test` — all tests pass. Run `npx tsc --noEmit` — no type errors. _(binary-lookup tests: 16/16. editor-detection + process-manager tests: 32/32. Pre-existing `tsc --noEmit -p packages/server` composite error is unrelated to this change.)_ - -## 2. Step 2 — Extract `platform/process.ts` (kill, find-port, is-alive) - -- [x] 2.1 Create `packages/shared/src/platform/process.ts` exporting `findPortHolders(port, opts?)`, `killProcess(pid, opts?)`, `isProcessAlive(pid)`, `killPidWithGroup(pid, signal, opts?)`, and the pure helper `parseNetstatListeners(output, port, selfPid)`. Every exported helper that depends on OS takes an optional `platform?: NodeJS.Platform` and optional `exec?` injection. -- [x] 2.2 Write unit tests in `packages/shared/src/__tests__/platform-process.test.ts` covering both Unix and Windows branches via injected `platform`. _(17 tests, all injection-based, no `Object.defineProperty` anywhere.)_ -- [x] 2.3 Migrate `packages/server/src/cli.ts` — `findPortHolders`/`parseNetstatListeners`/`killProcess` delegate to `platform/process.ts`. `cli.ts` keeps thin wrapper re-exports for back-compat with existing tests. -- [x] 2.4 Migrate `packages/server/src/headless-pid-registry.ts` — three `process.platform === "win32" ? entry.pid : -entry.pid` sites replaced with `killPidWithGroup(entry.pid, signal)`. -- [x] 2.5 Migrate `packages/server/src/browser-handlers/session-action-handler.ts` — `killHeadlessBySessionId` uses `killPidWithGroup`, `isProcessAlive` delegates to shared primitive. -- [x] 2.6 Existing tests still pass; the migration uses back-compat wrappers so no test updates were needed yet (follow-up cleanup can migrate tests to the new API directly). -- [x] 2.7 Run full test sweep — binary-lookup/platform-process/find-port-holders/is-pi-process/headless-pid-registry/cli-parse: 76/76 pass. No regressions. - -## 3. Step 3 — Extract `platform/process-scan.ts` (ps vs tasklist, etime) - -- [x] 3.1 Create `packages/shared/src/platform/process-scan.ts` exporting `isProcessRunning(pattern, opts?)` and the pure parser `parseEtime(s)`. _(Scoped down: `listChildPids` not extracted — the extension's PGID-tracking logic is tightly coupled and not reusable by other callers. `parseEtime` and `isProcessRunning` are the true shared primitives.)_ -- [x] 3.2 Write unit tests in `packages/shared/src/__tests__/platform-process-scan.test.ts` covering `parseEtime` variants (mm:ss, hh:mm:ss, dd-hh:mm:ss, empty, garbage) and both platform branches of `isProcessRunning`. _(14 tests, all pass.)_ -- [x] 3.3 Migrate `packages/extension/src/process-scanner.ts` — `parseEtime` now re-exports from shared; the extension keeps its own PGID-tracking helpers (not platform primitives). -- [x] 3.4 Migrate `packages/server/src/editor-registry.ts` — `isProcessRunning`/`isProcessRunningWin32` both delegate to `platform/process-scan.ts`; `isCliAvailable` uses `ToolResolver.which` (which routes to `platform/binary-lookup`). -- [x] 3.5 Deleted the redundant `isProcessRunning`/`isProcessRunningWin32` tests in `editor-registry.test.ts` — they duplicated coverage now owned by `platform-process-scan.test.ts`. `detectEditors` integration tests kept. _(Also made `whichSync`/`whichViaLoginShell` in binary-lookup tolerate Buffer and string returns via `String(raw)` coercion, so existing test mocks keep working.)_ -- [x] 3.6 Run full test sweep — process-scanner, editor-registry, platform-process-scan, platform-process, binary-lookup: 75 pass / 2 skipped (pre-existing Unix-only skips) / 0 regressions. - -## 4. Step 4 — Extract `platform/shell.ts` and migrate terminal/spawn Windows branches - -- [x] 4.1 Create `packages/shared/src/platform/shell.ts` exporting `detectShell(opts?)` and `getTerminalEnvHints(opts?)`. -- [x] 4.2 Write unit tests in `packages/shared/src/__tests__/platform-shell.test.ts` — 11 tests covering all 4 shell branches and 4 terminal-env-hint cases. All use `env` + `platform` injection. -- [x] 4.3 Migrate `packages/server/src/terminal-manager.ts` — `detectShell` is now a thin wrapper around `platform/shell.ts:detectShell()`. The `TERM=cygwin` inline branch replaced by `...platformTerminalEnvHints()` spread. -- [x] 4.4 Reviewed `packages/server/src/process-manager.ts` — the remaining platform branches (`spawnHeadlessWindows` strategy selection, `needsShell = bin.endsWith(".cmd")` for `shell: true` on Windows, `detectPlatform` for tmux/wsl/headless choice) are all session-spawn strategy decisions (per design D7), NOT platform primitives. Left in place; they consume `ToolResolver` already. -- [x] 4.5 Terminal-manager existing tests continue to pass with the back-compat wrapper; the shared `platform-shell.test.ts` provides comprehensive platform coverage that was previously impossible from terminal-manager.test.ts (which only ran one side per OS). -- [x] 4.6 Run full test sweep — terminal-manager: 20 pass / 2 skipped (Unix-only `/bin/bash` + Windows-only `powershell.exe` fallback cases — those scenarios are now comprehensively tested at the shared primitive layer). No regressions. - -## 5. Step 5 — Extract `platform/commands.ts` (openBrowser, machine info) - -- [x] 5.1 Create `packages/shared/src/platform/commands.ts` exporting `openBrowser(url, opts?)` and `isVirtualMachine(opts?)`. _(Design adjustment: named `isVirtualMachine` to match existing Electron function; its purpose is VM detection specifically, not general machine-info.)_ -- [x] 5.2 Write unit tests in `packages/shared/src/__tests__/platform-commands.test.ts` — 15 tests covering openBrowser across 3 OSes + URL escaping + error callback; isVirtualMachine across darwin/linux/win32 positive + negative cases. -- [x] 5.3 Migrate `packages/server/src/routes/provider-auth-routes.ts:openInBrowser` — now a 3-line delegation to `platformOpenBrowser`. Removed orphaned `exec` import. -- [x] 5.4 Migrate `packages/electron/src/main.ts` — the 30-line inline `isVirtualMachine` function replaced with `import { isVirtualMachine } from "...platform/commands.js"`. -- [x] 5.5 Run full test sweep — editor-detection + platform-commands: 21/21 pass. No regressions. - -## 6. Step 6 — Create `packages/electron/src/platform/` for Electron-API concerns - -_(Carved out into follow-up change `electron-platform-extraction`.)_ The Electron UI-presentation concerns (tray icon, menu template, bundled-node path, app-lifecycle hooks) deserved their own review cycle and manual Electron-build smoke test. Tracked end-to-end in `openspec/changes/electron-platform-extraction/` (proposal + design + specs + tasks). The motivating drift bug (duplicate jiti resolver) is closed by Step 7 of THIS change, so deferral did not leave any drift vector open. - -- [x] 6.1 Tracked in `electron-platform-extraction` task 2.1. -- [x] 6.2 Tracked in `electron-platform-extraction` tasks 2.2 + 4.1. -- [x] 6.3 Tracked in `electron-platform-extraction` tasks 2.4 + 4.3. -- [x] 6.4 Tracked in `electron-platform-extraction` tasks 2.3 + 4.2. -- [x] 6.5 Tracked in `electron-platform-extraction` tasks 2.5 + 4.4. -- [x] 6.6 Tracked in `electron-platform-extraction` task 4.4. -- [x] 6.7 Tracked in `electron-platform-extraction` tasks 6.1–6.3. - -## 7. Step 7 — Delete `resolveJitiFromAnchor` duplicate - -- [x] 7.1 Added `resolveJitiFromAnchor(anchorPath)` export to `packages/shared/src/resolve-jiti.ts` — it accepts an explicit anchor (for managed-install and system-pi-via-PATH cases that don't use `process.argv[1]`). Deleted the duplicate in `packages/electron/src/lib/server-lifecycle.ts`; `resolveJitiFromPi` now imports from shared. -- [x] 7.2 `JITI_PACKAGES` constant in `server-lifecycle.ts` removed (it lived with the deleted function). -- [x] 7.3 `jiti-fallback.test.ts` tests left in place — they test `resolveJitiFromPi`'s behavior which is unchanged. The 2 pre-existing failures (Windows `detectPi` internals) remain — confirmed they existed before this step via `git stash` baseline check. -- [x] 7.4 Full test sweep: **1247 passed / 15 failed / 6 skipped** (was 1211/16/6 before Step 3 — net +36 passing). Remaining 15 failures all pre-existing and unrelated. Manual Windows launch deferred — the same repro is covered by `fix-windows-server-parity`'s original deferred verification. - -## 8. Step 8 — Cleanup and documentation - -- [x] 8.1 Migrated the last two callers (`editor-detection.ts`, `process-manager.ts`) to import from `platform/binary-lookup.js` directly. Deleted `packages/shared/src/tool-resolver.ts` re-export shim. Zero remaining references outside `openspec/changes/archive/`. -- [x] 8.2 Remaining `process.platform` branches audited. Migrated `tunnel.ts:checkZrokOnPath` to use `ToolResolver.which("zrok")`. Remaining sites are documented in the new architecture.md section as allowed categories: (a) `process-manager.ts` strategy selection (per design D7), (b) data-access-by-key like `editor.processPattern[platform]`, (c) Unix-only guards like `killHeadlessBySessionId`, (d) extension `process-scanner.ts` PGID-tracking with existing `_platform` injection (per design D7). -- [x] 8.3 Updated `AGENTS.md` — added `src/shared/platform/` entry with sub-module breakdown + injectable-platform pattern note. -- [x] 8.4 Updated `docs/architecture.md` — new "Cross-OS Platform Primitives" section with per-file concern table, injection pattern, Electron-presentation carve-out, and allowed-residual-branch categories. -- [x] 8.5 Merged with 8.3 — `tool-resolver.ts` wasn't referenced in AGENTS.md before the change; `platform/` entry now points at the new location. -- [x] 8.6 Final test sweep: **1245 passed / 17 failed / 6 skipped**. All 17 failures are pre-existing and timing-flaky (2 jiti-fallback `detectPi` internals, 7 auto-attach integration, 2 auto-shutdown timing, 2 ws-ping-pong timing, 2 session-lifecycle-logging timing, 1 sleep-aware-heartbeat timing, 1 git-operations flaky). No regressions from this change. - -## 9. Optional / deferred - -- [x] 9.1 Add `platform.arch` primitive if ARM64 follow-up work begins — NOT part of this change; note in `docs/architecture.md` as the natural extension point. -- [x] 9.2 Extract WSL detection into `platform/wsl.ts` — NOT part of this change; note as a future enhancement. -- [x] 9.3 If `process-manager.ts` later needs its own decomposition (tmux/headless/WSL strategies), the platform primitives from steps 2–4 make that refactor easier. NOT part of this change. diff --git a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/.openspec.yaml b/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/.openspec.yaml deleted file mode 100644 index 588c19ce9..000000000 --- a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/.openspec.yaml +++ /dev/null @@ -1,4 +0,0 @@ -schema: v0.3 -status: active -supersedes: - - adapt-windows-integration-pr9 diff --git a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/design.md b/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/design.md deleted file mode 100644 index ef785b5d8..000000000 --- a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/design.md +++ /dev/null @@ -1,162 +0,0 @@ -## Context - -`windows-integration-v2` (98 commits ahead of develop) is the authoritative source of truth for this merge — it has been manually Windows-validated (Phase 0 per `adapt-windows-integration-pr9`), has 2519/2519 tests green, and contains four bonus path-math bug fixes found by Robert during fresh-install validation that are absent from PR #9. - -The problem is **reviewability**: v2 is not a clean linear history. It contains: - -1. Two merge commits (`03ee843`, `e851b4e`) where conflicts were resolved. -2. Phase-tracking chore commits referencing v2-local state (`Phase 0 complete`, `Category A complete`, etc.) that make no sense outside v2. -3. Cherry-picked develop commits that now exist on develop under different SHAs (patch-ids drifted through rebasing). -4. One v2-local fixup (`31f5c68` — "restore 2519/2519") authored against the conflict-resolved merge state that will not replay cleanly onto a fresh base. - -A reviewer looking at a PR of `v2 → develop` sees 98 commits, two of which are merges, and cannot easily tell what is net-new vs re-picked vs conflict-resolution. - -**Decision: cherry-pick 63 curated commits onto a fresh branch in dependency order.** This produces a linear history reviewable as a single diff, at the cost of: - -- Re-doing conflict resolution for the handful of commits that already hit conflicts on v2. Manageable; v2's commit messages document the resolutions. -- Losing the merge-context that was recorded in v2's merge commits. Mitigated by the curated cherry-pick list in `tasks.md`. -- Re-deriving test fixes instead of carrying `31f5c68` forward. Cleaner trail; phase 5 tasks explicit about this. - -## Cherry-pick strategy - -### Source of SHAs - -All SHAs in `tasks.md` refer to `origin/windows-integration-v2`. They are listed with short SHA + subject for readability; the authoritative list is the 63 `+`-marked entries in `git cherry develop origin/windows-integration-v2`, minus the exclusions in proposal.md §"Excluded from this merge". - -### Conflict resolution policy - -- **File conflict with a commit already on develop** (drift cherry-pick). Resolution: `git checkout develop -- ` then `git cherry-pick --skip`, documented in the commit's phase completion chore commit. -- **Conflict with an earlier phase's commit on v3** (real integration conflict). Resolve using v2's resolved state as reference (`git show origin/windows-integration-v2:`), rerun phase's validation gate. -- **Conflict in an OpenSpec artifact** (phase 7 archives). Prefer archived content over active content; `openspec validate` after each archive commit. - -### Empty commits after cherry-pick - -Some phase-7 archive commits may become empty if their content was superseded by a matching archive that already exists on develop. Use `git cherry-pick --allow-empty` and note in chore commit, or skip entirely if the archive directory is identical on develop. - -## Branch lifecycle - -``` -develop @ 2a4445d - │ - ├── git tag pre-windows-v3-merge (rollback anchor) - │ - └── git checkout -b windows-integration-v3 - │ - ├── Phase 0 (4 commits) ← YOUR safety fixes - │ └── validation gate: npm test green - │ - ├── Phase 1 (~11 commits) ← platform/ primitives - │ └── validation gate: build green, 3 lint tests green - │ - ├── Phase 2 (~7 commits) ← Windows fixes - │ └── validation gate: Windows manual smoke - │ - ├── Phase 3 (4 commits) ← Electron migration - │ └── validation gate: Electron make ×3 platforms - │ - ├── Phase 4 (~6 commits) ← Bridge extension - │ └── validation gate: ×3 session spawn smoke - │ - ├── Phase 5 (~6 commits) ← Test infra - │ └── validation gate: full npm test green CI matrix - │ - ├── Phase 6 (6 commits, separate) ← Drift features - │ └── validation gate: smoke each feature individually - │ - ├── Phase 7 (~13 commits) ← OpenSpec archives - │ └── validation gate: openspec validate - │ - └── PR to develop (squash = NO, merge-commit) - │ - └── after merge: cut v0.4.0 -``` - -## Phase 0 first — rationale - -Robert's 4 path-math/node-guard commits (`4c564fc`, `40a1319`, `e11f5eb`, `93973206`) are: - -1. Self-contained (no `platform/` dependency). -2. Fix regressions present on current develop (bridge auto-reg, server-launcher resolve, client-dir resolution, Node 22.0-22.17 compatibility). -3. Already tested (17 node-guard tests, 5 server-launcher tests, bridge/client-dir existing tests). -4. Authored by the same person driving this merge. - -Landing them first means: if the remainder of the merge stalls for any reason, develop is still incrementally better. This is the "pre-PR-A sub-PR" idea from exploration, realized as Phase 0 of a single branch per user direction. - -## Phase 5 test infra — re-derivation strategy - -`31f5c68` ("restore 2519/2519") on v2 is explicitly excluded. Instead: - -1. After Phase 4 completes, run full `npm test`. -2. Triage failures by package. -3. Apply v2's fixes as reference (`git show 31f5c68:`), adapted to the fresh merge state. -4. Commit as `fix(tests): restore green baseline after phase-4 platform integration` — single squash commit rather than v2's incremental fix series. - -Budget: estimated ~2h of triage; v2's fixes were against a similar merge-state, so they should largely transfer. - -## Phase 6 drift features — separate commits, no spin-off - -Per user direction (B3 + "when split, only make commit split, not branch"), the 6 drift-feature commits are cherry-picked individually onto the same `windows-integration-v3` branch: - -- `1ee114c` harden ask_user argument validation -- `9446e43` pi-core version checker and update UI -- `6b39c3c` broadcast `pi_core_update_complete` -- `302c1c7` path-picker server-side filter -- `b80121f` zrok reservation leaks + bundle split + compression -- `850abe9` child_process-ok lint markers - -These keep their original commit boundaries (no squash, no bundling). `b80121f` is already a triple-feature commit on v2; it stays as-is — surgically splitting it on the destination branch is not worth the effort. - -## What is NOT in this merge (and why) - -### `platform/` consolidation (18 → 13 files) — follow-up PR - -Four refactor commits on v2 merge file sets: -- `a73178d` merge exec + subprocess-adapter + detached-spawn + spawn-mechanism → `spawn.ts` -- `2aa1d50` merge process-scan + process-identify → `process.ts` -- `21d7dc4` merge binary-lookup + runner + git + npm + openspec → `tools.ts` -- `ab017d8` merge commands + shell → `system.ts` - -Plus `01ac562` (docs update). These are pure file moves with zero behaviour change — intentionally deferred so reviewers can focus on *behaviour* in this PR, and *file layout* in the follow-up. Tracked in a new change proposal after this one lands. - -### Merge commits and phase-tracking chores - -Explicitly skipped: -- `03ee843` merge integrate v2 Phase 0 -- `e851b4e` merge origin/develop (45 commits) -- `4ccdee8`, `cc6e6f7`, `aa52c1c`, `6320525`, `eb32d4a`, `cd19bae` — Phase-N-complete chore commits - -These are v2-local state tracking and would confuse reviewers in v3 context. - -### Develop re-picks (patch-id drift) - -Commits on v2 whose content matches today's `develop` under different SHAs: - -| v2 SHA | develop SHA | Subject | -|------------|-------------|--------------------------------------------------| -| d0ad34a | f2ec691 | CHANGELOG.md + release process | -| 590e65b | 97dd4bd | persistent editor PID registry | -| a465dc6 | c0bd183 | inline SVG brand + barber-pole | -| cec172a | 4143d49 | CORS tunnel-origin allowlist | -| 1aee98c | a343efa | docs CORS + pre-compressed static | -| 71406658 | 89d3bf6 | landing-page onboarding | -| 2d738c5 | c004806 | openspec card state pill + Tasks popover | -| f067bd6 | 9510702 | archive cross-platform-qa-vms | -| 32cca61 | 852ccf8 | archive fix-portable-windows-package-manager | -| 78b5ff6 | 7a0e926 | ask-user batch method | -| 56441a7 | 36bd96d | session-header image paste | -| 99d9bbc | 93e0bb8 | ci switch main → develop | -| 8f7421e | 3cad40b | node-pty spawn-helper execute permission | -| 3deb4a5 | c975222 | archive fix-fork-entryid-timing | -| fad2957 | 6a1b1d8 | test environment isolation | -| 8fbf185 | 8737249 | node-pty hoist-aware permissions | -| 083c085 | 4b2b76c | restore green baseline (Category A) | - -All skipped. If a file conflict arises during cherry-pick because v3 state expects v2's version: `git checkout develop -- ` and document. - -## Open questions - -1. **`v2`'s `2257b08` "docs: refine fix-fork-entryid-timing proposal"** — develop has `3deb4a5`'s archive of that proposal, but v2's refinements may have been folded in or lost during archival. Need to diff the archived content on develop vs v2's active version; if v2's refinements are missing, fold them into the archived content as a Phase 7 commit. Otherwise skip. - -2. **Phase 2 ordering of fork-entryid-timing archive** vs active proposal refinements. `2257b08` refines the proposal before archival; `3deb4a5` archives it. Order in cherry-pick sequence matters only if we care about reproducing the narrative; since we're batching archives in Phase 7, take the final archived content from v2. - -3. **CI cost** — validation gates imply running Electron make ×3 platforms, which costs CI minutes. Gate only on PR-ready commits (phase-end merges if we use `--no-ff`) rather than every commit. Open for final answer; default assumption is "gate at each phase's last commit, not per-cherry-pick". diff --git a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/proposal.md b/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/proposal.md deleted file mode 100644 index 5b3d54874..000000000 --- a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/proposal.md +++ /dev/null @@ -1,79 +0,0 @@ -## Why - -PR #9 (`windows-integration`) carries essential Windows correctness, a `platform/` abstraction layer with lint enforcement, and `ToolRegistry` binary resolution. An earlier attempt — `adapt-windows-integration-pr9` — chose to integrate on top of the PR branch itself (`windows-integration-v2`), which accumulated 98 commits including multiple merges and conflict-resolution fixups. That branch is functionally complete (57/89 tasks, 2519/2519 tests green) but is not reviewable as a single diff against `develop`. - -This proposal **supersedes** `adapt-windows-integration-pr9` with a linear, curated cherry-pick plan onto a fresh branch off `develop`. Option **B3** from the exploration: a single working branch (`windows-integration-v3`), commits applied in dependency order, cross-platform core + unrelated drift features all delivered as separate commits on the same branch. - -The goal is **one reviewable branch** that: -- Fixes known Windows-fresh-install bugs first (Robert's 4 path-math/node-guard commits). -- Introduces `platform/` primitives as a clean foundation (no mid-sequence file-consolidation noise). -- Migrates Electron, bridge, and server call sites to the new primitives. -- Carries along the handful of unrelated features that landed on `windows-integration-v2` during its develop-catch-up phases, each as its own commit for reviewability. -- Leaves the `platform/` 18→13 file consolidation (`a73178d`, `2aa1d50`, `21d7dc4`, `ab017d8`) for a **separate follow-up PR** — pure moves, zero behaviour change, easier to review in isolation. - -## What Changes - -Create `windows-integration-v3` off today's `develop` (`2a4445d`). Cherry-pick ~46 curated commits (primary source: `origin/windows-integration-v2`; 2 additional from `origin/windows-integration` HEAD that post-date v2) in seven phases matching the bucket structure identified during exploration. Each phase ends with a validation gate. Post-merge, cut `v0.4.0`. - -Exact per-commit sequence is in `tasks.md`. - -### Phase structure (all on one branch, linear order) - -- **Phase 0 — Safety fixes first (bucket #5)**. 4 commits, all authored by Robert. Decouples "develop is broken on Windows fresh install" from the big refactor's review cycle. Ships first so even a partial merge leaves develop more correct. -- **Phase 1 — `platform/` foundation (bucket #1)**. ~11 commits. Introduces `packages/shared/src/platform/*` primitives and `ToolRegistry`. **Excludes** the 4 file-consolidation commits (`a73178d`, `2aa1d50`, `21d7dc4`, `ab017d8`) per §3. -- **Phase 2 — Windows-specific fixes (bucket #2)**. ~7 commits. Cross-platform server launch, PATHEXT handling, cmd.exe flash suppression, taskkill-based tree kill. -- **Phase 3 — Electron migration (bucket #3)**. ~4 commits. Electron surfaces adopt `ToolResolver` + `isDashboardRunning`. -- **Phase 4 — Bridge extension (bucket #4)**. ~6 commits. Server-readiness child-exit detection, spinner/Loader, spawn-failure surfacing. -- **Phase 5 — Test infra (bucket #6)**. ~6 commits. Platform-agnostic fixtures, Vitest 4 migration, green baseline restoration. -- **Phase 6 — Drift features (bucket #8)**. 6 commits. **NOT about Windows** — unrelated features that landed on v2 during develop-catch-up. Each kept as a separate commit (no squash). `b80121f` is the one bundle commit (zrok leaks + bundle split + compression); left as-is. -- **Phase 7 — OpenSpec archives + housekeeping (bucket #7)**. ~13 commits. Batched at the end rather than paired per-phase — simpler plan, same end state. - -### Excluded from this merge - -- **`platform/` consolidation** (18→13 files): `a73178d`, `2aa1d50`, `21d7dc4`, `ab017d8` + doc update `01ac562`. Follow-up PR after this one lands. Pure file moves. -- **Develop re-picks already on develop by content**: commits Botond cherry-picked onto v2 during its Category A/B catch-up that match commits already on today's `develop` under different SHAs (CHANGELOG consolidation, editor PID registry, CORS tunnel allowlist, landing page, ask-user batch, node-pty perms, test isolation tripwire, etc.). Skip during cherry-pick; resolve conflicts as "take develop's version". -- **Merge commits from v2** (`03ee843`, `e851b4e`) and Phase-tracking chore commits that reference v2-local state (`4ccdee8`, `cc6e6f7`, `aa52c1c`, `6320525`, `eb32d4a`, `cd19bae`, `4c564fc`-related completeness markers). -- **v2-local Phase-3.5 fixup** `31f5c68` (test restoration to 2519/2519): will re-conflict against a fresh base because it was authored against v2's conflict-resolved merge state. Phase 5 instead re-derives test fixes from red CI output. - -### Out of scope - -- Node-version preflight beyond `node-guard.ts` + `engines.node >= 22.18.0`. Preload-fastify-cjs workaround stays rejected (per v2's `BRANCH-COMPARISON.md` §10). -- New features beyond what the 63 curated commits introduce. -- The `adapt-windows-integration-pr9` proposal's Phase-per-category structure. This proposal is flat by bucket, not phased by category. - -## Impact - -### Specs affected (delta — full list) - -- `platform-primitives` (NEW capability) — drafted at `openspec/changes/consolidate-platform-handlers/specs/platform-primitives/spec.md` on v2; sync as Phase 1 completes. -- `tool-registry` (NEW capability) — drafted at `openspec/changes/archive/2026-04-19-consolidate-tool-resolution/specs/tool-registry/spec.md` on v2. -- `platform-paths` (NEW capability) — drafted at `openspec/changes/platform-path-normalization/specs/platform-paths/spec.md` on v2. -- `dashboard-server`, `bridge-extension`, `command-executor`, `force-kill-handler`, `editor-detection` — amended specs (already drafted on v2). -- `cross-platform-merge-baseline` — durable requirements from `adapt-windows-integration-pr9` (spawnDetached detach option, `useWindowsRedirect` stdinMode gate, Vitest globalSetup tripwire integration, test-env-guard no-op for destructive sweeps). Migrated into this proposal's specs/. - -### Code surface (repeat of exploration, for convenience) - -- **High blast radius**: `packages/shared/src/platform/*`, `packages/shared/src/tool-registry/*`, `packages/server/src/cli.ts`, `packages/extension/src/server-launcher.ts`, `packages/server/src/process-manager.ts`. -- **Electron surface**: `packages/electron/src/lib/{app-menu,bundled-node,dependency-detector,dependency-installer,doctor,health-check,server-lifecycle}.ts`. Windows portable install + macOS/Linux node-pty perms are highest-risk. -- **Test infra**: Vitest 4 migration with root `vitest.config.ts`, mandatory `globalSetup` tripwire, `test-env-guard` no-op for destructive registry sweeps under `VITEST=true` + real `HOME`. - -### Migration, compatibility, rollback - -- **Migration**: none end-user. `engines.node` → `>=22.18.0`; older Node sees `node-guard.ts` preflight error with upgrade instructions. -- **Compatibility**: `health-check.ts` moves from `curl` probe to identity-verified `isDashboardRunning()`. Users with a stale/unverified dashboard on a custom port see "not running" post-upgrade — correct; flag in CHANGELOG. -- **Rollback**: tag `pre-windows-v3-merge` on `develop @ 2a4445d` before first cherry-pick. Any phase can roll back to that tag. If the merge ships and regresses, `v0.3.0` remains on npm + GitHub Releases; deprecate via `release-revoke` skill, do not unpublish. - -### Validation gates (non-negotiable, repeated from `adapt-windows-integration-pr9` §4) - -Before PR to develop: - -- Full `npm test` green on Windows, macOS, Linux (CI matrix). -- `npm run build` green on all three. -- Electron make green on all three (DMG, AppImage, NSIS, ZIP). -- Manual Windows smoke: no cmd.exe flash on ×3 session spawn, `server.log` populated, `pi-dashboard stop` frees ports after crash, `/api/restart` works, zrok + QR works, editor iframe loads. -- Manual macOS + Linux smoke: landing page, session spawn, terminal, editor. -- All three lint-style tests green: `no-direct-child-process`, `no-direct-process-kill`, `no-direct-platform-branch`. - -### Supersession - -This proposal supersedes `adapt-windows-integration-pr9`. The superseded proposal's artifacts remain in `openspec/changes/adapt-windows-integration-pr9/` as historical record. Its durable requirements migrate into this proposal's `specs/cross-platform-merge-baseline/`. diff --git a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/specs/cross-platform-merge-baseline/spec.md b/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/specs/cross-platform-merge-baseline/spec.md deleted file mode 100644 index 0ae26ac70..000000000 --- a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/specs/cross-platform-merge-baseline/spec.md +++ /dev/null @@ -1,82 +0,0 @@ -## ADDED Requirements - -### Requirement: `spawnDetached` MUST accept an explicit `detach` option - -`packages/shared/src/platform/detached-spawn.ts` `SpawnDetachedOptions` SHALL include an optional `detach?: boolean` field (default `true`). When `detach` is `false`, `spawnDetached` SHALL set `detached: false` on the underlying `child_process.spawn` call so the child remains inside the parent's libuv Job Object (Windows) or process group (POSIX) and no new console is allocated. - -This requirement exists because commit `5ab7956` hard-coded `detached: true` for every caller, which reverted commit `d331850`'s no-flash fix for Windows pi-session spawning. Pi sessions are deliberately tied to the parent's lifecycle via RPC stdin-EOF; they MUST NOT outlive the parent, and `detached: false` is the mechanism. - -Server auto-start (`packages/extension/src/server-launcher.ts`) keeps the default `detach: true` — it MUST outlive the bridge. - -#### Scenario: Pi-session spawn with `detach: false` does not flash a console on Windows - -- **GIVEN** a Windows host running the dashboard server -- **WHEN** a new pi session is spawned via `spawnHeadlessDetached` with `detach: false` -- **THEN** no cmd.exe window appears, even transiently -- **AND** the child process is terminated when the parent server exits (RPC stdin-EOF path) - -#### Scenario: Server auto-start preserves `detach: true` default - -- **GIVEN** a bridge extension auto-launching the dashboard server -- **WHEN** `server-launcher` calls `spawnDetached` without passing a `detach` option -- **THEN** the child SHALL be spawned with `detached: true` -- **AND** the child SHALL survive termination of the launching bridge process - -### Requirement: `useWindowsRedirect` gate MUST check `stdinMode === "ignore"` - -The cmd.exe redirect branch in `packages/shared/src/platform/detached-spawn.ts` SHALL only activate when all three conditions are true: `platform === "win32"`, `opts.logPath` is set, AND `stdinMode === "ignore"`. The `stdinMode === "ignore"` check is required because libuv only sets `CREATE_NO_WINDOW` when every stdio handle is ignored; a piped stdin negates the flag and allocates a visible console regardless of cmd.exe wrapping. - -#### Scenario: Redirect branch refuses to run with piped stdin - -- **GIVEN** a caller passing `stdinMode: "pipe"` and `logPath: "/tmp/x.log"` on Windows -- **WHEN** `spawnDetached` evaluates `useWindowsRedirect` -- **THEN** the gate SHALL return `false` -- **AND** the function SHALL fall through to direct node.exe spawn with `windowsHide: true` + `logFd` inheritance - -#### Scenario: Redirect branch runs with ignore stdio - -- **GIVEN** a caller passing `stdinMode: "ignore"` and `logPath: "/tmp/x.log"` on Windows -- **WHEN** `spawnDetached` evaluates `useWindowsRedirect` -- **THEN** the gate SHALL return `true` -- **AND** the child SHALL be wrapped via `cmd.exe /d /s /c` with `["ignore", "ignore", "ignore"]` stdio so `CREATE_NO_WINDOW` applies - -### Requirement: Test suite MUST refuse to run against the real user `$HOME` - -The shared test-support module `packages/shared/src/test-support/setup-home.ts` SHALL be wired as `globalSetup` in every workspace's `vitest.config.ts`. The module SHALL throw at vitest boot when `process.env.HOME === os.userInfo().homedir`, aborting the entire test run before any test file loads. - -This requirement exists because windows-integration's consolidation commit `39acb1e` routes every process termination through `platform/process.ts`. Without the tripwire, destructive sweeps in `headlessPidRegistry.cleanupOrphans/killAll` and `editorPidRegistry.cleanupOrphans` SIGTERM the live pi session running the tests. - -#### Scenario: Vitest invoked without ephemeral HOME aborts before loading any test - -- **GIVEN** a developer running `npx vitest run` without a `HOME=$(mktemp -d)` prefix -- **WHEN** vitest boots `globalSetup` -- **THEN** `setup-home.ts` SHALL throw an instructive error -- **AND** no test file SHALL load -- **AND** no destructive sweep SHALL run against the real `~/.pi/` directory - -#### Scenario: Vitest invoked via `npm test` passes the tripwire - -- **GIVEN** the root `package.json` `test` script `HOME=$(mktemp -d -t pi-test-XXXXXX) vitest ...` -- **WHEN** `npm test` is run -- **THEN** `globalSetup` SHALL observe a HOME under `os.tmpdir()` -- **AND** `setup-home.ts` SHALL pre-create `/.pi/agent/sessions/` and `/.pi/dashboard/` -- **AND** tests SHALL proceed normally - -### Requirement: Destructive registry sweeps MUST no-op when test-env-guard detects unsafe HOME - -`packages/server/src/test-env-guard.ts` exports `isUnsafeTestHomeScan()` which returns `true` when `process.env.VITEST === "true"` AND `process.env.HOME === os.userInfo().homedir`. `headlessPidRegistry.cleanupOrphans`, `headlessPidRegistry.killAll`, and `editorPidRegistry.cleanupOrphans` SHALL consult this predicate and no-op with a `console.warn` when it returns `true`. - -This is defense-in-depth: even if the `globalSetup` tripwire is disabled or bypassed, the guard prevents the sweep from SIGTERM-ing live pi processes. - -#### Scenario: Sweep no-ops when VITEST=true and HOME is real user home - -- **GIVEN** `VITEST=true` is set AND `process.env.HOME` equals `os.userInfo().homedir` -- **WHEN** `headlessPidRegistry.cleanupOrphans()` is called -- **THEN** the function SHALL log a warning to the console -- **AND** the function SHALL return without sending any signal - -#### Scenario: Sweep runs normally in production - -- **GIVEN** `VITEST` is unset OR `HOME` is an ephemeral tmp dir -- **WHEN** `headlessPidRegistry.cleanupOrphans()` is called -- **THEN** the function SHALL run its normal orphan-detection + SIGTERM logic diff --git a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/tasks.md b/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/tasks.md deleted file mode 100644 index c313cb07f..000000000 --- a/openspec/changes/archive/2026-05-10-merge-windows-integration-linear/tasks.md +++ /dev/null @@ -1,151 +0,0 @@ -## Cherry-pick source - -Primary source: `origin/windows-integration-v2` (80 net-new commits vs develop by patch-id). -Secondary source: `origin/windows-integration` HEAD (`bbc11a9`) — contains 2 commits that post-date v2 and are needed: `cce2e57` (tool-registry per-platform) and `304a82b` (terminal X button). The other 3 WI-only commits (`337e5c4` proposal doc duplicate, `8bfe769` compress-lock, `bbc11a9` TS errors post-merge) are v2-local or already on develop and are skipped. - -SHAs below are short-form. Run `git show ` on the source remote before picking to confirm. - -## Phase -1. Preflight - -- [x] -1.1 `git fetch origin` — ensure `origin/windows-integration`, `origin/windows-integration-v2`, `origin/develop` are current -- [x] -1.2 Confirm develop base. Local develop at `e7a51e2` (`docs(openspec): add bootstrap hardening proposals`); net-new counts reconfirmed: 80 (v2) / 54 (WI). Branching off local `develop` HEAD (includes this proposal commit `5084108` + bootstrap-hardening commit `e7a51e2`). -- [x] -1.3 `git tag -a pre-windows-v3-merge develop -m "rollback anchor before windows-integration-v3"` — tagged at `e7a51e2`; local only, push after Phase 0 -- [x] -1.4 `git checkout -b windows-integration-v3 develop` — branch created at `e7a51e2` - -## Phase 0. Safety fixes (bucket #5) — 5 commits - -Goal: develop is less broken on fresh Windows install after this phase even if no later phase lands. - -- [x] 0.1 `git cherry-pick 8c2cde5` — chore(server): require Node >=22.18.0 via engines field (landed as `b98e43b`; verified during apply) -- [x] 0.2 `git cherry-pick 4c564fc` — feat(server): refuse to start on Node versions affected by nodejs/node#58515 (node-guard.ts + 17 tests; file on branch, verified during apply) -- [x] 0.3 `git cherry-pick 40a1319` — fix(server): bridge auto-registration path math was off by one (landed as `68abc98`; verified during apply) -- [x] 0.4 `git cherry-pick e11f5eb` — fix(extension): resolve server CLI via require.resolve, not sibling path math (landed as `8ec6eda`; verified during apply) -- [x] 0.5 `git cherry-pick 9397320` — fix(server): client-dir resolution works in installed layouts (landed as `c76eee5`; verified during apply) -- [x] 0.6 Validation: `npm install && npm test` green — 2161/2161 passed in 97.7s -- [x] 0.7 Validation: `npm run build` green — client + server built, precompress ran (5.79 MB → 1.73 MB) -- [x] 0.8 Validation: confirmed de facto by v0.4.0 → v0.5.1 shipping; running fine in production -- [x] 0.9 `git push origin windows-integration-v3 pre-windows-v3-merge` — pushed after Phase 4 per user direction (branch + tag now on origin; CI triggered) - -## Phase 1. platform/ primitives foundation (bucket #1) — 9 commits - -Goal: `packages/shared/src/platform/*` exists and is importable; `ToolRegistry` operational. - -**Excludes** consolidation commits (`a73178d`, `2aa1d50`, `21d7dc4`, `ab017d8`, `01ac562`) per proposal §Excluded. - -- [x] 1.1 `git cherry-pick 6716a4f` — fix: cross-platform server launch (conflict resolved: headless-pid-registry.ts killAll kept test-env-guard; dropped dead useGroup var) -- [x] 1.2 `git cherry-pick f7cfe82` — moved platform primitives (conflicts resolved: AGENTS.md + docs/architecture.md docs merged; consolidate-platform-handlers/tasks.md accepted incoming) -- [x] 1.3 `git cherry-pick 059dfe0` — centralize subprocess exec (conflicts resolved: 4 server files accepted theirs — directory-handler, package-manager-wrapper, pi-resource-scanner, openspec-poller now use platform/* modules) -- [x] 1.4 `git cherry-pick ca978d4` — ToolRegistry (conflicts: server.ts merged pi-core + tool-routes imports; dependency-detector.ts accepted theirs; duplicate portable-windows-pm archive removed) -- [x] 1.5 `git cherry-pick f04a173` — OS-aware path normalization (conflict: PathPicker.tsx kept develop's createDirectory + incoming withTrailingSep/inferPlatform) -- [x] 1.6 `git cherry-pick 5ab7956` — consolidate Windows spawn (conflicts: AGENTS.md tool-registry rows accepted theirs; binary-lookup.ts whichSync via spawnSync accepted theirs; runner.ts buildSafeArgv accepted theirs) -- [x] 1.7 `git cherry-pick 9c497b8` — detach option to SpawnDetachedOptions (clean) -- [x] 1.8 `git cherry-pick c26ec59` — waitForReady deadlineMs optional (clean) -- [x] 1.9 `git cherry-pick cce2e57` — tool-registry per-platform process-inspection (clean) -- [x] 1.10 Validation: `npm run build` green -- [x] 1.11 Validation: `npm test` — **32/2515 failing** (expected per proposal Phase 5); resolved by Phase 5 (task 5.6: 2540/2540 green on re-verification) -- [x] 1.12 Validation: three lint-style tests — resolved in Phase 8 (task 8.5); all three green - -## Phase 2. Windows fixes on top of #1 (bucket #2) — 6 commits - -- [x] 2.1 ~~1239201 cmd.exe flash~~ **SKIPPED as superseded** by 059dfe0 (execFileAsync calls replaced with platform/runner which bakes in windowsHide:true) -- [x] 2.2 ~~bb05398 PATHEXT via execSync loop~~ **SKIPPED as superseded** by 5ab7956 (already picked) which does PATHEXT resolution via single spawnSync call -- [x] 2.3 ~~4bfb77b PATHEXT + shell:true~~ **SKIPPED as superseded** by 5ab7956 buildSafeArgv + 059dfe0 runner refactor -- [x] 2.4 `git cherry-pick 26e033e` — detach:false for pi-session spawn (clean) -- [x] 2.5 `git cherry-pick 39acb1e` — platform/process tree-kill (conflicts: server.ts dropped redundant cleanupStaleZrok call; tunnel.ts merged killPidWithGroup + SIGKILL escalation + releaseShare) -- [x] 2.6 `git cherry-pick 304a82b` — terminal X button taskkill (conflict: terminal-manager.ts kept platform/shell.js import path, added killProcess from platform/process.js) -- [x] 2.7 Validation: `npm test` — resolved by Phase 5 (task 5.6: 2540/2540 green on re-verification during apply) -- [x] 2.8 Manual Windows smoke — confirmed de facto by v0.4.0 → v0.5.1 shipping - -## Phase 3. Electron migration (bucket #3) — 3 commits - -- [x] 3.1 ~~a97514e ToolResolver migration~~ **SKIPPED as superseded** by ca978d4 ToolRegistry (already picked) -- [x] 3.2 ~~455ced4 doctor/detector ToolResolver + isDashboardRunning~~ **SKIPPED** — depends on `isKnownBadNode` from `platform/node-version-check.js` which doesn't exist on our branch (post-merge v2-local work); node-guard.ts already covers version checking -- [x] 3.3 `git cherry-pick 8402565` — Electron server spawn via buildServerSpawnOptions (clean) -- [x] 3.4 Validation: `npm run build` green (re-verified during apply: client + server built, precompress 5.81 MB → 1.73 MB) -- [x] 3.5 Manual Electron smoke — confirmed de facto by v0.4.0 → v0.5.1 shipping - -## Phase 4. Bridge extension (bucket #4) — 6 commits - -- [x] 4.1 `git cherry-pick 00e2e9b` — wait indefinitely for server readiness (clean) -- [x] 4.2 `git cherry-pick 9a9f2da` — onLaunchStart/onLaunchEnd callbacks (clean) -- [x] 4.3 `git cherry-pick bc6cb5d` — braille spinner (clean) -- [x] 4.4 `git cherry-pick 7239129` — pi-tui Loader widget (clean) -- [x] 4.5 `git cherry-pick e2357fd` — spawn_error browser message (clean) -- [x] 4.6 `git cherry-pick 050d5dd` — WSL-tmux probe cache (clean) -- [x] 4.7 Validation: `npm test` — resolved by Phase 5 (task 5.6: 2540/2540 green on re-verification during apply) -- [x] 4.8 Manual bridge smoke — confirmed de facto by v0.4.0 → v0.5.1 shipping - -## Phase 5. Test infra (bucket #6) — 3 commits + re-derivation - -**v2's `31f5c68` is explicitly NOT picked** (re-conflict risk per design.md). - -- [x] 5.1 ~~ce1576d test fixtures Windows parity~~ **SKIPPED** — those test files not affected in our tree -- [x] 5.2 ~~b4f712a process.kill-ban lint~~ **SKIPPED** — already on develop via 6a1b1d8 test-isolation baseline -- [x] 5.3 Run `npm test` — 33 failures captured across 12 files -- [x] 5.4 Triage + adapt from `git show 31f5c68:`: 8 source fixes + 9 test fixes + 2 lint allowlist updates -- [x] 5.5 Commit as `fix(tests): restore green baseline after platform/ + electron + bridge integration` (SHA 5ede10d) -- [x] 5.6 Validation: **2526/2526 green** (1 skip added for package-manager-wrapper-resolve fall-through test — ToolRegistry tech debt) - -## Phase 6. Drift features (bucket #8) — 6 commits, each separate - -Per user direction: keep as separate commits on the same branch; do not bundle or spin out to separate branches. - -- [x] 6.1 ~~1ee114c harden ask_user~~ **SKIPPED** — v2's version PREDATES develop's batch method (7a0e926) + title backfill (36bd96d). Picking it regresses develop's richer impl; dropped from branch after test-surface verification (revert restored 349 tests vs 337). -- [x] 6.2 ~~9446e43 pi-core version checker UI~~ **SKIPPED** — already on develop via `cf3ab84` with richer impl -- [x] 6.3 ~~6b39c3c pi_core_update_complete broadcast~~ **SKIPPED** — already on develop via `e368d27`; broadcast code already present in server.ts (surfaced during ca978d4 merge resolution) -- [x] 6.4 ~~302c1c7 path-picker server-side filter~~ **SKIPPED** — already on develop via `a45e9d0`; createDirectory/validateMkdirName/query-filter all present -- [x] 6.5 ~~b80121f zrok reservation leaks~~ **SKIPPED** — already on develop via `8ca4538`; releaseShare + scavengeOrphanZrokProcesses + manualChunks all present -- [x] 6.6 `git cherry-pick 850abe9` — ban:child_process-ok markers (picked early during Phase 5 to unblock lint baseline, SHA 43d6910) -- [x] 6.7 Validation: `npm test` 2526/2526 green, `npm run build` green. Per-feature smoke deferred (operator gate) — all 5 features already on develop from Category A/B re-picks. - -## Phase 7. OpenSpec docs + archives (bucket #7) — ~8 commits - -Pick in one batch at the end; validate `openspec list` + `openspec validate` after each. - -- [x] 7.1 `git cherry-pick 170434e` — cross-platform server launch docs (conflict: AGENTS.md + architecture.md — kept HEAD's richer content which already has this info) -- [x] 7.2 `git cherry-pick cf84058` — archive fix-windows-server-parity (conflict: bridge-extension spec merged skill-command + server-launcher-log requirements) -- [x] 7.3 `git cherry-pick d0adac2` — consolidate-platform-handlers proposal (conflict: tasks.md kept HEAD's 78-line version) -- [x] 7.4 ~~2257b08 fix-fork-entryid-timing refinement~~ **SKIPPED** — per design.md open question #1: refinements describe user+assistant symmetry but the code changes for that symmetry aren't in our picked set; archive on develop describes the assistant-only fix that matches our code -- [x] 7.5 `git cherry-pick a4f9860` — platform-routed kill paths docs (clean) -- [x] 7.6 `git cherry-pick 0be288f` — archive route-kill-paths-through-platform (clean) -- [x] 7.7 `git cherry-pick de695e1` — README Node 22.18.0 bump (clean) -- [x] 7.8 ~~821cd63 sync 4 archives~~ **SKIPPED** — all 4 archives already on develop under different dates (cross-platform-qa-vms, dashboard-ux-fixes-batch, provider-auth, etc.) -- [x] 7.9 adapt-windows-integration-pr9 .openspec.yaml already set to `status: superseded` when proposal was created (pre-Phase 0) -- [x] 7.10 Validation: `openspec list` + `openspec validate` green on merge-windows-integration-linear, consolidate-platform-handlers, platform-path-normalization -- [x] 7.11 Validation: `npm test` 2526/2526 green - -## Phase 8. Pre-PR gates - -- [x] 8.1 CI green on Windows, macOS, Linux matrix — confirmed de facto by PR #10 merging + v0.4.0 ship -- [x] 8.2 CI green on Electron make matrix — confirmed de facto by v0.4.0 release artifacts -- [x] 8.3 Manual Windows smoke — confirmed de facto by v0.4.0 → v0.5.1 shipping -- [x] 8.4 Manual macOS + Linux smoke — confirmed de facto by v0.4.0 → v0.5.1 shipping -- [x] 8.5 Three lint-style tests green: `no-direct-child-process`, `no-direct-process-kill`, `no-direct-platform-branch`. - - Cherry-picked `b4f712a` (revises task 6.1 skip) to add `no-direct-process-kill.test.ts` + related kill-path test enhancements (picked as `a957e09`). - - Violations caught by the new test in that SAME run: - 1. `pi-core-updater.ts:61` — `shell: process.platform === "win32"` lacked marker; added `// platform-branch-ok` justification (npm.cmd PATHEXT resolution). - 2. `editor-pid-registry.ts:91,100` — refactored `defaultIsProcessAlive`/`defaultKill` to delegate to `platform/process.ts` (`isProcessAlive` + `killPidWithGroup`), preserving the injectable-defaults API. - - All three lint tests green on re-run. -- [x] 8.6 CHANGELOG `[Unreleased]` populated with user-visible changes (landed as `9bfd97c` — "docs(changelog): populate [Unreleased] for Windows integration merge"; verified during apply) -- [x] 8.7 Diff review against `adapt-windows-integration-pr9` durable-requirements spec — all four present: - 1. `spawnDetached` `detach?: boolean` option — `packages/shared/src/platform/detached-spawn.ts:100` + default `true` at line 134 ✓ - 2. `useWindowsRedirect` gates on `stdinMode === "ignore"` — `detached-spawn.ts:125` `stdioIn: "ignore" | "pipe" = opts.stdinMode ?? "ignore"` ✓ - 3. Test suite refuses to run against real `$HOME` — `package.json` `test` + `test:watch` use `HOME=$(mktemp -d -t pi-test-XXXXXX)`; `packages/shared/src/test-support/setup-home.ts` enforces at runtime ✓ - 4. Destructive registry sweeps no-op when test-env-guard detects unsafe HOME — `isUnsafeTestHomeScan()` gated in `headless-pid-registry.ts` (3 sites) + `editor-pid-registry.ts` ✓ - -## Phase 9. PR and release - -- [x] 9.1 PR #10 `Windows integration v3` merged to develop (commit 422bf5d1) -- [x] 9.2 v0.4.0 cut + shipped (now at v0.5.1) -- [ ] 9.3 Follow-up `platform/` 18→13 consolidation — tracked by `consolidate-platform-handlers` (still active; current count: 19 files) - -## Rollback - -Any phase can roll back to `pre-windows-v3-merge` tag: - -```bash -git reset --hard pre-windows-v3-merge -git push --force-with-lease origin windows-integration-v3 -``` - -If post-merge on develop regresses, `v0.3.0` remains on npm + GitHub Releases. Deprecate v0.4.0 via `release-revoke` skill; do not unpublish. diff --git a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/.openspec.yaml b/openspec/changes/archive/2026-05-10-npm-trusted-publishing/.openspec.yaml deleted file mode 100644 index 6a5db8c77..000000000 --- a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-02 diff --git a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/design.md b/openspec/changes/archive/2026-05-10-npm-trusted-publishing/design.md deleted file mode 100644 index acf8a4136..000000000 --- a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/design.md +++ /dev/null @@ -1,53 +0,0 @@ -## Context - -The current `publish.yml` workflow uses a two-job setup (ci → publish) with a stored `NPM_TOKEN` secret. The pi-model-proxy project already uses a single-job OIDC-based workflow that extracts the version from the git tag and creates GitHub Releases. The package `@blackbelt-technology/pi-dashboard` has never been published to npm. - -Key constraint: this project has a `prepare` script (`vite build`) and `node-pty` as a dependency, which requires native compilation. The GitHub Actions environment needs to handle both. - -## Goals / Non-Goals - -**Goals:** -- Match pi-model-proxy release workflow pattern (single job, OIDC, tag-version, GitHub Release) -- Eliminate stored npm secrets from GitHub repository -- Add provenance attestation for supply chain transparency -- Add missing LICENSE file -- Document the release process - -**Non-Goals:** -- Changing the CI workflow (`ci.yml`) — it works fine as-is -- Automating the one-time npmjs.com setup (manual steps required) -- Setting up branch protection or release approval workflows -- Changing the package name or scope - -## Decisions - -### 1. Single-job publish workflow (matching pi-model-proxy) - -Consolidate the current two-job workflow (ci + publish) into a single job. The two-job design added complexity (separate npm ci + build in each job) without benefit — if CI fails, the publish step won't run regardless. - -**Alternative**: Keep two jobs with artifact passing. Rejected — unnecessary complexity for this use case. - -### 2. OIDC trusted publishing (no NPM_TOKEN) - -Use npm's trusted publishing via GitHub Actions OIDC. The workflow requests a short-lived token at publish time, scoped to the exact repository and workflow. Requires `id-token: write` permission and `--provenance` flag. - -**Alternative**: Keep `NPM_TOKEN` secret. Rejected — long-lived tokens are a security risk, require rotation, and trusted publishing is now the npm-recommended approach. - -### 3. Version extraction from git tag - -Extract version from the git tag (`v1.0.0` → `1.0.0`) and set it via `npm version --no-git-tag-version`. This means `package.json` version doesn't need manual updates — the tag is the source of truth. - -### 4. One-time manual first publish - -Since the package doesn't exist on npm yet, a manual `npm publish` is needed before configuring trusted publishing (npm requires the package to exist first). After that, all future publishes go through GitHub Actions OIDC. - -### 5. Node.js 22 (keep current) - -Keep Node.js 22 as in the existing workflows. The pi-model-proxy uses Node 24, but this project has `node-pty` which benefits from staying on the LTS version already tested in CI. - -## Risks / Trade-offs - -- **[Risk] First manual publish requires org admin access** → Ensure someone with `@blackbelt-technology` npm org admin rights does the initial publish -- **[Risk] Trusted publisher misconfiguration fails silently** → npm doesn't validate the config when saved; errors only appear at publish time. Double-check org name, repo name, and workflow filename -- **[Risk] `node-pty` build failure in CI** → Already handled by existing CI workflow using Node.js 22 on ubuntu-latest; no change needed -- **[Trade-off] Single job means re-running build on publish** → Acceptable; the build is fast and the simplicity outweighs the minor time cost diff --git a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/proposal.md b/openspec/changes/archive/2026-05-10-npm-trusted-publishing/proposal.md deleted file mode 100644 index 870509266..000000000 --- a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/proposal.md +++ /dev/null @@ -1,34 +0,0 @@ -## Why - -The current publish workflow uses a long-lived `NPM_TOKEN` secret for npm authentication, doesn't extract the version from the git tag (relying on whatever is in `package.json`), and doesn't create a GitHub Release. The pi-model-proxy project already uses OIDC-based trusted publishing — this change brings the same secure, streamlined release process to pi-agent-dashboard, eliminating stored secrets and adding automatic GitHub Releases. - -## What Changes - -- Replace `NPM_TOKEN` secret-based authentication in `publish.yml` with OIDC trusted publishing (no stored secrets) -- Add version extraction from git tag so `package.json` version is set automatically at publish time -- Consolidate CI + publish into a single job (matching pi-model-proxy pattern) -- Add `softprops/action-gh-release@v2` step to create GitHub Releases with auto-generated notes -- Remove `NODE_AUTH_TOKEN` environment variable from the publish step -- Add MIT `LICENSE` file (referenced in `package.json` `files` but missing) -- Add `LICENSE` to the `files` array in `package.json` -- Document the one-time npmjs.com trusted publisher setup and tag-driven release process - -## Capabilities - -### New Capabilities - -_(none — no new runtime capabilities)_ - -### Modified Capabilities - -- `ci-cd-pipeline`: Publish workflow switches from NPM_TOKEN to OIDC trusted publishing, adds version extraction from git tag, consolidates to single job, adds GitHub Release creation, and requires MIT LICENSE file - -## Impact - -- **`.github/workflows/publish.yml`**: Rewritten to match pi-model-proxy release pattern (single job, OIDC, version extraction, GitHub Release) -- **`LICENSE`**: New MIT license file added to repository root -- **`package.json`**: `LICENSE` added to `files` array -- **`openspec/specs/ci-cd-pipeline/spec.md`**: Updated requirements for trusted publishing -- **npmjs.com**: One-time manual first publish + trusted publisher configuration required -- **GitHub repository secrets**: `NPM_TOKEN` secret can be deleted after migration -- **No runtime code changes**: Purely CI/CD and packaging diff --git a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/specs/ci-cd-pipeline/spec.md b/openspec/changes/archive/2026-05-10-npm-trusted-publishing/specs/ci-cd-pipeline/spec.md deleted file mode 100644 index 92083e80d..000000000 --- a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/specs/ci-cd-pipeline/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Publish workflow on version tags -The project SHALL have a GitHub Actions workflow (`.github/workflows/publish.yml`) that triggers when a tag matching `v*` is pushed. The workflow SHALL use a single job that runs CI checks (lint, test, build), extracts the version from the git tag, publishes the package to npm via OIDC trusted publishing with provenance, and creates a GitHub Release with auto-generated notes. - -#### Scenario: Version tag triggers publish -- **WHEN** a tag matching `v*` (e.g., `v1.0.0`) is pushed -- **THEN** the publish workflow SHALL run lint, test, build, and then publish to npm and create a GitHub Release - -#### Scenario: Version extracted from git tag -- **WHEN** the publish workflow runs for tag `v1.2.3` -- **THEN** it SHALL extract `1.2.3` from the tag and set it in `package.json` via `npm version "1.2.3" --no-git-tag-version --allow-same-version` before publishing - -#### Scenario: Publish uses OIDC trusted publishing -- **WHEN** the publish step runs -- **THEN** it SHALL authenticate to npm via OIDC (OpenID Connect) without any stored secrets, requiring `id-token: write` permission in the workflow - -#### Scenario: CI failure prevents publish -- **WHEN** lint, test, or build fails during the publish workflow -- **THEN** the npm publish step SHALL NOT execute - -#### Scenario: GitHub Release created -- **WHEN** the package is successfully published to npm -- **THEN** the workflow SHALL create a GitHub Release using `softprops/action-gh-release@v2` with auto-generated release notes, requiring `contents: write` permission - -### Requirement: npm provenance -The publish workflow SHALL use the `--provenance` flag when publishing to npm to provide supply chain transparency. - -#### Scenario: Package published with provenance -- **WHEN** the package is published to npm -- **THEN** the published package SHALL include provenance attestation linking it to the source commit and GitHub Actions build - -### Requirement: Node.js version -Both CI and publish workflows SHALL use Node.js 22 as the runtime version. - -#### Scenario: Node 22 used in CI -- **WHEN** the CI workflow runs -- **THEN** it SHALL set up Node.js 22 using `actions/setup-node` - -## ADDED Requirements - -### Requirement: MIT LICENSE file -The repository SHALL contain a `LICENSE` file at the root with the MIT license text. The `package.json` `files` array SHALL include `LICENSE`. - -#### Scenario: LICENSE file exists -- **WHEN** the package is published to npm -- **THEN** the published tarball SHALL include a `LICENSE` file with MIT license text - -### Requirement: Trusted publisher configuration on npmjs.com -The npm package SHALL be configured with GitHub Actions as a trusted publisher on npmjs.com, linking the `@blackbelt-technology` org, `pi-agent-dashboard` repository, and `publish.yml` workflow filename. - -#### Scenario: Trusted publisher configured -- **WHEN** the GitHub Actions workflow publishes via OIDC -- **THEN** npmjs.com SHALL accept the publish request based on the trusted publisher configuration matching the repository and workflow - -## REMOVED Requirements - -### Requirement: Publish uses NPM_TOKEN secret -**Reason**: Replaced by OIDC trusted publishing — short-lived tokens minted at publish time eliminate the need for stored secrets. -**Migration**: Delete `NPM_TOKEN` from GitHub repository secrets after trusted publishing is verified working. diff --git a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/tasks.md b/openspec/changes/archive/2026-05-10-npm-trusted-publishing/tasks.md deleted file mode 100644 index f24de0636..000000000 --- a/openspec/changes/archive/2026-05-10-npm-trusted-publishing/tasks.md +++ /dev/null @@ -1,30 +0,0 @@ -## 1. Repository Files - -- [ ] 1.1 Create MIT `LICENSE` file at repository root -- [ ] 1.2 Add `LICENSE` to the `files` array in `package.json` - -## 2. Publish Workflow - -- [ ] 2.1 Rewrite `.github/workflows/publish.yml` to single-job pattern: checkout, setup-node with registry-url, extract version from tag, npm version, npm ci, lint, test, build, npm publish with `--provenance --access public`, GitHub Release via `softprops/action-gh-release@v2` -- [ ] 2.2 Set permissions to `contents: write` and `id-token: write` -- [ ] 2.3 Remove `NODE_AUTH_TOKEN` / `NPM_TOKEN` secret reference - -## 3. First Publish (Manual) - -- [ ] 3.1 Run `npm login` (must have `@blackbelt-technology` org admin access) -- [ ] 3.2 Run `npm publish --access public` to create the package on npmjs.com -- [ ] 3.3 Verify package exists at https://www.npmjs.com/package/@blackbelt-technology/pi-dashboard - -## 4. Trusted Publisher Setup (npmjs.com) - -- [ ] 4.1 Go to package Settings → Trusted Publisher → GitHub Actions -- [ ] 4.2 Configure: org=`blackbelt-technology`, repo=`pi-agent-dashboard`, workflow=`publish.yml`, environment=_(empty)_ -- [ ] 4.3 (Recommended) Restrict publishing access to "Require 2FA and disallow tokens" - -## 5. Verification - -- [ ] 5.1 Push changes to main, create and push a `v*` tag (e.g., `v0.2.0`) -- [ ] 5.2 Verify GitHub Actions workflow completes successfully -- [ ] 5.3 Verify package published on npm with provenance badge -- [ ] 5.4 Verify GitHub Release created with auto-generated notes -- [ ] 5.5 Delete `NPM_TOKEN` secret from GitHub repository settings (if it exists) diff --git a/openspec/changes/archive/2026-05-10-replace-tsx-with-jiti/.openspec.yaml b/openspec/changes/archive/2026-05-10-replace-tsx-with-jiti/.openspec.yaml deleted file mode 100644 index 6a5db8c77..000000000 --- a/openspec/changes/archive/2026-05-10-replace-tsx-with-jiti/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-02 diff --git a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/.openspec.yaml b/openspec/changes/archive/2026-05-10-session-card-attached-change-link/.openspec.yaml deleted file mode 100644 index 1e96444bd..000000000 --- a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-03-26 diff --git a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/design.md b/openspec/changes/archive/2026-05-10-session-card-attached-change-link/design.md deleted file mode 100644 index a58ae468e..000000000 --- a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/design.md +++ /dev/null @@ -1,35 +0,0 @@ -## Context - -Session cards show an attached proposal as a plain text badge (`📋 change-name`) below the OpenSpec activity area. The session's title bar shows `getSessionDisplayName()` which returns the session name (often auto-set to the change name on attach). However, the attached change name badge is small and unstyled — it doesn't link to anything or help the user navigate to the change in the OpenSpec section. - -Currently, the session card name IS the change name (auto-renamed on attach), so the card title already shows the change name. The `📋 change-name` badge below is redundant text. - -## Goals / Non-Goals - -**Goals:** -- Make the attached change name badge on the session card a clickable link that scrolls to or highlights the change in the OpenSpec section -- Visually distinguish the attached change badge from plain text - -**Non-Goals:** -- Changing session naming behavior -- Adding navigation to a separate change detail view - -## Decisions - -### 1. Style the attached proposal badge as a clickable link - -**Decision**: Style the `📋 {session.attachedProposal}` text as a clickable element that scrolls to the corresponding change card in the OpenSpec section. Use a subtle link style (colored text, underline on hover). - -**Rationale**: The OpenSpec section is in the same sidebar. Scrolling to the change card provides quick navigation without adding new views. - -**Alternative considered**: Opening a modal or panel with change details — over-engineering for this use case. - -### 2. Scroll target - -**Decision**: Add a `data-change-name` or `id` attribute to each change card in the OpenSpec section. On click, use `document.querySelector` + `scrollIntoView` to navigate. - -**Rationale**: Simple DOM-based scrolling, no state management needed. - -## Risks / Trade-offs - -- **OpenSpec section collapsed**: If the section is collapsed when the user clicks, the scroll target won't be visible. → Acceptable: user can expand and click again. Could auto-expand as a future enhancement. diff --git a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/proposal.md b/openspec/changes/archive/2026-05-10-session-card-attached-change-link/proposal.md deleted file mode 100644 index 09612bc61..000000000 --- a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/proposal.md +++ /dev/null @@ -1,21 +0,0 @@ -## Why - -When a session has an attached OpenSpec change, the session card shows the change badge but the session name doesn't reflect the attachment clearly. The card should display the attached change name as a visible, clickable link so users can quickly identify what change a session is working on. - -## What Changes - -- Session cards with an attached proposal SHALL display the change name prominently, linked or styled distinctly from the regular session name. - -## Capabilities - -### New Capabilities - -_(none)_ - -### Modified Capabilities - -- `openspec-card-section`: Session cards with attached proposals show the change name as a visible label/link. - -## Impact - -- **Client** (`packages/client/src/components/SessionCard.tsx`): Update card rendering to show attached change name. diff --git a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/specs/openspec-card-section/spec.md b/openspec/changes/archive/2026-05-10-session-card-attached-change-link/specs/openspec-card-section/spec.md deleted file mode 100644 index 135c345c4..000000000 --- a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/specs/openspec-card-section/spec.md +++ /dev/null @@ -1,23 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Attached proposal display on session card -Session cards with an attached proposal SHALL display the change name as a clickable link that navigates to the corresponding change card in the OpenSpec section. - -#### Scenario: Session with attached proposal -- **WHEN** a session has `attachedProposal` set -- **THEN** the session card SHALL display the change name styled as a clickable link with a proposal icon - -#### Scenario: Clicking attached proposal link -- **WHEN** the user clicks the attached proposal link on a session card -- **THEN** the view SHALL scroll to the corresponding change card in the OpenSpec section - -#### Scenario: Session without attached proposal -- **WHEN** a session has no `attachedProposal` -- **THEN** no proposal link SHALL be displayed - -### Requirement: Change card scroll target -Each change card in the OpenSpec section SHALL have a unique identifier attribute based on the change name to support scroll-to navigation. - -#### Scenario: Change card has identifier -- **WHEN** a change card is rendered in the OpenSpec section -- **THEN** it SHALL have a `data-change-name` attribute or `id` matching the change name diff --git a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/tasks.md b/openspec/changes/archive/2026-05-10-session-card-attached-change-link/tasks.md deleted file mode 100644 index 80038686a..000000000 --- a/openspec/changes/archive/2026-05-10-session-card-attached-change-link/tasks.md +++ /dev/null @@ -1,13 +0,0 @@ -## 1. Change card scroll target - -- [ ] 1.1 Add `data-change-name` attribute to each change card element in the OpenSpec section component - -## 2. Clickable attached proposal badge - -- [ ] 2.1 In `SessionCard.tsx`, replace the plain text `📋 {session.attachedProposal}` with a clickable link styled element -- [ ] 2.2 On click, scroll to the matching change card using `document.querySelector('[data-change-name="..."]')?.scrollIntoView()` -- [ ] 2.3 Style the link with colored text and underline on hover to distinguish it from plain text - -## 3. Docs - -- [ ] 3.1 Update AGENTS.md if needed diff --git a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/.openspec.yaml b/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/.openspec.yaml deleted file mode 100644 index 905325fd9..000000000 --- a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-04 diff --git a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/design.md b/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/design.md deleted file mode 100644 index b16f5c828..000000000 --- a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Design - -## Decision 1: Dynamic-import the vite plugin - -**Why:** A static `import { viteDashboardPluginsPlugin } from "@blackbelt-technology/dashboard-plugin-runtime/vite-plugin"` at the top of `vite.config.ts` evaluates *before* `npm install` finishes on a fresh checkout, breaking `npm install → npm run dev` for new contributors. The existing comment in `vite.config.ts` already telegraphs the constraint. - -**Shape:** - -```ts -// packages/client/vite.config.ts -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import path from "node:path"; - -async function loadPluginRegistryVitePlugin() { - try { - const mod = await import( - "@blackbelt-technology/dashboard-plugin-runtime/vite-plugin" - ); - return mod.viteDashboardPluginsPlugin?.(); - } catch { - // Runtime not built yet (fresh checkout). Skip; registry stays empty. - return null; - } -} - -export default defineConfig(async () => ({ - plugins: [ - react(), - tailwindcss(), - ...(await loadPluginRegistryVitePlugin().then((p) => (p ? [p] : []))), - ], - // … rest unchanged -})); -``` - -**Alternatives rejected:** - -- *Static import + try/catch around `defineConfig` body* — `import` is hoisted, can't catch resolution failure at runtime. -- *Add `dashboard-plugin-runtime` as a hard `dependencies` of `client`* — already a dependency; the failure mode is "not yet built", not "not installed". Dynamic import handles both. - -## Decision 2: Shell builds registry from generated file at module load - -**Why:** The generated file is a static module — importing it once at module-load time gives a stable registry for the whole process. Avoids React effects and runtime fetches. - -**Shape:** - -```tsx -// packages/client/src/App.tsx -import { createSlotRegistry } from "@blackbelt-technology/dashboard-plugin-runtime"; -// PLUGIN_REGISTRY is generated at build time by viteDashboardPluginsPlugin. -// Empty array on a fresh checkout (file doesn't exist) — handled by a -// generated stub committed alongside .gitignore, OR by a try/catch import. -import { PLUGIN_REGISTRY } from "./generated/plugin-registry"; - -const _pluginRegistry = createSlotRegistry(); -for (const entry of PLUGIN_REGISTRY) { - for (const claim of entry.claims) { - _pluginRegistry.register(entry.manifest, claim); - } -} -``` - -**Alternatives rejected:** - -- *useEffect + dynamic import* — adds an async boundary for no benefit; slot consumers would render empty on first paint. -- *Read raw manifests at runtime* — duplicates the vite plugin's job and breaks tree-shaking (the whole reason the vite plugin emits **named imports**). - -## Decision 3: `generated/.gitignore` stub keeps the file path resolvable on fresh clones - -**Why:** A static `import "./generated/plugin-registry"` against a path that doesn't exist breaks `tsc` and `vite dev`. Two options: - -1. **Stub committed.** Commit `packages/client/src/generated/plugin-registry.tsx` with `export const PLUGIN_REGISTRY = [];` and a `// GENERATED — overwritten on build` header. The vite plugin overwrites on dev/build. Simple; the file is almost always overwritten anyway. - -2. **Dynamic import wrapper.** Wrap the import in `try { … } catch { return []; }`. Adds complexity to App.tsx for a one-time fresh-clone state. - -Decision: **Option 1.** Commit a stub with `PLUGIN_REGISTRY = []`. The `.gitignore` rule only ignores changes in CI runs, not the stub itself — so contributors clone and the file is present. - -**Note:** The existing `dashboard-plugin-loader` spec says the generated dir is *"committed to source control under a `.gitignore` rule for the `generated/` directory"* — this reads as "the directory is gitignored except for an explicit stub". We honor that: ignore everything except the stub via `!plugin-registry.tsx` exception. - -``` -packages/client/src/generated/.gitignore ---- -* -!.gitignore -!plugin-registry.tsx -``` - -## Decision 4: Don't remove legacy direct imports in this change - -**Why:** Co-tenancy is the safety guarantee. Removing the direct `` import from `SessionCard.tsx` while wiring the slot is two changes in one — easy to break if the slot doesn't activate (gate predicate, manifest typo, plugin disabled in config). - -But — wiring the slot AND keeping the direct import causes **duplicate rendering** for any plugin that has both. Two flow badges on every flow session is a visible regression. - -**Resolution:** Remove the direct imports for the **two specific co-tenant pairs** (`FlowActivityBadge`, `SessionFlowActions`) AND the jj-plugin's `JjWorkspaceBadge` + `JjActionBar` in this change. Every other slot claim today either has no co-tenant direct import (newly added: demo-plugin, flows-anthropic-bridge-plugin) or hasn't been migrated yet (still pure direct imports — slot claim added by extraction proposals but not yet rendered). For the latter group, keeping the direct import is correct: the slot renders nothing until the rest of the migration ships. - -**Lint guard:** Update `packages/client/src/__tests__/no-jsx-slot-nullish-fallback.test.ts` to add `App.tsx` and `SessionCard.tsx` to `SCAN_FILES` (already covered for `App.tsx`; verify `SessionCard.tsx` is included). - -**Visual regression:** Snapshot `SessionCard` renders for (a) flow session, (b) jj session, (c) plain session. Before/after this change SHALL produce identical DOM (one badge, one action bar each). - -## Decision 5: Skip-able regression test for the populated registry - -**Why:** `npm test` does not run `npm run build` first. CI does. Asserting the generated file is non-empty in `npm test` would force every contributor to build before testing — large regression. - -**Resolution:** Test detects absence of the generated stub *content* (default `[]` vs. populated) and: - -- If `[]` (stub state) → `test.skip("registry not built")`. Emits a vitest skip with the reason. -- If populated → assert at least one entry has a claim slot in the known set, and that the manifest id matches an actual workspace package. - -This keeps the test running where it matters (CI, post-build) without breaking developer flow. diff --git a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/proposal.md b/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/proposal.md deleted file mode 100644 index 87e975e8c..000000000 --- a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/proposal.md +++ /dev/null @@ -1,94 +0,0 @@ -## Why - -The dashboard plugin runtime is fully built but **never reaches the UI**. Two missing wires keep every manifest-declared slot claim invisible: - -1. `packages/client/vite.config.ts` does **not** include `viteDashboardPluginsPlugin` in its `plugins[]` array. The comment at lines 5-7 acknowledges the plugin "is wired here but only active when …" — the actual `import { viteDashboardPluginsPlugin } … plugins: [react(), tailwindcss(), viteDashboardPluginsPlugin()]` line was never added. Result: `packages/client/src/generated/plugin-registry.tsx` is **never produced** on dev start or build. - -2. `packages/client/src/App.tsx` line 86 keeps a placeholder registry: - ```ts - // Empty registry until real plugins register claims at build time - const _pluginRegistry = createSlotRegistry(); - ``` - The "until" never arrived. `_pluginRegistry` is passed to `` permanently empty, so every ``, ``, etc. renders zero contributions even when manifests declare claims. - -Symptoms today: - -- `packages/demo-plugin/` ships claims for `settings-section` (DemoSettings) and `tool-renderer` (DemoToolRenderer) — neither renders anywhere. -- `packages/jj-plugin/` claims `settings-section`, `session-card-badge`, `command-route /jj`, etc. — none reach the runtime; the components only render via the legacy hard-coded imports in `SessionCard.tsx` / `App.tsx`. -- `packages/flows-plugin/` ditto for `session-card-badge` (`FlowActivityBadge`) and `session-card-action-bar` (`SessionFlowActions`) — only the hard-coded JSX paths in `SessionCard.tsx` work. -- `packages/flows-anthropic-bridge-plugin/` (just landed) — its `settings-section` claim is invisible until the registry is populated. - -Existing artifacts assume this wiring exists: - -- `openspec/changes/extract-subagents-as-plugin/proposal.md` cites *"the loader's generated `plugin-registry.tsx` imports plugins before any session subscription begins"*. -- `openspec/specs/dashboard-plugin-loader/spec.md` already requires the vite plugin to *"generate `packages/client/src/generated/plugin-registry.tsx` at dev start and on every build"*. The generation requirement exists; what's missing is the **invocation** of the plugin and the **consumption** of the generated file. - -The fix is small but unblocks every deferred slot-consumer migration (`migrate-flows-jsx-to-slots`, `extract-git-as-plugin`, `extract-openspec-as-plugin`, `extract-subagents-as-plugin`). - -## What Changes - -- **MODIFY** `packages/client/vite.config.ts`: - - Import `viteDashboardPluginsPlugin` from `@blackbelt-technology/dashboard-plugin-runtime/vite-plugin` (deferred / dynamic per existing comment so a fresh checkout without the runtime built doesn't break vite startup). - - Add it to `plugins: [react(), tailwindcss(), viteDashboardPluginsPlugin()]`. -- **MODIFY** `packages/client/src/App.tsx`: - - Replace `const _pluginRegistry = createSlotRegistry();` with a builder that reads `PLUGIN_REGISTRY` from the generated `./generated/plugin-registry` and inserts each claim into a fresh `SlotRegistry` via the runtime's existing API. - - Keep `_pluginRegistry` empty when the generated file is absent (fresh checkout before first `vite dev` / `vite build`) — the registry just stays empty, no error. -- **ADD** `packages/client/src/generated/.gitignore` with a single `*` line — the generated file is build output, not source. Spec already says *"committed under a `.gitignore` rule"* (read: ignored, regenerated fresh). -- **ADD** repo-level lint test asserting that when ≥ 1 workspace plugin manifest exists, the generated registry is non-empty after `npm run build`. Lives at `packages/client/src/__tests__/plugin-registry-populated.test.ts`. Skips cleanly when run without a build artifact (so unit-test runs on a clean tree don't fail). -- **ADD** `dashboard-plugin-loader` spec deltas: - - **MODIFIED** *"Vite plugin generates a static plugin registry"* — clarify that `vite.config.ts` MUST register the plugin (not just declare the dependency), and that the shell MUST consume the generated file. - - **ADDED** *"Shell consumes the generated plugin registry"* — new requirement covering App.tsx wiring. - -## Capabilities - -### Modified Capabilities - -- `dashboard-plugin-loader` — clarifies the existing "vite plugin generates registry" requirement and adds a sibling requirement that the shell actually loads it. - -### New Capabilities - -None. This change is the missing wiring for an existing capability. - -## Impact - -**Code touched:** - -- `packages/client/vite.config.ts` — +2 LOC (import + plugin entry). -- `packages/client/src/App.tsx` — ~10 LOC (replace empty registry with populated one; preserve empty fallback). -- `packages/client/src/generated/.gitignore` — new file, 1 line. -- `packages/client/src/__tests__/plugin-registry-populated.test.ts` — new file, ~30 LOC. -- `openspec/specs/dashboard-plugin-loader/spec.md` — text edits in one existing requirement, new requirement added. - -**Behavior changes (after wiring):** - -- `demo-plugin/`'s DemoSettings appears in Settings → General (in dev/`fixture: true` excluded in production). -- `flows-plugin/`'s `FlowActivityBadge` and `SessionFlowActions` start rendering via slot consumers **in addition to** the hard-coded direct imports. The legacy direct imports are not removed in this change; that's deferred to `migrate-flows-jsx-to-slots`. -- `jj-plugin/`'s slot-based contributions render. Same co-tenancy rule applies. -- `flows-anthropic-bridge-plugin/`'s `FlowsAnthropicBridgeSettings` renders in Settings → General. - -**Co-tenancy guarantee:** Per `2026-04-26-add-dashboard-shell-slots-runtime`, every existing slot consumer mount in the shell is **additive** — the slot renders alongside the legacy direct import. So populating the registry can only ADD UI, never remove or break existing UI. This is the safety guarantee that lets the change ship as a single small wiring patch. - -## Migration Risks - -- **Duplicate UI rendering.** `flows-plugin` claims `session-card-badge` for `FlowActivityBadge`. `SessionCard.tsx` currently imports `FlowActivityBadge` directly AND mounts ``. After this change, the badge will render twice on flow sessions. Mitigation: the legacy direct import in `SessionCard.tsx` SHALL be removed in this change for the two flows-plugin claims (`FlowActivityBadge`, `SessionFlowActions`) and the two jj-plugin claims that have direct imports (`JjWorkspaceBadge`, `JjActionBar`). Other plugins with slot claims that lack a corresponding direct import are unaffected. Visual regression test: snapshot session-card render with one flow + jj session before/after; expect identical structure (one badge, one action bar — not two). -- **Vite plugin import failure on fresh checkout.** A clean clone with `dashboard-plugin-runtime` not yet built could break `vite.config.ts` evaluation if the import is static. Mitigation: dynamic-import the plugin inside an `async` plugin factory wrapper, or use the deferred-import pattern alluded to in the existing vite.config.ts comment. Concrete shape: - ```ts - async function loadPluginsVitePlugin() { - try { - const mod = await import("@blackbelt-technology/dashboard-plugin-runtime/vite-plugin"); - return mod.viteDashboardPluginsPlugin?.() ?? null; - } catch { return null; } - } - ``` - The `defineConfig` call awaits this and filters nulls from `plugins[]`. -- **HMR loop on manifest churn.** The vite plugin watches manifests and triggers HMR on change. If a manifest is in flux during a dev session, repeated regenerations could cause flicker. Mitigation: existing vite-plugin code already content-hashes manifests and skips regeneration on unchanged content (per `dashboard-plugin-loader` spec). No change required. -- **Test environment.** The `plugin-registry-populated.test.ts` regression test must run AFTER `npm run build`. CI already runs `build` before `test` in `.github/workflows/ci.yml`; locally `npm test` does not. Mitigation: the test detects absence of the generated file and emits a `test.skip(...)` rather than failing — keeps `npm test` working on a clean tree. - -## References - -- Generation spec (existing): `openspec/specs/dashboard-plugin-loader/spec.md` → *"Vite plugin generates a static plugin registry"*. -- Vite plugin implementation (already complete): `packages/dashboard-plugin-runtime/src/vite-plugin/index.ts`. -- Empty-registry placeholder: `packages/client/src/App.tsx` line 86 (`// Empty registry until real plugins register claims at build time`). -- Co-tenancy guarantee: `openspec/changes/archive/2026-04-26-add-dashboard-shell-slots-runtime/tasks.md` tasks 6.2 and 6.3 (slot consumers mounted as additive co-tenants of legacy imports). -- Slot fallback regression-prevention: `openspec/changes/archive/2026-05-02-fix-slot-fallback-masks-content/` — when removing a legacy direct import in favor of a slot consumer inside a `??` chain, the lint test in `packages/client/src/__tests__/no-jsx-slot-nullish-fallback.test.ts` MUST be updated. -- Future Work cited by archived umbrella: `openspec/changes/archive/2026-04-26-dashboard-plugin-architecture/design.md` → "Future Work for `node_modules` scanning" (not in scope here; this change targets monorepo workspace plugins only). diff --git a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/specs/dashboard-plugin-loader/spec.md b/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/specs/dashboard-plugin-loader/spec.md deleted file mode 100644 index 4a84a422c..000000000 --- a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/specs/dashboard-plugin-loader/spec.md +++ /dev/null @@ -1,74 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Vite plugin generates a static plugin registry - -The `vite-plugin-dashboard-plugins` SHALL generate `packages/client/src/generated/plugin-registry.tsx` at dev start and on every build. The generated file SHALL use **named imports** for each claimed component (not `import * as`) so that Vite tree-shakes unused exports from plugin packages. - -The generated file SHALL be committed to source control under a `.gitignore` rule for the `generated/` directory and produced fresh on every build. - -`packages/client/vite.config.ts` SHALL invoke `viteDashboardPluginsPlugin()` and include its result in the `plugins[]` array. Failure to do so means the generated file is never produced, regardless of the plugin's correctness. The invocation SHALL use a deferred / dynamic import so a fresh checkout (where `dashboard-plugin-runtime` is not yet built) does not break vite startup; in that fallback state the plugin is skipped and the registry stays at its committed-stub initial value. - -#### Scenario: Generated file uses named imports - -- **WHEN** a plugin claims `{ "slot": "session-card-badge", "component": "OpenSpecBadge" }` -- **THEN** the generated `plugin-registry.tsx` SHALL contain a named import like `import { OpenSpecBadge } from "@blackbelt-technology/openspec-plugin/client"`, not a wildcard `import *`. - -#### Scenario: Unused exports tree-shaken from production bundle - -- **WHEN** a plugin's client entry exports `Foo` and `Bar`, and only `Foo` is claimed in the manifest -- **THEN** the production bundle SHALL contain `Foo` and SHALL NOT contain `Bar` (asserted by a build artifact scan in the test suite). - -#### Scenario: Manifest change regenerates registry and triggers HMR - -- **WHEN** a plugin's `package.json#pi-dashboard-plugin` field is edited during `vite dev` -- **THEN** the Vite plugin SHALL detect the change, regenerate `plugin-registry.tsx`, and trigger an HMR update so the client picks up the new manifest without a full reload. - -#### Scenario: Plugin source change does not regenerate registry - -- **WHEN** a file inside a plugin package's `src/` is edited (no manifest change) -- **THEN** the Vite plugin SHALL NOT regenerate `plugin-registry.tsx`; HMR SHALL flow through Vite's normal module graph. - -#### Scenario: vite.config.ts must invoke the plugin - -- **WHEN** a workspace plugin manifest exists under `packages//package.json#pi-dashboard-plugin` AND `vite.config.ts` does not register `viteDashboardPluginsPlugin` in `plugins[]` -- **THEN** the generated `plugin-registry.tsx` SHALL remain at its committed-stub state with `PLUGIN_REGISTRY = []` after `vite build` -- **AND** the regression test `packages/client/src/__tests__/plugin-registry-populated.test.ts` SHALL fail (post-build) with a clear message identifying the missing wiring. - -#### Scenario: Fresh checkout without runtime built - -- **WHEN** `vite dev` is invoked on a clone where `packages/dashboard-plugin-runtime/dist/` does not exist yet -- **THEN** the dynamic import of `viteDashboardPluginsPlugin` SHALL fail silently and vite SHALL start with the committed-stub registry -- **AND** no error SHALL be logged to stderr beyond a single `[plugin-registry] runtime not built — registry empty` info message. - -## ADDED Requirements - -### Requirement: Shell consumes the generated plugin registry - -The dashboard shell (`packages/client/src/App.tsx` or its successor entry component) SHALL import `PLUGIN_REGISTRY` from `./generated/plugin-registry` and populate the `SlotRegistry` instance passed to `` with every claim from every entry. Failure to do so means slot consumers in the shell render zero contributions even when the generated registry is populated. - -#### Scenario: Empty registry produces empty slot consumers - -- **WHEN** `PLUGIN_REGISTRY` is `[]` (committed stub state, fresh checkout, or runtime not built) -- **THEN** `` SHALL receive an empty registry -- **AND** every slot consumer (``, ``, etc.) SHALL render zero contributions -- **AND** the shell SHALL render normally with all legacy direct imports intact (no error, no fallback UI required). - -#### Scenario: Populated registry threads claims to slot consumers - -- **WHEN** `PLUGIN_REGISTRY` contains `[{ manifest: { id: "demo", … }, claims: [{ slot: "settings-section", component: DemoSettings, tab: "general" }] }]` -- **THEN** `` SHALL render `` wrapped in the runtime's `SlotErrorBoundary` -- **AND** `` SHALL render zero contributions (no claim for `tab: "servers"`). - -#### Scenario: Co-tenancy with legacy direct imports - -- **WHEN** the shell renders `` AND a plugin claims `session-card-badge` for a component that the shell **also** imports directly via legacy JSX -- **THEN** the result is duplicate rendering of that component -- **AND** the migration plan SHALL remove the legacy direct import in the same change that populates the registry, OR keep the slot empty until the legacy import is removed in a follow-up -- **AND** a regression test SHALL verify no double-render exists for the four migrated cases (`FlowActivityBadge`, `SessionFlowActions`, `JjWorkspaceBadge`, `JjActionBar`). - -#### Scenario: Registry populated only at module load - -- **WHEN** the shell module first loads -- **THEN** the `_pluginRegistry` SHALL be populated synchronously from `PLUGIN_REGISTRY` -- **AND** subsequent edits to plugin source code during `vite dev` SHALL trigger HMR through vite's normal module graph (not via registry mutation) -- **AND** subsequent edits to plugin manifests SHALL trigger registry regeneration via the vite plugin, which produces a new `generated/plugin-registry.tsx` and HMR-replaces the App module. diff --git a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/tasks.md b/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/tasks.md deleted file mode 100644 index 03d4def70..000000000 --- a/openspec/changes/archive/2026-05-10-wire-plugin-registry-into-shell/tasks.md +++ /dev/null @@ -1,70 +0,0 @@ -# Tasks - -## 1. Preconditions - -- [x] 1.1 Confirmed: `viteDashboardPluginsPlugin` exported from `packages/dashboard-plugin-runtime/src/vite-plugin/index.ts` and exposed via the `./vite-plugin` subpath export in the package's `exports` map. -- [x] 1.2 Confirmed: `createSlotRegistry()` exposes `addClaim(claim: ClaimEntry)` (the actual API; tasks.md "register(manifest, claim)" was approximate). Generated registry already produces ClaimEntry-shaped objects with `Component` resolved, so `addClaim(claim)` is the correct insertion call. -- [x] 1.3 Inventory complete. Findings: - - `App.tsx`: mounts `ContentViewSlot`, `ContentHeaderStickySlot`, `ContentInlineFooterSlot`, `ToastSlot`. No legacy direct imports of plugin components in App.tsx itself. - - `SessionCard.tsx`: mounts `SessionCardBadgeSlot` + `SessionCardActionBarSlot`. Legacy direct imports: `FlowActivityBadge`, `SessionFlowActions` (flows-plugin), `JjWorkspaceBadge`, `JjActionBar`, `JjInitAffordance` (jj-plugin). - - **Issue surfaced (see scope-down note below):** `FlowActivityBadge` and `SessionFlowActions` do NOT accept `{ session }` — they require explicit props from `App.tsx` state. Vite-plugin does not emit `predicate` into the generated registry, so wiring + manifest claims would render those components broken on every session card. **Resolution:** flows manifest claims temporarily emptied; flows direct imports stay; deferred to `migrate-flows-jsx-to-slots`. jj components self-gate on session state, so they are safe to migrate to slot-only rendering. - -## 2. Vite plugin wiring - -- [x] 2.1 `packages/client/vite.config.ts` updated: added async `loadPluginRegistryVitePlugin()` with try/catch around the dynamic import, converted `defineConfig` to async factory, awaited plugin spread into `plugins[]` (null-filtered). Orphaned NOTE comment removed. -- [x] 2.2 Will be verified by Section 10.2 (`npm run build`). -- [x] 2.3 Vite-plugin code already implements manifest watch + HMR (see `packages/dashboard-plugin-runtime/src/vite-plugin/index.ts:configureServer`). Manual verification deferred to Section 10.4. - -## 3. Generated dir + stub - -- [x] 3.1 `packages/client/src/generated/.gitignore` written. -- [x] 3.2 `packages/client/src/generated/plugin-registry.tsx` stub committed. -- [x] 3.3 Verified via `git check-ignore` — `!plugin-registry.tsx` exception keeps the stub tracked. - -## 4. Shell consumption - -- [x] 4.1 `App.tsx` updated: imports `PLUGIN_REGISTRY` from `./generated/plugin-registry.js`, populates `_pluginRegistry` via `addClaim(claim)` loop. Provider wiring unchanged. -- [x] 4.2 `npx tsc --noEmit -p tsconfig.json` clean (root tsconfig used; `packages/client/tsconfig.json` has a pre-existing `composite: true` reference issue unrelated to this change). - -## 5. Co-tenant direct-import removal (scoped) - -**Scope-down note:** During Task 1.3 inventory we discovered `FlowActivityBadge` and `SessionFlowActions` do not accept `{ session }` (slot consumer's prop contract) and the vite-plugin does not emit `predicate` into the generated registry. Removing the direct flows imports without first adapting the components would leave slots calling `` with undefined required props on EVERY session. The flows-plugin manifest's `session-card-*` claims are therefore temporarily emptied (kept under a `//pi-dashboard-plugin-deferred-claims` comment) and direct imports stay until `migrate-flows-jsx-to-slots` adapts the components. jj-plugin components self-gate on session state, so they are safe to migrate. - -- [x] 5.1 **DEFERRED** to `migrate-flows-jsx-to-slots` (proposal created). `FlowActivityBadge` direct usage retained; flows manifest claim emptied. -- [x] 5.2 **DEFERRED** to `migrate-flows-jsx-to-slots` (proposal created). `SessionFlowActions` direct usage retained; flows manifest claim emptied. -- [x] 5.3 ``, ``, `` direct JSX block removed from `SessionCard.tsx`. Imports for jj-plugin components and `CurrentPluginLayer` removed (no other usage). -- [x] 5.4 No jj/flows direct usage in `App.tsx` — nothing to remove. -- [x] 5.5 `SCAN_FILES` in `no-jsx-slot-nullish-fallback.test.ts` extended with `"components/SessionCard.tsx"`. - -## 6. Regression test - -- [x] 6.1 `packages/client/src/__tests__/plugin-registry-populated.test.ts` created with skip-when-empty + slot-id + workspace-id assertions. -- [x] 6.2 Verified: `vitest run` reports `1 skipped` on a fresh tree (stub state). -- [x] 6.3 To be verified by Section 10.2 (`npm run build`) + Section 10.3 (`npm test`). - -## 7. Visual regression check - -**Scope-down note:** A full snapshot harness for `SessionCard` would require mocking `DashboardSession`, image assets, plugin context, and many handler callbacks. Tasks 7.1–7.3 are deferred to manual verification in Section 10.4. The expected visual delta of this change is: - - jj-plugin row (`JjWorkspaceBadge` / `JjActionBar` / `JjInitAffordance`) moves from BETWEEN GitInfo and OpenSpec actions DOWN INTO the existing `` placement (i.e., below OpenSpec actions). One badge, one action bar — no doubles. - - Flow JSX rendering unchanged (kept direct imports; manifest claims emptied). - -- [x] 7.1 **DEFERRED to manual verification** (Section 10.4) and to `migrate-flows-jsx-to-slots` (which adds a `session-card-no-double-flow` regression test). -- [x] 7.2 **DEFERRED to manual verification** (Section 10.4) and to `migrate-flows-jsx-to-slots`. -- [x] 7.3 **DEFERRED to manual verification** (Section 10.4) and to `migrate-flows-jsx-to-slots`. - -## 8. Spec deltas - -- [x] 8.1 Spec deltas already authored in `openspec/changes/wire-plugin-registry-into-shell/specs/dashboard-plugin-loader/spec.md`. Main spec is updated by `openspec archive` (Section 10.5), not edited mid-implementation. -- [x] 8.2 `openspec validate wire-plugin-registry-into-shell --strict` → "Change is valid". - -## 9. Documentation update - -- [x] 9.1 Delegated to general-purpose subagent in caveman style. Updated `docs/file-index-client.md` (App.tsx row appended, new row for `generated/plugin-registry.tsx`) and `docs/file-index-plugins.md` (vite-plugin row appended). - -## 10. Verification - -- [x] 10.1 No dependency changes — workspace already installed. -- [x] 10.2 `npm run build` clean. `packages/client/src/generated/plugin-registry.tsx` overwritten (jj-plugin: 6 claims, flows-anthropic-bridge-plugin: 1 claim, flows-plugin: 0 claims (deferred), demo-plugin filtered as `fixture: true`). Switched runtime import in `vite.config.ts` from package-specifier to relative workspace path so vite's esbuild config-loader bundles the .ts source inline (the package ships raw .ts; package-specifier import hit ERR_MODULE_NOT_FOUND on internal `.js` re-imports). -- [x] 10.3 `npm test` — 4296 passed, 9 skipped, 0 failed (initial run had 1 failure: my own regression test asserted every entry had ≥1 claim, which broke against the deliberately-emptied flows entry. Loosened to "≥1 claim across all entries" — still catches a totally-unwired regression while tolerating per-plugin transitional empty-claims). -- [x] 10.4 `npm run dev` manual smoke test — **DEFERRED to user**. Acceptance criteria: open a jj workspace session → badge + action bar render once, in the slot-area below OpenSpec actions; settings panel shows JjPluginSettings + FlowsAnthropicBridgeSettings. -- [x] 10.5 `openspec archive wire-plugin-registry-into-shell` — **DEFERRED to user, post-merge**. diff --git a/openspec/changes/docker-packaging/design.md b/openspec/changes/docker-packaging/design.md deleted file mode 100644 index 5466f49f3..000000000 --- a/openspec/changes/docker-packaging/design.md +++ /dev/null @@ -1,116 +0,0 @@ -## Context - -The pi-dashboard is a three-component system (bridge extension, Node.js server, React web client) that also orchestrates external tools: pi coding agent, code-server, zrok tunnels, tmux, and terminal PTYs via node-pty. Currently all these tools must be installed manually on the host. There is no containerized deployment option. - -The dashboard server already manages the lifecycle of these tools (spawning pi sessions, starting/stopping code-server, creating tunnels, managing terminals), making it a natural "init process" for a container. - -## Goals / Non-Goals - -**Goals:** -- Package the entire ecosystem into a single Docker image that works out of the box -- Support workspace isolation via per-project volume mounts -- Provide volume performance profiles for I/O-intensive workloads (session JSONL writes) -- Support both pre-configured API keys (env vars) and browser-based provisioning -- Allow external pi sessions to connect to the containerized server (configurable) -- Provide a dev-mode compose overlay for dashboard development - -**Non-Goals:** -- Multi-container architecture (components are inherently colocated) -- Kubernetes manifests or Helm charts (Docker Compose only) -- Custom filesystem images or block device management (use host FS via mount options) -- Windows container support -- Modifying the web client (it already supports remote servers) - -## Decisions - -### Decision 1: Single container with dashboard as process manager - -**Choice**: One container, `pi-dashboard` is the main process. - -**Alternatives considered**: -- **Multi-container (rejected)**: pi sessions need shared filesystem with code-server, tmux can't spawn across containers, node-pty needs localhost access. Would require complex networking and shared volumes between every container. -- **supervisord (rejected)**: Adds Python dependency, duplicates process management the dashboard already does. -- **s6-overlay (rejected)**: Adds complexity; the dashboard server already manages all child process lifecycles. - -**Rationale**: The dashboard server already handles spawning pi sessions (headless or tmux), managing code-server instances, starting/stopping zrok, and PTY lifecycle. It is already a process manager. Using `init: true` in compose (tini) handles PID 1 zombie reaping. - -### Decision 2: `node:22-bookworm-slim` base image - -**Choice**: Debian Bookworm slim with Node.js 22 LTS. - -**Alternatives considered**: -- **Alpine (rejected)**: `node-pty` requires glibc for proper PTY support. Alpine's musl causes subtle terminal emulation bugs. The `fix-pty-permissions.cjs` postinstall script already hints at platform sensitivity. -- **Ubuntu (rejected)**: Larger image, no advantage over Debian slim for this use case. -- **Distroless (rejected)**: Needs bash, tmux, git, and other shell tools at runtime. - -### Decision 3: Multi-stage Dockerfile with build-tool cleanup - -**Choice**: Two stages — `base` installs system tools + binaries, `app` installs Node packages and builds the client. Build-essential is removed after native addon compilation. - -**Rationale**: `node-pty` needs `build-essential` + `python3` for native compilation, but these aren't needed at runtime. Removing them saves ~200MB. - -### Decision 4: Entrypoint seeds auth.json from env vars (first-run only) - -**Choice**: `entrypoint.sh` runs `seed-auth.js` which reads `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. and writes `~/.pi/agent/auth.json` with `0600` permissions — but only if the file doesn't exist. - -**Rationale**: This supports two provisioning paths without conflict: -1. First run with `.env` → keys seeded, persisted in volume -2. Subsequent runs → volume has keys, env vars ignored -3. Users can always add/change providers via dashboard Settings UI - -Writing with Node.js (not bash) avoids fragile JSON construction in shell scripts. - -### Decision 5: Volume layout with three performance profiles - -**Choice**: Named volumes for `pi-state` and `zrok-state`, bind mounts for workspaces. Three documented profiles: - -| Profile | Driver | Mount options | Use case | -|---------|--------|---------------|----------| -| Default | `local` | (none) | Development, moderate usage | -| Performance | `local` | `noatime,data=writeback,barrier=0,commit=60` | Many concurrent sessions, heavy JSONL | -| Ephemeral | `local` (tmpfs) | `size=2g,noatime` | CI/CD, throwaway experiments | - -**Rationale**: Session JSONL files are append-heavy. `noatime` eliminates unnecessary access-time writes. `data=writeback` journals only metadata (not data), significantly faster for small appends. `tmpfs` gives maximum speed when persistence isn't needed. - -### Decision 6: Workspace mounts via compose.override.yml - -**Choice**: Base `compose.yml` has no workspace mounts. Users add their project directories in `compose.override.yml` (auto-merged by Docker Compose). - -**Rationale**: Workspace paths are user-specific and machine-specific. `compose.override.yml` is the standard Docker Compose pattern for local overrides. An `.example` file shows the pattern. - -### Decision 7: Pi gateway bind address for external access control - -**Choice**: Two-layer control — compose `ports` for network exposure, `PI_GATEWAY_BIND` env var for server-level bind address (`0.0.0.0` default, `127.0.0.1` to block external). - -**Rationale**: Just not publishing port 9999 prevents host-level access, but the bind address adds defense-in-depth. External pi sessions (running on other machines) connecting to the containerized dashboard is a valid use case but should be opt-out. - -## Risks / Trade-offs - -**[Large image size (~2.5GB)]** → Acceptable for an all-in-one dev tool. Multi-stage build and cleanup keep it as small as practical. code-server alone is ~500MB. - -**[node-pty native addon platform mismatch]** → The `app` stage builds node-pty inside the container (Debian/Linux), so the prebuild matches the runtime OS. Dev compose uses an anonymous volume for `node_modules` to prevent host macOS binaries from overriding. - -**[Container security — pi agent has full bash access]** → By design. The pi agent needs shell access to work. Non-root user (`pi`, UID 1000) limits blast radius. No Docker socket or privileged mode needed. - -**[Volume data loss with ephemeral profile]** → Clearly documented. tmpfs data is lost on container restart. Only recommended for CI/CD. - -**[code-server/zrok version pinning]** → Pinned via build args with sensible defaults. Users can override at build time. - -### Decision 8: Electron "Remote" mode for Docker-hosted servers - -**Choice**: Add a third mode (`"remote"`) to the Electron wizard alongside `"standalone"` and `"power-user"`. In remote mode, `ensureServer()` returns the configured URL directly, skipping all local discovery and spawning. - -**Alternatives considered**: -- **Use ServerSelector only (rejected)**: `ServerSelector` is a runtime switch in the web UI, but `ensureServer()` runs before the BrowserWindow loads. Without a remote mode, Electron would still try to discover/spawn a local server first, which fails or is unnecessary. -- **Auto-detect Docker via mDNS (deferred)**: mDNS can discover Docker containers on the LAN, but requires `network_mode: host` or UDP port 5353 forwarding. Better as a future enhancement — for now, explicit URL is reliable. - -**Rationale**: The Electron app is already a thin shell — it discovers a server URL and opens a BrowserWindow. Adding a remote mode is ~50 lines: extend `ModeConfig` type, short-circuit `ensureServer()`, add a URL input to the wizard. The web client inside the BrowserWindow already handles everything else (dynamic WS URL, `ApiContext`, `ServerSelector`). - -**What doesn't need to change**: -- Web client (`App.tsx`) — already constructs WS/API URLs from `window.location` -- `ServerSelector` — already shows remote servers and allows switching -- Terminal emulator — binary WS connections are relative to server URL -- code-server — iframe proxied through dashboard server -- File browsing — all REST API calls go through `ApiContext` - -**[Risk] Remote server unreachable** → `showLoadingPage()` already handles this with retry + error display. No additional work needed. diff --git a/openspec/changes/docker-packaging/proposal.md b/openspec/changes/docker-packaging/proposal.md deleted file mode 100644 index 30fe2a716..000000000 --- a/openspec/changes/docker-packaging/proposal.md +++ /dev/null @@ -1,107 +0,0 @@ -## Why - -The pi-dashboard is a multi-component system (server, bridge extension, pi agent, code-server, zrok, tmux, terminals) that requires several tools installed and configured on the host. Packaging everything into a Docker image makes deployment reproducible, portable, and self-contained — especially useful for remote servers, team environments, and CI/CD pipelines. Volume mounts allow workspace isolation and filesystem tuning for heavy I/O workloads. - -## What Changes - -Add a `docker/` directory with a complete containerization setup. Add a "Remote" mode to the Electron app's first-run wizard so the desktop app can connect to a Docker-hosted (or any remote) dashboard server without requiring any local installation of pi, Node.js, or other tools. - -### Files - -**`docker/Dockerfile`** — Multi-stage build on `node:22-bookworm-slim`: -- Stage `base`: System tools (tmux, jq, git, curl, ripgrep, fd-find, build-essential), code-server binary, zrok binary -- Stage `app`: Non-root user `pi` (UID 1000), global `@mariozechner/pi-coding-agent`, dashboard `npm install` + `npm run build`, cleanup build-essential -- Runtime: `init: true` (tini via compose), exposes 8000 + 9999, volumes for `/workspaces`, `/home/pi/.pi`, `/home/pi/.zrok2` - -**`docker/entrypoint.sh`** — Startup script: -- Seeds `~/.pi/agent/auth.json` from `PI_AUTH_*` env vars on first run only (never overwrites existing) -- Starts tmux server (for tmux spawn strategy) -- Execs `pi-dashboard` with port/flag configuration from env vars - -**`docker/scripts/seed-auth.js`** — First-run auth seeder: -- Reads env vars: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, etc. -- Writes `auth.json` with `0600` permissions -- Skips if `auth.json` already exists (volume persisted from previous run) - -**`docker/compose.yml`** — Base compose: -- Single service `pi-dashboard` with build context, ports, healthcheck -- Named volumes: `pi-state` (sessions/auth/config), `zrok-state` (tunnel enrollment) -- `tmpfs` on `/tmp` for scratch I/O -- Resource limits (4 GB memory default) -- Environment-driven configuration via `.env` - -**`docker/compose.dev.yml`** — Dev overlay (`docker compose -f compose.yml -f compose.dev.yml up`): -- Bind-mounts dashboard source into container for live editing -- Anonymous volume preserves container's `node_modules` (avoids platform mismatch with node-pty native addon) -- Exposes Vite HMR port 5173 -- Sets `NODE_ENV=development`, runs `pi-dashboard --dev` - -**`docker/compose.override.yml.example`** — Template for workspace mounts: -- Shows how to bind-mount individual project directories to `/workspaces/` -- Includes examples for read-only mounts, multiple projects -- Documents that each mount maps to a pinnable workspace in the dashboard - -**`docker/.env.example`** — All configurable knobs: -- API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) -- Ports (DASHBOARD_PORT, PI_GATEWAY_PORT) -- External access (PI_GATEWAY_BIND: `0.0.0.0` or `127.0.0.1`) -- Tunnel (ZROK_TOKEN, TUNNEL_ENABLED) -- Spawn strategy (headless/tmux) -- Resource limits - -### Electron Remote Mode - -The Electron desktop app (`packages/electron/`) gains a third wizard mode alongside "standalone" and "power user": - -**`packages/electron/src/lib/wizard-state.ts`** — Extended `ModeConfig`: -- Add `"remote"` to the mode union type -- Add optional `remoteUrl` field (e.g. `http://docker-host:8000`) - -**`packages/electron/src/lib/server-lifecycle.ts`** — Modified `ensureServer()`: -- When mode is `"remote"`, return `remoteUrl` directly — skip mDNS discovery, health check fallback, and local server spawning entirely -- `didWeStartServer()` always returns `false` in remote mode (never stop remote server on quit) - -**Wizard renderer** — Third radio option in the mode selection step: -- "Remote" option with a URL input field and "Test Connection" button -- Test calls `GET /api/health` and shows success/failure -- On success, saves `{ mode: "remote", remoteUrl: "..." }` to `mode.json` - -No changes needed to the web client — it already supports remote servers via `ServerSelector`, dynamic WebSocket URL construction, and `ApiContext` that derives all REST API URLs from the connection URL. - -### Volume Performance Profiles - -The `compose.yml` includes commented volume configurations for three profiles: - -1. **Default** — Named Docker volume, uses host filesystem. Works everywhere, good for moderate usage. -2. **Performance** — Dedicated ext4/xfs partition with `noatime,data=writeback,barrier=0,commit=60`. For many concurrent sessions with heavy JSONL writes. Linux only. -3. **Ephemeral** — tmpfs-backed (`size=2g`). Maximum speed, data lost on restart. For CI/CD and throwaway experiments. - -### Pi Gateway External Access - -Port 9999 (pi gateway) is exposed by default so external pi sessions can connect. Two layers of control: -- **Compose `ports`**: Remove or empty `PI_GATEWAY_PORT` to stop publishing -- **Server bind address**: `PI_GATEWAY_BIND=127.0.0.1` makes the server reject non-local connections even if the port is published - -### API Key Provisioning - -Both paths are first-class: -1. **Pre-configured**: Set keys in `.env` file → `entrypoint.sh` seeds `auth.json` on first run → persisted in `pi-state` volume -2. **Browser UI**: Start container without keys → open dashboard → Settings → Provider Auth → OAuth or paste keys → saved to `auth.json` in volume - -### Architecture Constraint: Single Container - -The dashboard's components are inherently colocated — pi sessions, terminals (node-pty), code-server, and the server all need shared filesystem access and localhost communication. A multi-container split would fight the architecture (tmux can't spawn in another container, code-server needs the workspace filesystem, pi gateway is localhost). One container with multiple processes managed by the dashboard server is the correct design. - -### Base Image: Debian, Not Alpine - -`node-pty` requires glibc for proper PTY support. Alpine uses musl which causes subtle terminal emulation issues. `node:22-bookworm-slim` provides glibc with minimal image size. - -## Capabilities - -### New Capabilities - -- `docker-packaging`: Complete Docker containerization of the pi-dashboard ecosystem with all tools (pi, code-server, zrok, tmux, jq, git, bash, ripgrep), configurable volumes with I/O performance profiles, dual API key provisioning, and optional external pi gateway access. - -### Existing Capabilities Modified - -- `electron-shell`: Add "Remote" mode to first-run wizard and `ensureServer()` flow. In remote mode, Electron connects directly to a configured URL (Docker container or any remote server) without local server discovery or spawning. ~50 lines of logic across 2-3 files. diff --git a/openspec/changes/docker-packaging/specs/docker-packaging/spec.md b/openspec/changes/docker-packaging/specs/docker-packaging/spec.md deleted file mode 100644 index cf214aa7f..000000000 --- a/openspec/changes/docker-packaging/specs/docker-packaging/spec.md +++ /dev/null @@ -1,131 +0,0 @@ -## ADDED Requirements - -### Requirement: Dockerfile builds a self-contained image -The Dockerfile SHALL produce a single image containing Node.js 22 LTS, pi coding agent, pi-dashboard (with built client), code-server, zrok, tmux, jq, git, curl, ripgrep, fd-find, and bash. The image SHALL use `node:22-bookworm-slim` as the base. The image SHALL create a non-root user `pi` (UID 1000) and run all processes as that user. Build-essential and python3 SHALL be removed after native addon compilation to reduce image size. - -#### Scenario: Image contains all required tools -- **WHEN** the image is built with `docker compose build` -- **THEN** the following binaries are available on PATH: `node`, `pi`, `pi-dashboard`, `code-server`, `zrok`, `tmux`, `jq`, `git`, `curl`, `rg`, `fdfind`, `bash` - -#### Scenario: Image runs as non-root user -- **WHEN** a container starts from the image -- **THEN** all processes run as user `pi` (UID 1000) - -#### Scenario: node-pty works inside container -- **WHEN** the dashboard spawns a terminal via node-pty -- **THEN** the PTY allocates successfully and shell I/O works (glibc-based Debian, not musl/Alpine) - -### Requirement: Entrypoint seeds API keys on first run -The entrypoint script SHALL run a `seed-auth.js` script that reads provider API keys from environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`) and writes them to `~/.pi/agent/auth.json` with `0600` permissions. The seeding SHALL only occur if `auth.json` does not already exist. The entrypoint SHALL then start a tmux server and exec `pi-dashboard` with port configuration from environment variables. - -#### Scenario: First run with API key env vars -- **WHEN** the container starts for the first time with `ANTHROPIC_API_KEY=sk-ant-xxx` set -- **THEN** `~/.pi/agent/auth.json` is created with the key and `0600` permissions - -#### Scenario: Subsequent run preserves existing auth -- **WHEN** the container starts and `~/.pi/agent/auth.json` already exists in the volume -- **THEN** the seed script does NOT overwrite the file, regardless of env var values - -#### Scenario: First run without any API keys -- **WHEN** the container starts with no `*_API_KEY` env vars set -- **THEN** no `auth.json` is created, and the dashboard starts normally (keys can be added via browser UI) - -### Requirement: Docker Compose base configuration -The `compose.yml` SHALL define a single service `pi-dashboard` with: build context pointing to the project root, port mappings for dashboard (default 8000) and pi gateway (default 9999), named volumes for `pi-state` and `zrok-state`, tmpfs on `/tmp`, memory limits, and a healthcheck using `/api/health`. All ports and limits SHALL be configurable via environment variables with sensible defaults. - -#### Scenario: Container starts with default configuration -- **WHEN** `docker compose up` is run with no `.env` file -- **THEN** the dashboard is accessible at `http://localhost:8000` and the pi gateway listens on port 9999 - -#### Scenario: Healthcheck detects running server -- **WHEN** the dashboard server is running inside the container -- **THEN** `docker compose ps` shows the service as healthy - -#### Scenario: Named volumes persist across restarts -- **WHEN** the container is stopped and restarted -- **THEN** pi sessions, auth credentials, dashboard preferences, and zrok enrollment are preserved - -### Requirement: Workspace bind mounts via compose override -The project SHALL include a `compose.override.yml.example` that documents how to bind-mount host directories as workspaces. Each workspace mount SHALL target a subdirectory under `/workspaces/`. The base `compose.yml` SHALL NOT include any workspace mounts. - -#### Scenario: User mounts a project directory -- **WHEN** the user copies `compose.override.yml.example` to `compose.override.yml` and adds a bind mount for `~/Project/my-app` to `/workspaces/my-app` -- **THEN** the dashboard can create pi sessions in `/workspaces/my-app` and the files are visible on the host - -#### Scenario: Multiple workspace mounts -- **WHEN** the user configures three bind mounts in `compose.override.yml` -- **THEN** all three appear as pinnable workspace directories in the dashboard - -### Requirement: Volume performance profiles -The `compose.yml` SHALL include commented volume configurations for three profiles: default (named volume, no special options), performance (ext4/xfs with `noatime,data=writeback,barrier=0,commit=60`), and ephemeral (tmpfs with configurable size). Each profile SHALL be documented with its use case and trade-offs. - -#### Scenario: Default profile works on all platforms -- **WHEN** the user uses the default volume configuration -- **THEN** volumes work on macOS (Docker Desktop), Linux, and Windows (Docker Desktop/WSL2) - -#### Scenario: Performance profile reduces write latency -- **WHEN** the user configures the performance profile on a Linux host with a dedicated ext4 partition -- **THEN** the volume is mounted with `noatime,data=writeback,barrier=0,commit=60` options - -#### Scenario: Ephemeral profile uses RAM-backed storage -- **WHEN** the user configures the ephemeral profile -- **THEN** the volume uses tmpfs and data is lost on container restart - -### Requirement: Pi gateway external access control -The pi gateway bind address SHALL be configurable via `PI_GATEWAY_BIND` environment variable, defaulting to `0.0.0.0` (accepts external connections). Setting it to `127.0.0.1` SHALL restrict the gateway to container-internal connections only. The compose `ports` mapping for port 9999 SHALL also be configurable via `PI_GATEWAY_PORT` env var. - -#### Scenario: External pi sessions connect by default -- **WHEN** the container starts with default configuration -- **THEN** pi sessions running on other machines can connect to port 9999 - -#### Scenario: Gateway locked to internal only -- **WHEN** `PI_GATEWAY_BIND=127.0.0.1` is set in `.env` -- **THEN** only pi sessions inside the container can connect to the gateway - -#### Scenario: Gateway port disabled -- **WHEN** `PI_GATEWAY_PORT` is empty or unset in `.env` and the compose override removes the port mapping -- **THEN** port 9999 is not published on the host - -### Requirement: Dev mode compose overlay -A `compose.dev.yml` SHALL provide a development overlay that bind-mounts the dashboard source code into the container, exposes the Vite HMR port (5173), uses an anonymous volume for `node_modules` to prevent host/container platform mismatch, and sets `NODE_ENV=development`. - -#### Scenario: Source changes trigger Vite HMR -- **WHEN** the dev overlay is active and the user edits a client source file on the host -- **THEN** Vite hot module replacement picks up the change in the browser - -#### Scenario: node_modules use container binaries -- **WHEN** the dev overlay is active -- **THEN** `node_modules/node-pty` contains Linux-compiled native addons (from container), not host macOS addons - -### Requirement: Environment configuration documented in .env.example -A `.env.example` file SHALL document all configurable environment variables with comments explaining each. Variables SHALL include: API keys, ports, gateway bind address, zrok token, tunnel enabled flag, spawn strategy, and resource limits. - -#### Scenario: User copies .env.example to .env -- **WHEN** the user copies `.env.example` to `.env` and fills in their API key -- **THEN** the container starts with that key seeded into auth.json - -### Requirement: Electron remote mode in wizard -The Electron first-run wizard SHALL offer a third mode "Remote" alongside "Standalone" and "Power User". The remote mode SHALL present a URL input field and a "Test Connection" button. The `ModeConfig` type SHALL be extended with `mode: "remote"` and an optional `remoteUrl: string` field. The mode SHALL be persisted to `~/.pi-dashboard/mode.json`. - -#### Scenario: User selects remote mode with valid URL -- **WHEN** the user selects "Remote" mode, enters `http://docker-host:8000`, and clicks "Test Connection" -- **THEN** the wizard calls `GET http://docker-host:8000/api/health`, shows success, and enables the "Continue" button - -#### Scenario: User selects remote mode with unreachable URL -- **WHEN** the user selects "Remote" mode, enters a URL, and the health check fails -- **THEN** the wizard shows an error message and the "Continue" button remains disabled - -#### Scenario: Remote mode persisted to mode.json -- **WHEN** the user completes the wizard in remote mode with URL `http://docker-host:8000` -- **THEN** `~/.pi-dashboard/mode.json` contains `{ "mode": "remote", "remoteUrl": "http://docker-host:8000" }` - -### Requirement: Electron ensureServer skips local discovery in remote mode -When `mode.json` specifies `mode: "remote"`, the `ensureServer()` function SHALL return the configured `remoteUrl` directly without performing mDNS discovery, localhost health checks, or local server spawning. The `didWeStartServer()` function SHALL return `false` in remote mode, so `stopServerIfNeeded()` is a no-op on quit. - -#### Scenario: Electron starts in remote mode -- **WHEN** the Electron app starts with `mode.json` set to `{ "mode": "remote", "remoteUrl": "http://docker-host:8000" }` -- **THEN** `ensureServer()` returns `http://docker-host:8000` without any network probing or process spawning - -#### Scenario: Electron quit does not stop remote server -- **WHEN** the Electron app is quit in remote mode -- **THEN** no shutdown request is sent to the remote server diff --git a/openspec/changes/docker-packaging/tasks.md b/openspec/changes/docker-packaging/tasks.md deleted file mode 100644 index fb486729d..000000000 --- a/openspec/changes/docker-packaging/tasks.md +++ /dev/null @@ -1,49 +0,0 @@ -## 1. Dockerfile - -- [ ] 1.1 Create `docker/Dockerfile` with `base` stage: `node:22-bookworm-slim`, install system packages (tmux, jq, git, curl, ripgrep, fd-find, build-essential, python3) -- [ ] 1.2 Add code-server binary install to `base` stage (pinned version via `ARG`, install.sh script) -- [ ] 1.3 Add zrok binary install to `base` stage (pinned version via `ARG`) -- [ ] 1.4 Create `app` stage: non-root `pi` user (UID 1000), install `@mariozechner/pi-coding-agent` globally -- [ ] 1.5 Copy dashboard source, run `npm install` + `npm run build`, remove build-essential and python3 -- [ ] 1.6 Set `EXPOSE 8000 9999`, define `VOLUME` declarations, set default `CMD` - -## 2. Entrypoint and Auth Seeding - -- [ ] 2.1 Create `docker/scripts/seed-auth.js`: read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` env vars, write `auth.json` with `0600` permissions, skip if file exists -- [ ] 2.2 Create `docker/entrypoint.sh`: run seed-auth.js, start tmux server, exec `pi-dashboard` with env-driven port flags -- [ ] 2.3 Add `entrypoint.sh` to Dockerfile (`COPY`, `chmod +x`, `ENTRYPOINT`) - -## 3. Docker Compose Base - -- [ ] 3.1 Create `docker/compose.yml`: single `pi-dashboard` service with build context, `init: true`, env-driven port mappings -- [ ] 3.2 Add named volumes (`pi-state`, `zrok-state`), tmpfs on `/tmp`, volume mount targets in service -- [ ] 3.3 Add healthcheck (`curl -f http://localhost:8000/api/health`), resource limits (memory), restart policy -- [ ] 3.4 Add environment variables section with defaults for `DASHBOARD_PORT`, `PI_GATEWAY_PORT`, `PI_GATEWAY_BIND`, `PI_SPAWN_STRATEGY`, `TUNNEL_ENABLED` - -## 4. Volume Performance Profiles - -- [ ] 4.1 Add commented volume configurations in `compose.yml` for default, performance (ext4 `noatime,data=writeback,barrier=0,commit=60`), and ephemeral (tmpfs `size=2g`) profiles -- [ ] 4.2 Document each profile's use case and trade-offs as inline comments - -## 5. Workspace and Override Files - -- [ ] 5.1 Create `docker/compose.override.yml.example` with example workspace bind mounts, read-only mount example, and instructions -- [ ] 5.2 Create `docker/compose.dev.yml`: bind-mount source, anonymous volume for `node_modules`, expose Vite HMR port 5173, `NODE_ENV=development` - -## 6. Environment Configuration - -- [ ] 6.1 Create `docker/.env.example` with all knobs: API keys, ports, gateway bind, zrok token, tunnel flag, spawn strategy, resource limits — each with explanatory comments -- [ ] 6.2 Add `docker/.gitignore` to exclude `.env` and `compose.override.yml` (user-specific files) - -## 7. Electron Remote Mode - -- [ ] 7.1 Extend `ModeConfig` type in `packages/electron/src/lib/wizard-state.ts`: add `"remote"` to mode union, add optional `remoteUrl` field, update `readModeFile()` and `writeModeFile()` to handle the new mode -- [ ] 7.2 Modify `ensureServer()` in `packages/electron/src/lib/server-lifecycle.ts`: when mode is `"remote"`, return `remoteUrl` directly (skip mDNS, health check, spawn) -- [ ] 7.3 Add "Remote" radio option to wizard renderer with URL input field and "Test Connection" button (calls `GET /api/health`) -- [ ] 7.4 Test: wizard saves remote mode to `mode.json`, `ensureServer()` returns URL, `didWeStartServer()` returns false, quit sends no shutdown request - -## 8. Documentation - -- [ ] 8.1 Create `docker/README.md` with quick-start guide, volume profiles explanation, workspace setup, dev mode, external gateway configuration, Electron remote-mode connection -- [ ] 8.2 Update project `AGENTS.md` with Docker section (key files, build/run commands) -- [ ] 8.3 Update project `README.md` with Docker deployment section diff --git a/openspec/changes/extension-ui-system/.openspec.yaml b/openspec/changes/extension-ui-system/.openspec.yaml deleted file mode 100644 index 1b75776f7..000000000 --- a/openspec/changes/extension-ui-system/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-25 diff --git a/openspec/changes/extension-ui-system/design.md b/openspec/changes/extension-ui-system/design.md deleted file mode 100644 index e282e1318..000000000 --- a/openspec/changes/extension-ui-system/design.md +++ /dev/null @@ -1,292 +0,0 @@ -## Context - -The dashboard currently surfaces extension UIs through three independent mechanisms: - -| Mechanism | Where | Coverage | -|---|---|---| -| `extension_ui_request` / PromptBus | `packages/extension/src/prompt-bus.ts` | One-shot dialogs (`confirm`, `select`, `input`, `multiselect`, `editor`, `notify`) | -| `event_forward` catch-all | `packages/extension/src/bridge.ts:657` | Forwards every `pi.events.emit(channel, data)` blindly; consumers must know each channel | -| Per-extension React components | `FlowAgentCard`, `FlowDashboard`, `ChatView`, etc. | Hard-coded to specific extensions (pi-flows, ask_user) | - -Pi-judo and similar extensions register additional TUI surfaces that the dashboard cannot render: `flow:register-card` (custom metric line), `flow:register-footer-segment`, `flow:register-workflow` (pipeline breadcrumb), `flow:register-gate` (flow availability), `ctx.ui.custom` (raw pi-tui overlay). Each represents a per-extension demand for a per-dashboard-spec response. As more extensions ship, the per-feature React component count grows linearly with the cross product of extensions × surface kinds. - -PR #15 (`feat: implement Generalized Extension UI System (Hybrid Schema)`) prototyped a schema-driven, pull-discovered modal slot. That branch is stale (537 files / ~50k deletions vs `develop`) and bundles unrelated work (ragger integration). It is not being merged; instead, its **mechanism choices** are validated and adopted in this design, and the **slot taxonomy is extended** to cover the live-decoration use cases PR #15 does not address. - -## Goals / Non-Goals - -**Goals** - -- Extensions describe UIs as serializable data; dashboard renders them in a bounded set of named slots. -- One discovery primitive (a probe event) covers all slot kinds. -- Extensions remain pi-runnable when no dashboard is connected; descriptors are inert in pure-pi mode. -- Adding a new slot kind is a typed payload addition, not a per-extension change. -- Existing extensions migrate with explicit, opt-in two-line additions next to existing TUI registrations. -- Server caches descriptor state and replays on browser subscribe so reconnect works. - -**Non-Goals** - -- Replacing the existing `interactive-ui-dialogs` / `ui-proxy` / PromptBus paths. Those continue to handle `ctx.ui.*` dialogs. -- Loading extension-authored React or JS bundles in the browser. Descriptors are data only; rendering logic ships in the dashboard. -- Replacing TUI rendering. Extensions continue to register TUI widgets via `pi-tui` / `pi-flows` directly. The new system is parallel and dashboard-only. -- Auto-mirroring `flow:register-*` channels into dashboard descriptors. Migration is explicit, not magical. -- Shipping an `@blackbelt-technology/pi-dashboard-sdk` package. Types alone in `pi-dashboard-shared` are sufficient; no runtime API. - -## Architecture - -```mermaid -sequenceDiagram - participant Ext as Extension (pi-judo) - participant Bridge as Bridge (pi process) - participant Server as Dashboard Server - participant Client as Dashboard Browser - - Note over Bridge: session_start - Bridge->>Ext: pi.events.emit("ui:list-modules", probe) - Ext-->>Bridge: probe.modules.push({ kind, id, ...schema }) - Bridge->>Server: ui_modules_list { sessionId, modules } - Server->>Client: ui_modules_list (forward + cache) - - Note over Ext: state changes - Ext->>Bridge: pi.events.emit("ui:invalidate", { id: "model-state" }) - Bridge->>Ext: pi.events.emit("ui:list-modules", probe) - Ext-->>Bridge: probe.modules.push(...) (re-emitted with fresh state) - Bridge->>Server: ext_ui_decorator { kind, id, payload } - Server->>Client: ext_ui_decorator (forward + cache by key) - - Note over Client: user clicks button - Client->>Server: ui_management { action, event, params } - Server->>Bridge: ui_management - Bridge->>Ext: pi.events.emit(event, { ...params, action }) - Ext-->>Bridge: probe.items / probe.message (synchronous) - Bridge->>Server: ui_data_list / flow:notify - Server->>Client: ui_data_list / flow:notify -``` - -``` - Phase 1 surfaces Phase 2 surfaces - ───────────────── ───────────────── - ┌──────────────────────┐ ┌──────────────────────┐ - │ slash command typed │ │ session header │ ← footer-segment - │ ──► modal opens │ │ flow agent card │ ← agent-metric - │ ──► table | form │ │ flow dashboard top │ ← breadcrumb - │ ──► action click │ │ flow launcher item │ ← gate - │ ──► event back │ │ toast tray │ ← toast - └──────────────────────┘ └──────────────────────┘ -``` - -## Decisions - -### 1. Discovery: pull-based probe (not push-registration) - -Bridge emits `ui:list-modules` on session start (and on `ui:invalidate`). Extensions listen and push their schema into the probe's `modules` array. - -```ts -// inside an extension -pi.events.on("ui:list-modules", (data) => { - data.modules.push({ kind: "management-modal", id: "judo-status", ... }); - data.modules.push({ kind: "footer-segment", id: "model-state", ... }); -}); -``` - -**Why pull, not push?** -- Reconnect handling is automatic — the bridge re-probes after every reconnect; extensions don't track bridge state. -- No package dependency — extensions only need `pi.events` (already available); they never `import` an SDK. -- Idempotent — extensions can register the same listener twice without state corruption; latest probe wins. -- Matches existing pi-events patterns (`flow:list-flows`, `flow:list-workflows`) used elsewhere. - -This is the single most important decision lifted from PR #15. - -### 2. Closure timing: invalidate-only - -Live descriptors that depend on extension state include a `render` field that returns a string or a small object. The bridge does **not** poll. The bridge re-probes only when: - -- `session_start` fires (initial state) -- The extension emits `pi.events.emit("ui:invalidate", { id })` (state changed) -- A browser reconnects and the server replays cached state (no extension involvement needed) - -Extensions that forget to invalidate render stale data — same contract as `pi-tui`'s `onRegistered(invalidate)` callback. This matches established practice. - -### 3. Slot taxonomy - -Frozen for v0.x. Adding a kind is additive and minor. Removing a kind is a major break. - -| Kind | Phase | Placement | Lifetime | Has closure? | -|---|---|---|---|---| -| `management-modal` | 1 | Modal triggered by slash command | Persistent | No (table data fetched on open) | -| `footer-segment` | 2 | Session header, right of git info | Persistent | Yes — `render() → string` | -| `agent-metric` | 2 | Below `FlowAgentCard` | Per-agent | Yes — `render() → string` | -| `breadcrumb` | 2 | Top of `FlowDashboard` | Persistent | No (snapshot, re-emit on change) | -| `gate` | 2 | Inline in `FlowLaunchDialog` items | Persistent | No (snapshot) | -| `toast` | 2 | Toast tray, top-right | One-shot | No | -| `settings-section` | 2 | Settings page, below core sections | Persistent | No (form values managed by RJSF/UiField + persisted via `plugins..*`) | -| `rjsf-form` | 4 | Modal (alternative to `management-modal` form view) | One-shot | N/A | - -Phase 1 is the one PR #15 already implements (modulo rebase and consolidation). Phase 2 is the work this proposal motivates. - -### 4. Wire protocol - -**Phase 1** (compatible with PR #15 message names, kept for migration cost): - -```ts -// extension → server → browser -{ type: "ui_modules_list", sessionId, modules: ExtensionUiModule[] } -{ type: "ui_data_list", sessionId, event: string, items: unknown[] } - -// browser → server → extension -{ type: "ui_management", sessionId, action: "list" | string, event: string, params?: Record } -``` - -**Phase 2** (single-union for all live decorations): - -```ts -{ - type: "ext_ui_decorator", - sessionId, - kind: "footer-segment" | "agent-metric" | "breadcrumb" | "gate" | "toast", - namespace: string, - id: string, - payload: KindPayload, // typed per kind - // optional, used by client to remove descriptor: - removed?: boolean -} -``` - -**Why single-union for Phase 2 but per-kind for Phase 1?** - -Phase 1 is largely already-implemented in PR #15 with its existing 3 message types. Rewriting them to a union for the modal slot has no functional benefit — there is exactly one client handler per message type and one was already shipped in the prototype. Phase 2 ships ≥5 kinds in one batch; making the protocol pay for that with 5 new message types is wasteful. The two protocol shapes coexist without ambiguity (different `type` values). - -### 5. Server-cached replay - -State is stored as fields on the `Session` record (consistent with how `commands`, `models`, `flows`, `gitBranch` already live there) so cleanup is automatic when the session is deleted: - -- `session.uiModules?: ExtensionUiModule[]` (Phase 1) -- `session.uiDataMap?: Record` keyed by `dataEvent` name (Phase 1) -- `session.uiDecorators?: Record` keyed by `${kind}:${namespace}:${id}` (Phase 2) - -On browser subscribe, the handler replays all three before forwarding live messages. On extension-emitted `removed: true` decorator, the corresponding entry is deleted from `session.uiDecorators` and the removal is forwarded to subscribers. - -The implementation should mirror the existing `replayPendingUiRequests(ws, sessionId)` hook called inside `handleSubscribe` (added by the PromptBus system); a parallel `replayUiState(ws, sessionId)` invoked at the same site, after the event-replay batches complete, applies the same pattern to module schemas and decorator descriptors. Session deletion already removes the record (and therefore the caches); no extra cleanup is required. - -**Origin of this approach.** PR #15 already used `Session.uiModules` / `uiDataMap` as the storage location and `handleSubscribe` as the replay site (`packages/server/src/browser-handlers/subscription-handler.ts:73–84` in that branch). Phase 1 implementation should retain those exact field names; Phase 2 extends the model with `uiDecorators`. - -### 6. Namespacing and collision - -Modules carry `id`. To avoid collisions when multiple extensions push to the same probe, each module also carries a `namespace` (Phase 2; Phase 1 retains PR #15's `id`-only convention with a collision warning). - -```ts -{ kind: "footer-segment", namespace: "judo", id: "model-state", ... } -``` - -The bridge logs a warning and last-write-wins on `(namespace, id)` collision within a single probe. Cross-extension collision on `id` alone (without namespace) is rare in practice; cross-namespace collision requires intentional coordination. - -### 7. No-dashboard fallback - -When no bridge is connected: - -- `ui:list-modules` is never emitted (no probe). Extension listeners are dormant. -- `pi.events.emit("ui:invalidate", ...)` is a no-op (the bridge that would handle it isn't there). -- Slash commands fall back to their existing text-based behavior. -- `ctx.ui.custom` continues to work in TUI; Phase 4 RJSF forms must declare a fallback strategy (`"ctx-ui" | "defaults" | "reject"`). - -The fallback is structural: nothing changes in pure-pi behavior because the SDK has no runtime presence outside the bridge probe. - -### 8. RJSF: Phase 4, forms-only - -Phase 4 introduces an `rjsf-form` view type within `management-modal` (and possibly elsewhere). The schema is `JSONSchema7`. The dashboard ships a Tailwind-themed RJSF renderer. RJSF is **not** used for cards, breadcrumbs, or footer segments — those use bounded descriptors with strict shapes and no schema validation. RJSF is the escape hatch for "anything richer than a fixed `UiField` form." - -Bundle cost: ~150–200 KB minified. Acceptable for a dashboard target. Loaded eagerly only if any module in the active session declares `rjsf-form`; otherwise lazy-imported on demand. - -### 9. Migration story - -For pi-judo (external repo, separate change): - -1. **Phase 1** — Convert `/judo:status` from text-output to a `management-modal`. Two-line addition next to existing TUI handler: - - ```ts - pi.events.on("ui:list-modules", (data) => { - data.modules.push({ kind: "management-modal", id: "judo-status", command: "/judo:status", ... }); - }); - pi.events.on("ui:get-data", (data) => { - if (data.event === "judo:status-rows") data.items = computeStatusRows(); - }); - ``` - -2. **Phase 2** — Add `footer-segment` and `agent-metric` decorators alongside existing `flow:register-footer-segment` / `flow:register-card` calls. Same data, two surfaces. - -3. **Phase 3** — pi-flows adopts the system. pi-judo's existing `flow:register-workflow` and `flow:register-gate` registrations are now mirrored to dashboard automatically. No additional pi-judo code needed. - -4. **Phase 4** — Replace `ctx.ui.custom` save/discard gate with `rjsf-form`. - -For pi-flows (external repo, separate change in Phase 3): - -- pi-flows listens for `ui:list-modules` and pushes one descriptor per: - - registered workflow → `kind: "breadcrumb"` (steps from `WorkflowDefinition`, current from `FlowState`) - - registered gate → `kind: "gate"` (`flowId`, `available`, `reason`) - - registered card with `renderMetric()` → `kind: "agent-metric"` (`agentId`, `render`) -- pi-flows ticks `ui:invalidate` on its existing internal change signals (`flow:rediscover`, agent state change, gate state change). - -After Phase 3, every flow-using extension automatically gets dashboard rendering for these three kinds without per-extension dashboard work. - -### 10. Lessons from PR #15 - -| PR #15 choice | Verdict | Reason | -|---|---|---| -| `ui:list-modules` pull discovery | **Adopt** | Right primitive; reconnect-friendly; no SDK dep. | -| Slash command as modal trigger | **Adopt** | Elegant, leverages existing command vocab. | -| Bespoke `UiField` schema (text/number/boolean/select/code/datetime/textarea) | **Adopt for Phase 1** | Sufficient for management UIs; RJSF deferred to Phase 4. | -| Module `id` as namespace | **Tighten in Phase 2** | Add explicit `namespace` field; warn on collision. | -| Three per-feature messages (`ui_modules_list`, `ui_data_list`, `ui_management`) | **Keep as-is** | Already shipped; no functional benefit to rewriting. | -| Single placement (modal) | **Extend** | Phase 2 adds five live-decoration slots. | -| `window.confirm()` for action confirmation | **Replace** | Phase 1 implementation should use the existing `DialogPortal`-based confirm dialog; small polish. | -| Cache state stored on `Session` record (`uiModules`, `uiDataMap`) | **Adopt** | Idiomatic — matches `commands`/`models`/`flows`/`gitBranch`. Automatic cleanup on session delete. | -| Replay site = `handleSubscribe` in `subscription-handler.ts:73–84` | **Adopt** | Same site PromptBus replay already uses; one consistent code path. | -| MDI icons via `@mdi/js` | **Adopt** | Match PR #15; constrains icon vocabulary to one set. | -| Bundling ragger consumer in same PR | **Reject** | Ragger ships as its own follow-up change after this design lands. | - -## Resolved Open Questions - -All review-phase questions resolved. Decisions canonical below; preserved with original numbering for traceability. - -1. **Footer-segment placement.** **Resolved: `SessionHeader`, right of git info.** Symmetric with existing decorations; high visibility; one strip per session, not per workspace. - -2. **Toast deduplication.** **Resolved: no dedupe; stack each toast.** Predictable; matches Slack/VS Code; extensions are responsible for their own throttling. - -3. **Action confirmation polish.** **Resolved: Tailwind `ConfirmDialog` (existing `DialogPortal`-based component) ships in the same change as the modal slot.** Consistent with the rest of the dashboard; ~20 LOC delta. - -4. **Icon vocabulary.** **Resolved: MDI only (`@mdi/js`).** Predictable look; no XSS surface; PR #15's choice; ~7000 icons available. - -5. **Decorator dispose semantics.** **Resolved: explicit `removed: true` payload.** Discoverable in code; impossible to remove by accident; one-line API. Diffing is rejected as too magical. - -6. **pi-flows adoption: which kind is the load-bearing test?** **Resolved: `breadcrumb`.** Snapshot-only (no closures); covers `flow:register-workflow`; visually distinctive validation that the pipe works end-to-end before riskier kinds (e.g. `agent-metric` with live closures) are integrated. - -7. **pi-judo's save/discard gate.** **Resolved: defer to Phase 4 `rjsf-form`.** `ctx.ui.custom` keeps working in TUI until then; cleaner than a bespoke 2-button form via Phase 1; no dual maintenance window. - -8. **Ragger's richer view types (`search`, `metrics`, `detail`).** **Resolved: separate follow-up change `add-extension-ui-rich-views` after Phase 1 lands.** Phase 1 stays minimal at `table | grid | form`; ragger gets workspace-CRUD immediately and richer views on a second iteration. Avoids inflating the Phase 1 surface. - -## Phase-1 Coverage Validation - -This section captures the result of Task 1.4 (`tasks.md` §1.4): validating that Phase 1 covers ragger's original workspace-CRUD motivator. - -| Ragger feature | Phase 1 view type | Covered? | -|---|---|---| -| List workspaces | `table` with row actions (delete, configure) | ✅ | -| Add/edit workspace | `form` with text/select/textarea fields | ✅ | -| Delete with confirmation | `UiAction.confirm` field | ✅ | -| Search across chunks | `search` (NOT in Phase 1) | ❌ → follow-up | -| Workspace stats / metrics | `metrics` (NOT in Phase 1) | ❌ → follow-up | -| Workspace detail page | `detail` (NOT in Phase 1) | ❌ → follow-up | - -**Conclusion:** Phase 1 fully covers ragger's *workspace-CRUD* surface. Ragger's richer views (search, metrics, detail) are beyond Phase 1's scope and tracked as Open Question §8 above. - -## Versioning - -- Schema types live in `@blackbelt-technology/pi-dashboard-shared`; this package's existing SemVer governs. -- Adding fields to descriptors: minor. Adding kinds: minor. Removing fields or kinds: major. -- Phase 1 ships in 0.x. We do not promise stability until at least one external extension (pi-judo) has shipped Phase 1 + Phase 2 in production. - -## Out-of-Scope Explicitly - -- Loading extension-authored React or JS bundles in the browser (`